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.
913 lines
57 KiB
913 lines
57 KiB
# -*- coding: utf-8 -*-
|
|
"""票 07 — E2E 实测:商机核心域主流程(F01-F15)。
|
|
前置:seed-opportunity.py 已跑(12 条 e2e- 商机 + 规则/模板发布中)。
|
|
纪律:Python requests(禁 PS 中文 body);POST 显式 x-www-form-urlencoded;
|
|
Result<Void> data=null 非失败;孤立 401 重试一次;判 ❌ 前核对 flat 实际路径。
|
|
凭据:MinIO 若 os.environ 提供 E2E_MINIO_AK/SK 则连跑 F08,否则 F08 记 SKIP 留探针证据。
|
|
用法:python e2e-core.py # 全量
|
|
python e2e-core.py F09 F14 # 只跑指定流程
|
|
"""
|
|
import io, json, os, sys, time
|
|
from datetime import datetime, timedelta
|
|
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', 'B': '744842566742048768', 'C': '744842318024015872'}
|
|
UNAME = {'ADMIN': '罗伟健', 'A': '赖永利', 'B': '肖琴', 'C': '曾偲青'}
|
|
NOW = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
TOMORROW = (datetime.now() + timedelta(days=30)).strftime('%Y-%m-%d')
|
|
|
|
results = [] # (flow, case, verdict, evidence)
|
|
defects = [] # {flow, title, level, evidence}
|
|
skips = [] # (flow, reason)
|
|
|
|
|
|
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:
|
|
"""一个用户一个 Session:debug token 换取 + 统一请求头。"""
|
|
def __init__(self, key, uid):
|
|
self.key, self.uid = 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.tok = b['data']
|
|
self.h = {'Authorization': f'Bearer {self.tok}'}
|
|
|
|
def raw(self, method, path, form=None, params=None, json_body=None, files=None, _retry=True):
|
|
headers = dict(self.h)
|
|
data = None
|
|
if files is not None:
|
|
pass # multipart 交给 requests
|
|
elif form is not None:
|
|
headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
data = form
|
|
elif json_body is not None:
|
|
headers['Content-Type'] = 'application/json'
|
|
data = json.dumps(json_body, ensure_ascii=False).encode('utf-8')
|
|
r = requests.request(method, BASE + path, headers=headers, data=data,
|
|
params=params, files=files, timeout=30)
|
|
if r.status_code == 401 and _retry: # 孤立 401 重试一次(boot-verify 纪律)
|
|
r = requests.request(method, BASE + path, headers=headers, data=data,
|
|
params=params, files=files, timeout=30)
|
|
r.encoding = 'utf-8' # 响应无 charset 时 requests 默认 ISO-8859-1 → 出参中文 mojibake
|
|
return r
|
|
|
|
def api(self, method, path, form=None, params=None, json_body=None, step='', files=None):
|
|
"""标准 Result 契约:code==0 → data;否则打印错误返回 None。"""
|
|
try:
|
|
r = self.raw(method, path, form, params, json_body, files)
|
|
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='', files=None):
|
|
"""Result<Void> 端点:code==0 即成功(data=null 不是失败)。"""
|
|
try:
|
|
r = self.raw(method, path, form, params, files=files)
|
|
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
|
|
|
|
def expect_fail(self, method, path, form=None, params=None, step=''):
|
|
"""负路径:期望业务错误。返回 (是否如预期失败, 响应体)。"""
|
|
try:
|
|
r = self.raw(method, path, form, params)
|
|
b = r.json()
|
|
except Exception as e:
|
|
return False, {'_exc': str(e)}
|
|
ok = b.get('code') != 0 or r.status_code >= 400
|
|
return ok, b
|
|
|
|
|
|
SES = {}
|
|
def sess(k):
|
|
if k not in SES:
|
|
SES[k] = Sess(k, ADMIN_UID if k == 'ADMIN' else UID[k])
|
|
return SES[k]
|
|
|
|
|
|
def page_total(s, view, **kw):
|
|
form = {'viewType': view, 'current': 1, 'size': 100}
|
|
form.update(kw)
|
|
d = s.api('POST', '/api/opportunity/page', form=form, step=f'page {view}')
|
|
return (int(d['total']) if d else -1), (d or {}).get('content') or []
|
|
|
|
|
|
# seed 商机 id:动态读 seed-manifest.json(seed 重置后无需改脚本)
|
|
_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('智慧园区一期') # e2e-A-推进-智慧园区一期
|
|
A2 = _oid('智慧园区二期') # e2e-A-推进-智慧园区二期(有跟进/勘察/卡/关联客户)
|
|
AP = _oid('暂缓-数据中心改造')
|
|
APL = _oid('公海-展厅多媒体项目')
|
|
AC = _oid('关闭-老机房UPS替换')
|
|
B1 = _oid('会议系统扩容') # e2e-B-推进-会议系统扩容(A 已关注)
|
|
B2 = _oid('报告厅音视频')
|
|
BP = _oid('暂缓-剧场改造')
|
|
BPL = _oid('公海-法院信息化')
|
|
BC = _oid('关闭-厂房广播改造')
|
|
C1 = _oid('高校实验室建设')
|
|
C2 = _oid('医院护理呼叫系统')
|
|
SEED_CARD = str(_MF['scheme_cards'][0]['id']) # seed 方案卡(已提交,绑 A2)
|
|
POOL_RULE_ID = str(_MF['pool_rules'][0]['id']) # e2e-seed 公海规则
|
|
TPL_ID = str(_MF['scheme_templates'][0]['id']) # 发布中方案卡模板
|
|
STAGE1, STAGE2 = '747831302777995264', '747831302870269952'
|
|
|
|
NEW_ID = None # F01 新建的商机
|
|
|
|
|
|
# ================= F01 新建商机 =================
|
|
def f01():
|
|
global NEW_ID
|
|
print('\n== F01 新建商机 ==')
|
|
a = sess('A')
|
|
leads = a.api('GET', '/api/opportunity/convertible-leads', step='convertible-leads')
|
|
if leads is not None:
|
|
n = len(leads) if isinstance(leads, list) else -1
|
|
rec('F01', 'convertible-leads 下拉接口', '✅', f'返回 {n} 条可关联线索')
|
|
lead0 = (leads[0].get('leadId') or leads[0].get('id')) if n > 0 else None
|
|
else:
|
|
rec('F01', 'convertible-leads 下拉接口', '❌', '接口调用失败')
|
|
lead0 = None
|
|
ok, b = a.expect_fail('POST', '/api/opportunity',
|
|
form={'oppSource': 'opp_source_02', 'industryCode': 'gov'}, step='缺名称')
|
|
rec('F01', '必填校验(缺商机名称)', '✅' if ok else '⚠',
|
|
'被拒: ' + str(b.get('message'))[:60] if ok else '未拦截必填缺失!')
|
|
if not ok:
|
|
defect('F01', '新建商机未校验必填名称', 2, json.dumps(b, ensure_ascii=False)[:200])
|
|
elif '系统内部' in str(b.get('message')):
|
|
defect('F01', '必填校验以系统异常(500 类)实现而非参数错误提示', 2, str(b.get('message'))[:80])
|
|
form = {'opportunityName': 'e2e-F01-实测新建', 'oppSource': 'opp_source_03',
|
|
'industryCode': 'gov', 'bidForm': 'bid_form_02', 'localityType': 'locality_type_02',
|
|
# 票 09:D-14 生效后 create 六必填(缺省会被 66001 拒)
|
|
'provinceCode': '510000', 'cityCode': '510100',
|
|
'partyAClear': 0, 'partyA': 'E2E甲方-F01', 'remark': '票07 F01 实测'}
|
|
if lead0:
|
|
form['sourceLeadId'] = str(lead0)
|
|
t_old, rows_old = page_total(a, 'MINE', keyword='e2e-F01-实测新建')
|
|
if t_old >= 1: # 重跑复用,避免堆积
|
|
NEW_ID = str(rows_old[0].get('id'))
|
|
globals()['_F01_REUSED'] = True
|
|
rec('F01', '复用上次新建(幂等)', '✅', f'id={NEW_ID}')
|
|
else:
|
|
globals()['_F01_REUSED'] = False
|
|
d = a.api('POST', '/api/opportunity', form=form, step='新建')
|
|
if d is None:
|
|
rec('F01', '新建商机', '❌', '接口失败'); return
|
|
NEW_ID = str(d if isinstance(d, (int, str)) else d.get('id'))
|
|
rec('F01', '新建商机', '✅', f'id={NEW_ID}')
|
|
det = a.api('GET', '/api/opportunity/detail', params={'id': NEW_ID}, step='回查')
|
|
if det:
|
|
rec('F01', '初始负责人=当前用户', '✅' if str(det.get('ownerUserId')) == UID['A'] else '❌',
|
|
f"owner={det.get('ownerUserId')}")
|
|
rec('F01', '初始 oppStatus(U01 观察口径)', '✅',
|
|
f"实测={det.get('oppStatus')}(PRD §3.1 取值域内;seed 同现象=2 推进中)")
|
|
if lead0:
|
|
rec('F01', 'sourceLeadId 落库', '✅' if str(det.get('sourceLeadId') or '') == str(lead0) else '⚠',
|
|
f"detail={det.get('sourceLeadId')} 期望={lead0}")
|
|
t, _ = page_total(a, 'MINE', keyword='e2e-F01')
|
|
rec('F01', '新建后出现在 MINE 视图', '✅' if t >= 1 else '❌', f'MINE 命中 {t} 条')
|
|
|
|
|
|
# ================= F02 六视图分页 =================
|
|
def f02():
|
|
print('\n== F02 列表六视图分页 ==')
|
|
a, b_, c = sess('A'), sess('B'), sess('C')
|
|
exp = {'MANAGE': 12 + (1 if not globals().get('_F01_REUSED') else 0), 'MINE': 4 + (1 if not globals().get('_F01_REUSED') else 0),
|
|
'PUBLIC_POOL': 2, 'FOLLOWED': 1}
|
|
for v, e in exp.items():
|
|
t, _ = page_total(a, v)
|
|
rec('F02', f'A/{v} 视图 total={t}', '✅' if t == e else '⚠', f'期望 {e}(seed 校验值+F01 新建)')
|
|
t, rows = page_total(a, 'RECENT')
|
|
rec('F02', 'A/RECENT 初始(未上报前)', '✅', f'total={t}(空即符合「仅记录访问过的」)')
|
|
t, rows = page_total(a, 'PARTICIPATED')
|
|
rec('F02', 'A/PARTICIPATED', '✅', f'total={t}(观察值;seed 无团队成员样例,范围口径记录)')
|
|
# B 视角:互斥范围
|
|
tb, rb = page_total(b_, 'MINE')
|
|
names_b = [r.get('oppName') or r.get('opportunityName') for r in rb]
|
|
rec('F02', 'B/MINE 范围(公海态不计 MINE)', '✅' if tb == 4 else '⚠', f'total={tb}(期望 4:B 造 5 条-1 已入公海)')
|
|
ta, ra = page_total(a, 'MINE')
|
|
names_a = [r.get('oppName') or r.get('opportunityName') for r in ra]
|
|
cross = [n for n in names_a if n and n.startswith('e2e-B-')]
|
|
rec('F02', 'A/MINE 不含 B 商机(范围互斥)', '✅' if not cross else '❌', f'混入: {cross[:2]}')
|
|
# 筛选
|
|
t, _ = page_total(a, 'MANAGE', keyword='智慧园区')
|
|
rec('F02', 'keyword 筛选生效', '✅' if 1 <= t <= 2 else '⚠', f'命中 {t}(应仅智慧园区 2 条-F01 无关词)')
|
|
t, _ = page_total(a, 'MANAGE', statusIn='1')
|
|
rec('F02', 'statusIn=1 筛选(公海态)', '✅' if t == 2 else '⚠', f'total={t}(期望 2)')
|
|
# 回显列(PRD §6:方案卡状态/节点停留/最近跟进/公海原因)
|
|
t, rows = page_total(a, 'MANAGE')
|
|
keys = set()
|
|
for r in rows[:3]:
|
|
keys |= set(r.keys())
|
|
need = {'schemeCardStatus': '方案卡状态', 'stageStayDays': '节点停留', 'lastFollowTime': '最近跟进'}
|
|
miss = [v for k, v in need.items() if k not in keys]
|
|
rec('F02', 'page 出参回显列存在性', '⚠' if miss else '✅',
|
|
('缺列: ' + ','.join(miss)) if miss else 'schemeCardStatus/stageStayDays/lastFollowTime 均在')
|
|
rich = [r for r in rows if str(r.get('id')) == A2]
|
|
if rich:
|
|
r = rich[0]
|
|
nulls = [k for k in ('schemeCardStatus', 'stageStayDays', 'lastFollowTime')
|
|
if r.get(k) in (None, '')]
|
|
rec('F02', '有卡/有跟进商机的回显值非空', '⚠' if nulls else '✅',
|
|
('null 列: ' + ','.join(nulls)) if nulls else 'A2 行三列全有值')
|
|
if nulls:
|
|
defect('F02', f'列表回显列有 key 无值: {nulls}', 2, json.dumps(r, ensure_ascii=False)[:300])
|
|
tp, rp = page_total(a, 'PUBLIC_POOL')
|
|
if rp:
|
|
has_pool = rp[0].get('poolReason') is not None or 'poolReason' in rp[0]
|
|
rec('F02', '公海视图回显入池原因 poolReason', '✅' if has_pool else '⚠',
|
|
f"poolReason={rp[0].get('poolReason')} poolStageNameSnapshot={rp[0].get('poolStageNameSnapshot')}")
|
|
|
|
|
|
# ================= F03 看板 =================
|
|
def f03():
|
|
print('\n== F03 看板视图 ==')
|
|
a = sess('A')
|
|
expect = {'MANAGE': ('2', '=推进态(status2)'), 'PUBLIC_POOL': ('1', '=公海态(status1)')} # U06:两视图各一轮
|
|
for view, (st, label) in expect.items():
|
|
sm = a.api('POST', '/api/opportunity/board/stage-summary', form={'viewType': view}, step=f'summary {view}')
|
|
ts, _ = page_total(a, view, statusIn=st)
|
|
if sm is None:
|
|
rec('F03', f'{view} stage-summary', '❌', '接口失败'); continue
|
|
total = sum(x.get('count', 0) for x in sm)
|
|
rec('F03', f'{view} summary 合计={total} vs page({label})={ts}',
|
|
'✅' if total == ts else '⚠', '口径: ' + (label if total == ts else f'不吻合(全库推进态=7)'))
|
|
if total != ts:
|
|
defect('F03', f'{view} 看板合计未按该视图状态口径过滤(summary={total},视图内目标态商机={ts})', 2,
|
|
f'sum={total} page(statusIn={st})={ts}')
|
|
if view == 'MANAGE' and sm:
|
|
sid = sm[0]['stageId']
|
|
c1 = a.api('POST', '/api/opportunity/board/cards',
|
|
params={'stageId': sid, 'offset': 0, 'limit': 1}, step='cards 首批')
|
|
c2 = a.api('POST', '/api/opportunity/board/cards',
|
|
params={'stageId': sid, 'offset': 1, 'limit': 1}, step='cards 翻页')
|
|
if c1 is not None and c2 is not None:
|
|
l1 = c1 if isinstance(c1, list) else c1.get('cards') or c1.get('content') or []
|
|
l2 = c2 if isinstance(c2, list) else c2.get('cards') or c2.get('content') or []
|
|
i1 = [str(x.get('id')) for x in l1]
|
|
i2 = [str(x.get('id')) for x in l2]
|
|
rec('F03', 'board/cards 首批限量+翻页不重不漏', '✅' if not set(i1) & set(i2) else '⚠',
|
|
f'首页={i1} 次页={i2}')
|
|
|
|
|
|
# ================= F04 详情 + 最近访问 =================
|
|
def f04():
|
|
print('\n== F04 商机详情 + 最近访问上报 ==')
|
|
a = sess('A')
|
|
ok = a.api_void('POST', '/api/opportunity/view-touch', params={'oppId': A2}, step='view-touch A2')
|
|
rec('F04', 'view-touch 上报', '✅' if ok else '❌')
|
|
a.api_void('POST', '/api/opportunity/view-touch', params={'oppId': A1}, step='view-touch A1')
|
|
det = a.api('GET', '/api/opportunity/detail', params={'id': A2}, step='detail A2')
|
|
if det:
|
|
need = {'stageStayDays': '节点停留天数', 'nextStageName': '下阶段名', 'schemeCardStatus': '方案卡状态',
|
|
'schemeBudget': '方案预算', 'followed': '是否关注', 'lastFollowTime': '最近跟进'}
|
|
nulls = [v for k, v in need.items() if det.get(k) in (None, '')]
|
|
rec('F04', 'detail 出参全性(PRD §7.9)', '⚠' if nulls else '✅',
|
|
('null 字段: ' + ','.join(nulls)) if nulls else '6 个联动字段全非空')
|
|
if nulls:
|
|
defect('F04', f'detail 联动字段未回显: {nulls}', 2, json.dumps(det, ensure_ascii=False)[:400])
|
|
rec('F04', 'detail.followed(A 已关注 B1,非 A2)', '✅',
|
|
f"A2.followed={det.get('followed')}")
|
|
t, rows = page_total(a, 'RECENT')
|
|
ids = [str(r.get('id')) for r in rows]
|
|
rec('F04', 'RECENT 出现刚访问的商机', '✅' if A2 in ids and A1 in ids else '⚠', f'recent ids 前3={ids[:3]}')
|
|
if A2 in ids and A1 in ids:
|
|
rec('F04', 'RECENT 倒序(后访问在前)', '✅' if ids.index(A1) < ids.index(A2) else '⚠',
|
|
f'A1 idx={ids.index(A1)} A2 idx={ids.index(A2)}(A1 后 touch 应更靠前)')
|
|
|
|
|
|
# ================= F05 编辑商机 =================
|
|
def f05():
|
|
print('\n== F05 编辑商机 ==')
|
|
a = sess('A')
|
|
det = a.api('GET', '/api/opportunity/detail', params={'id': NEW_ID}, step='取全量')
|
|
if not det:
|
|
rec('F05', 'edit 前取全量', '❌'); return
|
|
# 全量回传(edit 整体 form 绑定,防漏字段清库);票 06 修复后语义:wrapper 显式 set 只有传入字段进 SET。
|
|
# LocalDateTime 字段转 ISO(空格→T,否则 40001 转换失败)
|
|
form = {k: (v.replace(' ', 'T') if isinstance(v, str) and len(v) == 19 and v[10] == ' ' else v)
|
|
for k, v in det.items() if v is not None}
|
|
form.update({'id': NEW_ID, 'remark': f'E2E改备注-{NOW[-8:]}'})
|
|
# D-14(票 06):编辑后核心必填仍须齐备——seed 存量省市为空串,模拟用户补齐后再保存(v29 星号集)
|
|
form['provinceCode'] = form.get('provinceCode') or '510000'
|
|
form['cityCode'] = form.get('cityCode') or '510100'
|
|
# D-14 负路径:改空被拒(DTO 属性真名 oppName;create 入参才叫 opportunityName)
|
|
ok, b = a.expect_fail('POST', '/api/opportunity/edit',
|
|
form={'id': NEW_ID, 'oppName': ''}, step='D-14 改空')
|
|
rec('F05', 'D-14 编辑改空被拒(66001 逐字段)', '✅' if ok else '❌',
|
|
('被拒: ' + str(b.get('message'))[:40]) if ok else f'改空仍放行!code={b.get("code")}')
|
|
ok = a.api_void('POST', '/api/opportunity/edit', form=form, step='edit')
|
|
rec('F05', '编辑回写', '✅' if ok else '❌')
|
|
det2 = a.api('GET', '/api/opportunity/detail', params={'id': NEW_ID}, step='回查')
|
|
rec('F05', 'remark 已更新', '✅' if det2 and str(det2.get('remark', '')).startswith('E2E改备注') else '⚠',
|
|
f"remark={det2 and det2.get('remark')}")
|
|
# 暂缓态编辑受限(P1-7 拍板)
|
|
ok, b = a.expect_fail('POST', '/api/opportunity/edit',
|
|
form={'id': AP, 'oppName': 'e2e-A-暂缓-数据中心改造', 'remark': 'x'}, step='暂缓态编辑')
|
|
rec('F05', '暂缓态编辑受限(负路径)', '✅' if ok else '⚠',
|
|
('被拒: ' + str(b.get('message'))[:60]) if ok else f'暂缓态仍可编辑!code={b.get("code")}')
|
|
if not ok:
|
|
defect('F05', '暂缓态商机编辑未被拦截', 1, json.dumps(b, ensure_ascii=False)[:200])
|
|
|
|
|
|
# ================= F06 关注/取关/批量 =================
|
|
def f06():
|
|
print('\n== F06 关注 / 取关 / 批量关注 ==')
|
|
a = sess('A')
|
|
a.api_void('POST', '/api/opportunity/unfocus', params={'oppId': B1}, step='unfocus')
|
|
t0, _ = page_total(a, 'FOLLOWED')
|
|
rec('F06', 'unfocus 后 FOLLOWED=0', '✅' if t0 == 0 else '⚠', f'total={t0}')
|
|
a.api_void('POST', '/api/opportunity/focus', params={'oppId': B1}, step='focus')
|
|
t1, _ = page_total(a, 'FOLLOWED')
|
|
det = a.api('GET', '/api/opportunity/detail', params={'id': B1}, step='detail')
|
|
rec('F06', 'focus 后 FOLLOWED=1 且 detail.followed=true', '✅' if t1 == 1 and det and det.get('followed') else '⚠',
|
|
f'FOLLOWED={t1} followed={det and det.get("followed")}')
|
|
ok = a.api_void('POST', '/api/opportunity/focus-batch', params={'ids': f'{C1},{C2}'}, step='focus-batch')
|
|
t2, _ = page_total(a, 'FOLLOWED')
|
|
rec('F06', 'focus-batch 两条 → FOLLOWED=3', '✅' if ok and t2 == 3 else '⚠', f'total={t2}')
|
|
b_ = sess('B')
|
|
ok = b_.api_void('POST', '/api/opportunity/focus', params={'oppId': A2}, step='B focus A2')
|
|
tb, _ = page_total(b_, 'FOLLOWED')
|
|
rec('F06', 'B 视角关注 A 商机(跨部门可见性)', '✅' if ok and tb >= 1 else '⚠', f'B_FOLLOWED={tb}')
|
|
|
|
|
|
# ================= F07 跟进记录 =================
|
|
def f07():
|
|
print('\n== F07 跟进记录 ==')
|
|
a = sess('A')
|
|
before = a.api('POST', '/api/opportunity/follow/page',
|
|
form={'oppId': A2, 'pageNum': 1, 'pageSize': 50}, step='follow/page before')
|
|
n0 = len(before['content']) if before else -1
|
|
det0 = a.api('GET', '/api/opportunity/detail', params={'id': A2}, step='detail before')
|
|
d = a.api('POST', '/api/opportunity/follow/add',
|
|
form={'oppId': A2, 'followContent': f'E2E跟进-{NOW}', 'followTime': NOW,
|
|
'followWay': 'follow_way_01', 'customerId': 0, 'resultTag': 'result_tag_01'},
|
|
step='follow/add')
|
|
if d is None:
|
|
d = a.api('POST', '/api/opportunity/follow/add',
|
|
form={'oppId': A2, 'followContent': f'E2E跟进-{NOW}', 'followTime': NOW,
|
|
'followWay': 'follow_way_01', 'customerId': 1, 'resultTag': 'result_tag_01'},
|
|
step='follow/add retry customerId=1')
|
|
if d is None:
|
|
rec('F07', '写跟进', '❌', '两次 customerId 均被拒'); return
|
|
fid = str(d if isinstance(d, (int, str)) else d.get('id'))
|
|
rec('F07', '写跟进', '✅', f'id={fid}')
|
|
after = a.api('POST', '/api/opportunity/follow/page',
|
|
form={'oppId': A2, 'pageNum': 1, 'pageSize': 50}, step='follow/page after')
|
|
n1 = len(after['content']) if after else -1
|
|
rec('F07', '跟进分页 +1', '✅' if n1 == n0 + 1 else '⚠', f'{n0} → {n1}')
|
|
det1 = a.api('GET', '/api/opportunity/detail', params={'id': A2}, step='detail after')
|
|
upd = det1 and str(det1.get('lastFollowTime') or '') >= str(det0.get('lastFollowTime') or '') and det1.get('lastValidFollowTime')
|
|
rec('F07', 'lastFollowTime/lastValidFollowTime 刷新(回收锚点)', '✅' if upd else '⚠',
|
|
f"last={det1 and det1.get('lastFollowTime')} valid={det1 and det1.get('lastValidFollowTime')}")
|
|
if not upd:
|
|
defect('F07', '写跟进后最近跟进/有效跟进时间未刷新', 2)
|
|
ok = a.api_void('POST', '/api/opportunity/follow/delete', params={'id': fid}, step='follow/delete')
|
|
after2 = a.api('POST', '/api/opportunity/follow/page',
|
|
form={'oppId': A2, 'pageNum': 1, 'pageSize': 50}, step='follow/page del')
|
|
n2 = len(after2['content']) if after2 else -1
|
|
rec('F07', 'delete 后分页 -1', '✅' if ok and n2 == n1 - 1 else '⚠', f'{n1} → {n2}')
|
|
|
|
|
|
# ================= F08 附件 =================
|
|
def f08():
|
|
print('\n== F08 附件(MinIO) ==')
|
|
ak, sk = os.environ.get('E2E_MINIO_AK'), os.environ.get('E2E_MINIO_SK')
|
|
if not ak or not sk:
|
|
skips.append(('F08', 'MinIO 凭据未注入(E2E_MINIO_AK/SK),整链 SKIP;保留 file/upload 探针证据'))
|
|
a = sess('A')
|
|
try:
|
|
r = a.raw('POST', '/api/file/upload',
|
|
files={'file': ('e2e-probe.txt', b'e2e probe', 'text/plain')},
|
|
params={'bizDomain': 'opportunity'})
|
|
b = r.json()
|
|
rec('F08', '探针:file/upload(无有效凭据)', '⚠',
|
|
f"code={b.get('code')} msg={b.get('message')}——预期失败,证明 MinIO 链路前置未通")
|
|
except Exception as e:
|
|
rec('F08', '探针:file/upload 异常', '⚠', str(e)[:120])
|
|
return
|
|
a = sess('A')
|
|
d = a.api('POST', '/api/file/upload',
|
|
files={'file': ('e2e-attach.txt', 'E2E 附件内容'.encode('utf-8'), 'text/plain')},
|
|
params={'bizDomain': 'opportunity'}, step='file/upload')
|
|
if not d:
|
|
rec('F08', 'file/upload', '❌', '凭据已注入仍失败'); return
|
|
fid = d.get('fileId') or d.get('id') # FileInfoDTO 出参主键=fileId(String),无 id 字段
|
|
rec('F08', 'file/upload(MinIO 直传)', '✅', f'fileId={fid} name={d.get("fileName") or d.get("originalName")}')
|
|
# 负路径:D-21(票 06)修复后 bizType 缺省/非法统一 66001(四值封闭 OPP/FOLLOW_UP/SITE_SURVEY/SCHEME_CARD)
|
|
# —— 旧断言「缺省即 50001 契约矛盾」已过期,验收回归(票 02)起改为正式用例断言 66001
|
|
r1 = a.raw('POST', '/api/opportunity/attachment/add',
|
|
form={'oppId': A2, 'fileId': fid, 'materialType': '', 'remark': '票07 F08'})
|
|
b1 = r1.json()
|
|
rec('F08', 'bizType 缺省 → 66001(D-21 修复后契约)', '✅' if b1.get('code') == 66001 else '❌',
|
|
f"code={b1.get('code')} msg={str(b1.get('message'))[:60]}")
|
|
ok = a.api_void('POST', '/api/opportunity/attachment/add',
|
|
form={'oppId': A2, 'fileId': fid, 'bizType': 'OPP', 'materialType': '', 'remark': '票07 F08'},
|
|
step='attachment/add(bizType=OPP)')
|
|
lst = a.api('GET', '/api/opportunity/attachment/list', params={'oppId': A2}, step='attachment/list')
|
|
hit = ok and lst and any(str(x.get('fileId')) == str(fid) for x in lst)
|
|
rec('F08', 'add→list 出现', '✅' if hit else '❌', f'list {len(lst or [])} 条')
|
|
aid = None
|
|
if lst:
|
|
aid = next((x.get('id') for x in lst if str(x.get('fileId')) == str(fid)), None)
|
|
rec('F08', '资料类型/出参结构', '✅', f"字段示例: {sorted(lst[0].keys())[:10]}")
|
|
if aid:
|
|
a.api_void('POST', '/api/opportunity/attachment/delete', params={'id': aid}, step='attachment/delete')
|
|
lst2 = a.api('GET', '/api/opportunity/attachment/list', params={'oppId': A2}, step='list after del')
|
|
gone = not lst2 or not any(str(x.get('fileId')) == str(fid) for x in lst2)
|
|
rec('F08', 'delete 后消失', '✅' if gone else '⚠')
|
|
# 下载端点缺陷候选(附录二:Bruno 无下载请求)
|
|
try:
|
|
r = a.raw('GET', '/api/file/download', params={'fileId': fid})
|
|
rec('F08', '下载端点探测 /api/file/download', '✅' if r.status_code < 400 else '⚠',
|
|
f'HTTP {r.status_code}(若 404 按缺陷候选核源码)')
|
|
except Exception as e:
|
|
rec('F08', '下载端点探测异常', '⚠', str(e)[:120])
|
|
|
|
|
|
def db_ensure_second_customer(opp_id):
|
|
"""customer/add API 缺口(seed 同款豁免先例)→ DB 直插 A2 第二关联客户,返回 customer_id。"""
|
|
try:
|
|
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()
|
|
cust_id = 920000000000000101
|
|
cur.execute('SELECT id FROM opportunity_customer WHERE opportunity_id=%s AND customer_id=%s',
|
|
(int(opp_id), cust_id))
|
|
if cur.fetchone() is None:
|
|
cur.execute('INSERT INTO opportunity_customer '
|
|
'(id, opportunity_id, customer_id, customer_name_snapshot, customer_role, '
|
|
' is_primary_intended, delete_key, create_time, update_time) '
|
|
'VALUES (%s,%s,%s,%s,%s,0,0,NOW(),NOW())',
|
|
(920000000001000000 + int(opp_id) % 100000, int(opp_id), cust_id,
|
|
'华东设计院(E2E第二客户)', 'customer_role_01'))
|
|
conn.close()
|
|
return str(cust_id)
|
|
except Exception as e:
|
|
print(f" [EXC] db_ensure_second_customer: {e}")
|
|
return None
|
|
|
|
|
|
# ================= F09 方案卡全生命周期 =================
|
|
def f09():
|
|
print('\n== F09 方案卡全生命周期 ==')
|
|
a = sess('A')
|
|
tpls = a.api('GET', '/api/opportunity/scheme-card/selectable-templates',
|
|
params={'oppId': A2}, step='selectable-templates')
|
|
tid = next((t.get('templateId') or t.get('id') for t in (tpls or [])
|
|
if t.get('templateId') or t.get('id')), None)
|
|
rec('F09', 'selectable-templates 含发布中模板', '✅' if tid else '⚠',
|
|
f"{len(tpls or [])} 条,templateId={tid}" + (f"(元素 keys={sorted(tpls[0].keys())[:8]})" if tpls and not tid else ''))
|
|
if not tid:
|
|
tid = TPL_ID # 兜底:seed-manifest 发布中模板
|
|
# 以 seed 卡 detail 的 valuesJson 为骨架(已验证只含显示字段+必填全填)
|
|
seed = a.api('GET', '/api/opportunity/scheme-card/detail', params={'id': SEED_CARD}, step='seed 卡 detail')
|
|
if not seed:
|
|
rec('F09', 'seed 卡读取', '❌'); return
|
|
# 从 seed 卡 fields 装配 valuesJson(fields=绑定模板显示项+已填值;结构化三键禁止进 valuesJson,66007)
|
|
fields = seed.get('fields') or []
|
|
vals = [{'fieldKey': f.get('fieldKey'), 'fieldValue': f.get('fieldValue') or ''}
|
|
for f in fields
|
|
if f.get('fieldKey') and str(f.get('fieldKey')) not in (
|
|
'scheme_budget', 'bidding_form', 'enter_stage', 'schemeBudget', 'biddingForm', 'enterStage')
|
|
and not any(s in str(f.get('fieldKey')) for s in ('budget', 'bidd', 'stage_'))]
|
|
if vals:
|
|
vj = json.dumps(vals, ensure_ascii=False)
|
|
else:
|
|
vj = seed.get('valuesJson') or seed.get('values') or []
|
|
if not isinstance(vj, str):
|
|
vj = json.dumps(vj, ensure_ascii=False) # 后端要求 JSON 数组字符串
|
|
rec('F09', 'valuesJson 装配(seed 卡 fields 骨架)', '✅' if vals else '⚠',
|
|
f"fields={len(fields)} 动态项={len(vals)}")
|
|
base = {'oppId': A2, 'templateId': tid, 'schemeBudget': '120000',
|
|
'biddingForm': seed.get('biddingForm') or 'bid_form_01',
|
|
'enterStage': seed.get('enterStage') or 'opp_stage_01', 'valuesJson': vj}
|
|
cust = a.api('GET', '/api/opportunity/customer/list', params={'oppId': A2}, step='customer/list')
|
|
cid0 = (cust or [{}])[0].get('customerId') if cust else None
|
|
if cid0 is not None: # seed 卡已占用此客户 → 一客户一卡负路径
|
|
ok, bb = a.expect_fail('POST', '/api/opportunity/scheme-card/save', form=dict(base, customerId=cid0),
|
|
step='同客户重复建卡')
|
|
rec('F09', '一客户一卡约束(同客户重复建卡被拒)', '✅' if ok else '⚠',
|
|
('被拒: ' + str(bb.get('message'))[:50]) if ok else '竟成功!')
|
|
if not ok:
|
|
defect('F09', '一客户一卡约束未生效(同客户可重复建卡)', 2)
|
|
cid = db_ensure_second_customer(A2) # customerId 写入必填:无第二客户则草稿生命周期无卡可用
|
|
if cid is None:
|
|
rec('F09', '第二关联客户(DB 豁免补行)', '⚠', '补行失败,草稿链路中断'); return
|
|
d = a.api('POST', '/api/opportunity/scheme-card/save', form=dict(base, customerId=cid), step='save 草稿')
|
|
if d is None:
|
|
rec('F09', '新建方案卡草稿', '❌'); return
|
|
card = str(d if isinstance(d, (int, str)) else d.get('id'))
|
|
rec('F09', '新建方案卡草稿', '✅', f'cardId={card}')
|
|
det = a.api('GET', '/api/opportunity/scheme-card/detail', params={'id': card}, step='卡回查')
|
|
rec('F09', '草稿态 cardStatus=1', '✅' if det and int(det.get('cardStatus') or 0) == 1 else '⚠',
|
|
f"cardStatus={det and det.get('cardStatus')}")
|
|
a.api('POST', '/api/opportunity/scheme-card/save',
|
|
form=dict(base, customerId=cid, id=card, schemeBudget='130000'), step='save 编辑')
|
|
det2 = a.api('GET', '/api/opportunity/scheme-card/detail', params={'id': card}, step='卡回查2')
|
|
rec('F09', '草稿可编辑(card_status<3)+ 预算回写',
|
|
'✅' if det2 and det2.get('schemeBudget') is not None and float(det2.get('schemeBudget')) == 130000 else '⚠',
|
|
f"budget={det2 and det2.get('schemeBudget')}")
|
|
a.api_void('POST', '/api/opportunity/scheme-card/submit', params={'id': card}, step='submit')
|
|
det3 = a.api('GET', '/api/opportunity/scheme-card/detail', params={'id': card}, step='卡回查3')
|
|
rec('F09', '提交后 cardStatus 推进', '✅' if det3 and int(det3.get('cardStatus') or 0) == 2 else '⚠',
|
|
f"cardStatus={det3 and det3.get('cardStatus')}")
|
|
det4 = a.api('GET', '/api/opportunity/detail', params={'id': A2}, step='主表回显')
|
|
b_ok = det4 and det4.get('schemeBudget') not in (None, '')
|
|
rec('F09', '提交后主表 schemeBudget/schemeCardStatus 回显(P0-4 锚点)',
|
|
'✅' if b_ok else '⚠',
|
|
f"主表 budget={det4 and det4.get('schemeBudget')} cardStatus={det4 and det4.get('schemeCardStatus')}(多卡取值口径观察)")
|
|
if not b_ok:
|
|
defect('F09', '方案卡提交后主表预算未回显(多卡时取值口径需人工核 P2-3)', 2)
|
|
lst = a.api('GET', '/api/opportunity/scheme-card/list', params={'oppId': A2}, step='卡列表')
|
|
rec('F09', '多卡列表口径(2 张卡并列)', '✅' if lst and len(lst) >= 2 else '⚠', f'{len(lst or [])} 张')
|
|
|
|
|
|
# ================= F10 现场勘察 =================
|
|
def f10():
|
|
print('\n== F10 现场勘察 ==')
|
|
a = sess('A')
|
|
d = a.api('POST', '/api/opportunity/site-survey/add',
|
|
form={'oppId': A1, 'surveyDate': TOMORROW, 'engineerUserId': UID['B'],
|
|
'applyWay': 'apply_way_01', 'surveyDesc': 'E2E勘察说明-自动次数'},
|
|
step='add 不传 surveySeq')
|
|
# D-18 翻案(票 02 裁决,v29 字段说明):勘察次数非自动生成,须调用方传——不传被拒即正确
|
|
rec('F10', '勘察次数需调用方传(D-18 翻案)', '✅' if d is None else '⚠',
|
|
'不传 surveySeq 被拒(符合裁决)' if d is None else f'不传竟成功 id={d},与裁决不符')
|
|
d = a.api('POST', '/api/opportunity/site-survey/add',
|
|
form={'oppId': A1, 'surveyDate': TOMORROW, 'engineerUserId': UID['B'],
|
|
'applyWay': 'apply_way_01', 'surveySeq': 'survey_seq_01',
|
|
'surveyDesc': 'E2E勘察说明-自动次数'},
|
|
step='add 传 surveySeq')
|
|
if d is None:
|
|
rec('F10', '新增勘察(传 surveySeq)', '❌'); return
|
|
rec('F10', '新增勘察(传 surveySeq)', '✅', f'id={d if isinstance(d, (int, str)) else d.get("id")}')
|
|
pg = a.api('POST', '/api/opportunity/site-survey/page',
|
|
form={'oppId': A1, 'pageNum': 1, 'pageSize': 50}, step='page')
|
|
rows = pg['content'] if pg else []
|
|
seqs = [r.get('surveySeq') for r in rows]
|
|
rec('F10', '次数列表回显(调用方传值落库)', '✅' if seqs and all(seqs) else '⚠', f'surveySeq 列表={seqs}')
|
|
# D-19 翻案(票 02 裁决,v29 字段说明):补充说明非必填——不传 surveyDesc 应放行
|
|
d = a.api('POST', '/api/opportunity/site-survey/add',
|
|
form={'oppId': A1, 'surveyDate': TOMORROW, 'engineerUserId': UID['B'],
|
|
'applyWay': 'apply_way_01', 'surveySeq': 'survey_seq_02'}, step='缺说明(D-19)')
|
|
rec('F10', '补充说明非强制(D-19 翻案)', '✅' if d is not None else '⚠',
|
|
'空说明放行(符合裁决)' if d is not None else '空说明被拒,与裁决不符')
|
|
if rows:
|
|
sid = rows[0].get('id')
|
|
a.api_void('POST', '/api/opportunity/site-survey/delete', params={'id': sid}, step='delete')
|
|
pg2 = a.api('POST', '/api/opportunity/site-survey/page',
|
|
form={'oppId': A1, 'pageNum': 1, 'pageSize': 50}, step='page after del')
|
|
seqs2 = [r.get('surveySeq') for r in (pg2 or {}).get('content', [])]
|
|
rec('F10', '删除后次数重算(口径观察)', '✅', f'删后 surveySeq={seqs2}')
|
|
|
|
|
|
# ================= F11/F12 缺陷实锤(404 探测) =================
|
|
def f11():
|
|
print('\n== F11 关联客户 ==')
|
|
a = sess('A')
|
|
lst = a.api('GET', '/api/opportunity/customer/list', params={'oppId': A2}, step='customer/list')
|
|
rec('F11', 'customer/list(seed DB 补入的关联客户)', '✅' if lst else '⚠', f'{len(lst or [])} 条')
|
|
if lst:
|
|
c = lst[0]
|
|
rec('F11', '出参结构(角色/快照/主要意向标记)', '✅',
|
|
f"role={c.get('customerRole')} snapshot={c.get('customerNameSnapshot')} primary={c.get('isPrimaryIntended')}")
|
|
det = a.api('GET', '/api/opportunity/detail', params={'id': A2}, step='detail')
|
|
rec('F11', 'primaryCustomerSnapshot 与 detail 回显一致', '✅' if det and det.get('primaryCustomerNameSnapshot') else '⚠',
|
|
f"detail.primary={det and det.get('primaryCustomerNameSnapshot')}")
|
|
probes = [('POST', '/api/opportunity/customer/add', '添加关联客户(原型 A3-1-1-2-4 有按钮)'),
|
|
('POST', '/api/opportunity/customer/search', '客户搜索(添加弹窗第一步)'),
|
|
('POST', '/api/opportunity/customer/set-primary', '设为主要意向客户(原型 A3-1-1-1-2)')]
|
|
for m, p, name in probes:
|
|
try:
|
|
r = a.raw(m, p, form={'oppId': A2, 'customerId': 1})
|
|
rec('F11', f'端点探测 {p}', '❌' if r.status_code == 404 else '✅', f'HTTP {r.status_code}')
|
|
if r.status_code == 404:
|
|
defect('F11', f'{name} 无对应端点(404)', 1, p)
|
|
except Exception as e:
|
|
rec('F11', f'端点探测 {p} 异常', '⚠', str(e)[:100])
|
|
|
|
|
|
def f12():
|
|
print('\n== F12 团队成员 ==')
|
|
a = sess('A')
|
|
lst = a.api('GET', '/api/opportunity/team/list', params={'oppId': A2}, step='team/list')
|
|
rec('F12', 'team/list', '✅' if lst is not None else '❌', f'{len(lst or [])} 条(seed 无样例)')
|
|
probes = [('POST', '/api/opportunity/team/add', '添加团队成员(原型 A3-1-1-1-8 +添加成员)'),
|
|
('POST', '/api/opportunity/team/delete', '移除团队成员'),
|
|
('POST', '/api/opportunity/team/update', '编辑团队成员角色')]
|
|
for m, p, name in probes:
|
|
try:
|
|
r = a.raw(m, p, form={'oppId': A2, 'userId': UID['B']})
|
|
rec('F12', f'端点探测 {p}', '❌' if r.status_code == 404 else '✅', f'HTTP {r.status_code}')
|
|
if r.status_code == 404:
|
|
defect('F12', f'{name} 无对应端点(404)', 1, p)
|
|
except Exception as e:
|
|
rec('F12', f'端点探测 {p} 异常', '⚠', str(e)[:100])
|
|
t, _ = page_total(a, 'PARTICIPATED')
|
|
rec('F12', 'PARTICIPATED 视图(成员联动口径,无样例记录观察)', '✅', f'total={t}')
|
|
|
|
|
|
# ================= F14 状态机全家桶 =================
|
|
def f14():
|
|
print('\n== F14 状态机全家桶 ==')
|
|
a, b_, c, adm = sess('A'), sess('B'), sess('C'), sess('ADMIN')
|
|
# -- 暂缓族
|
|
a.api_void('POST', '/api/opportunity/pause',
|
|
form={'id': C1, 'pauseReason': 'pause_reason_01', 'pauseExpectedRestartDate': TOMORROW,
|
|
'pauseRemark': 'E2E暂缓'}, step='pause C1')
|
|
d = a.api('GET', '/api/opportunity/detail', params={'id': C1}, step='detail')
|
|
rec('F14', '暂缓 E4: 2→3', '✅' if d and int(d.get('oppStatus') or 0) == 3 else '❌',
|
|
f"oppStatus={d and d.get('oppStatus')}")
|
|
# D-08 翻案(retest-verdicts):PRD E6 明确推进中/暂缓中→已关闭均为合法边,暂缓可关闭
|
|
a.api_void('POST', '/api/opportunity/close', form={'id': C1, 'closeReason': 'close_reason_01'}, step='close C1(暂缓态)')
|
|
d = a.api('GET', '/api/opportunity/detail', params={'id': C1}, step='detail')
|
|
rec('F14', '暂缓态关闭(E6 2,3→4 合法边,D-08 翻案)', '✅' if d and int(d.get('oppStatus') or 0) == 4 else '❌',
|
|
f"oppStatus={d and d.get('oppStatus')}")
|
|
# 数据修正回暂缓态(后续 CAS/resume 用例需要)
|
|
a.api_void('POST', '/api/opportunity/reopen', form={'id': C1, 'reopenReason': 'E2E数据修正'}, step='reopen C1 修正')
|
|
a.api_void('POST', '/api/opportunity/pause',
|
|
form={'id': C1, 'pauseReason': 'pause_reason_01', 'pauseExpectedRestartDate': TOMORROW,
|
|
'pauseRemark': 'E2E暂缓-重新进入'}, step='re-pause C1')
|
|
d = a.api('GET', '/api/opportunity/detail', params={'id': C1}, step='detail')
|
|
rec('F14', '数据修正后回到暂缓态', '✅' if d and int(d.get('oppStatus') or 0) == 3 else '❌',
|
|
f"oppStatus={d and d.get('oppStatus')}")
|
|
ok, bb = a.expect_fail('POST', '/api/opportunity/pause', form={'id': C1, 'pauseReason': 'x'}, step='二次暂缓 CAS')
|
|
rec('F14', 'CAS 守卫(§3.4):前置态已变再提交', '✅' if ok else '⚠',
|
|
('被拒: ' + str(bb.get('message'))[:50]) if ok else '二次 pause 竟成功(自环?观察)')
|
|
a.api_void('POST', '/api/opportunity/resume', params={'id': C1}, step='resume')
|
|
d = a.api('GET', '/api/opportunity/detail', params={'id': C1}, step='detail')
|
|
rec('F14', '取消暂缓 E5: 3→2', '✅' if d and int(d.get('oppStatus') or 0) == 2 else '❌')
|
|
# -- 关闭族
|
|
a.api_void('POST', '/api/opportunity/close', form={'id': C2, 'closeReason': 'close_reason_02', 'closeRemark': 'E2E关闭'}, step='close C2')
|
|
d = a.api('GET', '/api/opportunity/detail', params={'id': C2}, step='detail')
|
|
rec('F14', '关闭 E6: 2→4', '✅' if d and int(d.get('oppStatus') or 0) == 4 else '❌')
|
|
ok, bb = a.expect_fail('POST', '/api/opportunity/resume', params={'id': C2}, step='已关闭 resume')
|
|
rec('F14', '受限态出边:已关闭不可 resume', '✅' if ok else '⚠', str(bb.get('message'))[:50] if ok else '竟成功!')
|
|
if not ok:
|
|
defect('F14', '已关闭商机可被 resume(非法边未拦)', 1)
|
|
a.api_void('POST', '/api/opportunity/reopen', form={'id': C2, 'reopenReason': 'E2E重启'}, step='reopen')
|
|
d = a.api('GET', '/api/opportunity/detail', params={'id': C2}, step='detail')
|
|
rec('F14', '重启 E7: 4→2', '✅' if d and int(d.get('oppStatus') or 0) == 2 else '❌')
|
|
# -- 公海族
|
|
a.api_void('POST', '/api/opportunity/release-pool', form={'id': A1, 'poolReason': 'pool_reason_02'}, step='release A1')
|
|
b_.api_void('POST', '/api/opportunity/release-pool', form={'id': B2, 'poolReason': 'pool_reason_03'}, step='release B2')
|
|
tp, rp = page_total(a, 'PUBLIC_POOL')
|
|
rec('F14', '抛公海 E3: 2→1(公海 total)', '✅' if tp == 4 else '⚠', f'PUBLIC_POOL={tp}(期望 2+2)')
|
|
a1row = next((r for r in rp if str(r.get('id')) == A1), None)
|
|
rec('F14', 'poolReason 枚举落库+公海回显(§3.5)', '✅' if a1row and a1row.get('poolReason') else '⚠',
|
|
f"poolReason={a1row and a1row.get('poolReason')}")
|
|
b_.api_void('POST', '/api/opportunity/claim', params={'id': A1}, step='B claim A1')
|
|
d = b_.api('GET', '/api/opportunity/detail', params={'id': A1}, step='detail')
|
|
rec('F14', '领取 E1: 1→2 + 负责人变更', '✅' if d and int(d.get('oppStatus') or 0) == 2 and str(d.get('ownerUserId')) == UID['B'] else '❌',
|
|
f"status={d and d.get('oppStatus')} owner={d and d.get('ownerUserId')}")
|
|
# seed v2(票 11 ensure_dept_claim_rule):e2e-seed冠军团队禁领 覆盖 B2/BPL(owner_dept=冠军团队)
|
|
# → claim-batch 整批 66014 被拒 = D-09 批量端点禁领守卫的运行时验证(正常路径由 t05-verify + 单测覆盖)
|
|
okb, bb = c.expect_fail('POST', '/api/opportunity/claim-batch', params={'ids': f'{B2},{BPL}'}, step='claim-batch 冠军团队')
|
|
rec('F14', '批量领取(冠军团队商机 → 66014 整批禁领,D-09 批量守卫)',
|
|
'✅' if okb and bb.get('code') == 66014 else '❌',
|
|
f"code={bb and bb.get('code')} msg={str(bb and bb.get('message'))[:50]}")
|
|
adm.api_void('POST', '/api/opportunity/assign', params={'id': APL, 'userId': UID['A']}, step='assign APL→A')
|
|
d = a.api('GET', '/api/opportunity/detail', params={'id': APL}, step='detail')
|
|
rec('F14', '分配 E2: 1→2 指定负责人', '✅' if d and str(d.get('ownerUserId')) == UID['A'] else '❌',
|
|
f"owner={d and d.get('ownerUserId')}")
|
|
# 再造公海测 assign-batch / close-batch
|
|
a.api_void('POST', '/api/opportunity/release-pool', form={'id': A2, 'poolReason': 'pool_reason_05'}, step='release A2')
|
|
a.api_void('POST', '/api/opportunity/release-pool', form={'id': C1, 'poolReason': 'pool_reason_05'}, step='release C1')
|
|
adm.api_void('POST', '/api/opportunity/assign-batch', params={'ids': f'{A2},{C1}', 'userId': UID['A']}, step='assign-batch→A')
|
|
d = a.api('GET', '/api/opportunity/detail', params={'id': A2}, step='detail')
|
|
rec('F14', '批量分配', '✅' if d and str(d.get('ownerUserId')) == UID['A'] and int(d.get('oppStatus') or 0) == 2 else '❌')
|
|
adm.api_void('POST', '/api/opportunity/close-batch', params={'ids': f'{A2},{C1}'}, step='close-batch')
|
|
d = a.api('GET', '/api/opportunity/detail', params={'id': A2}, step='detail')
|
|
rec('F14', '批量关闭', '✅' if d and int(d.get('oppStatus') or 0) == 4 else '❌')
|
|
a.api_void('POST', '/api/opportunity/reopen', form={'id': A2, 'reopenReason': 'E2E重启后移交'}, step='reopen A2')
|
|
a.api_void('POST', '/api/opportunity/handover',
|
|
params={'id': A2, 'expectedOwnerUserId': UID['A'], 'newOwnerUserId': UID['B']}, step='handover A→B')
|
|
d = b_.api('GET', '/api/opportunity/detail', params={'id': A2}, step='detail')
|
|
rec('F14', '移交 E9(自环换 owner)', '✅' if d and str(d.get('ownerUserId')) == UID['B'] and int(d.get('oppStatus') or 0) == 2 else '❌',
|
|
f"owner={d and d.get('ownerUserId')}")
|
|
# -- U02 禁领负路径
|
|
print(' -- U02 禁领规则版本')
|
|
# seed v2 冠军团队禁领规则永久覆盖 B1(owner_dept=冠军团队)→ 换 C2(跨中心)验证默认位禁领版本发布/停用周期
|
|
c.api_void('POST', '/api/opportunity/release-pool', form={'id': C2, 'poolReason': 'pool_reason_04'}, step='release C2(留公海)')
|
|
pg = adm.api('POST', '/api/rule/opp-pool-rule/page', form={'current': 1, 'size': 100}, step='规则全查')
|
|
# ruleCode 系统生成只读(DTO 注释:入参侧忽略,出参回显)→ 识别/反查一律用 ruleName
|
|
deny_rows = [r for r in ((pg or {}).get('content') or []) if r.get('ruleName') == 'e2e-禁领验证']
|
|
for r in deny_rows: # 清旧:发布中的先停用
|
|
if int(r.get('status') or 0) == 2:
|
|
adm.api_void('POST', '/api/rule/opp-pool-rule/disable', params={'id': r.get('id')}, step='disable 旧禁领')
|
|
drafts = [r for r in deny_rows if int(r.get('status') or 0) == 1]
|
|
for extra in drafts[1:]: # 历史遗留草稿清理(delete 仅草稿可删)
|
|
adm.api_void('POST', '/api/rule/opp-pool-rule/delete', params={'id': extra.get('id')}, step='清多余草稿')
|
|
did = drafts[0].get('id') if drafts else None
|
|
if did is None:
|
|
draft = adm.api('POST', '/api/rule/opp-pool-rule/save-draft',
|
|
form={'ruleName': 'e2e-禁领验证', 'applyScope': 1,
|
|
'isDefault': 1, 'allowManualPool': 1, 'autoRecycleEnabled': 0,
|
|
'allowFreeClaim': 0, 'recycleRemindEnabled': 0, 'ruleDesc': 'U02 禁领版本'},
|
|
step='save-draft 禁领')
|
|
did = draft if isinstance(draft, (int, str)) else (draft or {}).get('id')
|
|
if did is None: # Result<Void> 兜底:反查草稿
|
|
pg2 = adm.api('POST', '/api/rule/opp-pool-rule/page', form={'current': 1, 'size': 100}, step='反查草稿')
|
|
did = next((r.get('id') for r in ((pg2 or {}).get('content') or [])
|
|
if r.get('ruleName') == 'e2e-禁领验证' and int(r.get('status') or 0) == 1), None)
|
|
if did is None:
|
|
rec('F14', 'U02 禁领规则草稿', '❌', 'save-draft 无返回 id 且反查不到草稿'); return
|
|
rec('F14', 'U02 禁领规则草稿', '✅', f'id={did}')
|
|
# publish = 保存并发布(form 绑定完整 DTO:id + 业务字段;只传 id 会 64008 规则名称不能为空)
|
|
ok = adm.api_void('POST', '/api/rule/opp-pool-rule/publish',
|
|
form={'id': did, 'ruleName': 'e2e-禁领验证', 'applyScope': 1,
|
|
'isDefault': 1, 'allowManualPool': 1, 'autoRecycleEnabled': 0,
|
|
'allowFreeClaim': 0, 'recycleRemindEnabled': 0, 'ruleDesc': 'U02 禁领版本'},
|
|
step='publish 禁领')
|
|
rec('F14', 'U02 禁领版本发布', '✅' if ok else '❌')
|
|
ok, bb = c.expect_fail('POST', '/api/opportunity/claim', params={'id': C2}, step='C claim 禁领期')
|
|
rec('F14', 'U02 禁领负路径:领取被拒', '✅' if ok else '⚠',
|
|
('被拒: ' + str(bb.get('message'))[:60]) if ok else '禁领版发布后仍可领取!')
|
|
if not ok:
|
|
defect('F14', '禁领规则版本未生效(allowFreeClaim=0 仍可自由领取)', 1)
|
|
if not ok: # 缺陷场景:禁领期被领走 → C2 已非公海态,修正回公海再测恢复领取
|
|
c.api_void('POST', '/api/opportunity/release-pool', form={'id': C2, 'poolReason': 'pool_reason_04'}, step='C release C2 修正')
|
|
adm.api_void('POST', '/api/rule/opp-pool-rule/disable', params={'id': did}, step='disable 禁领版')
|
|
ok = c.api_void('POST', '/api/opportunity/claim', params={'id': C2}, step='C claim 恢复后')
|
|
d = c.api('GET', '/api/opportunity/detail', params={'id': C2}, step='detail')
|
|
rec('F14', '停用禁领版后恢复领取', '✅' if ok and d and int(d.get('oppStatus') or 0) == 2 else '⚠')
|
|
# -- 切阶段(轴 2)
|
|
print(' -- 轴 2 手动切阶段')
|
|
ok = b_.api_void('POST', '/api/opportunity/stage/switch',
|
|
form={'oppId': A1, 'toStageId': STAGE2, 'remark': 'E2E 前进'}, step='switch 前进')
|
|
p = b_.api('GET', '/api/opportunity/stage/progress', params={'oppId': A1}, step='progress')
|
|
nodes = (p or {}).get('nodes') or []
|
|
cur = [n for n in nodes if n.get('nodeState') == 'CURRENT']
|
|
rec('F14', '切阶段前进(progress 当前节点变化)', '✅' if ok and nodes else '⚠',
|
|
f'当前节点={cur[0] if cur else nodes[:1]}')
|
|
ok = b_.api_void('POST', '/api/opportunity/stage/switch',
|
|
form={'oppId': A1, 'toStageId': STAGE1, 'remark': 'E2E 回退'}, step='switch 回退')
|
|
p2 = b_.api('GET', '/api/opportunity/stage/progress', params={'oppId': A1}, step='progress2')
|
|
cur2 = [n for n in ((p2 or {}).get('nodes') or []) if n.get('nodeState') == 'CURRENT']
|
|
rec('F14', '切阶段回退', '✅' if ok and cur2 else '⚠')
|
|
ok, bb = b_.expect_fail('POST', '/api/opportunity/stage/switch',
|
|
form={'oppId': A1, 'toStageId': '999999', 'remark': '非法节点'}, step='非法 toStageId')
|
|
rec('F14', '非法 toStageId 负路径', '✅' if ok else '⚠', str(bb.get('message'))[:60] if ok else '竟成功!')
|
|
|
|
|
|
# ================= F13 操作日志 + 阶段历史 =================
|
|
def f13():
|
|
print('\n== F13 操作日志 + 阶段历史/指引 ==')
|
|
a, b_ = sess('A'), sess('B')
|
|
og = b_.api('POST', '/api/opportunity/oplog/page', form={'oppId': A2, 'pageNum': 1, 'pageSize': 50}, step='oplog A2')
|
|
n = len((og or {}).get('content') or [])
|
|
rec('F13', 'oplog/page(A2 经历 release/close/reopen/handover 应有多条)', '✅' if n >= 3 else '⚠', f'{n} 条')
|
|
if n:
|
|
o = og['content'][0]
|
|
rec('F13', 'oplog 字段(实体/字段/旧值/新值/说明 P1-5)', '✅', f"keys={sorted(o.keys())[:8]}")
|
|
kinds = [(x.get('logType'), x.get('fieldName')) for x in og['content']]
|
|
rec('F13', 'oplog 覆盖内容(流转是否留痕观察)', '✅' if n >= 4 else '⚠', f'{n} 条: {kinds}')
|
|
if n < 4:
|
|
defect('F13', 'oplog 未覆盖全部操作(A2 经历 release/assign/close/reopen/handover 等仅 ' + str(n) + ' 条留痕)', 2, str(kinds))
|
|
hg = b_.api('GET', '/api/opportunity/stage/history', params={'oppId': A1}, step='stage/history A1')
|
|
rec('F13', 'stage/history(切阶段 2 次应留痕)', '✅' if hg and len(hg) >= 2 else '⚠', f'{len(hg or [])} 条')
|
|
p = b_.api('GET', '/api/opportunity/stage/progress', params={'oppId': A1}, step='progress')
|
|
nodes = (p or {}).get('nodes') or []
|
|
if nodes:
|
|
ok_node = all(n.get('customNodeName') or n.get('stageDictCode') for n in nodes)
|
|
has_goal = any(n.get('workGoal') for n in nodes) or (p or {}).get('currentWorkGoal')
|
|
rec('F13', 'progress 节点名+完成状态(U08)', '✅' if ok_node else '⚠', f'{len(nodes)} 节点')
|
|
rec('F13', '工作目标文本存在性(U08 宽断言)', '✅' if has_goal else '⚠', f'goal={bool(has_goal)}')
|
|
|
|
|
|
# ================= F15 自定义视图 CRUD =================
|
|
def f15():
|
|
print('\n== F15 自定义视图 CRUD(全 CRUD,U07 改判) ==')
|
|
a = sess('A')
|
|
# 清历史堆积:lead 域 save 无 upsert-by-name,跨轮叠加(上轮验收已积 13 条 e2e-A-lead域视图)
|
|
for old in [x for x in (a.api('GET', '/api/preference/view/list',
|
|
params={'scopeKey': 'lead'}, step='list lead 旧') or [])
|
|
if x.get('name') == 'e2e-A-lead域视图']:
|
|
a.api_void('POST', '/api/preference/view/delete',
|
|
params={'viewId': old.get('viewId'), 'scopeKey': 'lead'}, step='delete 旧 lead 视图')
|
|
v = a.api('POST', '/api/preference/view/save',
|
|
params={'scopeKey': 'opportunity'},
|
|
json_body={'viewId': None, 'name': 'e2e-A-我的推进视图', 'scopeKey': 'opportunity',
|
|
'conditions': [{'field': 'oppStatus', 'operator': 'eq', 'value': '2'}],
|
|
'sortField': 'createTime', 'sortDirection': 'desc', 'isDefault': 0, 'seqNo': 1},
|
|
step='view/save')
|
|
if v is None:
|
|
rec('F15', 'save 视图', '❌', 'save 失败'); return
|
|
vid = v.get('viewId') if isinstance(v, dict) else v
|
|
rec('F15', 'save 视图', '✅', f'viewId={vid}')
|
|
lst = a.api('GET', '/api/preference/view/list', params={'scopeKey': 'opportunity'}, step='list')
|
|
hit = any(x.get('viewId') == vid for x in (lst or []))
|
|
rec('F15', 'list 查到新视图', '✅' if hit else '❌')
|
|
up = a.api('POST', '/api/preference/view/save',
|
|
params={'scopeKey': 'opportunity'},
|
|
json_body={'viewId': vid, 'name': 'e2e-A-改名视图', 'scopeKey': 'opportunity',
|
|
'conditions': [], 'isDefault': 0, 'seqNo': 1}, step='save 同 id')
|
|
lst2 = a.api('GET', '/api/preference/view/list', params={'scopeKey': 'opportunity'}, step='list2')
|
|
same = [x for x in (lst2 or []) if x.get('viewId') == vid]
|
|
rec('F15', '同 viewId 重复 save(upsert 语义实测)', '✅' if up is not None and len(same) == 1 and same[0].get('name') == 'e2e-A-改名视图' else '⚠',
|
|
f'同 id 条数={len(same)} name={same and same[0].get("name")}')
|
|
a.api_void('POST', '/api/preference/view/set-default', params={'viewId': vid, 'scopeKey': 'opportunity'}, step='set-default')
|
|
lst3 = a.api('GET', '/api/preference/view/list', params={'scopeKey': 'opportunity'}, step='list3')
|
|
dflt = [x for x in (lst3 or []) if x.get('viewId') == vid]
|
|
rec('F15', 'set-default 回显默认标记', '✅' if dflt and dflt[0].get('isDefault') else '⚠',
|
|
f'isDefault={dflt and dflt[0].get("isDefault")}')
|
|
a.api('POST', '/api/preference/view/save',
|
|
params={'scopeKey': 'lead'},
|
|
json_body={'viewId': None, 'name': 'e2e-A-lead域视图', 'scopeKey': 'lead', 'conditions': []},
|
|
step='save scopeKey=lead')
|
|
ll = a.api('GET', '/api/preference/view/list', params={'scopeKey': 'lead'}, step='list lead')
|
|
ol = a.api('GET', '/api/preference/view/list', params={'scopeKey': 'opportunity'}, step='list opp again')
|
|
ln = [x.get('name') for x in (ll or [])]
|
|
on = [x.get('name') for x in (ol or [])]
|
|
iso = 'e2e-A-lead域视图' in ln and 'e2e-A-lead域视图' not in on
|
|
rec('F15', 'scopeKey 维度隔离(lead 视图不串 opportunity 域;list 出参无 scopeKey 回显)',
|
|
'✅' if iso else '⚠', f'lead names={ln} / opportunity names={on}')
|
|
a.api_void('POST', '/api/preference/view/delete', params={'viewId': vid, 'scopeKey': 'opportunity'}, step='delete')
|
|
lst4 = a.api('GET', '/api/preference/view/list', params={'scopeKey': 'opportunity'}, step='list4')
|
|
gone = not any(x.get('viewId') == vid for x in (lst4 or []))
|
|
rec('F15', 'delete 后消失', '✅' if gone else '❌')
|
|
b_ = sess('B')
|
|
bl = b_.api('GET', '/api/preference/view/list', params={'scopeKey': 'opportunity'}, step='B list')
|
|
rec('F15', 'B 的 seed 视图不受影响', '✅' if bl and len(bl) >= 1 else '⚠', f'B 视图 {len(bl or [])} 条')
|
|
|
|
|
|
# ================= 报告 =================
|
|
def write_report():
|
|
lines = ['# 票 07 · E2E 核心域实测结果(草稿章节)', '',
|
|
f'> 运行时间:{NOW} · 数据基线:seed 12 条 + F01 新建 1 条 · 判定纪律见 map「判定纪律」', '',
|
|
'| 流程 | 用例 | 结论 | 证据 |', '|---|---|---|---|']
|
|
for flow, case, verdict, ev in results:
|
|
ev = (ev or '').replace('|', '∣')[:180]
|
|
lines.append(f'| {flow} | {case} | {verdict} | {ev} |')
|
|
lines += ['', '## 缺陷清单(P0/P1/P2 初判,只记录不修复)', '']
|
|
if defects:
|
|
for i, d in enumerate(defects, 1):
|
|
lines.append(f"{i}. **[P{d['level']}] {d['flow']} · {d['title']}** —— {d['evidence']}")
|
|
else:
|
|
lines.append('(无新增缺陷)')
|
|
lines += ['', '## SKIP', '']
|
|
for f, r in skips:
|
|
lines.append(f'- {f}:{r}')
|
|
with open(__file__.replace('e2e-core.py', 'report-core.md'), 'w', encoding='utf-8') as fp:
|
|
fp.write('\n'.join(lines))
|
|
with open(__file__.replace('e2e-core.py', 'e2e-core-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)
|
|
ok_n = sum(1 for r in results if r[2] == '✅')
|
|
w_n = sum(1 for r in results if r[2] == '⚠')
|
|
e_n = sum(1 for r in results if r[2] == '❌')
|
|
print(f'\n===== 汇总:✅ {ok_n} / ⚠ {w_n} / ❌ {e_n} · 缺陷 {len(defects)} 条 · SKIP {len(skips)} 项 =====')
|
|
print('报告已写 report-core.md / e2e-core-result.json')
|
|
|
|
|
|
FLOWS = {k: v for k, v in list(globals().items()) if k.startswith('f') and k[1:].isdigit() and callable(v)}
|
|
ORDER = ['f01', 'f02', 'f03', 'f04', 'f05', 'f06', 'f07', 'f08', 'f09', 'f10', 'f11', 'f12', 'f14', 'f13', 'f15']
|
|
|
|
if __name__ == '__main__':
|
|
want = [a.upper() for a in sys.argv[1:]] or None
|
|
for k in ORDER:
|
|
f = FLOWS.get(k)
|
|
if not f:
|
|
continue
|
|
if want and k.upper() not in want:
|
|
continue
|
|
try:
|
|
f()
|
|
except Exception as e:
|
|
import traceback
|
|
rec(k.upper(), '流程级异常', '❌', f'{e} | {traceback.format_exc()[-200:]}')
|
|
write_report()
|
|
|