"""票 03 — 修复运行时验证(D-13 四路径 owner_dept_id + D-10/11/12 三族编辑发布中 + U03 生效层)。 前置:服务运行中(含修复 jar);存量回填已 apply(affected=13,残留 0)。 复用 e2e-rules.py 的 Sess 封装模式(中文 body 用 Python requests;Result data=null 非失败)。 账号:ADMIN 罗伟健 / A 赖永利 / B 肖琴 / C 曾偲青(dept 映射开头 DB 直查打印)。 风险控制:三族自建规则测完即删草稿/停用发布中;U03 部门专用规则不占默认位、测完停用; 不碰种子模板(OPP_STAGE_TPL_01)/兜底方案卡/e2e-seed公海规则。 用法:python t03-verify.py [B|A|U|CLEANUP ...] # 无参全量 """ 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' UID = {'ADMIN': '739564171091247104', # 罗伟健 管理员 'A': '744842565802524672', # 赖永利 'B': '744842566742048768', # 肖琴 'C': '744842318024015872'} # 曾偲青 TAG = 't03-' + datetime.now().strftime('%H%M%S') results, fails = [], [] def rec(case, ok, ev=''): v = '✅' if ok else '❌' results.append((case, ok, ev)) if not ok: fails.append((case, ev)) print(f" [{v}] {case}" + (f" —— {ev}" if ev else '')) class Sess: def __init__(self, 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' return r def api(self, method, path, form=None, params=None, step=''): try: b = self.raw(method, path, form, params).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: b = self.raw(method, path, form, params).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): if k not in SES: SES[k] = Sess(UID[k]) return SES[k] def db(): import pymysql return pymysql.connect(host='8.129.84.155', port=3306, user='root', password='Itc@123456', database='crm', charset='utf8mb4', autocommit=True) def db_one(sql, args=None): conn = db() try: cur = conn.cursor() cur.execute(sql, args or ()) return cur.fetchone() finally: conn.close() def opp_dept(opp_id): row = db_one('SELECT owner_user_id, owner_dept_id FROM opportunity WHERE id=%s', (int(opp_id),)) return (str(row[0]) if row and row[0] else None, str(row[1]) if row and row[1] else None) 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): 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, size=100): form = {'current': 1, 'size': size} if keyword: form['keyword'] = keyword 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, nmkey=None): for r in rows: nm = r.get(nmkey) if nmkey else (r.get('templateName') or r.get('ruleName')) if nm == name and (status is None or r.get('status') == status): return r return None # ================= Part 0 预检 ================= DEPT = {} def part0(): print('\n== Part 0 预检(账号部门映射 / 回填残留 / 部门专用规则现场) ==') conn = db() cur = conn.cursor() cur.execute('SELECT id, username, dept_id FROM crm_auth_user WHERE id IN (%s,%s,%s,%s)', tuple(int(v) for v in UID.values())) for uid, uname, dept in cur.fetchall(): DEPT[str(uid)] = str(dept) if dept else None print(f" 账号 {uname} uid={uid} dept_id={dept}") conn.close() # 管理员罗伟健 crm_auth_user.dept_id=None 是数据现实(管理员无部门),不参与快照断言 tri = [k for k in UID if k != 'ADMIN'] rec('预检·A/B/C 部门映射齐备(管理员无部门为数据现实)', all(DEPT.get(UID[k]) for k in tri), f'map={DEPT}') row = db_one('SELECT COUNT(*) FROM opportunity WHERE owner_dept_id IS NULL AND owner_user_id IS NOT NULL AND deleted=0') rec('预检·存量回填残留(应为 0)', int(row[0]) == 0, f'残留 NULL 行={int(row[0])}') a = sess('ADMIN') dirty = [] for path in (ST_PATH, SC_PATH, PR_PATH): rows, _ = page_rows(a, path) dirty += [(path, r.get('templateName') or r.get('ruleName')) for r in rows if r.get('status') == 2 and (r.get('templateName') or r.get('ruleName') or '').startswith('t03-')] rec('预检·无 t03- 残留发布中规则', not dirty, f'{dirty or "无"}') # ================= Part B 四路径 owner_dept_id ================= def part_b(): print('\n== Part B D-13 四路径 owner_dept_id(创建 / assign / handover / claim) ==') a, b_, c = sess('A'), sess('B'), sess('C') deptA, deptB, deptC = DEPT[UID['A']], DEPT[UID['B']], DEPT[UID['C']] xa, xb = f'{TAG}-四路径X', f'{TAG}-四路径Y' # B1 创建:服务端按创建人(owner)部门落快照 xid = a.api('POST', '/api/opportunity', form={'opportunityName': xa, 'oppSource': 'opp_source_02', 'industryCode': 'gov', 'localityType': 'locality_type_01', 'bidForm': 'bid_form_01', 'partyAClear': 1, 'partyA': '票03甲方X', 'remark': '票03 D-13 四路径验证'}, step='创建 X') if not xid: rec('B1 创建→owner_dept_id=A 部门', False, '创建失败,后续中断') return xid = str(xid) ou, od = opp_dept(xid) det = a.api('GET', '/api/opportunity/detail', params={'id': xid}, step='X detail') or {} rec('B1 创建→owner_dept_id=A 部门', ou == UID['A'] and od == deptA, f"DB=({ou},{od}) 期望=({UID['A']},{deptA}) detail.ownerDeptId={det.get('ownerDeptId')}") # B2 抛池→assign:E2 分配语义=待领取(1)→推进中(2),前置=公海态(跟进中 assign 会 66003) ok = a.api_void('POST', '/api/opportunity/release-pool', form={'id': xid, 'poolReason': 'pool_reason_02'}, step='X 抛池进公海') ok = ok and a.api_void('POST', '/api/opportunity/assign', form={'id': xid, 'userId': UID['B']}, step='assign B') ou, od = opp_dept(xid) rec('B2 抛池→assign→B:快照跟人走', ok and ou == UID['B'] and od == deptB, f"DB=({ou},{od}) 期望=({UID['B']},{deptB})") # B3 handover:CAS 守卫 expectedOwnerUserId=B;ownerNameSnapshot 传参已删签名(ADR-0017 静默忽略) ok = b_.api_void('POST', '/api/opportunity/handover', form={'id': xid, 'expectedOwnerUserId': UID['B'], 'newOwnerUserId': UID['C'], 'ownerNameSnapshot': '残留参数应被忽略'}, step='handover B→C') ou, od = opp_dept(xid) rec('B3 handover→C:部门完整过户', ok and ou == UID['C'] and od == deptC, f"DB=({ou},{od}) 期望=({UID['C']},{deptC})") # B4 抛公海 → claim:新负责人 B 部门 yid = a.api('POST', '/api/opportunity', form={'opportunityName': xb, 'oppSource': 'opp_source_02', 'industryCode': 'gov', 'localityType': 'locality_type_01', 'bidForm': 'bid_form_01', 'partyAClear': 1, 'partyA': '票03甲方Y', 'remark': '票03 D-13 claim 路径'}, step='创建 Y') if not yid: rec('B4 release-pool→claim→快照=B 部门', False, 'Y 创建失败') return yid = str(yid) r = a.raw('POST', '/api/opportunity/release-pool', form={'id': yid, 'poolReason': 'pool_reason_02'}) bb = r.json() if bb.get('code') != 0: rec('B4 release-pool→claim→快照=B 部门', False, f"release-pool 被拦: code={bb.get('code')} msg={bb.get('message')}") return ok = b_.api_void('POST', '/api/opportunity/claim', params={'id': yid}, step='B claim') ou, od = opp_dept(yid) rec('B4 release-pool→claim→快照=B 部门', ok and ou == UID['B'] and od == deptB, f"DB=({ou},{od}) 期望=({UID['B']},{deptB})") # ================= Part A 三族编辑发布中→新草稿(不 50001) ================= def edit_published(s, path, pub_id, form, code_key, nmkey, name): """对发布中行 save-draft(带 id)→ 断言新草稿生成且不撞 50001。返回 (ok, ev)""" r = s.raw('POST', f'{path}/save-draft', form=form) b = r.json() if b.get('code') != 0: return False, f"编辑发布中被拒: code={b.get('code')} msg={b.get('message')}" rows, _ = page_rows(s, path, keyword=name) new = by_name(rows, name, status=1, nmkey=nmkey) if not new: return False, 'save-draft code=0 但找不到新草稿' d = s.api('GET', f'{path}/detail', params={'id': new['id']}, step='新草稿 detail') or {} ev = (f"newId={new['id']} ≠ srcId={pub_id};{code_key}={d.get(code_key)}(不变);" f"versionNo={d.get('versionNo')}(V1.0→顺延);status={d.get('status')}(1=草稿)") ok = (str(new['id']) != str(pub_id)) and d.get('versionNo') == 'V1.1' and d.get('status') == 1 return ok, ev def part_a(): print('\n== Part A D-10/11/12 三族「编辑发布中→新草稿」实测(不再 50001) ==') a = sess('ADMIN') # --- PR 公海规则 --- print(' -- PR 公海规则 --') name = f'{TAG}-PR规则' srow = by_name(page_rows(a, PR_PATH, keyword=name)[0], name, status=1, nmkey='ruleName') ok = a.api_void('POST', f'{PR_PATH}/save-draft', form={'ruleName': name, 'applyScope': 1, 'isDefault': 0, 'ruleDesc': '票03', 'allowManualPool': 1, 'allowFreeClaim': 1, 'autoRecycleEnabled': 1, 'recycleDays': 30, 'recycleRemindEnabled': 1, 'remindDays': 7}, step='PR 新建草稿') srow = by_name(page_rows(a, PR_PATH, keyword=name)[0], name, status=1, nmkey='ruleName') a.api_void('POST', f'{PR_PATH}/publish', form={'id': (srow or {}).get('id'), 'ruleName': name, 'applyScope': 1, 'isDefault': 0, 'allowManualPool': 1, 'allowFreeClaim': 1, 'autoRecycleEnabled': 1, 'recycleDays': 30, 'recycleRemindEnabled': 1, 'remindDays': 7}, step='PR publish') prow = by_name(page_rows(a, PR_PATH, keyword=name)[0], name, status=2, nmkey='ruleName') if prow: f = {'id': prow['id'], 'ruleName': name, 'applyScope': 1, 'isDefault': 0, 'allowManualPool': 0, 'allowFreeClaim': 0, 'autoRecycleEnabled': 1, 'recycleDays': 15, 'recycleRemindEnabled': 1, 'remindDays': 3} ok, ev = edit_published(a, PR_PATH, prow['id'], f, 'ruleCode', 'ruleName', name) rec('PR 编辑发布中→新草稿(不 50001,V1.1 顺延)', ok, ev) nd = by_name(page_rows(a, PR_PATH, keyword=name)[0], name, status=1, nmkey='ruleName') if nd: a.api_void('POST', f'{PR_PATH}/delete', params={'id': nd['id']}, step='PR 删新草稿') a.api_void('POST', f'{PR_PATH}/disable', params={'id': prow['id']}, step='PR 停用发布中') else: rec('PR 编辑发布中→新草稿(不 50001,V1.1 顺延)', False, 'publish 未生成发布中行') # --- ST 阶段模板 --- print(' -- ST 阶段模板 --') name = f'{TAG}-ST模板' nodes = [{'stageDictCode': 'OPP_STAGE_01', 'workGoal': '需求确认'}, {'stageDictCode': 'OPP_STAGE_02', 'customNodeName': '方案深化'}] a.api_void('POST', f'{ST_PATH}/save-draft', form={'templateName': name, 'applyScope': 1, 'isDefault': 0, 'templateDesc': '票03', **form_nodes(nodes)}, step='ST 新建草稿') srow = by_name(page_rows(a, ST_PATH, keyword=name)[0], name, status=1) a.api_void('POST', f'{ST_PATH}/publish', form={'id': (srow or {}).get('id'), 'templateName': name, 'applyScope': 1, 'isDefault': 0, **form_nodes(nodes)}, step='ST publish') prow = by_name(page_rows(a, ST_PATH, keyword=name)[0], name, status=2) if prow: f = {'id': prow['id'], 'templateName': name, 'applyScope': 1, 'isDefault': 0, **form_nodes([{'stageDictCode': 'OPP_STAGE_01', 'workGoal': '需求确认(改)'}, {'stageDictCode': 'OPP_STAGE_02'}, {'stageDictCode': 'OPP_STAGE_03'}, {'stageDictCode': 'OPP_STAGE_04'}])} ok, ev = edit_published(a, ST_PATH, prow['id'], f, 'templateCode', 'templateName', name) rec('ST 编辑发布中→新草稿(不 50001,V1.1 顺延)', ok, ev) nd = by_name(page_rows(a, ST_PATH, keyword=name)[0], name, status=1) if nd: a.api_void('POST', f'{ST_PATH}/delete', params={'id': nd['id']}, step='ST 删新草稿') a.api_void('POST', f'{ST_PATH}/disable', params={'id': prow['id']}, step='ST 停用发布中') else: rec('ST 编辑发布中→新草稿(不 50001,V1.1 顺延)', False, 'publish 未生成发布中行') # --- SC 方案卡模板 --- print(' -- SC 方案卡模板 --') name = f'{TAG}-SC模板' defs = a.api('GET', f'{SC_PATH}/field-defs', step='field-defs') or [] f3 = [f.get('fieldKey') for f in defs[:3]] fields = [{'fieldKey': f3[0], 'isVisible': 1, 'isRequired': 1}, {'fieldKey': f3[1], 'isVisible': 1, 'isRequired': 0}, {'fieldKey': f3[2], 'isVisible': 0}] a.api_void('POST', f'{SC_PATH}/save-draft', form={'templateName': name, 'applyScope': 1, 'isDefault': 0, 'templateDesc': '票03', **form_fields(fields)}, step='SC 新建草稿') srow = by_name(page_rows(a, SC_PATH, keyword=name)[0], name, status=1) a.api_void('POST', f'{SC_PATH}/publish', form={'id': (srow or {}).get('id'), 'templateName': name, 'applyScope': 1, 'isDefault': 0, **form_fields(fields)}, step='SC publish') prow = by_name(page_rows(a, SC_PATH, keyword=name)[0], name, status=2) if prow: f = {'id': prow['id'], 'templateName': name, 'applyScope': 1, 'isDefault': 0, **form_fields([{'fieldKey': f3[0], 'isVisible': 1, 'isRequired': 1}, {'fieldKey': f3[1], 'isVisible': 1, 'isRequired': 1}])} ok, ev = edit_published(a, SC_PATH, prow['id'], f, 'templateCode', 'templateName', name) rec('SC 编辑发布中→新草稿(不 50001,V1.1 顺延)', ok, ev) nd = by_name(page_rows(a, SC_PATH, keyword=name)[0], name, status=1) if nd: a.api_void('POST', f'{SC_PATH}/delete', params={'id': nd['id']}, step='SC 删新草稿') a.api_void('POST', f'{SC_PATH}/disable', params={'id': prow['id']}, step='SC 停用发布中') else: rec('SC 编辑发布中→新草稿(不 50001,V1.1 顺延)', False, 'publish 未生成发布中行') # ================= Part U U03 生效层 ================= def part_u(): print('\n== Part U U03 生效层:部门专用 allowManualPool=0 真拦截(修复前恒放行) ==') a = sess('ADMIN') deptC = DEPT[UID['C']] name = f'{TAG}-PR禁手动' a.api_void('POST', f'{PR_PATH}/save-draft', form={'ruleName': name, 'applyScope': 2, 'isDefault': 0, 'deptIds': deptC, 'allowManualPool': 0, 'allowFreeClaim': 1, 'autoRecycleEnabled': 0, 'recycleRemindEnabled': 0}, step='部门专用禁手动草稿') drow = by_name(page_rows(a, PR_PATH, keyword=name)[0], name, status=1, nmkey='ruleName') if not drow: rec('U03 部门专用规则发布', False, '草稿未找到') return a.api_void('POST', f'{PR_PATH}/publish', form={'id': drow['id'], 'ruleName': name, 'applyScope': 2, 'isDefault': 0, 'deptIds': deptC, 'allowManualPool': 0, 'allowFreeClaim': 1, 'autoRecycleEnabled': 0, 'recycleRemindEnabled': 0}, step='publish 部门专用') dpub = by_name(page_rows(a, PR_PATH, keyword=name)[0], name, status=2, nmkey='ruleName') rec('U03 部门专用规则发布(deptIds 绑定)', bool(dpub), f"deptIds={deptC} allowManualPool=0") # X 的 owner=C(deptC)→ release-pool 应被拦截;对照 Y owner=B 不受影响(B 部门无专用规则) conn = db() cur = conn.cursor() cur.execute("SELECT id, owner_dept_id FROM opportunity WHERE opp_name LIKE %s ORDER BY create_time DESC", (f'{TAG}%',)) t03_opps = cur.fetchall() conn.close() xid = next((str(r[0]) for r in t03_opps if str(r[1]) == deptC), None) yid = next((str(r[0]) for r in t03_opps if str(r[1]) == DEPT[UID['B']]), None) if not xid: rec('U03 生效层:拦截手动抛公海', False, '找不到 owner=C 部门的 t03 商机(先跑 B)') else: oc = sess('C') # C 是 X 当前 owner,由其发起抛池 r = oc.raw('POST', '/api/opportunity/release-pool', form={'id': xid, 'poolReason': 'pool_reason_02'}) b = r.json() if b.get('code') != 0: rec('U03 生效层:allowManualPool=0 拦截手动抛公海(修复前恒放行)', True, f"被拒: code={b.get('code')} msg={b.get('message')}(修复前 owner_dept_id 恒空不命中部门索引而放行)") else: rec('U03 生效层:allowManualPool=0 拦截手动抛公海(修复前恒放行)', False, '未被拦截!部门索引仍未命中,需回查') c_sess = sess('C') c_sess.api_void('POST', '/api/opportunity/claim', params={'id': xid}, step='X 领回原位') # 对照:B 部门商机不受该部门专用规则影响 if yid: ob = sess('B') r = ob.raw('POST', '/api/opportunity/release-pool', form={'id': yid, 'poolReason': 'pool_reason_02'}) b = r.json() if b.get('code') == 0: sess('B').api_void('POST', '/api/opportunity/claim', params={'id': yid}, step='Y 领回原位') rec('U03 对照:非绑定部门(B)商机不受影响', b.get('code') == 0, f"code={b.get('code')} msg={b.get('message')}") a.api_void('POST', f'{PR_PATH}/disable', params={'id': drow['id']}, step='停用部门专用规则(恢复现场)') # ================= CLEANUP ================= def cleanup(): a = sess('ADMIN') print('\n== CLEANUP:t03- 自建规则不留脏状态 ==') for path, nmkey in ((ST_PATH, 'templateName'), (SC_PATH, 'templateName'), (PR_PATH, 'ruleName')): rows, _ = page_rows(a, path, keyword='t03-') for r in rows: nm = r.get(nmkey) or '' if not nm.startswith('t03-'): continue rid, st = r['id'], r.get('status') if st == 2: a.api_void('POST', f'{path}/disable', params={'id': rid}, step=f'disable {nm}') elif st == 1: a.api_void('POST', f'{path}/delete', params={'id': rid}, step=f'delete {nm}') dirty = [] for path, nmkey in ((ST_PATH, 'templateName'), (SC_PATH, 'templateName'), (PR_PATH, 'ruleName')): rows, _ = page_rows(a, path, keyword='t03-') dirty += [r.get(nmkey) for r in rows if r.get('status') in (1, 2)] rec('清理后 t03- 无草稿/发布中残留', not dirty, f'残留={dirty or "无"}') FLOWS = {'B': part_b, 'A': part_a, 'U': part_u, 'P': part0, 'CLEANUP': cleanup} if __name__ == '__main__': want = [x.upper() for x in sys.argv[1:]] or ['P', 'B', 'A', 'U', 'CLEANUP'] if 'P' in want: part0() if 'B' in want: part_b() if 'A' in want: part_a() if 'U' in want: part_u() if 'CLEANUP' in want: cleanup() print(f'\n===== 汇总:✅ {sum(1 for r in results if r[1])} / ❌ {sum(1 for r in results if not r[1])} =====') if fails: print('失败项:') for c, ev in fails: print(f' ❌ {c} —— {ev}') base = os.path.dirname(os.path.abspath(__file__)) with open(os.path.join(base, 't03-verify-result.json'), 'w', encoding='utf-8') as fp: json.dump([{'case': c, 'ok': ok, 'evidence': ev} for c, ok, ev in results], fp, ensure_ascii=False, indent=1) print('结果已写 t03-verify-result.json')