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.
 
 
 
 
 
 

534 lines
31 KiB

# -*- coding: utf-8 -*-
"""票 08 — E2E 实测:商机规则三族 V-CONFIG 生命周期(R01 阶段模板 / R02 方案卡模板 / R03 公海规则)。
前置:seed-opportunity.py 已跑(种子阶段模板/兜底方案卡/e2e-seed公海规则 发布中)。
纪律:同 e2e-core(Python requests;POST x-www-form-urlencoded;Result<Void> data=null 非失败)。
V-CONFIG 语义(VersionedConfigSupport,运行前核对源码):同 code 至多一草稿一发布中;发布自我
顶替(原发布中转停用);仅草稿可删;编辑发布中/停用生新草稿(已有草稿则拒);版本号 minor 顺延;
绑定实体锁版本行 id,发新版不迁移。publish 默认顶替为全表跨 code —— 故本脚本一律 isDefault=0
(默认位语义已在票 07 U02 副作用实测,此处不复测以免破坏 seed 默认位)。
风险控制:不碰种子模板/兜底模板/e2e-seed公海规则 的状态;U03 用部门专用规则(不占默认位)测完即停用。
用法:python e2e-rules.py # 全量
python e2e-rules.py ST SC # 只跑指定族
"""
import io, json, os, sys
from datetime import datetime
import requests
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
BASE = 'http://localhost:8080'
ADMIN_UID = '739564171091247104'
UID = {'A': '744842565802524672'}
TAG = 'e2e-' + datetime.now().strftime('%H%M%S') # 本次运行自建规则名前缀(防撞历史运行残留)
results, defects, skips = [], [], []
def rec(flow, case, verdict, ev=''):
results.append((flow, case, verdict, ev))
print(f" [{verdict}] {flow} · {case}" + (f" —— {ev}" if ev else ''))
def defect(flow, title, level, ev=''):
defects.append({'flow': flow, 'title': title, 'level': level, 'evidence': ev})
print(f" [❌缺陷P{level}] {flow} · {title}" + (f" —— {ev}" if ev else ''))
class Sess:
def __init__(self, key, uid):
r = requests.get(f'{BASE}/api/auth/debug/token', params={'userId': uid}, timeout=15)
b = r.json()
assert b.get('code') == 0, f'debug token 失败 {b}'
self.h = {'Authorization': f"Bearer {b['data']}"}
def raw(self, method, path, form=None, params=None, _retry=True):
headers = dict(self.h)
data = None
if form is not None:
headers['Content-Type'] = 'application/x-www-form-urlencoded'
data = form
r = requests.request(method, BASE + path, headers=headers, data=data, params=params, timeout=30)
if r.status_code == 401 and _retry:
r = requests.request(method, BASE + path, headers=headers, data=data, params=params, timeout=30)
r.encoding = 'utf-8' # 响应无 charset 时 requests 默认 ISO-8859-1 → 出参中文 mojibake
return r
def api(self, method, path, form=None, params=None, step=''):
try:
r = self.raw(method, path, form, params)
b = r.json()
except Exception as e:
print(f" [EXC] {step or path}: {e}")
return None
if b.get('code') != 0:
print(f" [ERR] {step or path}: code={b.get('code')} msg={b.get('message')}")
return None
return b.get('data')
def api_void(self, method, path, form=None, params=None, step=''):
try:
r = self.raw(method, path, form, params)
b = r.json()
except Exception as e:
print(f" [EXC] {step or path}: {e}")
return False
if b.get('code') != 0:
print(f" [ERR] {step or path}: code={b.get('code')} msg={b.get('message')}")
return False
return True
SES = {}
def sess(k='A'):
if k not in SES:
SES[k] = Sess(k, UID[k])
return SES[k]
_MF = json.load(open(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'seed-manifest.json'), encoding='utf-8'))
def _oid(part):
for o in _MF['opportunities']:
if part in o['name']:
return str(o['id'])
raise SystemExit(f'seed-manifest 找不到商机: {part}')
A1 = _oid('智慧园区一期')
ST_PATH = '/api/rule/opp-stage-template'
SC_PATH = '/api/rule/opp-scheme-template'
PR_PATH = '/api/rule/opp-pool-rule'
def form_nodes(items):
"""List<NodeItem> 表单绑定:Spring 索引语法 nodes[0].stageDictCode=..."""
f = {}
for i, n in enumerate(items):
for k, v in n.items():
if v is not None:
f[f'nodes[{i}].{k}'] = v
return f
def form_fields(items):
f = {}
for i, n in enumerate(items):
for k, v in n.items():
if v is not None:
f[f'fields[{i}].{k}'] = v
return f
def page_rows(s, path, keyword=None, status=None, size=100):
form = {'current': 1, 'size': size}
if keyword:
form['keyword'] = keyword
if status:
form['status'] = status
d = s.api('POST', f'{path}/page', form=form, step=f'{path} page')
return ((d or {}).get('content') or [], int((d or {}).get('total') or 0))
def by_name(rows, name, status=None):
for r in rows:
nm = r.get('templateName') or r.get('ruleName')
if nm == name and (status is None or r.get('status') == status):
return r
return None
def expect_reject(s, flow, case, method, path, form=None, params=None):
"""负路径:期望被拒(code!=0)。被拒 ✅ 记证据;通过 ⚠+defect。"""
try:
r = s.raw(method, path, form, params)
b = r.json()
except Exception as e:
rec(flow, case, '', f'异常 {e}'); return
if b.get('code') != 0:
rec(flow, case, '', f"被拒: code={b.get('code')} msg={b.get('message')}")
else:
rec(flow, case, '', '未被拦截!')
defect(flow, f'{case} 未被拦截', 1, f'{method} {path} 通过')
# ================= R01 阶段模板族 =================
def r_st():
a = sess()
st_name = f'{TAG}-ST模板'
print('\n== R01 阶段模板(V-CONFIG) ==')
# 基线 + 契约:绑定商机数归商机域统计,不在本接口回显(Controller 注释口径)
rows, total = page_rows(a, ST_PATH)
bind_keys = [k for r in rows[:3] for k in r if 'bind' in k.lower() or 'opportunity' in k.lower()]
rec('R01', 'page 列表(按版本维度)', '' if rows else '', f'total={total} 绑定数字段={bind_keys or "无(符合注释契约)"}')
seed_rows = page_rows(a, ST_PATH, keyword='OPP_STAGE_TPL_01')[0] # 按 code 精确定位种子模板
seed_row = next((r for r in seed_rows if r.get('templateCode') == 'OPP_STAGE_TPL_01'), None)
det0 = a.api('GET', f'{ST_PATH}/detail', params={'id': seed_row['id']}, step='种子模板 detail') if seed_row else None
nodes0 = (det0 or {}).get('nodes') or []
fixed_ok = bool(nodes0) and nodes0[-1].get('stageDictCode') == 'OPP_STAGE_05' and nodes0[-1].get('isFixed') == 1
rec('R01', '种子模板节点结构(末位固定节点 isFixed=1)', '' if fixed_ok else '',
f"节点 {len(nodes0)} 个 末位={nodes0[-1].get('stageDictCode') if nodes0 else '-'}")
# 负路径
expect_reject(a, 'R01', 'save-draft 缺模板名', 'POST', f'{ST_PATH}/save-draft',
form={'applyScope': 1, 'isDefault': 0})
expect_reject(a, 'R01', 'applyScope=2 无部门', 'POST', f'{ST_PATH}/save-draft',
form={'templateName': st_name, 'applyScope': 2, 'isDefault': 0})
expect_reject(a, 'R01', '部门专用设默认', 'POST', f'{ST_PATH}/save-draft',
form={'templateName': st_name, 'applyScope': 2, 'isDefault': 1, 'deptIds': '1'})
expect_reject(a, 'R01', '固定节点手工配置(OPP_STAGE_05)', 'POST', f'{ST_PATH}/save-draft',
{**form_nodes([{'stageDictCode': 'OPP_STAGE_05'}]),
'templateName': st_name, 'applyScope': 1, 'isDefault': 0})
# 无节点草稿可暂存 → publish 被拒
ok = a.api_void('POST', f'{ST_PATH}/save-draft',
form={'templateName': f'{st_name}-空', 'applyScope': 1, 'isDefault': 0}, step='空草稿 save-draft')
empty_row = by_name(page_rows(a, ST_PATH, keyword=f'{st_name}-空')[0], f'{st_name}-空')
rec('R01', '无节点草稿可暂存', '' if ok and empty_row else '')
if empty_row:
expect_reject(a, 'R01', '空模板 publish(除固定节点外至少一个)', 'POST', f'{ST_PATH}/publish',
form={'id': empty_row['id'], 'templateName': f'{st_name}-空', 'applyScope': 1, 'isDefault': 0})
a.api_void('POST', f'{ST_PATH}/delete', params={'id': empty_row['id']}, step='空草稿 delete')
# 新建草稿:2 节点 → 系统补末位固定节点 + seq 重算
ok = a.api_void('POST', f'{ST_PATH}/save-draft',
form={'templateName': st_name, 'applyScope': 1, 'isDefault': 0, 'templateDesc': '票08 R01',
**form_nodes([{'stageDictCode': 'OPP_STAGE_01', 'workGoal': '需求确认'},
{'stageDictCode': 'OPP_STAGE_02', 'customNodeName': '方案深化'}])},
step='新建草稿')
row = by_name(page_rows(a, ST_PATH, keyword=st_name)[0], st_name, status=1)
d = a.api('GET', f'{ST_PATH}/detail', params={'id': row['id']}, step='草稿 detail') if row else None
nodes = (d or {}).get('nodes') or []
good = ok and d and d.get('status') == 1 and d.get('versionNo') == 'V1.0' and d.get('templateCode') \
and len(nodes) == 3 and nodes[-1].get('isFixed') == 1 and nodes[1].get('seqNo') == 2
rec('R01', '新建草稿 V1.0 + 节点规范化(末位系统补固定节点)', '' if good else '',
f"code={d and d.get('templateCode')} v={d and d.get('versionNo')} 节点={len(nodes)}")
tpl_id, tpl_code = (row or {}).get('id'), (d or {}).get('templateCode')
# 草稿原位编辑:版本/编码不变
a.api_void('POST', f'{ST_PATH}/save-draft',
form={'id': tpl_id, 'templateName': st_name, 'applyScope': 1, 'isDefault': 0,
**form_nodes([{'stageDictCode': 'OPP_STAGE_01'},
{'stageDictCode': 'OPP_STAGE_02', 'customNodeName': '方案深化'},
{'stageDictCode': 'OPP_STAGE_03', 'workGoal': '投标'}])},
step='草稿原位编辑')
d2 = a.api('GET', f'{ST_PATH}/detail', params={'id': tpl_id}, step='编辑后 detail')
vs = a.api('GET', f'{ST_PATH}/versions', params={'id': tpl_id}, step='versions') or []
rec('R01', '草稿原位编辑(版本编码不变、versions 仍 1 行)',
'' if d2 and d2.get('versionNo') == 'V1.0' and len(vs) == 1 else '',
f"v={d2 and d2.get('versionNo')} versions={len(vs)}")
# publish → 发布中;copy → 独立新模板;disable 草稿被拒;delete 草稿级联
a.api_void('POST', f'{ST_PATH}/publish',
form={'id': tpl_id, 'templateName': st_name, 'applyScope': 1, 'isDefault': 0,
**form_nodes([{'stageDictCode': 'OPP_STAGE_01'},
{'stageDictCode': 'OPP_STAGE_02', 'customNodeName': '方案深化'},
{'stageDictCode': 'OPP_STAGE_03', 'workGoal': '投标'}])},
step='publish')
dp = a.api('GET', f'{ST_PATH}/detail', params={'id': tpl_id}, step='发布后 detail')
rec('R01', 'publish → 发布中', '' if dp and dp.get('status') == 2 else '', f"status={dp and dp.get('status')}")
cid = a.api('POST', f'{ST_PATH}/copy', params={'id': tpl_id}, step='copy')
dc = a.api('GET', f'{ST_PATH}/detail', params={'id': cid}, step='copy detail') if cid else None
cgood = cid and dc and dc.get('status') == 1 and dc.get('versionNo') == 'V1.0' \
and dc.get('templateCode') != tpl_code and len(dc.get('nodes') or []) == 4
rec('R01', 'copy → 独立新编码 V1.0 草稿(节点随复制)', '' if cgood else '',
f"newCode={dc and dc.get('templateCode')} 节点={len((dc or {}).get('nodes') or [])}")
if cid:
expect_reject(a, 'R01', 'disable 草稿(仅发布中可停用)', 'POST', f'{ST_PATH}/disable', params={'id': cid})
a.api_void('POST', f'{ST_PATH}/delete', params={'id': cid}, step='copy 草稿 delete')
gone = a.api('GET', f'{ST_PATH}/detail', params={'id': cid}, step='删后 detail')
rec('R01', 'delete 草稿(级联删节点/部门)', '' if gone is None else '')
# versions 出参(实体直出检查 ADR-0017)
vs = a.api('GET', f'{ST_PATH}/versions', params={'id': tpl_id}, step='versions') or []
ent_keys = sorted(vs[0].keys()) if vs else []
leak = [k for k in ent_keys if k in ('deleted', 'creatorId', 'updaterId')]
rec('R01', 'versions 出参(实体直出检查 ADR-0017)', '' if leak else '',
f"内部字段泄露={leak or ''} keys={ent_keys[:12]}")
if leak:
defect('R01', 'versions 返回实体:出参泄露内部字段(违 ADR-0017 Entity 禁令/deleted 泄露)', 2, f'{leak}')
# 编辑发布中 → 生新草稿 V1.1(源码 resolveDraft:非草稿态同 code 生新草稿、已有草稿则拒)
r = a.raw('POST', f'{ST_PATH}/save-draft',
form={'id': tpl_id, 'templateName': st_name + '-V1.1', 'applyScope': 1, 'isDefault': 0,
**form_nodes([{'stageDictCode': 'OPP_STAGE_01', 'workGoal': '需求确认(改)'},
{'stageDictCode': 'OPP_STAGE_02'},
{'stageDictCode': 'OPP_STAGE_03'},
{'stageDictCode': 'OPP_STAGE_04'}])})
b = r.json()
if b.get('code') == 0:
rec('R01', '编辑发布中 → 生新草稿 V1.1', '')
else:
rec('R01', '编辑发布中 → 生新草稿 V1.1', '', f"code={b.get('code')} msg={b.get('message')}")
defect('R01', '编辑发布中生成新草稿 50001 主键冲突(resolveDraft 新草稿 insert 携带源版本 id,版本推进 API 不可用)', 1,
'日志: Duplicate entry for opportunity_stage_template.PRIMARY;三族同构(R02/R03 同现象)')
rec('R01', 'publish V1.1 自我顶替(V1.0 转停用)', '',
'被上条 P1 阻断(新草稿无法生成);顶替语义旁证:票07 U02 发布禁领版时 seed 规则被顶替取消默认')
# 锁版本不迁移:A1 商机绑种子模板行,三族测试全程节点集不变
p = a.api('GET', '/api/opportunity/stage/progress', params={'oppId': A1}, step='A1 progress')
n = len((p or {}).get('nodes') or [])
rec('R01', '绑定锁版本:A1 商机阶段节点集不受新模板发布影响', '' if n == len(nodes0) else '',
f'A1 progress {n} 节点(种子 {len(nodes0)}')
# 种子保护(D-01,安全负路径)
if seed_row:
expect_reject(a, 'R01', 'disable 种子模板(D-01 系统预设保护)', 'POST', f'{ST_PATH}/disable',
params={'id': seed_row['id']})
# ================= R02 方案卡模板族 =================
def r_sc():
a = sess()
sc_name = f'{TAG}-SC模板'
print('\n== R02 方案卡模板(V-CONFIG) ==')
defs = a.api('GET', f'{SC_PATH}/field-defs', step='field-defs')
rec('R02', 'field-defs 系统字段库', '' if defs else '', f"{len(defs or [])} 项 keys={sorted((defs[0]).keys()) if defs else '-'}")
fkeys = [f.get('fieldKey') for f in (defs or [])]
f3 = fkeys[:3]
rows, total = page_rows(a, SC_PATH)
rec('R02', 'page 列表', '' if rows else '', f'total={total}')
expect_reject(a, 'R02', 'save-draft 缺模板名', 'POST', f'{SC_PATH}/save-draft',
form={'applyScope': 1, 'isDefault': 0})
expect_reject(a, 'R02', 'applyScope=2 无部门', 'POST', f'{SC_PATH}/save-draft',
form={'templateName': sc_name, 'applyScope': 2, 'isDefault': 0})
expect_reject(a, 'R02', '伪造 fieldKey(快照防伪造)', 'POST', f'{SC_PATH}/save-draft',
form={'templateName': sc_name, 'applyScope': 1, 'isDefault': 0,
**form_fields([{'fieldKey': 'e2e_fake_field_no_exist'}])})
# 无字段项草稿 → publish 拒 → 原位填字段 → publish
a.api_void('POST', f'{SC_PATH}/save-draft',
form={'templateName': sc_name, 'applyScope': 1, 'isDefault': 0, 'templateDesc': '票08 R02'},
step='空草稿 save-draft')
row = by_name(page_rows(a, SC_PATH, keyword=sc_name)[0], sc_name, status=1)
if row:
expect_reject(a, 'R02', '空模板 publish(至少一个字段项)', 'POST', f'{SC_PATH}/publish',
form={'id': row['id'], 'templateName': sc_name, 'applyScope': 1, 'isDefault': 0})
# 关显示清必填规范化:isVisible=0 + isRequired=1
a.api_void('POST', f'{SC_PATH}/save-draft',
form={'id': row['id'], 'templateName': sc_name, 'applyScope': 1, 'isDefault': 0,
**form_fields([{'fieldKey': f3[0], 'isVisible': 1, 'isRequired': 1},
{'fieldKey': f3[1], 'isVisible': 1, 'isRequired': 0},
{'fieldKey': f3[2], 'isVisible': 0, 'isRequired': 1}])},
step='草稿填字段')
d = a.api('GET', f'{SC_PATH}/detail', params={'id': row['id']}, step='草稿 detail') if row else None
fs = (d or {}).get('fields') or []
hidden = next((x for x in fs if x.get('fieldKey') == f3[2]), {}) if fs else {}
snap_ok = bool(fs) and all(x.get('fieldName') and x.get('fieldType') for x in fs)
rec('R02', '字段项快照回显(fieldName/fieldType 服务层写入)', '' if snap_ok else '',
f"fields={[(x.get('fieldKey'), x.get('isVisible'), x.get('isRequired')) for x in fs]}")
rec('R02', '关显示同步清必填(isVisible=0 → isRequired=0)',
'' if hidden.get('isRequired') == 0 else ('' if not hidden else ''),
f"hidden.isRequired={hidden.get('isRequired')}")
if hidden.get('isRequired') == 1:
defect('R02', '关显示未同步清必填(isVisible=0 且 isRequired=1 落库)', 2, f'{f3[2]}')
sc_id = (row or {}).get('id')
a.api_void('POST', f'{SC_PATH}/publish',
form={'id': sc_id, 'templateName': sc_name, 'applyScope': 1, 'isDefault': 0,
**form_fields([{'fieldKey': f3[0], 'isVisible': 1, 'isRequired': 1},
{'fieldKey': f3[1], 'isVisible': 1, 'isRequired': 0},
{'fieldKey': f3[2], 'isVisible': 0}])},
step='publish')
dp = a.api('GET', f'{SC_PATH}/detail', params={'id': sc_id}, step='发布后 detail')
rec('R02', 'publish → 发布中', '' if dp and dp.get('status') == 2 else '')
cid = a.api('POST', f'{SC_PATH}/copy', params={'id': sc_id}, step='copy') if sc_id else None
dc = a.api('GET', f'{SC_PATH}/detail', params={'id': cid}, step='copy detail') if cid else None
rec('R02', 'copy → 独立新编码 V1.0 草稿(字段随复制)',
'' if cid and dc and dc.get('status') == 1 and dc.get('templateCode') != (d or {}).get('templateCode')
and len(dc.get('fields') or []) == 3 else '')
if cid:
expect_reject(a, 'R02', 'disable 草稿', 'POST', f'{SC_PATH}/disable', params={'id': cid})
a.api_void('POST', f'{SC_PATH}/delete', params={'id': cid}, step='copy 草稿 delete')
# 编辑发布中 → 生新草稿 V1.1(源码路径同 R01)
r = a.raw('POST', f'{SC_PATH}/save-draft',
form={'id': sc_id, 'templateName': sc_name + '-V1.1', 'applyScope': 1, 'isDefault': 0,
**form_fields([{'fieldKey': f3[0], 'isVisible': 1, 'isRequired': 1},
{'fieldKey': f3[1], 'isVisible': 1, 'isRequired': 1}])})
b = r.json()
if b.get('code') == 0:
rec('R02', '编辑发布中 → 生新草稿 V1.1', '')
else:
rec('R02', '编辑发布中 → 生新草稿 V1.1', '', f"code={b.get('code')} msg={b.get('message')}")
defect('R02', '编辑发布中生成新草稿 50001 主键冲突(同 R01 同构缺陷)', 1, '同 R01 根因')
rec('R02', 'publish V1.1 自我顶替(V1.0 转停用)', '', '被 P1 阻断;旁证=票07 U02 顶替副作用')
# ================= R03 公海规则族 =================
def a_owner_dept():
"""A1 商机 owner_dept_id 直查 + 全表非空统计(U03 缺陷证据锚点,seed 同款豁免先例)。"""
import pymysql
conn = pymysql.connect(host='8.129.84.155', port=3306, user='root', password='Itc@123456',
database='crm', charset='utf8mb4', autocommit=True)
cur = conn.cursor()
cur.execute('SELECT owner_dept_id FROM opportunity WHERE id=%s', (int(A1),))
row = cur.fetchone()
cur.execute('SELECT COUNT(*), SUM(owner_dept_id IS NOT NULL) FROM opportunity')
total, non_null = cur.fetchone()
conn.close()
return (str(row[0]) if row and row[0] else None), int(total or 0), int(non_null or 0)
def a_dept_id():
"""sys_dept 任一真实部门 id(部门专用规则 deptIds 配置层验证用,不依赖商机命中)。"""
import pymysql
conn = pymysql.connect(host='8.129.84.155', port=3306, user='root', password='Itc@123456',
database='crm', charset='utf8mb4', autocommit=True)
cur = conn.cursor()
cur.execute('SELECT id FROM sys_dept WHERE deleted=0 AND parent_id=0 ORDER BY create_time DESC LIMIT 1') # 最新 = seed 造的部门(最老一条 dept_name 是历史 mojibake 存量)
row = cur.fetchone()
conn.close()
return str(row[0]) if row else None
def r_pr():
a = sess()
pr_name = f'{TAG}-PR规则'
print('\n== R03 公海规则(V-CONFIG + U03 配置生效) ==')
rows, total = page_rows(a, PR_PATH)
seed_rule = by_name(rows, 'e2e-seed公海规则')
rec('R03', 'page 列表(seed 规则在场)', '' if seed_rule else '',
f"total={total} seed={'发布中' if seed_rule and seed_rule.get('status') == 2 else seed_rule and seed_rule.get('status')}")
expect_reject(a, 'R03', 'save-draft 缺规则名', 'POST', f'{PR_PATH}/save-draft',
form={'applyScope': 1, 'isDefault': 0})
expect_reject(a, 'R03', '开回收缺 recycleDays', 'POST', f'{PR_PATH}/save-draft',
form={'ruleName': pr_name, 'applyScope': 1, 'isDefault': 0, 'allowManualPool': 1,
'allowFreeClaim': 1, 'autoRecycleEnabled': 1, 'recycleRemindEnabled': 0})
expect_reject(a, 'R03', 'recycleDays=0', 'POST', f'{PR_PATH}/save-draft',
form={'ruleName': pr_name, 'applyScope': 1, 'isDefault': 0, 'allowManualPool': 1, 'allowFreeClaim': 1,
'autoRecycleEnabled': 1, 'recycleRemindEnabled': 0, 'recycleDays': 0})
expect_reject(a, 'R03', 'remindDays ≥ recycleDays', 'POST', f'{PR_PATH}/save-draft',
form={'ruleName': pr_name, 'applyScope': 1, 'isDefault': 0, 'allowManualPool': 1,
'allowFreeClaim': 1, 'autoRecycleEnabled': 1, 'recycleDays': 7,
'recycleRemindEnabled': 1, 'remindDays': 7})
expect_reject(a, 'R03', '未开回收先开提醒', 'POST', f'{PR_PATH}/save-draft',
form={'ruleName': pr_name, 'applyScope': 1, 'isDefault': 0, 'allowManualPool': 1,
'allowFreeClaim': 1, 'autoRecycleEnabled': 0,
'recycleRemindEnabled': 1, 'remindDays': 3})
# 正路径:五配置项齐备(isDefault=0 不碰默认位)
ok = a.api_void('POST', f'{PR_PATH}/save-draft',
form={'ruleName': pr_name, 'applyScope': 1, 'isDefault': 0, 'ruleDesc': '票08 R03',
'allowManualPool': 1, 'allowFreeClaim': 1, 'autoRecycleEnabled': 1,
'recycleDays': 30, 'recycleRemindEnabled': 1, 'remindDays': 7},
step='新建草稿')
row = by_name(page_rows(a, PR_PATH, keyword=pr_name)[0], pr_name, status=1)
a.api_void('POST', f'{PR_PATH}/publish',
form={'id': (row or {}).get('id'), 'ruleName': pr_name, 'applyScope': 1, 'isDefault': 0,
'allowManualPool': 1, 'allowFreeClaim': 1, 'autoRecycleEnabled': 1,
'recycleDays': 30, 'recycleRemindEnabled': 1, 'remindDays': 7},
step='publish')
rows = page_rows(a, PR_PATH, keyword=pr_name)[0]
pub = by_name(rows, pr_name, status=2)
rec('R03', 'publish → 发布中(五配置项落库)', '' if ok and pub else '',
f"回收=30天 提醒=7天 手动抛池=1 自由领取=1")
# U03:部门专用 allowManualPool=0 配置生效观察(不占默认位,测完即停用)
owner_dept, opp_total, opp_nonnull = a_owner_dept()
if not owner_dept:
defect('R03', '商机 owner_dept_id 恒空:创建/领取/分配/移交均不写部门快照 → 部门专用公海规则(applyScope=2)对任何商机永不命中,DataScope 部门天花板同源失效', 1,
f'DB 直查: opportunity 全表 {opp_total} 行 owner_dept_id 非空仅 {opp_nonnull} 行(含 seed 与 F01 新建,历经 claim/assign/handover 仍空)')
dept = a_dept_id()
if not dept:
rec('R03', 'U03 部门专用规则验证', '', 'sys_dept 无可用部门 id,配置层验证跳过')
else:
dn = f'{TAG}-PR禁手动'
a.api_void('POST', f'{PR_PATH}/save-draft',
form={'ruleName': dn, 'applyScope': 2, 'isDefault': 0, 'deptIds': dept,
'allowManualPool': 0, 'allowFreeClaim': 1, 'autoRecycleEnabled': 0,
'recycleRemindEnabled': 0},
step='部门专用禁手动草稿')
drow = by_name(page_rows(a, PR_PATH, keyword=dn)[0], dn, status=1)
if drow:
a.api_void('POST', f'{PR_PATH}/publish',
form={'id': drow['id'], 'ruleName': dn, 'applyScope': 2, 'isDefault': 0, 'deptIds': dept,
'allowManualPool': 0, 'allowFreeClaim': 1, 'autoRecycleEnabled': 0,
'recycleRemindEnabled': 0},
step='publish 部门专用')
dpub = by_name(page_rows(a, PR_PATH, keyword=dn)[0], dn, status=2)
dd = a.api('GET', f'{PR_PATH}/detail', params={'id': drow['id']}, step='部门专用 detail') if dpub else None
rec('R03', 'U03 配置层:部门专用规则发布 + deptIds 绑定回显', '' if dpub and dd else '',
f"deptIds={dd and dd.get('deptIds')} deptNames={dd and dd.get('deptNames')}")
# 生效层观察:owner_dept_id 恒空 → 匹配器部门索引查不到 → 规则不命中(与缺陷一致的预期放行)
r = a.raw('POST', '/api/opportunity/release-pool',
form={'id': A1, 'poolReason': 'pool_reason_02'})
b = r.json()
if b.get('code') == 0:
rec('R03', 'U03 生效层:部门专用禁手动未拦截 release-pool(按缺陷预期放行)', '',
'owner_dept_id 空不命中部门索引 → 放行;根因已被缺陷覆盖')
a.api_void('POST', '/api/opportunity/claim', params={'id': A1}, step='A1 领回原位')
else:
rec('R03', 'U03 生效层:allowManualPool=0 拦截手动抛公海', '',
f"被拒: code={b.get('code')} msg={b.get('message')}(意外命中——若稳定复现则缺陷降级)")
a.api_void('POST', f'{PR_PATH}/disable', params={'id': drow['id']}, step='停用部门专用规则(恢复)')
# 编辑发布中 → 生新草稿 V1.1(源码路径同 R01)
r = a.raw('POST', f'{PR_PATH}/save-draft',
form={'id': (pub or {}).get('id'), 'ruleName': pr_name + '-V1.1', 'applyScope': 1, 'isDefault': 0,
'allowManualPool': 0, 'allowFreeClaim': 0, 'autoRecycleEnabled': 1,
'recycleDays': 15, 'recycleRemindEnabled': 1, 'remindDays': 3})
b = r.json()
if b.get('code') == 0:
rec('R03', '编辑发布中 → 生新草稿 V1.1', '')
else:
rec('R03', '编辑发布中 → 生新草稿 V1.1', '', f"code={b.get('code')} msg={b.get('message')}")
defect('R03', '编辑发布中生成新草稿 50001 主键冲突(同 R01 同构缺陷)', 1, '同 R01 根因')
rec('R03', 'publish V1.1 自我顶替(V1.0 转停用)', '', '被 P1 阻断;旁证=票07 U02 顶替副作用')
# ================= 清理:不留脏发布中/草稿规则 =================
def cleanup():
a = sess()
print('\n== 清理自建规则(三族 e2e- 前缀) ==')
for flow, path, nmkey in (('R01', ST_PATH, 'templateName'), ('R02', SC_PATH, 'templateName'), ('R03', PR_PATH, 'ruleName')):
rows, _ = page_rows(a, path, keyword='e2e-')
for r in rows:
nm = r.get(nmkey) or ''
if not nm.startswith('e2e-') or nm.startswith('e2e-seed'):
continue # e2e-seed公海规则 是 F14 前置,保留
rid, st = r['id'], r.get('status')
if st == 2:
a.api_void('POST', f'{path}/disable', params={'id': rid}, step=f'cleanup disable {nm}')
elif st == 1:
a.api_void('POST', f'{path}/delete', params={'id': rid}, step=f'cleanup delete {nm}')
rows2, _ = page_rows(a, path, keyword='e2e-')
alive_pub = [r.get(nmkey) for r in rows2
if r.get('status') == 2 and not (r.get(nmkey) or '').startswith('e2e-seed')]
rec(flow, '清理后无发布中自建规则', '' if not alive_pub else '', f'残留发布中={alive_pub or ""}')
# seed 前置完好性
a2 = sess()
seed_rule = by_name(page_rows(a2, PR_PATH, keyword='e2e-seed公海规则')[0], 'e2e-seed公海规则')
rec('R03', 'seed 公海规则仍发布中(F14 前置未破坏)', '' if seed_rule and seed_rule.get('status') == 2 else '')
FLOWS = {'ST': r_st, 'SC': r_sc, 'PR': r_pr, 'CLEANUP': cleanup}
if __name__ == '__main__':
want = [x.upper() for x in sys.argv[1:]] or list(FLOWS)
for k, f in FLOWS.items():
if k in want:
f()
lines = ['# 票 08 — 商机规则三族 V-CONFIG 实测报告(report-rules.md)', '',
f'- 运行时间:{datetime.now().strftime("%Y-%m-%d %H:%M:%S")} 自建规则前缀:{TAG}',
f'- 汇总:✅ {sum(1 for r in results if r[2] == "")} / ⚠ {sum(1 for r in results if r[2] == "")} / '
f'{sum(1 for r in results if r[2] == "")} · 缺陷 {len(defects)} 条 · SKIP {len(skips)}',
'', '## 明细', '']
for fl, case, v, ev in results:
lines.append(f'- [{v}] {fl} · {case}' + (f' —— {ev}' if ev else ''))
lines += ['', '## 缺陷清单(只记录不修复)', '']
for i, d in enumerate(defects, 1):
lines.append(f"{i}. **[P{d['level']}] {d['flow']} · {d['title']}** —— {d['evidence']}")
if not defects:
lines.append('(无新增缺陷)')
lines += ['', '## SKIP', '']
for f_, r_ in skips:
lines.append(f'- {f_}{r_}')
base = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(base, 'report-rules.md'), 'w', encoding='utf-8') as fp:
fp.write('\n'.join(lines))
with open(os.path.join(base, 'e2e-rules-result.json'), 'w', encoding='utf-8') as fp:
json.dump({'results': [dict(zip(('flow', 'case', 'verdict', 'evidence'), r)) for r in results],
'defects': defects, 'skips': skips}, fp, ensure_ascii=False, indent=1)
print(f'\n===== 汇总:✅ {sum(1 for r in results if r[2] == "")} / '
f'{sum(1 for r in results if r[2] == "")} / ❌ {sum(1 for r in results if r[2] == "")}'
f' · 缺陷 {len(defects)} 条 · SKIP {len(skips)} 项 =====')
print('报告已写 report-rules.md / e2e-rules-result.json')