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.
574 lines
34 KiB
574 lines
34 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()
|
|
d11 = None
|
|
if b.get('code') == 0:
|
|
rec('R01', '编辑发布中 → 生新草稿 V1.1', '✅')
|
|
d11 = by_name(page_rows(a, ST_PATH, keyword=st_name + '-V1.1')[0], st_name + '-V1.1', status=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 同现象)')
|
|
# publish V1.1 → 自我顶替(D-10 修复端到端,票 09 补真实断言替代占位 ⚠)
|
|
if d11:
|
|
a.api_void('POST', f'{ST_PATH}/publish',
|
|
form={'id': d11['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'}])},
|
|
step='publish V1.1')
|
|
v10 = by_name(page_rows(a, ST_PATH, keyword=st_name)[0], st_name, status=3)
|
|
rec('R01', 'publish V1.1 自我顶替(V1.0 转停用)', '✅' if v10 else '⚠',
|
|
'V1.0 转停用 status=3(自我顶替成立)' if v10 else 'publish V1.1 后 V1.0 未转停用')
|
|
else:
|
|
rec('R01', 'publish V1.1 自我顶替(V1.0 转停用)', '⚠', '新草稿未生成(D-10 回归?),顶替无法端到端验证')
|
|
|
|
# 锁版本不迁移: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()
|
|
d11 = None
|
|
if b.get('code') == 0:
|
|
rec('R02', '编辑发布中 → 生新草稿 V1.1', '✅')
|
|
d11 = by_name(page_rows(a, SC_PATH, keyword=sc_name + '-V1.1')[0], sc_name + '-V1.1', status=1)
|
|
else:
|
|
rec('R02', '编辑发布中 → 生新草稿 V1.1', '❌', f"code={b.get('code')} msg={b.get('message')}")
|
|
defect('R02', '编辑发布中生成新草稿 50001 主键冲突(同 R01 同构缺陷)', 1, '同 R01 根因')
|
|
# publish V1.1 → 自我顶替(D-11 修复端到端,票 09 补真实断言替代占位 ⚠)
|
|
if d11:
|
|
a.api_void('POST', f'{SC_PATH}/publish',
|
|
form={'id': d11['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}])},
|
|
step='publish V1.1')
|
|
v10 = by_name(page_rows(a, SC_PATH, keyword=sc_name)[0], sc_name, status=3)
|
|
rec('R02', 'publish V1.1 自我顶替(V1.0 转停用)', '✅' if v10 else '⚠',
|
|
'V1.0 转停用 status=3(自我顶替成立)' if v10 else 'publish V1.1 后 V1.0 未转停用')
|
|
else:
|
|
rec('R02', 'publish V1.1 自我顶替(V1.0 转停用)', '⚠', '新草稿未生成(D-11 回归?),顶替无法端到端验证')
|
|
|
|
|
|
# ================= 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')}")
|
|
# 生效层(D-09 裁决 retest-verdicts):仅 claim 接禁领守卫,release/assign 不拦(分配是禁领替代路径)→ 放行=正确
|
|
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 放行(D-09 裁决:仅 claim 接禁领)', '✅',
|
|
'owner_dept 非空命中部门专用规则仍放行(票03修复后 owner_dept 已落库)')
|
|
a.api_void('POST', '/api/opportunity/claim', params={'id': A1}, step='A1 领回原位')
|
|
else:
|
|
rec('R03', 'U03 生效层:release-pool 被拦', '⚠',
|
|
f"code={b.get('code')} msg={b.get('message')}(若为 66014 则实现超出裁决范围)")
|
|
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()
|
|
d11 = None
|
|
if b.get('code') == 0:
|
|
rec('R03', '编辑发布中 → 生新草稿 V1.1', '✅')
|
|
d11 = by_name(page_rows(a, PR_PATH, keyword=pr_name + '-V1.1')[0], pr_name + '-V1.1', status=1)
|
|
else:
|
|
rec('R03', '编辑发布中 → 生新草稿 V1.1', '❌', f"code={b.get('code')} msg={b.get('message')}")
|
|
defect('R03', '编辑发布中生成新草稿 50001 主键冲突(同 R01 同构缺陷)', 1, '同 R01 根因')
|
|
# publish V1.1 → 自我顶替(D-12 修复端到端,票 09 补真实断言替代占位 ⚠)
|
|
if d11:
|
|
a.api_void('POST', f'{PR_PATH}/publish',
|
|
form={'id': d11['id'], 'ruleName': pr_name + '-V1.1', 'applyScope': 1, 'isDefault': 0,
|
|
'allowManualPool': 0, 'allowFreeClaim': 0, 'autoRecycleEnabled': 1,
|
|
'recycleDays': 15, 'recycleRemindEnabled': 1, 'remindDays': 3},
|
|
step='publish V1.1')
|
|
v10 = by_name(page_rows(a, PR_PATH, keyword=pr_name)[0], pr_name, status=3)
|
|
rec('R03', 'publish V1.1 自我顶替(V1.0 转停用)', '✅' if v10 else '⚠',
|
|
'V1.0 转停用 status=3(自我顶替成立)' if v10 else 'publish V1.1 后 V1.0 未转停用')
|
|
else:
|
|
rec('R03', 'publish V1.1 自我顶替(V1.0 转停用)', '⚠', '新草稿未生成(D-12 回归?),顶替无法端到端验证')
|
|
|
|
|
|
# ================= 清理:不留脏发布中/草稿规则 =================
|
|
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')
|
|
|