dfc-customer-sync 3.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dfc_customer_sync/__init__.py +4 -0
- dfc_customer_sync/__main__.py +13 -0
- dfc_customer_sync/auth.py +202 -0
- dfc_customer_sync/dfc_client.py +392 -0
- dfc_customer_sync/error_handler.py +263 -0
- dfc_customer_sync/jvdc_scraper.py +302 -0
- dfc_customer_sync/mapping.py +82 -0
- dfc_customer_sync/platform_utils.py +182 -0
- dfc_customer_sync/state.py +36 -0
- dfc_customer_sync/sync_daemon.py +851 -0
- dfc_customer_sync-3.6.0.dist-info/METADATA +384 -0
- dfc_customer_sync-3.6.0.dist-info/RECORD +15 -0
- dfc_customer_sync-3.6.0.dist-info/WHEEL +5 -0
- dfc_customer_sync-3.6.0.dist-info/entry_points.txt +2 -0
- dfc_customer_sync-3.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""dfc-customer-sync 包入口。
|
|
3
|
+
|
|
4
|
+
用法:
|
|
5
|
+
python -m dfc_customer_sync --setup
|
|
6
|
+
python -m dfc_customer_sync
|
|
7
|
+
python -m dfc_customer_sync --status
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from . import sync_daemon
|
|
11
|
+
|
|
12
|
+
if __name__ == "__main__":
|
|
13
|
+
sync_daemon.main()
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
大风车认证模块 — APP_KEY 方式
|
|
4
|
+
通过大风车开放平台 API 获取 Token
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import ssl
|
|
10
|
+
import sys
|
|
11
|
+
import urllib.request
|
|
12
|
+
import urllib.error
|
|
13
|
+
from typing import Dict, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# 大风车 Token API
|
|
17
|
+
TOKEN_URL = "https://ai-assistant-web.souche.com/openclaw/token/getByAppKey"
|
|
18
|
+
ACCOUNT_URL = "https://danube-tenant-web.souche.com/account/info.json"
|
|
19
|
+
SHOP_LIST_URL = "https://danube-tenant-web.souche.com/shops/list.json"
|
|
20
|
+
API_BASE = "https://crazyracing-kartrider.souche.com"
|
|
21
|
+
|
|
22
|
+
# 标准请求头
|
|
23
|
+
COMMON_HEADERS = {
|
|
24
|
+
"Content-Type": "application/json;charset=UTF-8",
|
|
25
|
+
"Accept": "application/json, text/plain, */*",
|
|
26
|
+
"User-Agent": "Mozilla/5.0 (Linux; Android 12; ALN-AL00) AppleWebKit/537.36",
|
|
27
|
+
"Origin": "https://xindafengche.souche.com",
|
|
28
|
+
"Referer": "https://xindafengche.souche.com/",
|
|
29
|
+
"_source_code": "WEB",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _ssl_context():
|
|
34
|
+
"""创建 SSL 上下文(禁用证书验证)"""
|
|
35
|
+
ctx = ssl.create_default_context()
|
|
36
|
+
ctx.check_hostname = False
|
|
37
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
38
|
+
return ctx
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_token(env_name: str = "APP_KEY") -> str:
|
|
42
|
+
"""
|
|
43
|
+
通过 APP_KEY 获取 Token
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
dfcToken 字符串
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
Exception: APP_KEY 未设置或获取失败时抛出
|
|
50
|
+
"""
|
|
51
|
+
app_key = os.getenv(env_name)
|
|
52
|
+
if not app_key:
|
|
53
|
+
raise Exception(
|
|
54
|
+
f"❌ 未找到环境变量 {env_name}\n"
|
|
55
|
+
f"请检查服务器是否已配置 {env_name}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
url = f"{TOKEN_URL}?appKey={app_key}"
|
|
59
|
+
req = urllib.request.Request(url, headers=COMMON_HEADERS, method="GET")
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
with urllib.request.urlopen(req, timeout=10, context=_ssl_context()) as resp:
|
|
63
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
64
|
+
except urllib.error.URLError as e:
|
|
65
|
+
raise Exception(f"获取 Token 失败: {e.reason}")
|
|
66
|
+
|
|
67
|
+
if data.get("code") != "200":
|
|
68
|
+
raise Exception(f"Token 获取失败: {data.get('msg', '未知错误')}")
|
|
69
|
+
|
|
70
|
+
token = data.get("data", {}).get("dfcToken")
|
|
71
|
+
if not token:
|
|
72
|
+
raise Exception("Token 获取失败: 响应中无 dfcToken")
|
|
73
|
+
|
|
74
|
+
return token
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_account_info(dfc_token: str) -> dict:
|
|
78
|
+
"""
|
|
79
|
+
通过 dfcToken 获取账户信息(orgId, shopCode, shopName)
|
|
80
|
+
"""
|
|
81
|
+
headers = COMMON_HEADERS.copy()
|
|
82
|
+
headers["Souche-Security-Token"] = dfc_token
|
|
83
|
+
|
|
84
|
+
req = urllib.request.Request(ACCOUNT_URL, headers=headers, method="GET")
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
with urllib.request.urlopen(req, timeout=10, context=_ssl_context()) as resp:
|
|
88
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
89
|
+
except urllib.error.URLError as e:
|
|
90
|
+
raise Exception(f"获取账户信息失败: {e.reason}")
|
|
91
|
+
|
|
92
|
+
info = data.get("data", {})
|
|
93
|
+
return {
|
|
94
|
+
"orgId": info.get("orgId"),
|
|
95
|
+
"shopCode": info.get("shopCode"),
|
|
96
|
+
"shopName": info.get("shopName"),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def list_shops(dfc_token: str) -> list:
|
|
101
|
+
"""获取门店列表"""
|
|
102
|
+
headers = COMMON_HEADERS.copy()
|
|
103
|
+
headers["Souche-Security-Token"] = dfc_token
|
|
104
|
+
req = urllib.request.Request(SHOP_LIST_URL, headers=headers, method="GET")
|
|
105
|
+
try:
|
|
106
|
+
with urllib.request.urlopen(req, timeout=10, context=_ssl_context()) as resp:
|
|
107
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
108
|
+
except urllib.error.URLError as e:
|
|
109
|
+
raise Exception(f"获取门店列表失败: {e.reason}")
|
|
110
|
+
if data.get("code") != "200":
|
|
111
|
+
raise Exception(f"获取门店列表失败: {data.get('msg', '未知错误')}")
|
|
112
|
+
items = (data.get("data") or {}).get("items", [])
|
|
113
|
+
return [{"shopCode": s.get("code"), "shopName": s.get("name")} for s in items if s.get("code")]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def resolve_shop_scope(
|
|
117
|
+
dfc_token: str,
|
|
118
|
+
scope: str = "current",
|
|
119
|
+
explicit_shop_codes: Optional[list] = None,
|
|
120
|
+
) -> tuple:
|
|
121
|
+
"""解析门店范围"""
|
|
122
|
+
account = get_account_info(dfc_token)
|
|
123
|
+
if scope == "current":
|
|
124
|
+
return [account["shopCode"]], f"当前门店({account['shopName']})"
|
|
125
|
+
if scope == "all":
|
|
126
|
+
shops = list_shops(dfc_token)
|
|
127
|
+
codes = [s.get("shopCode") for s in shops if s.get("shopCode")]
|
|
128
|
+
if not codes:
|
|
129
|
+
return [account["shopCode"]], f"当前门店({account['shopName']})"
|
|
130
|
+
return codes, f"全部门店(共 {len(codes)} 家)"
|
|
131
|
+
codes = [c for c in (explicit_shop_codes or []) if c]
|
|
132
|
+
if not codes:
|
|
133
|
+
codes = [account["shopCode"]]
|
|
134
|
+
return codes, f"指定门店(共 {len(codes)} 家)"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def build_headers(dfc_token: str, extra: Optional[dict] = None) -> dict:
|
|
138
|
+
"""构建业务 API 请求头"""
|
|
139
|
+
headers = COMMON_HEADERS.copy()
|
|
140
|
+
headers["Souche-Security-Token"] = dfc_token
|
|
141
|
+
headers["Host"] = "crazyracing-kartrider.souche.com"
|
|
142
|
+
headers["X-Souche-Servicechain"] = os.getenv("SOUCHE_SERVICECHAIN", "env-1025647")
|
|
143
|
+
if extra:
|
|
144
|
+
headers.update(extra)
|
|
145
|
+
return headers
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def api_post(endpoint: str, payload: dict, dfc_token: str, extra_headers: Optional[dict] = None, silent: bool = False) -> dict:
|
|
149
|
+
"""
|
|
150
|
+
发起 POST 请求
|
|
151
|
+
"""
|
|
152
|
+
url = f"{API_BASE}{endpoint}"
|
|
153
|
+
headers = build_headers(dfc_token, extra_headers)
|
|
154
|
+
|
|
155
|
+
data = json.dumps(payload).encode("utf-8")
|
|
156
|
+
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
with urllib.request.urlopen(req, timeout=15, context=_ssl_context()) as resp:
|
|
160
|
+
result = json.loads(resp.read().decode("utf-8"))
|
|
161
|
+
except urllib.error.HTTPError as e:
|
|
162
|
+
if not silent:
|
|
163
|
+
print(f"HTTP 错误 {e.code}: {e.reason}", file=sys.stderr)
|
|
164
|
+
sys.exit(1)
|
|
165
|
+
except urllib.error.URLError as e:
|
|
166
|
+
if not silent:
|
|
167
|
+
print(f"网络错误: {e.reason}", file=sys.stderr)
|
|
168
|
+
sys.exit(1)
|
|
169
|
+
|
|
170
|
+
if result.get("code") != "200":
|
|
171
|
+
if not silent:
|
|
172
|
+
print(f"API 业务错误: {result.get('msg', '未知错误')}", file=sys.stderr)
|
|
173
|
+
sys.exit(1)
|
|
174
|
+
|
|
175
|
+
return result.get("data", {})
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def get_servicechain() -> str:
|
|
179
|
+
"""获取服务链标识"""
|
|
180
|
+
return os.getenv("SOUCHE_SERVICECHAIN", "env-1025647")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def get_auth_headers() -> Dict[str, str]:
|
|
184
|
+
"""获取认证 Headers"""
|
|
185
|
+
return {
|
|
186
|
+
"_source_code": "WEB",
|
|
187
|
+
"content-type": "application/json;charset=UTF-8",
|
|
188
|
+
"souche-security-token": get_token(),
|
|
189
|
+
"x-souche-servicechain": get_servicechain(),
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
if __name__ == "__main__":
|
|
194
|
+
try:
|
|
195
|
+
print("🔑 正在通过 APP_KEY 获取 Token...")
|
|
196
|
+
token = get_token()
|
|
197
|
+
print(f"✅ Token 获取成功: {token[:30]}...")
|
|
198
|
+
print(f" 服务链: {get_servicechain()}")
|
|
199
|
+
except Exception as e:
|
|
200
|
+
print(f"{e}", file=sys.stderr)
|
|
201
|
+
print("\n💡 提示: 在服务器上 APP_KEY 已配置,无需手动设置", file=sys.stderr)
|
|
202
|
+
sys.exit(1)
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""大风车 CRM 客户读写客户端。
|
|
3
|
+
|
|
4
|
+
统一处理所有大风车 CRM 操作:
|
|
5
|
+
- 读操作(查重):POST danube-chord queryFindViewRecordPageInfo.json
|
|
6
|
+
- 写操作(新增):POST super-mario saveCustomer.json
|
|
7
|
+
|
|
8
|
+
认证方式:APP_KEY → Token(Souche-Security-Token header)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import ssl
|
|
13
|
+
import urllib.request
|
|
14
|
+
import urllib.error
|
|
15
|
+
from typing import Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
import mapping
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ============================================================
|
|
21
|
+
# API 端点
|
|
22
|
+
# ============================================================
|
|
23
|
+
QUERY_URL = "https://danube-chord.souche.com/generic/genericObjectAction/queryFindViewRecordPageInfo.json"
|
|
24
|
+
SAVE_CUSTOMER_URL = "https://super-mario.souche.com/pcbotwall/crm/customerObjectAction/saveCustomer.json"
|
|
25
|
+
|
|
26
|
+
COMMON_HEADERS = {
|
|
27
|
+
"Accept": "application/json, text/plain, */*",
|
|
28
|
+
"Accept-Language": "zh-CN,zh;q=0.9",
|
|
29
|
+
"Content-Type": "application/json;charset=UTF-8",
|
|
30
|
+
"Origin": "https://xindafengche.souche.com",
|
|
31
|
+
"Referer": "https://xindafengche.souche.com/",
|
|
32
|
+
"_source_code": "WEB",
|
|
33
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ============================================================
|
|
38
|
+
# 异常
|
|
39
|
+
# ============================================================
|
|
40
|
+
class DfcApiError(Exception):
|
|
41
|
+
"""大风车 API 异常。"""
|
|
42
|
+
def __init__(self, message: str, kind: str = "unknown", status: Optional[int] = None):
|
|
43
|
+
super().__init__(message)
|
|
44
|
+
self.message = message
|
|
45
|
+
self.kind = kind
|
|
46
|
+
self.status = status
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> Dict:
|
|
49
|
+
return {"kind": self.kind, "message": self.message, "status": self.status}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _classify_business_error(msg: str) -> str:
|
|
53
|
+
"""根据错误信息分类异常类型。"""
|
|
54
|
+
text = (msg or "").lower()
|
|
55
|
+
if any(key in text for key in ["token", "登录", "auth", "expired", "invalid token"]):
|
|
56
|
+
return "auth"
|
|
57
|
+
if any(key in text for key in ["必填", "参数", "格式", "校验", "validate"]):
|
|
58
|
+
return "validation"
|
|
59
|
+
if any(key in text for key in ["重复", "已存在", "duplicate", "exists"]):
|
|
60
|
+
return "duplicate"
|
|
61
|
+
return "business"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ============================================================
|
|
65
|
+
# SSL & HTTP
|
|
66
|
+
# ============================================================
|
|
67
|
+
def _ssl_context():
|
|
68
|
+
ctx = ssl.create_default_context()
|
|
69
|
+
ctx.check_hostname = False
|
|
70
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
71
|
+
return ctx
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _http_post(url: str, payload: Dict, token: str) -> Dict:
|
|
75
|
+
"""发 POST,返回完整响应;业务失败抛 DfcApiError。"""
|
|
76
|
+
headers = COMMON_HEADERS.copy()
|
|
77
|
+
headers["Souche-Security-Token"] = token
|
|
78
|
+
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
79
|
+
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
|
80
|
+
try:
|
|
81
|
+
with urllib.request.urlopen(req, timeout=30, context=_ssl_context()) as resp:
|
|
82
|
+
result = json.loads(resp.read().decode("utf-8"))
|
|
83
|
+
except urllib.error.HTTPError as e:
|
|
84
|
+
if e.code in (401, 403):
|
|
85
|
+
raise DfcApiError(f"HTTP {e.code}: {e.reason}", kind="auth", status=e.code)
|
|
86
|
+
raise DfcApiError(f"HTTP {e.code}: {e.reason}", kind="http", status=e.code)
|
|
87
|
+
except urllib.error.URLError as e:
|
|
88
|
+
raise DfcApiError(f"网络错误: {e}", kind="network")
|
|
89
|
+
if result.get("code") != "200":
|
|
90
|
+
msg = result.get("msg", "未知错误")
|
|
91
|
+
raise DfcApiError(msg, kind=_classify_business_error(msg))
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ============================================================
|
|
96
|
+
# 字段构造器(精简版,只保留同步流程中实际使用的字段)
|
|
97
|
+
# ============================================================
|
|
98
|
+
def _base(code, name, display_type, input_type="default", required=False, max_length=None,
|
|
99
|
+
match_case=None, mounted=True, display=True):
|
|
100
|
+
f = {
|
|
101
|
+
"builtIn": True, "canRead": True, "code": code,
|
|
102
|
+
"display": display, "displayType": display_type,
|
|
103
|
+
"editable": True, "inputType": input_type,
|
|
104
|
+
"name": name, "required": required, "type": "TEXT",
|
|
105
|
+
"mounted": mounted, "key": code, "validate": True, "validateMsg": "",
|
|
106
|
+
}
|
|
107
|
+
if max_length is not None:
|
|
108
|
+
f["maxLength"] = max_length
|
|
109
|
+
if match_case is not None:
|
|
110
|
+
f["matchCase"] = match_case
|
|
111
|
+
return f
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _text_field(code, name, content, max_length=255, match_case=False, display_type="short"):
|
|
115
|
+
f = _base(code, name, display_type, match_case=match_case, max_length=max_length)
|
|
116
|
+
f["type"] = "TEXT"
|
|
117
|
+
f["patterns"] = []
|
|
118
|
+
if content is not None:
|
|
119
|
+
f["content"] = content
|
|
120
|
+
return f
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _radio_field(code, name, code_chosen, elements_dict, display_type="radioButton"):
|
|
124
|
+
elements = [{"code": v, "name": k} for k, v in elements_dict.items()]
|
|
125
|
+
f = _base(code, name, display_type)
|
|
126
|
+
f["type"] = "RADIO"
|
|
127
|
+
f["codeChosen"] = code_chosen
|
|
128
|
+
f["elements"] = elements
|
|
129
|
+
return f
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _lookup_field(code, name, record_id, record_display, obj_code, target_obj_code,
|
|
133
|
+
required=False):
|
|
134
|
+
f = _base(code, name, "default", required=required)
|
|
135
|
+
f["type"] = "LOOKUP"
|
|
136
|
+
f["objCode"] = obj_code
|
|
137
|
+
f["targetObjCode"] = target_obj_code
|
|
138
|
+
f["recordId"] = record_id
|
|
139
|
+
f["recordDisplay"] = record_display
|
|
140
|
+
return f
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _create_type_field():
|
|
144
|
+
"""客户创建方式(必填),默认 PC-主动创建。"""
|
|
145
|
+
elements = [
|
|
146
|
+
{"code": "TfAFSLA4ES_option_value_CmAXtBu0M9", "name": "线索创建"},
|
|
147
|
+
{"code": "TfAFSLA4ES_option_value_zhwlM7FhH5", "name": "APP-主动创建"},
|
|
148
|
+
{"code": "TfAFSLA4ES_option_value_ZFA7I3dLUf", "name": "销售订单创建"},
|
|
149
|
+
{"code": "TfAFSLA4ES_option_value_swsV7sYozR", "name": "PC-主动创建"},
|
|
150
|
+
{"code": "TfAFSLA4ES_option_value_vKOkQdCzab", "name": "批量导入"},
|
|
151
|
+
{"code": "TfAFSLA4ES_option_value_tsK42eGzMd", "name": "通讯录导入"},
|
|
152
|
+
{"code": "TfAFSLA4ES_option_value_nksb0DdY6C", "name": "采购订单创建"},
|
|
153
|
+
{"code": "tA6f2zuK8A", "name": "企微销售助手创建"},
|
|
154
|
+
]
|
|
155
|
+
f = _base("customer_field_create_type", "客户创建方式", "picker")
|
|
156
|
+
f["type"] = "RADIO"
|
|
157
|
+
f["required"] = True
|
|
158
|
+
f["codeChosen"] = "TfAFSLA4ES_option_value_swsV7sYozR" # PC-主动创建
|
|
159
|
+
f["elements"] = elements
|
|
160
|
+
return f
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ============================================================
|
|
164
|
+
# 构建 fields 数组(精简版,只保留同步流程中实际使用的字段)
|
|
165
|
+
# ============================================================
|
|
166
|
+
def build_fields(data: dict) -> list:
|
|
167
|
+
"""
|
|
168
|
+
输入简化数据,返回 CRM 接口需要的完整 fields 数组。
|
|
169
|
+
|
|
170
|
+
实际使用的字段:
|
|
171
|
+
phone: 手机号(必填)
|
|
172
|
+
shop: {recordId, recordDisplay} 门店(必填)
|
|
173
|
+
name, source(中文名), gender(中文名), grade(H/A/B/C/N),
|
|
174
|
+
is_important(是/否), owner, intent, wechat
|
|
175
|
+
"""
|
|
176
|
+
fields = []
|
|
177
|
+
|
|
178
|
+
# 门店(必填)
|
|
179
|
+
shop = data.get("shop")
|
|
180
|
+
if shop:
|
|
181
|
+
fields.append(_lookup_field("customer_field_shop_code", "门店",
|
|
182
|
+
shop["recordId"], shop["recordDisplay"],
|
|
183
|
+
"scdo_shop", "scdo_shop", required=True))
|
|
184
|
+
|
|
185
|
+
# 销售
|
|
186
|
+
owner = data.get("owner")
|
|
187
|
+
if owner:
|
|
188
|
+
fields.append(_lookup_field("customer_field_owner", "销售",
|
|
189
|
+
owner["recordId"], owner["recordDisplay"],
|
|
190
|
+
"scdo_user", "scdo_user"))
|
|
191
|
+
|
|
192
|
+
# 手机号(必填)
|
|
193
|
+
if "phone" in data:
|
|
194
|
+
fields.append(_text_field("customer_field_phone", "手机号",
|
|
195
|
+
data["phone"], max_length=11, match_case=False))
|
|
196
|
+
|
|
197
|
+
# 姓名
|
|
198
|
+
if "name" in data and data["name"]:
|
|
199
|
+
fields.append(_text_field("customer_field_name", "姓名",
|
|
200
|
+
data["name"], max_length=255, match_case=True))
|
|
201
|
+
|
|
202
|
+
# 微信号
|
|
203
|
+
if "wechat" in data and data["wechat"]:
|
|
204
|
+
fields.append(_text_field("customer_field_weichat", "微信号",
|
|
205
|
+
data["wechat"], max_length=20, match_case=False))
|
|
206
|
+
|
|
207
|
+
# 性别
|
|
208
|
+
if "gender" in data:
|
|
209
|
+
gender_name = data["gender"]
|
|
210
|
+
code = mapping.GENDER.get(gender_name, mapping.GENDER["未知"])
|
|
211
|
+
fields.append(_radio_field("customer_field_gender", "性别", code, mapping.GENDER))
|
|
212
|
+
|
|
213
|
+
# 客户来源
|
|
214
|
+
if "source" in data:
|
|
215
|
+
source_name = data["source"]
|
|
216
|
+
code = mapping.SOURCE.get(source_name, mapping.SOURCE["其他"])
|
|
217
|
+
fields.append(_radio_field("customer_field_source", "客户来源", code, mapping.SOURCE, "picker"))
|
|
218
|
+
|
|
219
|
+
# 意向等级
|
|
220
|
+
if "grade" in data:
|
|
221
|
+
grade_name = data["grade"]
|
|
222
|
+
code = mapping.GRADE.get(grade_name, mapping.GRADE["C"])
|
|
223
|
+
fields.append(_radio_field("customer_field_grade", "意向等级", code, mapping.GRADE, "picker"))
|
|
224
|
+
|
|
225
|
+
# 重点客户
|
|
226
|
+
if "is_important" in data:
|
|
227
|
+
important_name = data["is_important"]
|
|
228
|
+
code = mapping.IMPORTANT.get(important_name, mapping.IMPORTANT["否"])
|
|
229
|
+
fields.append(_radio_field("customer_field_is_important", "重点客户", code, mapping.IMPORTANT))
|
|
230
|
+
|
|
231
|
+
# 意向描述
|
|
232
|
+
if "intent" in data and data["intent"]:
|
|
233
|
+
fields.append(_text_field("customer_field_intent", "意向描述",
|
|
234
|
+
data["intent"], max_length=1000, display_type="long"))
|
|
235
|
+
|
|
236
|
+
# 客户创建方式(必填)
|
|
237
|
+
fields.append(_create_type_field())
|
|
238
|
+
|
|
239
|
+
return fields
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ============================================================
|
|
243
|
+
# DfcClient 类(统一读写客户端)
|
|
244
|
+
# ============================================================
|
|
245
|
+
class DfcClient:
|
|
246
|
+
"""大风车 CRM 客户读写客户端(Token 认证)。"""
|
|
247
|
+
|
|
248
|
+
def __init__(self, token: str, shop_code: str, shop_name: str = "",
|
|
249
|
+
owner_id: str = "", owner_name: str = ""):
|
|
250
|
+
self.token = token
|
|
251
|
+
self.shop_code = shop_code
|
|
252
|
+
self.shop_name = shop_name
|
|
253
|
+
self.owner_id = owner_id
|
|
254
|
+
self.owner_name = owner_name
|
|
255
|
+
|
|
256
|
+
# ---- 读操作 ----
|
|
257
|
+
|
|
258
|
+
def phone_exists(self, phone: str) -> bool:
|
|
259
|
+
"""按手机号查大风车是否已有该客户。"""
|
|
260
|
+
payload = {"objCode": "customer", "pageNo": 1, "pageSize": 50, "keywords": phone}
|
|
261
|
+
try:
|
|
262
|
+
result = _http_post(QUERY_URL, payload, self.token)
|
|
263
|
+
data = result.get("data", {})
|
|
264
|
+
records = (data.get("common") or {}).get("records", [])
|
|
265
|
+
for rec in records:
|
|
266
|
+
for f in rec.get("fields", []):
|
|
267
|
+
if f.get("code") == "customer_field_phone" and f.get("value") == phone:
|
|
268
|
+
return True
|
|
269
|
+
return False
|
|
270
|
+
except DfcApiError:
|
|
271
|
+
raise
|
|
272
|
+
|
|
273
|
+
def phone_exists_result(self, phone: str) -> Dict:
|
|
274
|
+
"""按手机号查重,返回结构化结果。"""
|
|
275
|
+
try:
|
|
276
|
+
exists = self.phone_exists(phone)
|
|
277
|
+
return {"ok": True, "exists": exists, "error": None}
|
|
278
|
+
except DfcApiError as e:
|
|
279
|
+
return {"ok": False, "exists": None, "error": e.to_dict()}
|
|
280
|
+
|
|
281
|
+
def query_customer_by_phone(self, phone: str) -> Optional[dict]:
|
|
282
|
+
"""按手机号查询客户,返回第一个精确匹配的 record,或 None。"""
|
|
283
|
+
payload = {"objCode": "customer", "pageNo": 1, "pageSize": 20, "keywords": phone}
|
|
284
|
+
try:
|
|
285
|
+
result = _http_post(QUERY_URL, payload, self.token)
|
|
286
|
+
data = result.get("data", {})
|
|
287
|
+
records = (data.get("common") or {}).get("records", [])
|
|
288
|
+
for rec in records:
|
|
289
|
+
for field in rec.get("fields", []):
|
|
290
|
+
if field.get("code") == "customer_field_phone" and field.get("value") == phone:
|
|
291
|
+
return rec
|
|
292
|
+
return None
|
|
293
|
+
except DfcApiError:
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
# ---- 写操作 ----
|
|
297
|
+
|
|
298
|
+
def add_customer(self, lead: Dict, follower_mapping: Dict = None) -> Dict:
|
|
299
|
+
"""
|
|
300
|
+
新增客户(从巨懂车同步的 lead 数据)。
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
lead: 客户数据,包含 phone, name, source, grade, status, intent_model, follower 等
|
|
304
|
+
follower_mapping: 跟进人到销售的映射表 {"薛": {"recordId": "xxx", "recordDisplay": "薛"}}
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
{"ok": True, "customer_id": "..."} 或 {"ok": False, "error": {...}}
|
|
308
|
+
"""
|
|
309
|
+
# 映射字段
|
|
310
|
+
source_name = "懂车帝" # 固定填懂车帝,不做映射
|
|
311
|
+
grade_name = mapping.map_grade(lead.get("grade", ""), lead.get("status", ""))
|
|
312
|
+
gender_name = mapping.map_gender(lead.get("gender", ""))
|
|
313
|
+
|
|
314
|
+
# 构建意向描述
|
|
315
|
+
intent = ""
|
|
316
|
+
intent_model = (lead.get("intent_model") or "").strip()
|
|
317
|
+
if intent_model:
|
|
318
|
+
intent = f"巨懂车留资:{intent_model}"
|
|
319
|
+
|
|
320
|
+
# 构建简化数据
|
|
321
|
+
data = {
|
|
322
|
+
"phone": lead.get("phone", ""),
|
|
323
|
+
"name": lead.get("name", ""),
|
|
324
|
+
"source": source_name,
|
|
325
|
+
"grade": grade_name,
|
|
326
|
+
"gender": gender_name,
|
|
327
|
+
"is_important": lead.get("is_important", "否"),
|
|
328
|
+
"shop": {"recordId": self.shop_code, "recordDisplay": self.shop_name},
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
# 处理销售/跟进人映射
|
|
332
|
+
follower = (lead.get("follower") or "").strip()
|
|
333
|
+
if follower and follower_mapping and follower in follower_mapping:
|
|
334
|
+
owner_info = follower_mapping[follower]
|
|
335
|
+
data["owner"] = {"recordId": owner_info["recordId"], "recordDisplay": owner_info.get("recordDisplay", follower)}
|
|
336
|
+
elif self.owner_id:
|
|
337
|
+
data["owner"] = {"recordId": self.owner_id, "recordDisplay": self.owner_name}
|
|
338
|
+
|
|
339
|
+
if intent:
|
|
340
|
+
data["intent"] = intent
|
|
341
|
+
|
|
342
|
+
if lead.get("wechat"):
|
|
343
|
+
data["wechat"] = lead["wechat"]
|
|
344
|
+
|
|
345
|
+
# 构建 fields 并发送请求
|
|
346
|
+
fields = build_fields(data)
|
|
347
|
+
payload = {
|
|
348
|
+
"objCode": "customer",
|
|
349
|
+
"businessTypeCode": "customer_default_type",
|
|
350
|
+
"fields": fields,
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
result = _http_post(SAVE_CUSTOMER_URL, payload, self.token)
|
|
355
|
+
# _http_post 已经在 code != "200" 时抛出异常,成功返回即表示请求成功
|
|
356
|
+
resp_data = result.get("data", {})
|
|
357
|
+
customer_id = resp_data.get("customerId") or resp_data.get("id") or resp_data.get("recordId", "")
|
|
358
|
+
return {"ok": True, "customer_id": customer_id}
|
|
359
|
+
except DfcApiError as e:
|
|
360
|
+
return {"ok": False, "error": e.to_dict()}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ============================================================
|
|
364
|
+
# 兼容函数
|
|
365
|
+
# ============================================================
|
|
366
|
+
def check_dfc_login(user_data_dir: str) -> Dict:
|
|
367
|
+
"""检查大风车登录态 — Token 模式下始终返回 True(只要 APP_KEY 有效)。"""
|
|
368
|
+
try:
|
|
369
|
+
import auth
|
|
370
|
+
token = auth.get_token()
|
|
371
|
+
if token:
|
|
372
|
+
return {"logged_in": True, "source": "app_key_token"}
|
|
373
|
+
except Exception as e:
|
|
374
|
+
return {"logged_in": False, "reason": str(e)}
|
|
375
|
+
return {"logged_in": False, "reason": "APP_KEY 无效"}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
if __name__ == "__main__":
|
|
379
|
+
# 测试
|
|
380
|
+
import auth
|
|
381
|
+
token = auth.get_token()
|
|
382
|
+
account = auth.get_account_info(token)
|
|
383
|
+
|
|
384
|
+
client = DfcClient(
|
|
385
|
+
token=token,
|
|
386
|
+
shop_code=account["shopCode"],
|
|
387
|
+
shop_name=account["shopName"],
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
# 测试查重
|
|
391
|
+
result = client.phone_exists_result("13800138099")
|
|
392
|
+
print(f"查重结果: {json.dumps(result, ensure_ascii=False, indent=2)}")
|