# -*- coding: utf-8 -*- """票 07 — E2E 实测:商机核心域主流程(F01-F15)。 前置:seed-opportunity.py 已跑(12 条 e2e- 商机 + 规则/模板发布中)。 纪律:Python requests(禁 PS 中文 body);POST 显式 x-www-form-urlencoded; Result 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 端点: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', '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 绑定,防漏字段清库);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:]}'}) 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")}') # 负路径:Controller 声明 bizType optional,但表列 biz_type NOT NULL 无默认值 —— 契约矛盾 ok1 = a.api_void('POST', '/api/opportunity/attachment/add', form={'oppId': A2, 'fileId': fid, 'materialType': '', 'remark': '票07 F08'}, step='attachment/add(bizType 缺省)') if not ok1: defect('F08', 'attachment/add 可选参数 bizType 缺省即 50001(接口 optional vs 表 biz_type NOT NULL 契约矛盾)', 2, "日志: Field 'biz_type' doesn't have a default value") 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') if d is not None: rec('F10', '勘察次数自动生成(P1-2 拍板)', '✅', '不传 surveySeq 成功,后端自动生成') else: rec('F10', '勘察次数自动生成(P1-2 拍板)', '⚠', '不传 surveySeq 被拒——次数需调用方传,与拍板不符') defect('F10', '勘察次数需调用方传入(add 必填 surveySeq),与「次数自动生成」拍板不符', 2) 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', '勘察次数自动生成(P1-2)', '✅' if seqs and all(seqs) else '⚠', f'surveySeq 列表={seqs}') ok, b = a.expect_fail('POST', '/api/opportunity/site-survey/add', form={'oppId': A1, 'surveyDate': TOMORROW, 'engineerUserId': UID['B'], 'applyWay': 'apply_way_01', 'surveySeq': 'survey_seq_02'}, step='缺说明') rec('F10', '补充说明必填(P1-3 负路径)', '✅' if ok else '⚠', ('被拒: ' + str(b.get('message'))[:50]) if ok else '未拦截空说明!') if not ok: defect('F10', '现场勘察补充说明未强制必填', 2) 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')}") ok, bb = a.expect_fail('POST', '/api/opportunity/close', form={'id': C1, 'closeReason': 'close_reason_01'}, step='暂缓态直接关闭') rec('F14', '受限态出边(§3.3):暂缓不可直接关闭', '✅' if ok else '⚠', ('被拒: ' + str(bb.get('message'))[:50]) if ok else f'竟成功!{bb.get("code")}') if not ok: defect('F14', '暂缓态可直接关闭(迁移边约束缺失)', 1) 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')}") c.api_void('POST', '/api/opportunity/claim-batch', params={'ids': f'{B2},{BPL}'}, step='C claim-batch') d = c.api('GET', '/api/opportunity/detail', params={'id': B2}, step='detail') rec('F14', '批量领取', '✅' if d and int(d.get('oppStatus') or 0) == 2 else '❌') 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 禁领规则版本') b_.api_void('POST', '/api/opportunity/release-pool', form={'id': B1, 'poolReason': 'pool_reason_04'}, step='release B1(留公海)') 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 兜底:反查草稿 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': B1}, 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: # 缺陷场景:禁领期被领走 → B1 已非公海态,修正回公海再测恢复领取 c.api_void('POST', '/api/opportunity/release-pool', form={'id': B1, 'poolReason': 'pool_reason_04'}, step='C release B1 修正') 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': B1}, step='C claim 恢复后') d = c.api('GET', '/api/opportunity/detail', params={'id': B1}, 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') 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()