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

# -*- 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()