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