# -*- coding: utf-8 -*- """seed-customer.py — 客户模块 E2E 联调数据种子(票 03) 前缀约定: - 客户/商机/视图名统一 `e2c-` 前缀(客户域专属命名空间,**不撞商机域 seed 的 `e2e-`**) - 幂等 = 先物理清 e2c- 命名空间再重建;他人数据与 KH20260903xxx 冒烟残留不动 造数通道:API 优先(POST /api/customer 等),API 不可达的行 DB 直插/直拨(stage 2/3、 owner 换绑、过期 follow、超期锚点)——每处直插在 seed-ids.json 登记。 守卫:customer < 3 行或 e2c- 命中 > 60 行 → 疑似连错库,中止。 用法:py -X utf8 .scratch/customer-e2e/seed-customer.py [--keep] # --keep 跳过清理只补造 """ import sys, io, json, time sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') import requests import pymysql BASE = 'http://localhost:8080' ADMIN = '739564171091247104' # 罗伟健 ROLE_ADMIN dept=744841292348915712 BUDDY = '744842318024015872' # 曾偲青(职员部 744841292483133440,无角色——作协同/成员对象) BUDDY_NAME, BUDDY_DEPT, BUDDY_DEPT_NAME = '曾偲青', '744841292483133440', '职员' PFX = 'e2c-' IDS_FILE = '.scratch/customer-e2e/seed-ids.json' CONF = dict(host='8.129.84.155', port=3306, user='root', password='Itc@123456', database='crm', charset='utf8mb4', autocommit=True, connect_timeout=10, cursorclass=pymysql.cursors.DictCursor) ids = {'admin': ADMIN, 'buddy': BUDDY, 'customers': {}, 'contacts': {}, 'opportunities': {}, 'follows': {}, 'dbPatches': [], 'notes': []} def db(): return pymysql.connect(**CONF) def dbq(sql, args=None, fetch=True): with db() as conn, conn.cursor() as cur: cur.execute(sql, args) return cur.fetchall() if fetch else cur.rowcount def get_token(uid): r = requests.get(f'{BASE}/api/auth/debug/token', params={'userId': uid}, timeout=30) r.raise_for_status() d = r.json().get('data') tok = d if isinstance(d, str) else (d or {}).get('token') assert tok, f'debug token 异常: {r.text[:200]}' return tok S = requests.Session() S.headers['Authorization'] = f'Bearer {get_token(ADMIN)}' def api(method, path, form=None, json_body=None, params=None, step=''): """统一请求;业务失败返回 None 并打印(不中断,末尾汇总决定 exit code)。""" url = BASE + path try: if json_body is not None: r = S.post(url, json=json_body, params=params, timeout=30) elif method == 'POST': r = S.post(url, data=form, params=params, timeout=30) elif method == 'PUT': r = S.put(url, data=form, params=params, timeout=30) else: r = S.request(method, url, params=params, timeout=30) except Exception as e: print(f' ✘ {step}: 网络异常 {e}') return None if r.status_code != 200: print(f' ✘ {step}: HTTP {r.status_code} {r.text[:160]}') return None body = r.json() code = body.get('code') if code not in (0, '0', 200, '200'): print(f' ✘ {step}: code={code} {str(body.get("message"))[:160]}') return None return body.get('data') # ---------------- 字典 code 动态取 ---------------- def dict_codes(group, limit=5): rows = dbq( "SELECT i.code FROM dict_item i JOIN dict_group g ON i.group_id=g.id " "WHERE g.code=%s AND i.deleted=0 ORDER BY i.sort_no LIMIT %s", (group, limit)) assert rows, f'字典组 {group} 为空' return [r['code'] for r in rows] print('== 0. 字典准备 ==') CTYPES = dict_codes('customer_type') IND1 = dbq("SELECT i.code, i.id FROM dict_item i JOIN dict_group g ON i.group_id=g.id " "WHERE g.code='industry' AND i.deleted=0 AND i.parent_id IS NULL ORDER BY i.sort_no LIMIT 3") IND_CHILD = dbq("SELECT i.code FROM dict_item i JOIN dict_group g ON i.group_id=g.id " "WHERE g.code='industry' AND i.deleted=0 AND i.parent_id=%s ORDER BY i.sort_no LIMIT 1", (IND1[0]['id'],)) FOLLOW_WAY = dict_codes('follow_way') print(f' customer_type={CTYPES[:3]}... industry1={IND1[0]["code"]} child={IND_CHILD or "无"} follow_way={FOLLOW_WAY}') # ---------------- 1. 清理 ---------------- if '--keep' not in sys.argv: print('== 1. 清理 e2c- 命名空间 ==') n_all = dbq('SELECT COUNT(*) c FROM customer')[0]['c'] assert n_all >= 3, f'customer 仅 {n_all} 行,疑似连错库,中止' cust = dbq("SELECT id, customer_name FROM customer WHERE customer_name LIKE %s", (PFX + '%',)) assert len(cust) <= 60, f'e2c- 命中 {len(cust)} 行超守卫阈值 60,中止' cids = [str(r['id']) for r in cust] ph = ','.join(['%s'] * len(cids)) if cids else None with db() as conn, conn.cursor() as cur: if cids: # 商机侧:先绑定行后商机(仅 e2c- 商机与绑定到 e2c 客户的行) cur.execute(f"DELETE FROM opportunity_customer WHERE customer_id IN ({ph})", cids) print(f' 清 opportunity_customer {cur.rowcount}') cur.execute("DELETE FROM opportunity WHERE opp_name LIKE %s", (PFX + '%',)) print(f' 清 opportunity(e2c-) {cur.rowcount}') # 客户从表 for t in ['customer_contact', 'customer_oplog', 'customer_team_member', 'customer_follow', 'customer_pending_notice']: cur.execute(f'DELETE FROM {t} WHERE customer_id IN ({ph})', cids) print(f' 清 {t} {cur.rowcount}') for t in ['customer_focus', 'customer_view_log']: cur.execute(f'DELETE FROM {t} WHERE customer_id IN ({ph})', cids) print(f' 清 {t} {cur.rowcount}') # 交割单(经 detail 反查 e2c 客户) cur.execute(f'SELECT DISTINCT transfer_id FROM customer_transfer_detail WHERE customer_id IN ({ph})', cids) tids = [r['transfer_id'] for r in cur.fetchall()] if tids: ph2 = ','.join(['%s'] * len(tids)) cur.execute(f'DELETE FROM customer_transfer_detail WHERE transfer_id IN ({ph2})', tids) cur.execute(f'DELETE FROM customer_transfer WHERE id IN ({ph2})', tids) print(f' 清 customer_transfer {len(tids)} 单') cur.execute(f'DELETE FROM customer WHERE id IN ({ph})', cids) print(f' 清 customer {cur.rowcount}') cur.execute("DELETE FROM user_saved_view WHERE name LIKE %s AND user_id=%s", (PFX + '%', ADMIN)) print(f' 清 user_saved_view(e2c-) {cur.rowcount}') print(' 清理完成') else: print('== 1. --keep 跳过清理 ==') # ---------------- 2. 造客户(API) ---------------- print('== 2. 造客户 ==') CITY = ('440000', '440100', '440103') # 广东 广州 荔湾(sys_region 3432 条国标码) def make_customer(key, name, **extra): form = dict(customerName=name, customerType=CTYPES[0], provinceCode=CITY[0], cityCode=CITY[1], districtCode=CITY[2], industryCode=IND1[0]['code'], customerStarLevel=3, relationStarLevel=3, isBizNegotiated=0, isChild=0, ownerUserId=ADMIN, confirmSimilar='true', remark='票03 seed 造数样例(前端联调数据)') if IND_CHILD: form['industryChildCode'] = IND_CHILD[0]['code'] form.update(extra) d = api('POST', '/api/customer', form=form, step=f'create {name}') if d is None: return None if isinstance(d, dict) and d.get('needConfirm'): print(f' ✘ {name}: 命中相似需确认(confirmSimilar 未生效): {str(d.get("similarHits"))[:120]}') return None cid = str(d.get('id')) if isinstance(d, dict) else str(d) no = (d.get('customerNo') if isinstance(d, dict) else None) or \ dbq('SELECT customer_no FROM customer WHERE id=%s', (cid,))[0]['customer_no'] print(f' ✔ {name} id={cid} no={no}') ids['customers'][key] = {'id': cid, 'no': no, 'name': name} return cid make_customer('stage1', PFX + '阶段-潜在-甲') make_customer('stage2', PFX + '阶段-重潜-乙', customerStarLevel=4) make_customer('stage3', PFX + '阶段-成交-丙', customerStarLevel=5, relationStarLevel=4) make_customer('star5', PFX + '星级-五星', customerStarLevel=5, relationStarLevel=5) make_customer('full', PFX + '全字段-科技', unifiedCreditCode='91440101E2CTEST001', legalRepresentative='王测试', establishedDate='2018-05-20', registeredCapital='1000万元人民币', businessScope='软件开发;信息系统集成', staffSize='50-99人', annualRevenue='5000万元', businessAddress='广州市荔湾区测试路1号', companyDecisionMaker='王总', joinedPresidentClass=1, presidentClassPerson='王总', presidentClassPhone='13800000001', networkUnits=json.dumps(['标杆客户', '同行推荐']), cooperationSystem='OA对接', isBizNegotiated=1) make_customer('simA', PFX + '恒信达科技有限公司', unifiedCreditCode='91440101E2CHXDA001') make_customer('simB', PFX + '恒信达科技有限责任公司', unifiedCreditCode='91440101E2CHXDB001') make_customer('followFuture', PFX + '跟进-待办') make_customer('followOld', PFX + '提醒-超期') make_customer('collab', PFX + '协同-样例') make_customer('member', PFX + '成员-样例') make_customer('transfer1', PFX + '交割-甲') make_customer('transfer2', PFX + '交割-乙') make_customer('transfer3', PFX + '交割-丙') # 公海(不传 ownerUserId → enter_pool_time 记当下) make_customer('pool1', PFX + '公海-甲', ownerUserId=None) make_customer('pool2', PFX + '公海-乙', ownerUserId=None) # 归档样例 arch = make_customer('archived', PFX + '已归档') if arch: ok = api('POST', '/api/customer/archive', params={'id': arch}, step='archive 已归档') if ok is not None: print(' ✔ archived 归档完成') # ---------------- 3. DB 直拨(API 不可达行) ---------------- print('== 3. DB 直拨 ==') C = ids['customers'] if C.get('stage2'): dbq('UPDATE customer SET customer_stage=2 WHERE id=%s', (C['stage2']['id'],), fetch=False) ids['dbPatches'].append('stage2 customer_stage=2') if C.get('stage3'): dbq('UPDATE customer SET customer_stage=3 WHERE id=%s', (C['stage3']['id'],), fetch=False) ids['dbPatches'].append('stage3 customer_stage=3') if C.get('collab'): dbq('UPDATE customer SET owner_user_id=%s, owner_user_name_snapshot=%s, owner_dept_id=%s, ' 'owner_dept_name_snapshot=%s WHERE id=%s', (BUDDY, BUDDY_NAME, BUDDY_DEPT, BUDDY_DEPT_NAME, C['collab']['id']), fetch=False) ok = api('POST', f"/api/customer/{C['collab']['id']}/members", form={'memberUserIds': ADMIN}, step='members collab+admin') ids['dbPatches'].append(f"collab owner→曾偲青 + admin 成员(ok={ok is not None})") if C.get('followOld'): dbq('UPDATE customer SET last_valid_follow_time=NOW()-INTERVAL 30 DAY WHERE id=%s', (C['followOld']['id'],), fetch=False) ids['dbPatches'].append('followOld last_valid_follow_time=30天前(Job 超期样例)') # ---------------- 4. 跟进(API 未来 + DB 直插过期) ---------------- print('== 4. 跟进样例 ==') if C.get('followFuture'): from datetime import datetime, timedelta # LocalDateTime form 绑定按 ISO_LOCAL_DATE_TIME(T 分隔),空格分隔会 400 nxt = (datetime.now() + timedelta(days=1)).strftime('%Y-%m-%dT%H:%M:%S') ok = api('POST', f"/api/customer/{C['followFuture']['id']}/follow", form={'followWay': FOLLOW_WAY[0], 'followContent': 'seed 待办样例:明天回访确认需求', 'nextFollowTime': nxt}, step='follow followFuture') if ok is not None: print(f' ✔ followFuture 待跟进(next={nxt})') if C.get('followOld'): # 直插过期 follow(供 FOLLOW_UP_DUE 视图 EXISTS 命中) with db() as conn, conn.cursor() as cur: cur.execute('SELECT COUNT(*) c FROM customer_follow WHERE customer_id=%s', (C['followOld']['id'],)) if cur.fetchone()['c'] == 0: import time as _t fid = int(_t.time() * 1000) * 1000 + 7 cur.execute( 'INSERT INTO customer_follow (id, customer_id, follow_way, follow_content, ' 'next_follow_time, follow_by, follow_by_name, follow_dept_name, ' 'creator_id, create_time, updater_id, update_time, deleted) VALUES ' '(%s,%s,%s,%s,NOW()-INTERVAL 2 DAY,%s,%s,%s,%s,NOW()-INTERVAL 7 DAY,%s,NOW()-INTERVAL 7 DAY,0)', (fid, C['followOld']['id'], FOLLOW_WAY[0], 'seed 直插:已过期下次跟进(FOLLOW_UP_DUE 视图素材)', ADMIN, '罗伟健', '广东保伦电子股份有限公司', ADMIN, ADMIN)) print(f' ✔ 直插过期 follow id={fid}') ids['follows']['followOldExpired'] = str(fid) ids['dbPatches'].append('followOld 直插过期 follow(next_follow_time=2天前)') else: print(' ⊘ followOld 已有 follow(--keep 重跑),跳过') # ---------------- 5. 商机关联样例(API,商机域) ---------------- print('== 5. 商机关联样例 ==') def make_opp(key, name, bind_key=None, role='customer_role_01', primary=1): form = dict(opportunityName=name, oppSource='opp_source_02', industryCode=IND1[0]['code'], localityType='locality_type_01', bidForm='bid_form_01', provinceCode=CITY[0], cityCode=CITY[1], partyAClear=1, partyA='e2c甲方·' + name, remark='票03 seed 商机关联样例') d = api('POST', '/api/opportunity', form=form, step=f'opp {name}') if d is None: return None oid = str(d) print(f' ✔ {name} id={oid}') ids['opportunities'][key] = {'id': oid, 'name': name} if bind_key and C.get(bind_key): d2 = api('POST', '/api/opportunity/customer/add', form={'oppId': oid, 'customerId': C[bind_key]['id'], 'customerNameSnapshot': C[bind_key]['name'], 'customerRole': role, 'isPrimaryIntended': primary}, step=f'bind {name}←{C[bind_key]["name"]}') if d2 is not None: print(f' ✔ 绑定 {C[bind_key]["name"]}(primary={primary})') return oid # 交割-乙 绑进行中商机 → F-11 抛公海 67007 拦截样例;交割-甲 绑 → F-13 owner 联动样例 make_opp('oppBlock', PFX + '联动-进行中', bind_key='transfer2') make_opp('oppTransfer', PFX + '联动-交割', bind_key='transfer1') # ---------------- 6. 联系人(API) ---------------- print('== 6. 联系人样例 ==') def make_contact(key, cust_key, name, **extra): if not C.get(cust_key): return None form = dict(customerId=C[cust_key]['id'], name=name, jobTitleName=extra.pop('jobTitleName', '经理'), isKeyContact=0, isInternal=0) form.update(extra) d = api('POST', '/api/customer/contact', form=form, step=f'contact {name}') if d is None: return None print(f' ✔ {name} → {C[cust_key]["name"]} id={d}') ids['contacts'][key] = {'id': str(d), 'name': name, 'customerKey': cust_key} return d make_contact('key1', 'full', '张关键', phone='13812340001', isKeyContact=1, isInternal=1, jobTitleName='采购总监', giftRemark='偏好茶叶', source='contact_source_01') make_contact('normal1', 'full', '李普通', phone='13812340002', jobTitleName='工程师') make_contact('dupPhone', 'full', '王重复', phone='13812340001', jobTitleName='副总') # check-phone 命中样例 make_contact('other1', 'simA', '赵外线', phone='13812340003', jobTitleName='法人') # ---------------- 7. 库检验证 + 落盘 ---------------- print('== 7. 库检验证 ==') chk = [] chk.append(('e2c- 客户总数', dbq("SELECT COUNT(*) c FROM customer WHERE customer_name LIKE %s", (PFX + '%',))[0]['c'], 17)) chk.append(('公海(owner空)', dbq("SELECT COUNT(*) c FROM customer WHERE customer_name LIKE %s AND owner_user_id IS NULL", (PFX + '%',))[0]['c'], 2)) chk.append(('归档', dbq("SELECT COUNT(*) c FROM customer WHERE customer_name LIKE %s AND archive_status=2", (PFX + '%',))[0]['c'], 1)) chk.append(('stage 分布', sorted(r['customer_stage'] for r in dbq( "SELECT customer_stage FROM customer WHERE customer_name LIKE %s AND deleted=0", (PFX + '%',))), [1] * 15 + [2, 3])) chk.append(('联系人', dbq("SELECT COUNT(*) c FROM customer_contact ct JOIN customer c ON c.id=ct.customer_id WHERE c.customer_name LIKE %s", (PFX + '%',))[0]['c'], 4)) chk.append(('e2c- 商机', dbq("SELECT COUNT(*) c FROM opportunity WHERE opp_name LIKE %s AND deleted=0", (PFX + '%',))[0]['c'], 2)) chk.append(('商机绑定', dbq("SELECT COUNT(*) c FROM opportunity_customer oc JOIN customer cu ON cu.id=oc.customer_id WHERE cu.customer_name LIKE %s", (PFX + '%',))[0]['c'], 2)) fails = 0 for label, got, want in chk: mark = '✔' if got == want else '✘' if got != want: fails += 1 print(f' {mark} {label}: {got}(期望 {want})') with open(IDS_FILE, 'w', encoding='utf-8') as f: json.dump(ids, f, ensure_ascii=False, indent=2) print(f'\n== seed-ids.json 落盘 → {IDS_FILE} ==') print('数据矩阵摘要:') for k, v in C.items(): print(f" {k}: {v['name']} id={v['id']} no={v['no']}") print(f'\n{"ALL GREEN" if fails == 0 else f"{fails} 项校验不符"}') sys.exit(0 if fails == 0 else 1)