# -*- 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 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 表单绑定: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')