sync: local modifications to customer_management

- Updated core.py
- Updated base.ui
- Added new API files: check_tables, customer_pool_list, customers CRUD, handover_list
- Added new UI/DSPY files: customer_edit, customer_list, customer_pool, handover_list
This commit is contained in:
yumoqing 2026-04-28 18:51:23 +08:00
parent 12e2fb6978
commit 469255afe7
13 changed files with 752 additions and 322 deletions

View File

@ -5,7 +5,7 @@ import re
from ahserver.serverenv import ServerEnv from ahserver.serverenv import ServerEnv
from appPublic.worker import awaitify from appPublic.worker import awaitify
from sqlor.dbp import DBP from sqlor.dbpools import DBPools
async def create_customer( async def create_customer(
@ -21,47 +21,48 @@ async def create_customer(
region: str = None region: str = None
) -> Dict: ) -> Dict:
"""创建客户档案""" """创建客户档案"""
dbp = DBP() db = DBPools()
customer_id = str(uuid.uuid4()).replace('-', '') async with db.sqlorContext('default') as sor:
customer_id = str(uuid.uuid4()).replace('-', '')
# 数据校验
await validate_customer_data(dbp, phone, tax_id, customer_type) # 数据校验
await validate_customer_data(sor, phone, tax_id, customer_type)
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
customer_data = { now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
"id": customer_id, customer_data = {
"customer_name": customer_name, "id": customer_id,
"customer_type": customer_type, "customer_name": customer_name,
"phone": phone, "customer_type": customer_type,
"email": email, "phone": phone,
"tax_id": tax_id, "email": email,
"industry": industry, "tax_id": tax_id,
"customer_level": customer_level, "industry": industry,
"address": address, "customer_level": customer_level,
"owner_id": owner_id or get_current_user_id(), "address": address,
"region": region, "owner_id": owner_id or get_current_user_id(),
"last_follow_up": now, "region": region,
"created_at": now, "last_follow_up": now,
"updated_at": now, "created_at": now,
"status": "active" "updated_at": now,
} "status": "active"
}
await dbp.insert("customers", customer_data)
return customer_data await sor.C("customers", customer_data)
return customer_data
async def validate_customer_data(dbp, phone: str, tax_id: str, customer_type: str): async def validate_customer_data(sor, phone: str, tax_id: str, customer_type: str):
"""验证客户数据唯一性""" """验证客户数据唯一性"""
# 手机号唯一性校验 # 手机号唯一性校验
if phone: if phone:
existing_phone = await dbp.select_one("customers", {"phone": phone}) existing_phone = await sor.R("customers", {"filters": [{"field": "phone", "op": "=", "value": phone}]})
if existing_phone: if existing_phone and len(existing_phone) > 0:
raise ValueError(f"手机号 {phone} 已存在,不能重复建档") raise ValueError(f"手机号 {phone} 已存在,不能重复建档")
# 企业税号唯一性校验(仅对企业客户) # 企业税号唯一性校验(仅对企业客户)
if customer_type == "enterprise" and tax_id: if customer_type == "enterprise" and tax_id:
existing_tax = await dbp.select_one("customers", {"tax_id": tax_id}) existing_tax = await sor.R("customers", {"filters": [{"field": "tax_id", "op": "=", "value": tax_id}]})
if existing_tax: if existing_tax and len(existing_tax) > 0:
raise ValueError(f"企业税号 {tax_id} 已存在,不能重复建档") raise ValueError(f"企业税号 {tax_id} 已存在,不能重复建档")
@ -72,39 +73,40 @@ async def initiate_handover(
reviewer_id: str = None reviewer_id: str = None
) -> Dict: ) -> Dict:
"""发起客户交接流程""" """发起客户交接流程"""
dbp = DBP() db = DBPools()
async with db.sqlorContext('default') as sor:
# 获取客户信息 # 获取客户信息
customer = await dbp.select_one("customers", {"id": customer_id}) customer_records = await sor.R("customers", {"filters": [{"field": "id", "op": "=", "value": customer_id}]})
if not customer: if not customer_records or len(customer_records) == 0:
raise ValueError("客户不存在") raise ValueError("客户不存在")
if customer["status"] != "active": customer = customer_records[0]
raise ValueError("只能交接活跃状态的客户") if customer["status"] != "active":
raise ValueError("只能交接活跃状态的客户")
# 创建交接记录
handover_id = str(uuid.uuid4()).replace('-', '') # 创建交接记录
handover_data = { handover_id = str(uuid.uuid4()).replace('-', '')
"id": handover_id, handover_data = {
"customer_id": customer_id, "id": handover_id,
"from_owner_id": customer["owner_id"], "customer_id": customer_id,
"to_owner_id": to_owner_id, "from_owner_id": customer["owner_id"],
"handover_reason": handover_reason, "to_owner_id": to_owner_id,
"current_stage": "preparation", "handover_reason": handover_reason,
"reviewer_id": reviewer_id, "current_stage": "preparation",
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "reviewer_id": reviewer_id,
"updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S") "created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
} "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
}
await dbp.insert("customer_handover", handover_data)
await sor.C("customer_handover", handover_data)
# 自动生成交接清单
await generate_handover_items(dbp, handover_id, customer_id) # 自动生成交接清单
await generate_handover_items(sor, handover_id, customer_id)
return handover_data
return handover_data
async def generate_handover_items(dbp, handover_id: str, customer_id: str): async def generate_handover_items(sor, handover_id: str, customer_id: str):
"""自动生成交接清单""" """自动生成交接清单"""
items = [] items = []
@ -120,10 +122,12 @@ async def generate_handover_items(dbp, handover_id: str, customer_id: str):
}) })
# 未结商机 # 未结商机
opportunities = await dbp.query( opportunities = await sor.R("opportunities", {
"SELECT id, customer_name, estimated_amount, current_stage FROM opportunities WHERE customer_name = (SELECT customer_name FROM customers WHERE id = %(customer_id)s) AND status = 'active'", "filters": [
{"customer_id": customer_id} {"field": "customer_name", "op": "=", "value": f"(SELECT customer_name FROM customers WHERE id = '{customer_id}')"},
) {"field": "status", "op": "=", "value": "active"}
]
})
if opportunities: if opportunities:
for opp in opportunities: for opp in opportunities:
items.append({ items.append({
@ -138,10 +142,7 @@ async def generate_handover_items(dbp, handover_id: str, customer_id: str):
}) })
# 历史合同(假设合同管理模块存在) # 历史合同(假设合同管理模块存在)
contracts = await dbp.query( contracts = await sor.R("contracts", {"filters": [{"field": "customer_id", "op": "=", "value": customer_id}]})
"SELECT id, contract_no, amount, status FROM contracts WHERE customer_id = %(customer_id)s",
{"customer_id": customer_id}
)
if contracts: if contracts:
for contract in contracts: for contract in contracts:
items.append({ items.append({
@ -156,10 +157,12 @@ async def generate_handover_items(dbp, handover_id: str, customer_id: str):
}) })
# 服务工单(假设服务模块存在) # 服务工单(假设服务模块存在)
service_tickets = await dbp.query( service_tickets = await sor.R("service_tickets", {
"SELECT id, ticket_no, subject, status FROM service_tickets WHERE customer_id = %(customer_id)s AND status != 'closed'", "filters": [
{"customer_id": customer_id} {"field": "customer_id", "op": "=", "value": customer_id},
) {"field": "status", "op": "!=", "value": "closed"}
]
})
if service_tickets: if service_tickets:
for ticket in service_tickets: for ticket in service_tickets:
items.append({ items.append({
@ -174,10 +177,12 @@ async def generate_handover_items(dbp, handover_id: str, customer_id: str):
}) })
# 未解决回款问题 # 未解决回款问题
payment_issues = await dbp.query( payment_issues = await sor.R("payment_records", {
"SELECT id, invoice_no, amount, due_date FROM payment_records WHERE customer_id = %(customer_id)s AND status = 'overdue'", "filters": [
{"customer_id": customer_id} {"field": "customer_id", "op": "=", "value": customer_id},
) {"field": "status", "op": "=", "value": "overdue"}
]
})
if payment_issues: if payment_issues:
for issue in payment_issues: for issue in payment_issues:
items.append({ items.append({
@ -193,233 +198,246 @@ async def generate_handover_items(dbp, handover_id: str, customer_id: str):
# 批量插入交接项目 # 批量插入交接项目
for item in items: for item in items:
await dbp.insert("customer_handover_items", item) await sor.C("customer_handover_items", item)
async def complete_handover_preparation(handover_id: str) -> Dict: async def complete_handover_preparation(handover_id: str) -> Dict:
"""完成交接准备阶段""" """完成交接准备阶段"""
dbp = DBP() db = DBPools()
async with db.sqlorContext('default') as sor:
handover = await dbp.select_one("customer_handover", {"id": handover_id}) handover_records = await sor.R("customer_handover", {"filters": [{"field": "id", "op": "=", "value": handover_id}]})
if not handover: if not handover_records or len(handover_records) == 0:
raise ValueError("交接记录不存在") raise ValueError("交接记录不存在")
if handover["current_stage"] != "preparation": handover = handover_records[0]
raise ValueError("当前不在准备阶段") if handover["current_stage"] != "preparation":
raise ValueError("当前不在准备阶段")
# 更新为审核阶段
await dbp.update( # 更新为审核阶段
"customer_handover", await sor.U(
{ "customer_handover",
"current_stage": "review", {
"prepared_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "current_stage": "review",
"updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S") "prepared_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}, "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
{"id": handover_id} },
) {"filters": [{"field": "id", "op": "=", "value": handover_id}]}
)
return {"handover_id": handover_id, "stage": "review"}
return {"handover_id": handover_id, "stage": "review"}
async def approve_handover(handover_id: str, approver_id: str = None) -> Dict: async def approve_handover(handover_id: str, approver_id: str = None) -> Dict:
"""审核交接清单""" """审核交接清单"""
dbp = DBP() db = DBPools()
approver_id = approver_id or get_current_user_id() async with db.sqlorContext('default') as sor:
approver_id = approver_id or get_current_user_id()
handover = await dbp.select_one("customer_handover", {"id": handover_id})
if not handover: handover_records = await sor.R("customer_handover", {"filters": [{"field": "id", "op": "=", "value": handover_id}]})
raise ValueError("交接记录不存在") if not handover_records or len(handover_records) == 0:
raise ValueError("交接记录不存在")
if handover["current_stage"] != "review":
raise ValueError("当前不在审核阶段") handover = handover_records[0]
if handover["current_stage"] != "review":
# 更新为确认阶段 raise ValueError("当前不在审核阶段")
await dbp.update(
"customer_handover", # 更新为确认阶段
{ await sor.U(
"current_stage": "confirmation", "customer_handover",
"reviewed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), {
"updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S") "current_stage": "confirmation",
}, "reviewed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
{"id": handover_id} "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
) },
{"filters": [{"field": "id", "op": "=", "value": handover_id}]}
return {"handover_id": handover_id, "stage": "confirmation"} )
return {"handover_id": handover_id, "stage": "confirmation"}
async def confirm_handover(handover_id: str, confirm_by: str = None) -> Dict: async def confirm_handover(handover_id: str, confirm_by: str = None) -> Dict:
"""确认接收客户""" """确认接收客户"""
dbp = DBP() db = DBPools()
confirm_by = confirm_by or get_current_user_id() async with db.sqlorContext('default') as sor:
confirm_by = confirm_by or get_current_user_id()
handover = await dbp.select_one("customer_handover", {"id": handover_id})
if not handover: handover_records = await sor.R("customer_handover", {"filters": [{"field": "id", "op": "=", "value": handover_id}]})
raise ValueError("交接记录不存在") if not handover_records or len(handover_records) == 0:
raise ValueError("交接记录不存在")
if handover["current_stage"] != "confirmation":
raise ValueError("当前不在确认阶段") handover = handover_records[0]
if handover["current_stage"] != "confirmation":
# 更新客户负责人 raise ValueError("当前不在确认阶段")
await dbp.update(
"customers", # 更新客户负责人
{ await sor.U(
"owner_id": handover["to_owner_id"], "customers",
"updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S") {
}, "owner_id": handover["to_owner_id"],
{"id": handover["customer_id"]} "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
) },
{"filters": [{"field": "id", "op": "=", "value": handover["customer_id"]}]}
# 完成交接流程 )
await dbp.update(
"customer_handover", # 完成交接流程
{ await sor.U(
"current_stage": "completed", "customer_handover",
"confirmed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), {
"completed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "current_stage": "completed",
"updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S") "confirmed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}, "completed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
{"id": handover_id} "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
) },
{"filters": [{"field": "id", "op": "=", "value": handover_id}]}
# 发送客户通知(模拟) )
await send_customer_notification(handover["customer_id"], handover["to_owner_id"])
# 发送客户通知(模拟)
return {"handover_id": handover_id, "stage": "completed", "customer_id": handover["customer_id"]} await send_customer_notification(sor, handover["customer_id"], handover["to_owner_id"])
return {"handover_id": handover_id, "stage": "completed", "customer_id": handover["customer_id"]}
async def send_customer_notification(customer_id: str, new_owner_id: str): async def send_customer_notification(sor, customer_id: str, new_owner_id: str):
"""发送客户对接人变更通知""" """发送客户对接人变更通知"""
# 这里应该集成短信/邮件服务 # 这里应该集成短信/邮件服务
# 模拟实现 # 模拟实现
dbp = DBP() customer_records = await sor.R("customers", {"filters": [{"field": "id", "op": "=", "value": customer_id}]})
customer = await dbp.select_one("customers", {"id": customer_id}) customer = customer_records[0] if customer_records else None
new_owner = await dbp.select_one("users", {"id": new_owner_id}) # 假设用户表存在
notification_content = f"尊敬的{customer['customer_name']},您的客户经理已变更为{new_owner.get('name', '新经理')},联系方式:{new_owner.get('phone', '待更新')}" new_owner_records = await sor.R("users", {"filters": [{"field": "id", "op": "=", "value": new_owner_id}]})
new_owner = new_owner_records[0] if new_owner_records else {"name": "新经理", "phone": "待更新"}
# 记录通知日志(实际应调用短信/邮件API if customer:
print(f"客户通知已发送: {notification_content}") notification_content = f"尊敬的{customer['customer_name']},您的客户经理已变更为{new_owner.get('name', '新经理')},联系方式:{new_owner.get('phone', '待更新')}"
# 记录通知日志(实际应调用短信/邮件API
print(f"客户通知已发送: {notification_content}")
async def recycle_to_pool(customer_id: str, inactive_days: int = None, reason: str = "inactive_days"): async def recycle_to_pool(customer_id: str, inactive_days: int = None, reason: str = "inactive_days"):
"""回收客户到公海池""" """回收客户到公海池"""
dbp = DBP() db = DBPools()
async with db.sqlorContext('default') as sor:
customer = await dbp.select_one("customers", {"id": customer_id}) customer_records = await sor.R("customers", {"filters": [{"field": "id", "op": "=", "value": customer_id}]})
if not customer: if not customer_records or len(customer_records) == 0:
raise ValueError("客户不存在") raise ValueError("客户不存在")
if customer["status"] == "in_pool": customer = customer_records[0]
raise ValueError("客户已在公海池中") if customer["status"] == "in_pool":
raise ValueError("客户已在公海池中")
# 创建公海记录
pool_id = str(uuid.uuid4()).replace('-', '') # 创建公海记录
pool_data = { pool_id = str(uuid.uuid4()).replace('-', '')
"id": pool_id, pool_data = {
"customer_id": customer_id, "id": pool_id,
"original_owner_id": customer["owner_id"], "customer_id": customer_id,
"recycle_reason": reason, "original_owner_id": customer["owner_id"],
"inactive_days": inactive_days, "recycle_reason": reason,
"recycled_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "inactive_days": inactive_days,
"pool_status": "available", "recycled_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S") "pool_status": "available",
} "created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
}
await dbp.insert("customer_pool", pool_data)
await sor.C("customer_pool", pool_data)
# 更新客户状态
await dbp.update( # 更新客户状态
"customers", await sor.U(
{ "customers",
"status": "in_pool", {
"owner_id": "", # 清空负责人 "status": "in_pool",
"updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S") "owner_id": "", # 清空负责人
}, "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
{"id": customer_id} },
) {"filters": [{"field": "id", "op": "=", "value": customer_id}]}
)
return pool_data
return pool_data
async def claim_from_pool(pool_id: str, new_owner_id: str = None): async def claim_from_pool(pool_id: str, new_owner_id: str = None):
"""从公海池认领客户""" """从公海池认领客户"""
dbp = DBP() db = DBPools()
new_owner_id = new_owner_id or get_current_user_id() async with db.sqlorContext('default') as sor:
new_owner_id = new_owner_id or get_current_user_id()
pool_record = await dbp.select_one("customer_pool", {"id": pool_id})
if not pool_record: pool_records = await sor.R("customer_pool", {"filters": [{"field": "id", "op": "=", "value": pool_id}]})
raise ValueError("公海记录不存在") if not pool_records or len(pool_records) == 0:
raise ValueError("公海记录不存在")
if pool_record["pool_status"] != "available":
raise ValueError("该客户已被认领或分配") pool_record = pool_records[0]
if pool_record["pool_status"] != "available":
# 更新公海记录 raise ValueError("该客户已被认领或分配")
await dbp.update(
"customer_pool", # 更新公海记录
{ await sor.U(
"assigned_to": new_owner_id, "customer_pool",
"assigned_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), {
"pool_status": "claimed", "assigned_to": new_owner_id,
"updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S") "assigned_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}, "pool_status": "claimed",
{"id": pool_id} "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
) },
{"filters": [{"field": "id", "op": "=", "value": pool_id}]}
# 更新客户状态和负责人 )
await dbp.update(
"customers", # 更新客户状态和负责人
{ await sor.U(
"status": "active", "customers",
"owner_id": new_owner_id, {
"last_follow_up": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "status": "active",
"updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S") "owner_id": new_owner_id,
}, "last_follow_up": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
{"id": pool_record["customer_id"]} "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
) },
{"filters": [{"field": "id", "op": "=", "value": pool_record["customer_id"]}]}
return {"customer_id": pool_record["customer_id"], "new_owner_id": new_owner_id} )
return {"customer_id": pool_record["customer_id"], "new_owner_id": new_owner_id}
async def get_customer_360_view(customer_id: str) -> Dict: async def get_customer_360_view(customer_id: str) -> Dict:
"""获取客户360度视图""" """获取客户360度视图"""
dbp = DBP() db = DBPools()
async with db.sqlorContext('default') as sor:
# 客户基本信息 # 客户基本信息
customer = await dbp.select_one("customers", {"id": customer_id}) customer_records = await sor.R("customers", {"filters": [{"field": "id", "op": "=", "value": customer_id}]})
if not customer: if not customer_records or len(customer_records) == 0:
raise ValueError("客户不存在") raise ValueError("客户不存在")
# 商机记录 customer = customer_records[0]
opportunities = await dbp.query(
"SELECT id, estimated_amount, current_stage, expected_close_date, status FROM opportunities WHERE customer_name = %(customer_name)s ORDER BY created_at DESC", # 商机记录
{"customer_name": customer["customer_name"]} opportunities = await sor.R("opportunities", {
) "filters": [{"field": "customer_name", "op": "=", "value": customer["customer_name"]}],
"sortby": [{"field": "created_at", "direction": "desc"}]
# 合同历史(假设合同管理模块存在) })
contracts = await dbp.query(
"SELECT id, contract_no, amount, start_date, end_date, status FROM contracts WHERE customer_id = %(customer_id)s ORDER BY created_at DESC", # 合同历史(假设合同管理模块存在)
{"customer_id": customer_id} contracts = await sor.R("contracts", {
) "filters": [{"field": "customer_id", "op": "=", "value": customer_id}],
"sortby": [{"field": "created_at", "direction": "desc"}]
# 服务工单(假设服务模块存在) })
service_tickets = await dbp.query(
"SELECT id, ticket_no, subject, priority, status, created_at FROM service_tickets WHERE customer_id = %(customer_id)s ORDER BY created_at DESC", # 服务工单(假设服务模块存在)
{"customer_id": customer_id} service_tickets = await sor.R("service_tickets", {
) "filters": [{"field": "customer_id", "op": "=", "value": customer_id}],
"sortby": [{"field": "created_at", "direction": "desc"}]
# 回款情况 })
payments = await dbp.query(
"SELECT id, invoice_no, amount, paid_amount, due_date, status FROM payment_records WHERE customer_id = %(customer_id)s ORDER BY due_date DESC", # 回款情况
{"customer_id": customer_id} payments = await sor.R("payment_records", {
) "filters": [{"field": "customer_id", "op": "=", "value": customer_id}],
"sortby": [{"field": "due_date", "direction": "desc"}]
return { })
"customer": customer,
"opportunities": opportunities, return {
"contracts": contracts, "customer": customer,
"service_tickets": service_tickets, "opportunities": opportunities,
"payments": payments "contracts": contracts,
} "service_tickets": service_tickets,
"payments": payments
}
def get_current_user_id() -> str: def get_current_user_id() -> str:

View File

@ -0,0 +1,19 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json
result = {'keys': [], 'rows': []}
try:
dbname = get_module_dbname('customer_management')
async with DBPools().sqlorContext(dbname) as sor:
ns = {'page': 1, 'rows': 50, 'sort': 'TABLE_NAME'}
sql = "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA='crm_db'"
rows = await sor.sqlExe(sql, ns)
if isinstance(rows, dict):
rows = rows.get('rows', [])
if rows:
result['keys'] = list(dict(rows[0]).keys())
result['rows'] = [list(dict(r).values()) for r in rows]
result['success'] = True
except Exception as e:
result['error'] = str(e)
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,30 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Customer pool list API"""
import json
result = {'success': False, 'rows': [], 'total': 0}
try:
dbname = get_module_dbname('customer_management')
ns = {
'page': int(params_kw.get('page', 1)),
'rows': int(params_kw.get('rows', 20)),
'sort': 'created_at desc'
}
sql = "SELECT id, customer_id, original_owner_id, recycle_reason, inactive_days, recycled_at, pool_status, created_at FROM customer_pool"
async with DBPools().sqlorContext(dbname) as sor:
data = await sor.sqlExe(sql, ns)
if isinstance(data, dict):
result['total'] = data.get('total', 0)
result['rows'] = [dict(r) for r in data.get('rows', [])]
else:
result['rows'] = [dict(r) for r in (data or [])]
result['total'] = len(result['rows'])
result['success'] = True
except Exception as e:
result['error'] = str(e)
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,48 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Customer create API"""
import json, uuid, time
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid request', 'type': 'error'}}
try:
customer_name = params_kw.get('customer_name', '').strip()
customer_type = params_kw.get('customer_type', '').strip()
owner_id = params_kw.get('owner_id', '').strip()
if not customer_name:
result['options'] = {'title': 'Error', 'message': '客户名称不能为空', 'type': 'error'}
elif not customer_type:
result['options'] = {'title': 'Error', 'message': '客户类型不能为空', 'type': 'error'}
elif not owner_id:
result['options'] = {'title': 'Error', 'message': '负责人不能为空', 'type': 'error'}
else:
dbname = get_module_dbname('customer_management')
new_id = str(uuid.uuid4()).replace('-', '')
now = time.strftime('%Y-%m-%d %H:%M:%S')
phone = params_kw.get('phone', '').strip()
email = params_kw.get('email', '').strip()
tax_id = params_kw.get('tax_id', '').strip()
industry = params_kw.get('industry', '').strip()
customer_level = params_kw.get('customer_level', 'potential').strip()
address = params_kw.get('address', '').strip()
region = params_kw.get('region', '').strip()
status = params_kw.get('status', 'active').strip()
async with DBPools().sqlorContext(dbname) as sor:
await sor.sqlExe("""INSERT INTO customers (id, customer_name, customer_type, phone, email, tax_id, industry, customer_level, address, owner_id, region, status, created_at, updated_at)
VALUES (${id}$, ${customer_name}$, ${customer_type}$, ${phone}$, ${email}$, ${tax_id}$, ${industry}$, ${customer_level}$, ${address}$, ${owner_id}$, ${region}$, ${status}$, ${created_at}$, ${updated_at}$)""", {
'id': new_id, 'customer_name': customer_name, 'customer_type': customer_type,
'phone': phone, 'email': email, 'tax_id': tax_id, 'industry': industry,
'customer_level': customer_level, 'address': address, 'owner_id': owner_id,
'region': region, 'status': status,
'created_at': now, 'updated_at': now
})
result = {'widgettype': 'Message', 'options': {'title': 'Success', 'message': '客户创建成功', 'type': 'success'}}
except Exception as e:
result['options'] = {'title': 'Error', 'message': '创建失败: ' + str(e), 'type': 'error'}
return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,25 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Customer delete API"""
import json
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid request', 'type': 'error'}}
try:
record_id = params_kw.get('id', '').strip()
if not record_id:
result['options'] = {'title': 'Error', 'message': '记录ID不能为空', 'type': 'error'}
else:
dbname = get_module_dbname('customer_management')
async with DBPools().sqlorContext(dbname) as sor:
existing = await sor.sqlExe("SELECT id FROM customers WHERE id = ${id}$", {'id': record_id})
if not existing:
result['options'] = {'title': 'Error', 'message': '客户不存在', 'type': 'error'}
else:
await sor.sqlExe("DELETE FROM customers WHERE id = ${id}$", {'id': record_id})
result = {'widgettype': 'Message', 'options': {'title': 'Success', 'message': '删除成功', 'type': 'success'}}
except Exception as e:
result['options'] = {'title': 'Error', 'message': f'删除失败: {str(e)}', 'type': 'error'}
return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,30 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Customer list API - uses sqlor built-in pagination"""
import json
result = {'success': False, 'rows': [], 'total': 0}
try:
dbname = get_module_dbname('customer_management')
ns = {
'page': int(params_kw.get('page', 1)),
'rows': int(params_kw.get('rows', 20)),
'sort': 'created_at desc'
}
sql = "SELECT id, customer_name, customer_type, phone, email, industry, customer_level, region, status, created_at FROM customers"
async with DBPools().sqlorContext(dbname) as sor:
data = await sor.sqlExe(sql, ns)
if isinstance(data, dict):
result['total'] = data.get('total', 0)
result['rows'] = [dict(r) for r in data.get('rows', [])]
else:
result['rows'] = [dict(r) for r in (data or [])]
result['total'] = len(result['rows'])
result['success'] = True
except Exception as e:
result['error'] = str(e)
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,39 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Customer update API"""
import json, time
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid request', 'type': 'error'}}
try:
record_id = params_kw.get('id', '').strip()
if not record_id:
result['options'] = {'title': 'Error', 'message': '记录ID不能为空', 'type': 'error'}
else:
dbname = get_module_dbname('customer_management')
now = time.strftime('%Y-%m-%d %H:%M:%S')
fields = ['customer_name', 'customer_type', 'phone', 'email', 'tax_id', 'industry', 'customer_level', 'address', 'owner_id', 'region', 'status']
set_parts = []
params = {'id': record_id}
for f in fields:
val = params_kw.get(f, None)
if val is not None:
set_parts.append(f + " = ${" + f + "}$")
params[f] = val
set_parts.append("updated_at = ${updated_at}$")
params['updated_at'] = now
if len(set_parts) <= 1:
result['options'] = {'title': 'Error', 'message': '没有可更新的字段', 'type': 'error'}
else:
sql = "UPDATE customers SET " + ", ".join(set_parts) + " WHERE id = ${id}$"
async with DBPools().sqlorContext(dbname) as sor:
await sor.sqlExe(sql, params)
result = {'widgettype': 'Message', 'options': {'title': 'Success', 'message': '更新成功', 'type': 'success'}}
except Exception as e:
result['options'] = {'title': 'Error', 'message': '更新失败: ' + str(e), 'type': 'error'}
return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,30 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Customer handover list API"""
import json
result = {'success': False, 'rows': [], 'total': 0}
try:
dbname = get_module_dbname('customer_management')
ns = {
'page': int(params_kw.get('page', 1)),
'rows': int(params_kw.get('rows', 20)),
'sort': 'created_at desc'
}
sql = "SELECT id, customer_id, from_owner_id, to_owner_id, current_stage, handover_reason, created_at FROM customer_handover"
async with DBPools().sqlorContext(dbname) as sor:
data = await sor.sqlExe(sql, ns)
if isinstance(data, dict):
result['total'] = data.get('total', 0)
result['rows'] = [dict(r) for r in data.get('rows', [])]
else:
result['rows'] = [dict(r) for r in (data or [])]
result['total'] = len(result['rows'])
result['success'] = True
except Exception as e:
result['error'] = str(e)
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -1,51 +1,67 @@
{ {
"widgettype": "TabPanel", "type": "Page",
"options": { "title": "Customer Management",
"title": "客户管理" "content": {
}, "type": "VBox",
"subwidgets": [ "style": {"padding": "16px", "height": "100%"},
{ "children": [
"widgettype": "CRUD", {
"options": { "type": "HBox",
"title": "客户档案", "justify": "space-between",
"url": "{{entire_url(customers_list)}}" "style": {"marginBottom": "16px"},
} "children": [
{"type": "Text", "content": "Customer List", "style": {"fontSize": "20px", "fontWeight": "bold"}},
{"type": "Button", "id": "addCustomerBtn", "text": "Add Customer", "variant": "primary", "leadingIcon": "add", "onclick": "openDialog('addCustomerDialog')"}
]
},
{
"type": "CRUD",
"id": "customerCRUD",
"api": {
"list": "main/customer_management/api/customers_list.dspy",
"create": "main/customer_management/api/customers_create.dspy",
"update": "main/customer_management/api/customers_update.dspy",
"delete": "main/customer_management/api/customers_delete.dspy"
}, },
{ "columns": [
"widgettype": "CRUD", {"field": "customer_name", "title": "Customer Name", "width": 180},
"options": { {"field": "customer_type", "title": "Type", "width": 100, "formatter": "code:customer_type"},
"title": "客户交接", {"field": "phone", "title": "Phone", "width": 130},
"url": "{{entire_url(handover_list)}}" {"field": "email", "title": "Email", "width": 180},
} {"field": "industry", "title": "Industry", "width": 120},
}, {"field": "customer_level", "title": "Level", "width": 100, "formatter": "code:customer_level"},
{ {"field": "region", "title": "Region", "width": 100},
"widgettype": "CRUD", {"field": "status", "title": "Status", "width": 80, "formatter": "code:customer_status"},
"options": { {"field": "created_at", "title": "Created", "width": 160}
"title": "客户公海池", ],
"url": "{{entire_url(customer_pool_list)}}" "style": {"flex": 1}
} },
}, {
{ "type": "Dialog",
"widgettype": "Panel", "id": "addCustomerDialog",
"options": { "title": "Add Customer",
"title": "客户360度视图" "width": 600,
}, "content": {
"subwidgets": [ "type": "Form",
{ "id": "addCustomerForm",
"widgettype": "Form", "fields": [
"options": { {"field": "customer_name", "title": "Customer Name", "uitype": "TextField", "required": true},
"title": "客户查询", {"field": "customer_type", "title": "Type", "uitype": "SelectField", "required": true, "options": "code:customer_type"},
"fields": [ {"field": "phone", "title": "Phone", "uitype": "TextField"},
{ {"field": "email", "title": "Email", "uitype": "TextField"},
"name": "customer_id", {"field": "tax_id", "title": "Tax ID", "uitype": "TextField"},
"label": "客户ID", {"field": "industry", "title": "Industry", "uitype": "TextField"},
"type": "text" {"field": "customer_level", "title": "Level", "uitype": "SelectField", "options": "code:customer_level", "value": "potential"},
} {"field": "address", "title": "Address", "uitype": "TextField"},
], {"field": "region", "title": "Region", "uitype": "TextField"},
"onSubmit": "get_customer_360_view" {"field": "owner_id", "title": "Owner ID", "uitype": "TextField", "required": true}
} ],
} "actions": [
] {"type": "Button", "text": "Cancel", "onclick": "closeDialog('addCustomerDialog')"},
{"type": "Button", "text": "Submit", "variant": "primary", "onclick": "submitForm('addCustomerForm', 'main/customer_management/api/customers_create.dspy', 'addCustomerDialog', 'customerCRUD')"}
]
} }
}
] ]
}
} }

65
wwwroot/customer_edit.ui Normal file
View File

@ -0,0 +1,65 @@
{
"widgettype": "Page",
"options": {
"title": "客户编辑",
"style": {"height": "100vh", "padding": "0"}
},
"subwidgets": [
{
"widgettype": "VBox",
"options": {"style": {"padding": "16px", "flex": 1, "overflow": "auto"}},
"subwidgets": [
{
"widgettype": "Form",
"id": "customer_form",
"options": {
"submit_url": "{{entire_url('api/customers_create.dspy')}}",
"method": "POST",
"layout": "vertical",
"style": {"maxWidth": "600px"},
"fields": [
{"name": "customer_name", "label": "客户名称", "uitype": "text", "required": true},
{"name": "customer_type", "label": "客户类型", "uitype": "code", "required": true, "data": [
{"value": "enterprise", "text": "企业客户"},
{"value": "individual", "text": "个人客户"},
{"value": "government", "text": "政府客户"}
]},
{"name": "contact_person", "label": "联系人", "uitype": "text"},
{"name": "phone", "label": "电话", "uitype": "text"},
{"name": "email", "label": "邮箱", "uitype": "text"},
{"name": "tax_id", "label": "税号", "uitype": "text"},
{"name": "industry", "label": "行业", "uitype": "code", "data": [
{"value": "manufacturing", "text": "制造业"},
{"value": "technology", "text": "科技"},
{"value": "finance", "text": "金融"},
{"value": "retail", "text": "零售"},
{"value": "other", "text": "其他"}
]},
{"name": "customer_level", "label": "客户等级", "uitype": "code", "data": [
{"value": "potential", "text": "潜在客户"},
{"value": "active", "text": "活跃客户"},
{"value": "vip", "text": "VIP客户"}
]},
{"name": "address", "label": "地址", "uitype": "textarea"},
{"name": "owner_id", "label": "负责人", "uitype": "text", "required": true},
{"name": "region", "label": "区域", "uitype": "code", "data": [
{"value": "east", "text": "华东"},
{"value": "south", "text": "华南"},
{"value": "west", "text": "华西"},
{"value": "north", "text": "华北"}
]},
{"name": "status", "label": "状态", "uitype": "code", "data": [
{"value": "active", "text": "有效"},
{"value": "inactive", "text": "无效"}
]}
],
"buttons": [
{"type": "submit", "text": "保存", "variant": "primary"},
{"type": "button", "text": "取消", "action": "navigate('main/customer_management/customer_list.ui')"}
]
}
}
]
}
]
}

45
wwwroot/customer_list.ui Normal file
View File

@ -0,0 +1,45 @@
{
"widgettype": "Page",
"options": {
"title": "客户列表",
"style": {"height": "100vh", "padding": "0"}
},
"subwidgets": [
{
"widgettype": "VBox",
"options": {"style": {"padding": "16px", "flex": 1, "overflow": "hidden"}},
"subwidgets": [
{
"widgettype": "HBox",
"options": {"style": {"marginBottom": "16px", "gap": "8px"}},
"subwidgets": [
{"widgettype": "TextField", "id": "search_keyword", "options": {"label": "搜索", "placeholder": "客户名称/编码", "style": {"flex": 1}}},
{"widgettype": "Button", "id": "btn_search", "options": {"text": "搜索", "variant": "primary"}}
]
},
{
"widgettype": "DataGrid",
"id": "customer_grid",
"options": {
"url": "{{entire_url('api/customers_list.dspy')}}",
"style": {"flex": 1},
"columns": [
{"field": "customer_code", "header": "客户编码", "width": 120},
{"field": "customer_name", "header": "客户名称", "width": 200},
{"field": "customer_type", "header": "类型", "width": 100},
{"field": "industry", "header": "行业", "width": 120},
{"field": "contact_person", "header": "联系人", "width": 100},
{"field": "phone", "header": "电话", "width": 120},
{"field": "status", "header": "状态", "width": 80}
],
"toolbar": [
{"type": "button", "text": "新增", "icon": "add", "action": "navigate('main/customer_management/customer_edit.ui')"},
{"type": "button", "text": "编辑", "icon": "edit", "action": "navigate('main/customer_management/customer_edit.ui?id={% raw %}{{selectedRow.id}}{% endraw %}')"},
{"type": "button", "text": "删除", "icon": "delete", "action": "doDelete('{% raw %}{{selectedRow.id}}{% endraw %}')"}
]
}
}
]
}
]
}

34
wwwroot/customer_pool.ui Normal file
View File

@ -0,0 +1,34 @@
{
"widgettype": "Page",
"options": {
"title": "客户公海",
"style": {"height": "100vh", "padding": "0"}
},
"subwidgets": [
{
"widgettype": "VBox",
"options": {"style": {"padding": "16px", "flex": 1, "overflow": "hidden"}},
"subwidgets": [
{
"widgettype": "DataGrid",
"id": "pool_grid",
"options": {
"url": "{{entire_url('api/customer_pool_list.dspy')}}",
"style": {"flex": 1},
"columns": [
{"field": "customer_code", "header": "客户编码", "width": 120},
{"field": "customer_name", "header": "客户名称", "width": 200},
{"field": "industry", "header": "行业", "width": 120},
{"field": "recycle_reason", "header": "回收原因", "width": 150},
{"field": "pool_status", "header": "状态", "width": 80},
{"field": "recycle_time", "header": "回收时间", "width": 150}
],
"toolbar": [
{"type": "button", "text": "领取", "icon": "get_app", "action": "claimCustomer('{% raw %}{{selectedRow.id}}{% endraw %}')"}
]
}
}
]
}
]
}

31
wwwroot/handover_list.ui Normal file
View File

@ -0,0 +1,31 @@
{
"widgettype": "Page",
"options": {
"title": "客户交接",
"style": {"height": "100vh", "padding": "0"}
},
"subwidgets": [
{
"widgettype": "VBox",
"options": {"style": {"padding": "16px", "flex": 1, "overflow": "hidden"}},
"subwidgets": [
{
"widgettype": "DataGrid",
"id": "handover_grid",
"options": {
"url": "{{entire_url('api/handover_list.dspy')}}",
"style": {"flex": 1},
"columns": [
{"field": "customer_name", "header": "客户名称", "width": 200},
{"field": "from_owner_name", "header": "原负责人", "width": 120},
{"field": "to_owner_name", "header": "新负责人", "width": 120},
{"field": "handover_reason", "header": "交接原因", "width": 200},
{"field": "status", "header": "状态", "width": 80},
{"field": "created_at", "header": "创建时间", "width": 150}
]
}
}
]
}
]
}