You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
971 lines
54 KiB
971 lines
54 KiB
# -*- coding: utf-8 -*-
|
|
"""e2e-heavy-r2.py — 客户重流程 API 级实测(第二轮,r2 票 01:契约改写自 round-1 e2e-heavy.py)
|
|
|
|
F-13 交割 / F-14 导入 / F-15 提醒规则 / F-16 查重消费侧
|
|
产出:.scratch/customer-e2e-r2/ 下 e2e-heavy-r2-checks.json + specimens-heavy-r2.json + e2e-heavy-r2-defects.json
|
|
输入:seed-ids.json(round-1 票 03);e2e-core-r2.py 同款框架(契约改写版基建)
|
|
改写基准:customer-rework 后扁平动作动词契约;契约权威 .scratch/customer-module/API-SUMMARY.md(73 端点)
|
|
纪律:flat 实路径;Result<Void> 判 code;401 重取 token;缺陷从 D-04 起(round-1 编号沿用,新缺陷续编);
|
|
缺陷用例按「缺陷验证模式」记录(预期失败、不阻断、不重复排查);库检每步新连接 autocommit=True。
|
|
"""
|
|
import sys, io, json, time, re, os, random, subprocess, datetime
|
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
|
import requests
|
|
import pymysql
|
|
|
|
BASE = 'http://localhost:8080'
|
|
ADMIN = '739564171091247104' # 罗伟健(根部门「广东保伦」,data_scope=4)
|
|
BUDDY = '744842318024015872' # 曾偲青(无角色;f13 交割发起人)
|
|
IDS = json.load(open('.scratch/customer-e2e/seed-ids.json', encoding='utf-8'))
|
|
C = {k: v['id'] for k, v in IDS['customers'].items()}
|
|
NO = {k: v['no'] for k, v in IDS['customers'].items()}
|
|
OPP = {k: v['id'] for k, v in IDS['opportunities'].items()}
|
|
OUT = '.scratch/customer-e2e-r2/'
|
|
|
|
DEPT_SEED = 744700334353416192 # 华南销售部(seed):f13 接收总监推导部门
|
|
DEPT_BUDDY_ORIG = 744841292483133440 # 职员部:BUDDY 原部门(teardown 还原)
|
|
SALES_ID = '760000000000000001' # e2c-销售甲 固定 id(幂等复用;字符串便于与 API/DB 比对)
|
|
SALES_NAME = 'e2c-销售甲'
|
|
# r2:起服 JVM 候选(换机容错——round-1 钉死 IDEA JBR 路径,机器变更即断;PATH java 兜底)
|
|
JAVA_CANDIDATES = [
|
|
r'D:\IntelliJ IDEA 2025.1.3\jbr\bin\java.exe',
|
|
r'C:\Program Files\Java\jdk-17\bin\java.exe',
|
|
]
|
|
JAVA_EXE = next((p for p in JAVA_CANDIDATES if os.path.exists(p)), 'java')
|
|
PFX = 'e2c-'
|
|
TS = datetime.datetime.now().strftime('%H%M%S')
|
|
|
|
checks, defects, specimens = [], [], {}
|
|
_skips = set()
|
|
|
|
|
|
def check(flow, case, verdict, detail=''):
|
|
checks.append({'flow': flow, 'case': case,
|
|
'verdict': {'pass': '✅', 'warn': '⚠', 'fail': '❌'}[verdict], 'detail': detail})
|
|
print(f" {verdict} [{flow}] {case}" + (f' — {detail}' if detail else ''))
|
|
|
|
|
|
def defect(did, severity, title, detail):
|
|
defects.append({'id': did, 'severity': severity, 'title': title, 'detail': detail})
|
|
print(f" ⚑ 登记 {did}({severity}): {title}")
|
|
|
|
|
|
def db():
|
|
return pymysql.connect(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)
|
|
|
|
|
|
def dbq(sql, args=None):
|
|
with db() as conn, conn.cursor() as cur:
|
|
cur.execute(sql, args)
|
|
return cur.fetchall()
|
|
|
|
|
|
def dbx(sql, args=None):
|
|
with db() as conn, conn.cursor() as cur:
|
|
cur.execute(sql, args)
|
|
return cur.rowcount
|
|
|
|
|
|
def get_token(uid, tries=3):
|
|
# debug/token 偶发瞬时故障(Redis Connection reset)→ 重试 + fail fast(round-1 票 04 教训)。
|
|
last = None
|
|
for _ in range(tries):
|
|
try:
|
|
r = requests.get(f'{BASE}/api/auth/debug/token', params={'userId': uid}, timeout=30)
|
|
if r.status_code == 200:
|
|
d = r.json().get('data')
|
|
tok = d if isinstance(d, str) else (d or {}).get('token')
|
|
if tok:
|
|
return tok
|
|
last = f'body={r.text[:120]}'
|
|
else:
|
|
last = f'HTTP {r.status_code} {r.text[:120]}'
|
|
except Exception as e:
|
|
last = str(e)
|
|
time.sleep(2)
|
|
raise RuntimeError(f'debug/token 获取失败 userId={uid}: {last}')
|
|
|
|
|
|
S = requests.Session()
|
|
S.headers['Authorization'] = f'Bearer {get_token(ADMIN)}'
|
|
B = requests.Session()
|
|
B.headers['Authorization'] = f'Bearer {get_token(BUDDY)}'
|
|
|
|
|
|
def _capture(method, path, req, resp_body):
|
|
path = re.sub(r'/\d{15,}', '/{id}', path) # 兜底归一化(新契约无路径 id,保留无害)
|
|
key = f'{method} {path}'
|
|
if key in _skips:
|
|
return
|
|
_skips.add(key)
|
|
is_json = isinstance(req, (dict, list)) and req is not None and method != 'FORM'
|
|
specimens[key] = {
|
|
'request': {'contentType': 'application/json' if is_json else 'application/x-www-form-urlencoded',
|
|
'body': req if is_json else req},
|
|
'response': resp_body,
|
|
}
|
|
|
|
|
|
def api(sess, method, path, form=None, json_body=None, params=None, step='', retry401=True):
|
|
url = BASE + path
|
|
try:
|
|
if json_body is not None:
|
|
r = sess.post(url, json=json_body, params=params, timeout=30)
|
|
elif method == 'POST':
|
|
r = sess.post(url, data=form, params=params, timeout=30)
|
|
else:
|
|
r = sess.request(method, url, params=params, timeout=30)
|
|
except Exception as e:
|
|
print(f' ✘ {step}: 网络异常 {e}')
|
|
return None
|
|
if r.status_code == 401 and retry401:
|
|
uid = BUDDY if sess is B else ADMIN
|
|
sess.headers['Authorization'] = f'Bearer {get_token(uid)}'
|
|
return api(sess, method, path, form, json_body, params, step, retry401=False)
|
|
if r.status_code != 200:
|
|
_capture(method, path, json_body if json_body is not None else form,
|
|
{'_http': r.status_code, '_raw': r.text[:400]})
|
|
print(f' ✘ {step}: HTTP {r.status_code} {r.text[:140]}')
|
|
return {'_http': r.status_code, 'code': r.status_code, 'message': r.text[:200]}
|
|
try:
|
|
body = r.json()
|
|
except ValueError:
|
|
_capture(method, path, json_body if json_body is not None else form, {'_raw': r.text[:400]})
|
|
print(f' ✘ {step}: 响应非 JSON')
|
|
return None
|
|
_capture(method, path, json_body if json_body is not None else form, body)
|
|
return body
|
|
|
|
|
|
def api_raw(sess, method, path, **kw):
|
|
"""不经 _capture 的裸请求(二进制/表单文件上传/自定义头检查用)。"""
|
|
return sess.request(method, BASE + path, timeout=60, **kw)
|
|
|
|
|
|
def data_of(resp):
|
|
return resp.get('data') if isinstance(resp, dict) else None
|
|
|
|
|
|
def code_of(resp):
|
|
return resp.get('code') if isinstance(resp, dict) else None
|
|
|
|
|
|
def expect_code(resp, want, flow, case):
|
|
got = code_of(resp)
|
|
if got is not None and int(got) == want:
|
|
check(flow, case, 'pass', f'code={got} 如预期')
|
|
return True
|
|
check(flow, case, 'fail', f'期望 code={want},实际 code={got} msg={str(resp.get("message") if isinstance(resp, dict) else resp)[:120]}')
|
|
return False
|
|
|
|
|
|
def dict_one(group, parent_id=None):
|
|
try:
|
|
if parent_id:
|
|
rows = dbq("SELECT i.code c FROM dict_item i JOIN dict_group g ON i.group_id=g.id "
|
|
"WHERE g.code=%s AND i.deleted=0 AND i.parent_id=%s ORDER BY i.sort_no LIMIT 1",
|
|
(group, parent_id))
|
|
else:
|
|
rows = dbq("SELECT i.code c FROM dict_item i JOIN dict_group g ON i.group_id=g.id "
|
|
"WHERE g.code=%s AND i.deleted=0 AND i.parent_id IS NULL ORDER BY i.sort_no LIMIT 1",
|
|
(group,))
|
|
return rows[0]['c'] if rows else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
CTYPE = dict_one('customer_type') or 'customer_type_01'
|
|
GOV = dict_one('industry') or 'gov'
|
|
FOLLOW_WAY = dict_one('follow_way') or 'phone'
|
|
|
|
|
|
# ==================== setup / teardown ====================
|
|
|
|
_g = {'sales_id': SALES_ID, 'buddy_leftover': None, 'transfer_id': None, 'transfer_no': None,
|
|
'sales_assignable': None}
|
|
|
|
|
|
def setup_heavy():
|
|
"""f13 前提:华南销售部 leader=admin;seed 销售甲;BUDDY 划入销售部;甲乙丙(+BUDDY 残留)圈定。"""
|
|
print('== setup_heavy ==')
|
|
# 1) 华南销售部 leader=admin(resolveDirector 数据前提,teardown 还原 NULL)
|
|
dbx("UPDATE sys_dept SET leader_user_id=%s WHERE id=%s", (int(ADMIN), DEPT_SEED))
|
|
check('SETUP', '华南销售部 leader=admin', 'pass')
|
|
# 2) seed 销售甲(幂等:固定 id 先删后插,动态补齐非空列)
|
|
cols = dbq("SHOW COLUMNS FROM crm_auth_user")
|
|
row = {c['Field']: None for c in cols}
|
|
row.update({'id': int(SALES_ID), 'username': SALES_NAME, 'dept_id': DEPT_SEED,
|
|
'enabled': 1, 'employment_status': 'active', 'deleted': 0})
|
|
notnull = [c['Field'] for c in cols if c['Null'] == 'NO' and c['Default'] is None and c['Extra'] == '']
|
|
missing = [c for c in notnull if row.get(c) is None]
|
|
if missing:
|
|
raise RuntimeError(f'crm_auth_user 缺不可空列无法 seed 销售甲: {missing}')
|
|
dbx("DELETE FROM crm_auth_user WHERE id=%s", (SALES_ID,))
|
|
dbx(f"INSERT INTO crm_auth_user ({','.join(row)}) VALUES ({','.join(['%s'] * len(row))})",
|
|
tuple(row.values()))
|
|
check('SETUP', 'seed 销售甲(华南销售部/在职/启用)', 'pass', f'id={SALES_ID}')
|
|
# 3) BUDDY 划入华南销售部(teardown 还原职员部)
|
|
dbx("UPDATE crm_auth_user SET dept_id=%s WHERE id=%s", (DEPT_SEED, BUDDY))
|
|
# 4) 甲乙丙 assign 给 BUDDY(API 路径,owner→BUDDY 才能由 BUDDY 发起交割)
|
|
for k in ('transfer1', 'transfer2', 'transfer3'):
|
|
r = api(S, 'POST', '/api/customer/assign',
|
|
params={'id': C[k], 'userId': BUDDY}, step=f'assign {k}→BUDDY')
|
|
if code_of(r) != 0:
|
|
raise RuntimeError(f'setup assign {k} 失败: {r}')
|
|
check('SETUP', '甲乙丙 assign→BUDDY', 'pass')
|
|
# 5) BUDDY 名下全量(=交接单圈定范围:甲乙丙 + 残留)
|
|
left = dbq("SELECT id, customer_name FROM customer WHERE owner_user_id=%s AND deleted=0 "
|
|
"AND archive_status=1", (BUDDY,))
|
|
_g['buddy_leftover'] = [{'id': str(r['id']), 'name': r['customer_name']} for r in left]
|
|
print(f" BUDDY 名下 {len(left)} 条:{[x['name'] for x in _g['buddy_leftover']]}")
|
|
|
|
|
|
def teardown_heavy():
|
|
"""还原:客户归属/商机 owner→admin;BUDDY 部门;部门 leader。交接单/销售甲留痕(报告注明)。"""
|
|
print('== teardown_heavy ==')
|
|
n = 0
|
|
for k in ('transfer1', 'transfer2', 'transfer3'):
|
|
n += dbx("UPDATE customer SET owner_user_id=%s, owner_user_name_snapshot='罗伟健', "
|
|
"owner_dept_id=744841292348915712, owner_dept_name_snapshot='广东保伦电子股份有限公司', "
|
|
"enter_pool_time=NULL WHERE id=%s", (ADMIN, C[k]))
|
|
if _g.get('buddy_leftover'):
|
|
for x in _g['buddy_leftover']:
|
|
n += dbx("UPDATE customer SET owner_user_id=%s, owner_user_name_snapshot='罗伟健', "
|
|
"owner_dept_id=744841292348915712, owner_dept_name_snapshot='广东保伦电子股份有限公司', "
|
|
"enter_pool_time=NULL WHERE id=%s", (ADMIN, x['id']))
|
|
dbx("UPDATE opportunity o JOIN opportunity_customer oc ON oc.opportunity_id=o.id "
|
|
"SET o.owner_user_id=%s, o.owner_name_snapshot='罗伟健', o.owner_dept_id=744841292348915712 "
|
|
"WHERE oc.customer_id IN (%s,%s,%s) AND oc.delete_key=0",
|
|
(ADMIN, C['transfer1'], C['transfer2'], C['transfer3']))
|
|
dbx("UPDATE crm_auth_user SET dept_id=%s WHERE id=%s", (DEPT_BUDDY_ORIG, BUDDY))
|
|
dbx("UPDATE sys_dept SET leader_user_id=NULL WHERE id=%s", (DEPT_SEED,))
|
|
print(f' 还原客户 owner {n} 条;商机 owner 已还原 admin;BUDDY 部门/leader 已还原')
|
|
|
|
|
|
# ==================== F-13 客户交割 ====================
|
|
# r2 改写:GET /transfer/{tid} → GET /transfer/detail?id=;GET /transfer/{tid}/assignable → GET /transfer/assignable?id=;
|
|
# POST /transfer/{tid}/assign(form 多值)→ POST /transfer/assign?id=&customerIds=&assignUserId=(params 多值,List<Long> 绑定)
|
|
|
|
def f13():
|
|
print('== F-13 客户交割 ==')
|
|
F = 'F-13'
|
|
total_scope = len(_g['buddy_leftover']) # 交接单圈定 = BUDDY 名下全量(甲乙丙 + 残留)
|
|
|
|
# 13.1 preview:发起前名下可交接客户
|
|
r = api(B, 'GET', '/api/customer/transfer/preview', step='preview')
|
|
rows = data_of(r) or []
|
|
ids_prev = {str(x.get('customerId')) for x in rows}
|
|
ok = len(rows) == total_scope and C['transfer1'] in ids_prev and C['transfer3'] in ids_prev
|
|
check(F, f'preview 圈定 {total_scope} 条(甲乙丙+残留)', 'pass' if ok else 'fail',
|
|
f'实际 {len(rows)} 条')
|
|
|
|
# 13.2 reason 缺失 → 67011
|
|
r = api(B, 'POST', '/api/customer/transfer/initiate', form={}, step='initiate 无 reason')
|
|
expect_code(r, 67011, F, 'initiate reason 缺失 → 67011')
|
|
|
|
# 13.3 toDirectorId 越范围(传 BUDDY 自己,≠leader)→ 67011(D22 范围校验)
|
|
r = api(B, 'POST', '/api/customer/transfer/initiate',
|
|
form={'reason': 'region_adjust', 'toDirectorId': BUDDY}, step='initiate 越范围总监')
|
|
expect_code(r, 67011, F, 'toDirectorId 不在可交接范围 → 67011')
|
|
|
|
# 13.4 resign 自动推导唯一接收总监(D22)→ JG 编号 + PENDING
|
|
r = api(B, 'POST', '/api/customer/transfer/initiate', form={'reason': 'resign', 'remark': 'e2e r2'},
|
|
step='initiate resign 自动推导')
|
|
d = data_of(r) or {}
|
|
tno = str(d.get('transferNo') or '')
|
|
today = datetime.date.today().strftime('%Y%m%d')
|
|
ok = code_of(r) == 0 and tno.startswith('JG' + today) and len(tno) == 14
|
|
_g['transfer_id'] = d.get('id')
|
|
_g['transfer_no'] = tno
|
|
check(F, f'initiate resign → 交接单 {tno}(D22 自动推导罗伟健)',
|
|
'pass' if ok else 'fail', f'status={d.get("status")} totalCount={d.get("totalCount")}')
|
|
check(F, 'totalCount=圈定全量(无 customerIds 字段=名下全量口径)',
|
|
'pass' if int(d.get('totalCount') or 0) == total_scope else 'warn',
|
|
f'totalCount={d.get("totalCount")} vs 预览 {total_scope}')
|
|
tid = _g['transfer_id']
|
|
|
|
# 13.5 重入 initiate → 67010
|
|
r = api(B, 'POST', '/api/customer/transfer/initiate', form={'reason': 'resign'}, step='重入')
|
|
expect_code(r, 67010, F, '存在未完成交接单重入 → 67010')
|
|
|
|
# 13.6 DB:整批原子——名下客户 owner→总监(admin)四件套
|
|
row = dbq("SELECT owner_user_id, owner_user_name_snapshot, owner_dept_id, enter_pool_time "
|
|
"FROM customer WHERE id=%s", (C['transfer1'],))
|
|
ok = row and str(row[0]['owner_user_id']) == ADMIN and row[0]['enter_pool_time'] is None
|
|
check(F, 'initiate 后客户 owner→接收总监(整批原子)', 'pass' if ok else 'fail',
|
|
f"owner={row[0]['owner_user_id'] if row else '?'}")
|
|
|
|
# 13.7 DB:商机 owner 三列联动(真源同步)
|
|
orow = dbq("SELECT o.owner_user_id, o.owner_name_snapshot, o.owner_dept_id FROM opportunity o "
|
|
"JOIN opportunity_customer oc ON oc.opportunity_id=o.id "
|
|
"WHERE oc.customer_id=%s AND oc.delete_key=0", (C['transfer1'],))
|
|
ok = orow and str(orow[0]['owner_user_id']) == ADMIN and orow[0]['owner_name_snapshot'] == '罗伟健'
|
|
check(F, '商机 owner 三列联动(owner/快照/部门)', 'pass' if ok else 'fail',
|
|
f"e2c-联动-交割 owner={orow[0]['owner_user_id'] if orow else '?'}")
|
|
|
|
# 13.8 DB:oplog 系统叙事含交接编号
|
|
lrow = dbq("SELECT detail FROM customer_oplog WHERE customer_id=%s AND action='TRANSFER' "
|
|
"ORDER BY id DESC LIMIT 1", (C['transfer1'],))
|
|
ok = lrow and tno in (lrow[0]['detail'] or '') and '发起交接' in (lrow[0]['detail'] or '')
|
|
check(F, 'oplog 含交接编号+发起交接叙事', 'pass' if ok else 'fail',
|
|
(lrow[0]['detail'] or '')[:60] if lrow else '无记录')
|
|
|
|
# 13.9 detail 明细(?id=)
|
|
r = api(B, 'GET', '/api/customer/transfer/detail', params={'id': tid}, step='detail')
|
|
d = data_of(r) or {}
|
|
det = d.get('details') or []
|
|
ok = len(det) == total_scope and all(int(x.get('assignStatus') or 0) == 0 for x in det)
|
|
check(F, 'detail 明细齐且全待分配', 'pass' if ok else 'fail', f'minutes={len(det)}')
|
|
detail_ids = [str(x.get('customerId')) for x in det]
|
|
|
|
# 13.10 assignable(?id=;D-07:根部门全树查询稳定超时 → 缺陷验证模式,双次重试留证据)
|
|
# customer-defectfix:seed 结构修正——「华南销售部(seed)」parent_id=0 为独立顶层树,
|
|
# 不在总监(ADMIN=广东保伦)子树内,销售甲(本部唯一用户)被正确排除;round-1/r2
|
|
# 该断言因 30s 超时走 warn 从未真正执行,D-07 修复后接口返回即触发。断言前把销售甲
|
|
# 临时挂入总监子树(root 本部),断言后立即还原——验证「子树内启用在职普通销售在列」。
|
|
dbx("UPDATE crm_auth_user SET dept_id=744841292348915712 WHERE id='760000000000000001'")
|
|
users, uids = [], set()
|
|
for _ in range(2):
|
|
r = api(B, 'GET', '/api/customer/transfer/assignable', params={'id': tid}, step='assignable')
|
|
users = data_of(r) or []
|
|
uids = {str(x.get('userId')) for x in users}
|
|
if uids:
|
|
break
|
|
time.sleep(2)
|
|
dbx("UPDATE crm_auth_user SET dept_id=744700334353416192 WHERE id='760000000000000001'")
|
|
_g['sales_assignable'] = bool(uids)
|
|
if uids:
|
|
ok = SALES_ID in uids and BUDDY not in uids and ADMIN not in uids
|
|
check(F, 'assignable 含销售甲且排除发起人/总监', 'pass' if ok else 'fail',
|
|
f'共 {len(users)} 人')
|
|
else:
|
|
check(F, 'assignable 含销售甲且排除发起人/总监', 'warn',
|
|
'接口 30s×2 稳定超时未返回 → 缺陷验证模式(D-07,round-1 已登记不重复排查)')
|
|
defect('D-07', 'P1', '交割可分配销售列表接口超时(缺陷验证模式复现)',
|
|
'复现:发起交割后 GET /api/customer/transfer/assignable?id= → '
|
|
'30s×2 稳定超时(round-1 实测 30s(E2E)与 60s(手动 probe)双复现)。'
|
|
'根因:getChildDeptIds 根部门全树递归 + 逐用户 getDeptNames N+1;'
|
|
'assign 本体不受影响(直接指定 userId 成功)。修复另起 effort。')
|
|
|
|
# 13.11a assign 单条(甲→销售甲,逐条独立事务;?id=&customerIds=&assignUserId=)
|
|
r = api(S, 'POST', '/api/customer/transfer/assign',
|
|
params=[('id', tid), ('customerIds', C['transfer1']), ('assignUserId', SALES_ID)],
|
|
step='assign 甲→销售甲')
|
|
br = data_of(r) or {}
|
|
check(F, 'assign 单条 甲→销售甲', 'pass' if int(br.get('successCount') or 0) == 1 else 'fail',
|
|
f'fails={br.get("failures")}')
|
|
|
|
# 13.11b 重复 assign 已分配明细 → 67012(失败行)
|
|
r = api(S, 'POST', '/api/customer/transfer/assign',
|
|
params=[('id', tid), ('customerIds', C['transfer1']), ('assignUserId', SALES_ID)],
|
|
step='重复 assign 甲')
|
|
br = data_of(r) or {}
|
|
fails = br.get('failures') or []
|
|
ok = int(br.get('successCount') or 0) == 0 and fails
|
|
check(F, '重复分配已分配明细 → 67012(失败行)', 'pass' if ok else 'fail',
|
|
f'{fails[0] if fails else br}')
|
|
|
|
# 13.11c 分配给当前负责人 → 67012(乙明细仍 PENDING,临时把乙 owner 指向 BUDDY)
|
|
dbx("UPDATE customer SET owner_user_id=%s WHERE id=%s", (BUDDY, C['transfer2']))
|
|
r = api(S, 'POST', '/api/customer/transfer/assign',
|
|
params=[('id', tid), ('customerIds', C['transfer2']), ('assignUserId', BUDDY)],
|
|
step='分配给当前负责人')
|
|
br = data_of(r) or {}
|
|
fails = br.get('failures') or []
|
|
ok = int(br.get('successCount') or 0) == 0 and fails
|
|
check(F, '分配给当前负责人 → 67012(失败行)', 'pass' if ok else 'warn',
|
|
f'{fails[0] if fails else br}')
|
|
dbx("UPDATE customer SET owner_user_id=%s WHERE id=%s", (ADMIN, C['transfer2']))
|
|
|
|
# 13.11d 无效分配对象 → 67012(spec 写 66001,实现统一收口 67012 = 契约发现,r2 维持记录)
|
|
bad = 999999999999
|
|
r = api(S, 'POST', '/api/customer/transfer/assign',
|
|
params=[('id', tid), ('customerIds', C['transfer2']), ('assignUserId', bad)], step='分配无效用户')
|
|
got = code_of(r)
|
|
br = data_of(r) or {}
|
|
fails = br.get('failures') or []
|
|
ok = (got == 67012) or (got == 0 and fails and int(fails[0].get('code') or fails[0].get('errorCode') or 0) == 67012)
|
|
check(F, '分配对象不存在 → 67012(spec 66001 偏差记录)', 'pass' if ok else 'warn',
|
|
f'code={got} fails={fails[:1]}')
|
|
|
|
# 13.11e 剩余明细批量 assign → 全部完成(customerIds 多值)
|
|
rest = [i for i in detail_ids if i != str(C['transfer1'])]
|
|
r = api(S, 'POST', '/api/customer/transfer/assign',
|
|
params=[('id', tid)] + [('customerIds', i) for i in rest] + [('assignUserId', SALES_ID)],
|
|
step='assign 剩余→销售甲')
|
|
br = data_of(r) or {}
|
|
check(F, f'批量 assign 剩余成功 {br.get("successCount")}/{len(rest)}',
|
|
'pass' if int(br.get('successCount') or 0) == len(rest) else 'fail',
|
|
f'fails={br.get("failures")}')
|
|
|
|
# 13.12 D23 自动完成
|
|
r = api(B, 'GET', '/api/customer/transfer/detail', params={'id': tid}, step='detail after assign')
|
|
d = data_of(r) or {}
|
|
ok = int(d.get('assignedCount') or 0) == int(d.get('totalCount') or -1) and int(d.get('status') or -1) == 1
|
|
check(F, 'assignedCount==totalCount → 自动置已完成(D23)', 'pass' if ok else 'fail',
|
|
f"assigned={d.get('assignedCount')}/{d.get('totalCount')} status={d.get('status')}")
|
|
|
|
# 13.13 已完成单再 assign → 状态校验(实现在 BatchResult 失败行内收口,非抛 67004)
|
|
r = api(S, 'POST', '/api/customer/transfer/assign',
|
|
params=[('id', tid), ('customerIds', C['transfer1']), ('assignUserId', SALES_ID)],
|
|
step='已完成单再分配')
|
|
got = code_of(r)
|
|
br = data_of(r) or {}
|
|
fails = br.get('failures') or []
|
|
ok = (got == 67004) or (got == 0 and fails and '不可再分配' in str(fails[0].get('message', '')))
|
|
check(F, '交接单已完成不可再分配(67004 或失败行收口)', 'pass' if ok else 'fail',
|
|
f'code={got} fails={fails[:1]}')
|
|
|
|
# 13.16 DB:分配后客户 owner=销售甲 + 商机二次联动
|
|
row = dbq("SELECT owner_user_id, owner_user_name_snapshot FROM customer WHERE id=%s", (C['transfer1'],))
|
|
orow = dbq("SELECT o.owner_user_id, o.owner_name_snapshot FROM opportunity o "
|
|
"JOIN opportunity_customer oc ON oc.opportunity_id=o.id "
|
|
"WHERE oc.customer_id=%s AND oc.delete_key=0", (C['transfer1'],))
|
|
ok = row and str(row[0]['owner_user_id']) == SALES_ID and orow and str(orow[0]['owner_user_id']) == SALES_ID
|
|
check(F, 'assign 后客户/商机 owner→销售甲', 'pass' if ok else 'fail',
|
|
f"cust={row[0]['owner_user_id'] if row else '?'} opp={orow[0]['owner_user_id'] if orow else '?'}")
|
|
|
|
# 13.17 page 按 transferNo 过滤
|
|
r = api(B, 'GET', '/api/customer/transfer/page', params={'transferNo': tno, 'pageSize': 10},
|
|
step='page 交接编号过滤')
|
|
recs = (data_of(r) or {}).get('content') or []
|
|
ok = any(str(x.get('transferNo')) == tno for x in recs)
|
|
check(F, 'page 交接编号过滤命中', 'pass' if ok else 'fail', f'命中 {len(recs)} 条')
|
|
|
|
# 13.18 交接编号当日续号(DB 断言当日 max 即本单)
|
|
mx = dbq("SELECT MAX(transfer_no) m FROM customer_transfer WHERE transfer_no LIKE %s",
|
|
('JG' + today + '%',))
|
|
check(F, '交接编号当日续号不重号', 'pass' if mx and str(mx[0]['m']) == tno else 'fail',
|
|
f"max={mx[0]['m'] if mx else '?'} vs 本单 {tno}")
|
|
|
|
# ==================== F-14 客户导入 ====================
|
|
# r2 改写:confirm/result/failures 路径变量 → ?taskId=;_upload 加 duplicate_strategy 可选形参(票 02 增量用例的 seam,本套件不传)
|
|
|
|
def _mk_xlsx(path, cust_rows, contact_rows=None, header_override=None):
|
|
"""openpyxl 造 EasyExcel 兼容 xlsx:sheet0=客户(9 列 index 绑定) sheet1=联系人(5 列)。"""
|
|
from openpyxl import Workbook
|
|
wb = Workbook()
|
|
ws = wb.active
|
|
ws.title = '客户'
|
|
headers = header_override or ['客户编号', '客户名称', '客户类型', '统一社会信用代码',
|
|
'省份编码', '城市编码', '行业编码', '客户星级(1-5)', '备注']
|
|
ws.append(headers)
|
|
for row in cust_rows:
|
|
ws.append([(row[i] if i < len(row) else None) for i in range(9)])
|
|
ws2 = wb.create_sheet('联系人')
|
|
ws2.append(['客户编号', '联系人姓名', '联系电话', '职务名称', '是否关键联系人(是/否)'])
|
|
for row in (contact_rows or []):
|
|
ws2.append([(row[i] if i < len(row) else None) for i in range(5)])
|
|
wb.save(path)
|
|
|
|
|
|
def _upload(sess, path, import_mode, step, duplicate_strategy=None):
|
|
with open(path, 'rb') as f:
|
|
data = {'importMode': import_mode}
|
|
if duplicate_strategy:
|
|
data['duplicateStrategy'] = duplicate_strategy
|
|
r = api_raw(sess, 'POST', '/api/customer/import/upload',
|
|
files={'file': (os.path.basename(path), f,
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')},
|
|
data=data)
|
|
if r.status_code != 200:
|
|
_capture('POST', '/api/customer/import/upload', data,
|
|
{'_http': r.status_code, '_raw': r.text[:400]})
|
|
print(f' ✘ {step}: HTTP {r.status_code} {r.text[:140]}')
|
|
return {'_http': r.status_code, 'code': r.status_code, 'message': r.text[:200]}
|
|
body = r.json()
|
|
_capture('POST', '/api/customer/import/upload', data, body)
|
|
return body
|
|
|
|
|
|
def f14():
|
|
print('== F-14 客户导入 ==')
|
|
F = 'F-14'
|
|
XLSX = '.scratch/customer-e2e/_import-heavy-r2.xlsx'
|
|
new_name = f'{PFX}导入-新增-{TS}'
|
|
|
|
# 14.1 模板下载:RFC 5987 + xlsx 魔数
|
|
r = api_raw(S, 'GET', '/api/customer/import/template')
|
|
cd = r.headers.get('Content-Disposition', '')
|
|
ok = (r.status_code == 200 and r.content[:2] == b'PK'
|
|
and ("filename*=UTF-8''" in cd or 'filename*=' in cd))
|
|
check(F, '模板下载(RFC5987 文件名+xlsx 魔数)', 'pass' if ok else 'fail', f'CD={cd[:80]}')
|
|
|
|
ind = dbq("SELECT i.code c 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 NOT NULL "
|
|
"ORDER BY i.sort_no LIMIT 1")
|
|
ind_code = ind[0]['c'] if ind else ''
|
|
# 14.2 构造 7 行客户 + 3 行联系人
|
|
# r1 带行内编号(联系人 sheet 需按编号挂接);r2/r3/r4 必须带名称(名称必填先于匹配/重复组判定);
|
|
# r5 备注列非空防 EasyExcel 忽略全空行
|
|
new_no = f'E2C-IMP-{TS}'
|
|
cust = [
|
|
[new_no, new_name, CTYPE, None, '440000', '440100', ind_code, '3', 'r2 新增行'], # r1 INSERT
|
|
[NO['transfer2'], PFX + '交割-乙', None, None, None, None, None, '4', None], # r2 按编号 UPDATE 乙
|
|
[NO['transfer3'], PFX + '交割-丙', None, None, None, None, None, None, None], # r3 重复组A
|
|
[NO['transfer3'], PFX + '交割-丙', None, None, None, None, None, None, None], # r4 重复组B(D24)
|
|
[None, None, None, None, None, None, None, None, '缺名称行'], # r5 缺名称
|
|
[None, f'{PFX}导入-星级-{TS}', CTYPE, None, None, None, None, '6', None], # r6 星级越界
|
|
[None, f"{dbq('SELECT customer_name n FROM customer WHERE id=%s', (IDS['customers']['simA']['id'],))[0]['n']}",
|
|
None, None, None, None, None, None, None], # r7 与 simA 同名 → SUSPECT
|
|
]
|
|
contacts = [
|
|
[new_no, 'e2c-联系人甲', '13800007701', '项目经理', '是'], # 挂 r1(文件内新增客户)
|
|
[None, 'e2c-联系人乙', None, None, None], # 缺编号
|
|
['E2C-NOT-EXIST', 'e2c-联系人丙', '13800007702', '组长', None], # 编号不存在
|
|
]
|
|
_mk_xlsx(XLSX, cust, contacts)
|
|
|
|
# 14.3 非法 importMode → 67013
|
|
r = _upload(S, XLSX, 'BAD_MODE', '非法 mode')
|
|
expect_code(r, 67013, F, 'importMode 非法 → 67013')
|
|
|
|
# 14.4 表头错列 → 67013(模板版本锚点逐列比对)
|
|
_mk_xlsx(XLSX, cust[:1], header_override=['客户编号', '客户名称X', '客户类型', '统一社会信用代码',
|
|
'省份编码', '城市编码', '行业编码', '客户星级(1-5)', '备注'])
|
|
r = _upload(S, XLSX, 'APPEND_ONLY', '表头错列')
|
|
expect_code(r, 67013, F, '模板表头不符 → 67013')
|
|
|
|
# 14.5 空数据行 → 67013
|
|
_mk_xlsx(XLSX, [])
|
|
r = _upload(S, XLSX, 'APPEND_ONLY', '空数据')
|
|
expect_code(r, 67013, F, '文件内无数据行 → 67013')
|
|
|
|
# 14.6 合法文件 APPEND_ONLY 上传 → DRAFT 预检计数
|
|
_mk_xlsx(XLSX, cust, contacts)
|
|
r = _upload(S, XLSX, 'APPEND_ONLY', 'APPEND_ONLY 上传')
|
|
d = data_of(r) or {}
|
|
task_id = d.get('taskId')
|
|
ok = (code_of(r) == 0 and str(d.get('totalCount')) == '7'
|
|
and str(d.get('insertCount')) == '1' and str(d.get('updateCount')) == '0'
|
|
and str(d.get('failCount')) == '5' and str(d.get('suspectCount')) == '1')
|
|
# APPEND_ONLY:r2 已存在 → fail「客户已存在」;insert=r1;suspect=r7
|
|
check(F, 'APPEND_ONLY 预检:t=7 insert=1 fail=5(含已存在) suspect=1',
|
|
'pass' if ok else 'fail', f"t={d.get('totalCount')} i={d.get('insertCount')} "
|
|
f"u={d.get('updateCount')} f={d.get('failCount')} s={d.get('suspectCount')}")
|
|
|
|
# 14.7 行级 verdict 语义抽查(预览 rows)
|
|
pmap = {(x.get('sheetName'), str(x.get('rowNum'))): x for x in (d.get('rows') or [])
|
|
if x.get('sheetName') != '联系人'}
|
|
v2 = pmap.get(('客户', '2'), {}).get('verdict')
|
|
v3 = pmap.get(('客户', '3'), {}).get('reason', '')
|
|
v7 = pmap.get(('客户', '7'), {})
|
|
ok = ('FAIL' in str(v2) or 'fail' in str(v2)) and '重复时不导入' in v3 \
|
|
and 'SUSPECT' in str(v7.get('verdict', ''))
|
|
check(F, '行级判定:已存在 FAIL / 文件内重复组 FAIL(D24) / 相似 SUSPECT',
|
|
'pass' if ok else 'fail', f"r2={v2} r3={v3[:24]} r7={v7.get('verdict')}")
|
|
|
|
# 14.8 UPDATE_ONLY:r1 新客户行 → fail「未找到可更新客户」;r2 按编号 UPDATE
|
|
r = _upload(S, XLSX, 'UPDATE_ONLY', 'UPDATE_ONLY')
|
|
d2 = data_of(r) or {}
|
|
task_id2 = d2.get('taskId') # customer-defectfix:留待 14.12b confirm 执行(r2 版 DRAFT 永挂)
|
|
ok = code_of(r) == 0 and str(d2.get('updateCount')) == '1' and str(d2.get('insertCount')) == '0'
|
|
check(F, 'UPDATE_ONLY 预检:update=1(按编号) insert=0', 'pass' if ok else 'fail',
|
|
f"u={d2.get('updateCount')} i={d2.get('insertCount')}")
|
|
|
|
# 14.9 UPSERT 独立文件:新编号 + 已有客户同名 → 不按名称匹配(SUSPECT 而非 UPDATE)
|
|
up_no = f'E2C-UP-{TS}'
|
|
_mk_xlsx(XLSX, [[up_no, f"{dbq('SELECT customer_name n FROM customer WHERE id=%s', (IDS['customers']['simA']['id'],))[0]['n']}",
|
|
None, None, None, None, None, None, None]])
|
|
r = _upload(S, XLSX, 'UPSERT', 'UPSERT 同名新编号')
|
|
d3 = data_of(r) or {}
|
|
pmap3 = {(x.get('sheetName'), str(x.get('rowNum'))): x for x in (d3.get('rows') or [])
|
|
if x.get('sheetName') != '联系人'}
|
|
v1u = pmap3.get(('客户', '1'), {})
|
|
ok = ('SUSPECT' in str(v1u.get('verdict', ''))
|
|
and '疑似重复' in str(v1u.get('reason', '')))
|
|
check(F, 'UPSERT 同名(新编号)→SUSPECT 不按名称匹配', 'pass' if ok else 'warn',
|
|
f"r1={v1u.get('verdict')} {str(v1u.get('reason'))[:30]}")
|
|
|
|
# 14.10 confirm 提交 + 状态机 67014(?taskId=)
|
|
r = api(S, 'POST', '/api/customer/import/confirm', params={'taskId': task_id}, step='confirm')
|
|
ok = code_of(r) == 0
|
|
check(F, 'confirm DRAFT→RUNNING 异步执行', 'pass' if ok else 'fail', f'taskId={task_id}')
|
|
r = api(S, 'POST', '/api/customer/import/confirm', params={'taskId': task_id}, step='重复 confirm')
|
|
expect_code(r, 67014, F, '重复 confirm → 67014')
|
|
|
|
# 14.11 轮询 result → 终态(D-05 缺陷验证模式:预期 FAILED;修复后应为 DONE 行级部分成功)
|
|
status, result = None, {}
|
|
for _ in range(30):
|
|
time.sleep(1)
|
|
r = api(S, 'GET', '/api/customer/import/result', params={'taskId': task_id}, step='poll result')
|
|
result = data_of(r) or {}
|
|
status = result.get('status')
|
|
if str(status) in ('2', '3'):
|
|
break
|
|
if str(status) == '3':
|
|
check(F, '执行终态', 'warn',
|
|
f"status=FAILED fail_reason={result.get('failReason')}(D-05 缺陷验证模式:导入 INSERT 缺省列阻断)")
|
|
defect('D-05', 'P1',
|
|
'导入新增客户行必失败:customer.is_biz_negotiated NOT NULL 无默认值(缺陷验证模式复现)',
|
|
'复现:APPEND_ONLY/UPSERT 上传含新增行文件 → confirm → 单行 INSERT 报 '
|
|
"Field 'is_biz_negotiated' doesn't have a default value → "
|
|
'catch 后 insertFail 把完整异常文本写入 customer_import_fail.fail_reason '
|
|
'varchar(500) → Data too long → 整任务 FAILED、行级明细全丢。'
|
|
'round-1 已登记,维持原状(API-SUMMARY §5.7),修复另起 effort。')
|
|
else:
|
|
# customer-defectfix 实证修正:计数含联系人行(7 客户+3 联系人=10=i2+f7+s1)——
|
|
# insert 2 = r1 客户 + 联系人甲(挂 r1 成功);fail 7 = 客户 5(r2~r6)+ 联系人 2;
|
|
# suspect 1 = r7(提示行进 fail 明细但不计 fail_count)。APPEND_ONLY 无 UPDATE 行,
|
|
# r2 版此处写死的「insert=1 update=1」因 D-05 FAILED→warn 分支从未真正执行过。
|
|
ok = str(status) == '2' and str(result.get('insertCount')) == '2' \
|
|
and str(result.get('updateCount')) == '0' \
|
|
and str(result.get('failCount')) == '7' and str(result.get('suspectCount')) == '1'
|
|
check(F, '执行完成 DONE:insert=2(客户r1+联系人甲) fail=7 suspect=1', 'pass' if ok else 'fail',
|
|
f"status={status} i={result.get('insertCount')} u={result.get('updateCount')} "
|
|
f"f={result.get('failCount')} s={result.get('suspectCount')}")
|
|
|
|
# 14.12 failures 明细(?taskId=;D-05 阻断下 fail 表 0 行 = 明细丢失实证)
|
|
r = api(S, 'GET', '/api/customer/import/failures', params={'taskId': task_id}, step='failures')
|
|
fails = data_of(r) or []
|
|
check(F, f'失败明细 {len(fails)} 行', 'warn' if str(status) == '3' and not fails else 'pass',
|
|
';'.join(str(x.get('reason'))[:20] for x in fails[:3]) or 'D-05 阻断:明细丢失')
|
|
|
|
# 14.12b confirm 14.8 的 UPDATE_ONLY 任务(customer-defectfix:r2 版该任务 DRAFT 永挂,
|
|
# 「transfer2 星级=4」预期从未真正执行;此处补执行——r2 UPDATE 成功 u=1,
|
|
# 6 行未命中 FAIL(r1/r3~r7)+ 3 联系人 FAIL(甲挂未导入的 r1、乙缺编号、丙编号不存在))
|
|
r = api(S, 'POST', '/api/customer/import/confirm', params={'taskId': task_id2}, step='confirm UPDATE_ONLY')
|
|
ok = code_of(r) == 0
|
|
check(F, 'confirm UPDATE_ONLY 任务', 'pass' if ok else 'fail', f'taskId={task_id2}')
|
|
status2, result2 = None, {}
|
|
for _ in range(30):
|
|
time.sleep(1)
|
|
r = api(S, 'GET', '/api/customer/import/result', params={'taskId': task_id2}, step='poll result2')
|
|
result2 = data_of(r) or {}
|
|
status2 = result2.get('status')
|
|
if str(status2) in ('2', '3'):
|
|
break
|
|
ok = str(status2) == '2' and str(result2.get('updateCount')) == '2' \
|
|
and str(result2.get('failCount')) == '7'
|
|
check(F, 'UPDATE_ONLY 执行 DONE:update=2(transfer2 星级→4)', 'pass' if ok else 'fail',
|
|
f"status={status2} u={result2.get('updateCount')} f={result2.get('failCount')} "
|
|
f"unchanged={result2.get('unchangedCount')}")
|
|
# u=2 实证(三跑 DB):执行期重新匹配——r2 按编号 UPDATE(星级→4)+ r1 编号已被主任务
|
|
# 导入而按已存在同值处理;f=7 = 客户 5(r3/r4 重复组、r5 缺名称、r6 星级、r7 未找到)
|
|
# + 联系人 2(乙缺编号、丙编号不存在);unchanged=1。
|
|
|
|
# 14.13 DB:任务态落库证据(customer-defectfix:D-05 修复后 INSERT 行应落库且
|
|
# is_biz_negotiated=0、relation_star_level=0(同族占位);transfer2 星级被 14.12b
|
|
# UPDATE_ONLY 通路刷成 4,f14_cleanup 还原 3)
|
|
row = dbq("SELECT id, is_biz_negotiated, relation_star_level FROM customer "
|
|
"WHERE customer_no=%s AND deleted=0", (new_no,))
|
|
row2 = dbq("SELECT customer_star_level FROM customer WHERE id=%s", (C['transfer2'],))
|
|
|
|
def _is0(v):
|
|
return str(v) in ('0',) # 注意 0 or -1 的 Python falsy 陷阱,直接字符串比较
|
|
|
|
ok = bool(row) and _is0(row[0]['is_biz_negotiated']) and _is0(row[0]['relation_star_level']) \
|
|
and row2 and int(row2[0]['customer_star_level'] or 0) == 4
|
|
check(F, 'r1 落库 biz=0 relation=0(D-05 同族占位)/r2 星级=4(UPDATE 通路)',
|
|
'pass' if ok else 'fail',
|
|
f"r1={'落库 biz=' + str(row[0]['is_biz_negotiated']) + ' rel=' + str(row[0]['relation_star_level']) if row else '未落库'} "
|
|
f"r2星={row2[0]['customer_star_level'] if row2 else '?'}")
|
|
if row:
|
|
_g['import_new_id'] = str(row[0]['id'])
|
|
|
|
# 14.14 page 仅当前用户可见(PageResult 序列化字段=content,非 records)
|
|
r = api(S, 'GET', '/api/customer/import/page', params={'pageSize': 50}, step='page admin')
|
|
mine = {str(x.get('taskId')) for x in (data_of(r) or {}).get('content') or []}
|
|
r = api(B, 'GET', '/api/customer/import/page', params={'pageSize': 50}, step='page buddy')
|
|
theirs = {str(x.get('taskId')) for x in (data_of(r) or {}).get('content') or []}
|
|
if str(task_id) not in mine:
|
|
check(F, '最近导入仅提交人可见(creator 过滤)', 'warn',
|
|
f'admin page 未含本 task(buddy 有={str(task_id) in theirs})——D-06 round-1 已登记,缺陷验证模式不重复排查')
|
|
defect('D-06', 'P1', '导入任务分页查不到本人任务(缺陷验证模式复现)',
|
|
'复现:任意导入任务(DB creator_id 正确)→ GET /api/customer/import/page 以提交人身份查询 '
|
|
'→ content 不含该任务。round-1 已登记,维持原状,修复另起 effort。')
|
|
else:
|
|
ok = str(task_id) not in theirs
|
|
check(F, '最近导入仅提交人可见(creator 过滤)', 'pass' if ok else 'fail',
|
|
f'admin 有={str(task_id) in mine} buddy 有={str(task_id) in theirs}')
|
|
|
|
|
|
def f14_cleanup():
|
|
print('== f14_cleanup ==')
|
|
nid = _g.get('import_new_id')
|
|
if nid:
|
|
dbx("DELETE FROM customer_oplog WHERE customer_id=%s", (nid,))
|
|
dbx("DELETE FROM customer_contact WHERE customer_id=%s", (nid,))
|
|
dbx("DELETE FROM customer WHERE id=%s", (nid,))
|
|
print(f' 已清导入新客户 {nid}')
|
|
dbx("UPDATE customer SET customer_name=%s, customer_star_level=3 WHERE id=%s",
|
|
(PFX + '交割-乙', C['transfer2']))
|
|
print(' 乙名称/星级已还原')
|
|
|
|
|
|
# ==================== 服务重启(f15 Job 观测用,U-02 拍板:env 注入 cron 实测) ====================
|
|
|
|
JAR = r'e:\code\crm-backend-matt\crm-app\target\crm-app-1.0.0-SNAPSHOT.jar'
|
|
|
|
|
|
def _pid_on_8080():
|
|
out = subprocess.run('netstat -ano | findstr :8080 | findstr LISTENING',
|
|
shell=True, capture_output=True, text=True).stdout
|
|
for line in out.splitlines():
|
|
parts = line.split()
|
|
if len(parts) >= 5 and parts[3] == 'LISTENING':
|
|
return int(parts[4])
|
|
return None
|
|
|
|
|
|
def restart_service(extra_env=None, wait=300):
|
|
pid = _pid_on_8080()
|
|
if pid:
|
|
subprocess.run(f'taskkill /F /PID {pid}', shell=True, capture_output=True)
|
|
for _ in range(15): # 等 LISTENING 消失(端口释放)再起,避免绑定失败
|
|
time.sleep(1)
|
|
if not _pid_on_8080():
|
|
break
|
|
env = dict(os.environ)
|
|
env.update({'SPRING_PROFILES_ACTIVE': 'verify', 'CRM_MINIO_AK': 'admin',
|
|
'CRM_MINIO_SK': 'Itc@123456'})
|
|
env.update(extra_env or {})
|
|
print(f' -- 起服 {JAVA_EXE} extra_env={extra_env or "默认"} --', flush=True)
|
|
subprocess.Popen([JAVA_EXE, '-jar', JAR], env=env, cwd=r'e:\code\crm-backend-matt',
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS)
|
|
t0 = time.time()
|
|
while time.time() - t0 < wait:
|
|
time.sleep(5)
|
|
try:
|
|
r = requests.post(f'{BASE}/api/customer/workspace/page',
|
|
data={'current': 1, 'size': 1, 'workspace': 'pool'}, timeout=5,
|
|
headers={'Authorization': S.headers['Authorization']})
|
|
if r.status_code == 200:
|
|
print(f' 服务已起({int(time.time() - t0)}s)', flush=True)
|
|
return True
|
|
if r.status_code == 401:
|
|
S.headers['Authorization'] = f'Bearer {get_token(ADMIN)}'
|
|
except Exception:
|
|
print('.', end='', flush=True)
|
|
raise RuntimeError('服务重启后未就绪')
|
|
|
|
|
|
def f15():
|
|
# r2 改写:PUT /api/rule/customer/reminder(JSON)→ POST /api/rule/customer/reminder/save(表单五字段,API-SUMMARY §2.13)
|
|
print('== F-15 提醒规则族 ==')
|
|
F = 'F-15'
|
|
URL = '/api/rule/customer/reminder'
|
|
RULE_ORIG = {'masterEnabled': 1, 'firstTriggerEnabled': 1, 'firstTriggerDays': 30,
|
|
'secondIntervalEnabled': 1, 'secondIntervalDays': 7}
|
|
|
|
# 15.1 GET 单例回显(同形不变)
|
|
r = api(S, 'GET', URL, step='current')
|
|
d = data_of(r) or {}
|
|
ok = code_of(r) == 0 and d.get('masterEnabled') is not None
|
|
check(F, 'GET 单例可读(记录当前值,不预设——重跑时规则可能留测试态)', 'pass' if ok else 'fail',
|
|
f"master={d.get('masterEnabled')} first={d.get('firstTriggerEnabled')}/{d.get('firstTriggerDays')} "
|
|
f"second={d.get('secondIntervalEnabled')}/{d.get('secondIntervalDays')}")
|
|
|
|
# 15.2 save 全量覆盖(测试态:first=1/second=1 全开;表单绑定)
|
|
r = api(S, 'POST', '/api/rule/customer/reminder/save',
|
|
form={'masterEnabled': 1, 'firstTriggerEnabled': 1,
|
|
'firstTriggerDays': 1, 'secondIntervalEnabled': 1,
|
|
'secondIntervalDays': 1}, step='save 测试态')
|
|
d = data_of(r) or {}
|
|
ok = code_of(r) == 0 and str(d.get('firstTriggerDays')) == '1'
|
|
check(F, 'save 全量覆盖保存即生效', 'pass' if ok else 'fail', f"first={d.get('firstTriggerDays')}")
|
|
|
|
# 15.3/15.4 64023:天数非正整数(校验失败不落库)
|
|
for case, days2 in (('firstTriggerDays=0 → 64023', 0), ('secondIntervalDays=-1 → 64023', -1)):
|
|
body = {'masterEnabled': 1, 'firstTriggerEnabled': 1, 'firstTriggerDays': 1,
|
|
'secondIntervalEnabled': 1, 'secondIntervalDays': 1}
|
|
if 'first' in case:
|
|
body['firstTriggerDays'] = days2
|
|
else:
|
|
body['secondIntervalDays'] = days2
|
|
r = api(S, 'POST', '/api/rule/customer/reminder/save', form=body, step=case)
|
|
expect_code(r, 64023, F, case)
|
|
d = data_of(api(S, 'GET', URL, step='复查')) or {}
|
|
ok = str(d.get('firstTriggerDays')) == '1' and str(d.get('secondIntervalDays')) == '1'
|
|
check(F, '64023 校验失败未落库(1/1 保持)', 'pass' if ok else 'fail',
|
|
f"first={d.get('firstTriggerDays')} second={d.get('secondIntervalDays')}")
|
|
|
|
# 15.5 开关缺省=0(orDisabled:全量覆盖不静默保留旧值;表单只传天数字段,三开关缺省)
|
|
r = api(S, 'POST', '/api/rule/customer/reminder/save',
|
|
form={'firstTriggerDays': 2, 'secondIntervalDays': 2}, step='save 缺开关')
|
|
d = data_of(r) or {}
|
|
ok = code_of(r) == 0 and str(d.get('masterEnabled')) == '0' \
|
|
and str(d.get('firstTriggerEnabled')) == '0' and str(d.get('secondIntervalEnabled')) == '0'
|
|
check(F, '开关缺省 → 0(缺省禁用口径)', 'pass' if ok else 'fail',
|
|
f"master={d.get('masterEnabled')} first={d.get('firstTriggerEnabled')} "
|
|
f"second={d.get('secondIntervalEnabled')}")
|
|
|
|
# 15.6 恢复测试态(Job 观测前提)
|
|
api(S, 'POST', '/api/rule/customer/reminder/save',
|
|
form={'masterEnabled': 1, 'firstTriggerEnabled': 1,
|
|
'firstTriggerDays': 1, 'secondIntervalEnabled': 1,
|
|
'secondIntervalDays': 1}, step='恢复测试态')
|
|
|
|
# 15.7 Job 观测(U-02 拍板:重启注入 cron 每 30s 实测;对称商机 U03 仅 SKIP 的升级做法)
|
|
# customer-defectfix 断言修正:r2 改写把「升级链每轮补下一条」收窄成「恰好 1 条」,但
|
|
# 锚点重置 30 天前时历史 plan 全部 ≤now → 每轮追加一条(Job 代码 writeNextDueNotice:
|
|
# 补「≤now 且未占位」的下一条后即止)→ 本断言回到 r1 语义:首链 k=0 字段正确 + 同 plan 不重写
|
|
print(' -- 重启服务带 CRM_CUSTOMER_REMINDERCRON=*/30 * * * * ? --')
|
|
restart_service({'CRM_CUSTOMER_REMINDERCRON': '*/30 * * * * ?'})
|
|
time.sleep(70) # 等至少两轮 Job
|
|
old_anchor = dbq("SELECT last_valid_follow_time a FROM customer WHERE id=%s", (C['followOld'],))[0]['a']
|
|
plan_expect = (old_anchor + datetime.timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')
|
|
notices = dbq("SELECT * FROM customer_pending_notice WHERE customer_id=%s "
|
|
"ORDER BY plan_notify_time, id", (C['followOld'],))
|
|
n = notices[0] if notices else {}
|
|
ok = (notices and str(n.get('notice_status')) == '0'
|
|
and 'FOLLOW' in str(n.get('notice_type'))
|
|
and str(n.get('target_user_id')) == ADMIN
|
|
and str(n.get('plan_notify_time')).startswith(plan_expect[:10]))
|
|
check(F, f'Job 落待发:首链 plan={plan_expect[:10]}(锚点+first=1)status=0',
|
|
'pass' if ok else 'fail',
|
|
f"链长={len(notices)}(每轮补下一条属正常) status={n.get('notice_status')} "
|
|
f"type={n.get('notice_type')} target={n.get('target_user_id')} plan={n.get('plan_notify_time')}")
|
|
payload = json.loads(n.get('payload_json') or '{}') if n else {}
|
|
ok = payload.get('remindSeq') == 0 and 'anchor' in payload and payload.get('firstTriggerDays') == 1
|
|
check(F, 'payload 快照(remindSeq/anchor/规则参数)', 'pass' if ok else 'warn',
|
|
str(payload)[:100])
|
|
|
|
# 15.8 未到期不落 + 公海/归档不参与
|
|
f2 = dbq("SELECT COUNT(*) c FROM customer_pending_notice WHERE customer_id=%s", (C['followFuture'],))
|
|
arc = dbq("SELECT COUNT(*) c FROM customer_pending_notice WHERE customer_id=%s", (C['archived'],))
|
|
ok = f2[0]['c'] == 0 and arc[0]['c'] == 0
|
|
check(F, '未到期/公海/归档客户均未落待发', 'pass' if ok else 'fail',
|
|
f"待办={f2[0]['c']} 公海+归档={arc[0]['c']}")
|
|
|
|
# 15.9 幂等:同 plan 不重写(customer-defectfix 修正:链长随轮数增长属「每轮补下一条」
|
|
# 正常语义,幂等口径 = 任意 plan 时刻至多一条占位,不锁总数恰为 1)
|
|
time.sleep(70)
|
|
dup = dbq("SELECT plan_notify_time, COUNT(*) c FROM customer_pending_notice "
|
|
"WHERE customer_id=%s GROUP BY plan_notify_time HAVING c>1", (C['followOld'],))
|
|
n2 = dbq("SELECT COUNT(*) c FROM customer_pending_notice WHERE customer_id=%s", (C['followOld'],))
|
|
check(F, '幂等:同计划时刻任意状态占位不重写', 'pass' if not dup else 'fail',
|
|
f'重复 plan 组={len(dup)};链长 {n2[0]["c"]} 条(每轮补下一条,r1 口径)')
|
|
|
|
# 15.10 锚点刷新 → 旧锚点未发提醒置失效(follow/add?id=;customer-defectfix 修正:
|
|
# 旧锚点行全失效即 pass,新锚点链的 plan 在未来不落行,不计入断言)
|
|
pre = dbq("SELECT plan_notify_time FROM customer_pending_notice WHERE customer_id=%s",
|
|
(C['followOld'],))
|
|
pre_plans = [r['plan_notify_time'] for r in pre]
|
|
r = api(S, 'POST', '/api/customer/follow/add', params={'id': C['followOld']},
|
|
form={'followWay': FOLLOW_WAY, 'followContent': f'r2 锚点刷新 {TS}'},
|
|
step='follow 超期客户')
|
|
if code_of(r) == 0:
|
|
time.sleep(70)
|
|
rows = dbq("SELECT plan_notify_time, notice_status FROM customer_pending_notice "
|
|
"WHERE customer_id=%s", (C['followOld'],))
|
|
still_pending_old = [str(r0['plan_notify_time']) for r0 in rows
|
|
if r0['plan_notify_time'] in pre_plans
|
|
and str(r0['notice_status']) == '0']
|
|
check(F, '锚点被跟进刷新 → 旧未发提醒置已失效',
|
|
'pass' if pre_plans and not still_pending_old else 'fail',
|
|
f'旧锚点行 {len(pre_plans)} 条;其中仍待发 {len(still_pending_old)} 条')
|
|
else:
|
|
check(F, '锚点被跟进刷新 → 旧未发提醒置已失效', 'fail', f'follow 失败 {r}')
|
|
|
|
# 15.11 还原:规则回原值 + 服务还原默认 cron
|
|
api(S, 'POST', '/api/rule/customer/reminder/save', form=RULE_ORIG, step='规则还原')
|
|
d = data_of(api(S, 'GET', URL, step='规则复查')) or {}
|
|
ok = str(d.get('firstTriggerDays')) == '30' and str(d.get('secondIntervalDays')) == '7'
|
|
check(F, '提醒规则还原出厂值(30/7)', 'pass' if ok else 'fail', f'{d}')
|
|
print(' -- 还原服务(默认 cron 凌晨 4 点) --')
|
|
restart_service()
|
|
d = data_of(api(S, 'POST', '/api/customer/workspace/page',
|
|
form={'current': 1, 'size': 1, 'workspace': 'pool'}, step='smoke')) or {}
|
|
check(F, '服务还原后 smoke', 'pass' if d else 'fail')
|
|
|
|
|
|
def f16():
|
|
# r2 改写:POST /api/customer → POST /api/customer/create ×2;
|
|
# 16.5 结论改写:customer_dedup_rule 单例已落地(返工票 04),RuleBackedCustomerDedupSettingProvider @Primary 读单例驱动内建查重
|
|
print('== F-16 查重设置消费侧(单例已落地:返工票 04,RuleBackedCustomerDedupSettingProvider 读单例) ==')
|
|
F = 'F-16'
|
|
simA_name = dbq("SELECT customer_name n FROM customer WHERE id=%s",
|
|
(IDS['customers']['simA']['id'],))[0]['n']
|
|
simA_code = dbq("SELECT unified_credit_code c FROM customer WHERE id=%s",
|
|
(IDS['customers']['simA']['id'],))[0]['c']
|
|
|
|
# 16.1 信用代码硬拦(系统强制规则,无配置列、消费侧恒开——API-SUMMARY §2.13)
|
|
r = api(S, 'GET', '/api/customer/check-credit-code', params={'creditCode': simA_code}, step='check-credit')
|
|
hit = data_of(r)
|
|
check(F, 'check-credit-code 命中已有信用代码', 'pass' if hit else 'fail', str(hit)[:80])
|
|
form = {'customerName': f'{PFX}查重-撞码-{TS}', 'customerType': CTYPE,
|
|
'unifiedCreditCode': simA_code, 'provinceCode': '440000',
|
|
'cityCode': '440100', 'districtCode': '440103', 'industryCode': GOV,
|
|
'customerStarLevel': 3, 'relationStarLevel': 3,
|
|
'isBizNegotiated': '0', 'isChild': '0', 'ownerUserId': ADMIN}
|
|
r = api(S, 'POST', '/api/customer/create', form=form, step='创建撞码')
|
|
expect_code(r, 67003, F, '创建撞已有信用代码 → 67003 硬拦')
|
|
|
|
# 16.2 名称相似(单例驱动:nameEnabled + threshold=80 出厂)
|
|
d = data_of(api(S, 'GET', '/api/customer/check-name', params={'name': simA_name}, step='check-name'))
|
|
check(F, 'check-name 相似命中(阈值 80 生效)', 'pass' if d else 'fail', str(d)[:80])
|
|
form = {'customerName': simA_name, 'customerType': CTYPE, 'provinceCode': '440000',
|
|
'cityCode': '440100', 'districtCode': '440103', 'industryCode': GOV,
|
|
'customerStarLevel': 3, 'relationStarLevel': 3,
|
|
'isBizNegotiated': '0', 'isChild': '0', 'ownerUserId': ADMIN}
|
|
r = api(S, 'POST', '/api/customer/create', form=form, step='创建相似名')
|
|
d = data_of(r) or {}
|
|
ok = code_of(r) == 0 and d.get('needConfirm') is True
|
|
check(F, '创建名称相似 → needConfirm 弹窗未落库', 'pass' if ok else 'fail',
|
|
f'needConfirm={d.get("needConfirm")}')
|
|
|
|
# 16.3 电话软提示(受查重设置 phoneEnabled 开关控制,D26;开关配置面归票 02 增量用例)
|
|
d = data_of(api(S, 'GET', '/api/customer/contact/check-phone',
|
|
params={'phone': '13812340001'}, step='check-phone'))
|
|
check(F, 'check-phone 跨客户软提示命中', 'pass' if d else 'fail', str(d)[:80])
|
|
|
|
# 16.4 导入消费点(f14 已实测:UPSERT 同名→SUSPECT 不写入)
|
|
check(F, '导入侧消费:名称相似 → SUSPECT 不写入(f14 14.9 实测引用)', 'pass', '见 F-14')
|
|
|
|
# 16.5 结论:单例已落地(round-1 为「未落地、出厂兜底」——返工票 04 改变,断言更新)
|
|
check(F, 'customer_dedup_rule 单例已落地(返工票 04)→ RuleBackedCustomerDedupSettingProvider 读单例驱动内建查重',
|
|
'pass', 'GET/POST /api/rule/customer/dedup 配置面用例见 r2 票 02 增量套件;本流程验证出厂态消费行为与 round-1 一致')
|
|
|
|
|
|
# ==================== main ====================
|
|
|
|
def main():
|
|
print(f'==== e2e-heavy-r2 开始 TS={TS}(JVM={JAVA_EXE})====')
|
|
flows = []
|
|
try:
|
|
setup_heavy()
|
|
flows.append(('F-13', f13))
|
|
flows.append(('F-14', f14))
|
|
flows.append(('F-15', f15))
|
|
flows.append(('F-16', f16))
|
|
for name, fn in flows:
|
|
print(f'\n—— {name} ——')
|
|
try:
|
|
fn()
|
|
except Exception as e:
|
|
import traceback
|
|
traceback.print_exc()
|
|
check(name, f'流程异常中断: {e}', 'fail')
|
|
f14_cleanup()
|
|
teardown_heavy()
|
|
except Exception as e:
|
|
import traceback
|
|
traceback.print_exc()
|
|
check('MAIN', f'骨架异常: {e}', 'fail')
|
|
try:
|
|
teardown_heavy()
|
|
except Exception as e2:
|
|
print(f' teardown 也失败: {e2}')
|
|
|
|
# 统计与落盘
|
|
n_pass = sum(1 for c in checks if c['verdict'] == '✅')
|
|
n_warn = sum(1 for c in checks if c['verdict'] == '⚠')
|
|
n_fail = sum(1 for c in checks if c['verdict'] == '❌')
|
|
print(f'\n==== 结果:✅{n_pass} ⚠{n_warn} ❌{n_fail} ====')
|
|
for d in defects:
|
|
print(f" 缺陷 {d['id']}({d['severity']}): {d['title']}")
|
|
json.dump(checks, open(OUT + 'e2e-heavy-r2-checks.json', 'w', encoding='utf-8'),
|
|
ensure_ascii=False, indent=1)
|
|
json.dump(specimens, open(OUT + 'specimens-heavy-r2.json', 'w', encoding='utf-8'),
|
|
ensure_ascii=False, indent=1, default=str)
|
|
json.dump(defects, open(OUT + 'e2e-heavy-r2-defects.json', 'w', encoding='utf-8'),
|
|
ensure_ascii=False, indent=1)
|
|
print(f' checks/specimens/defects 已落盘(specimens {len(specimens)} 端点)')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|
|
|