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.

431 lines
22 KiB

7 days ago
"""票 03 — 修复运行时验证(D-13 四路径 owner_dept_id + D-10/11/12 三族编辑发布中 + U03 生效层)。
前置服务运行中含修复 jar存量回填已 applyaffected=13残留 0
复用 e2e-rules.py Sess 封装模式中文 body Python requestsResult<Void> 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')