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.
886 lines
46 KiB
886 lines
46 KiB
# -*- coding: utf-8 -*-
|
|
"""
|
|
商机 E2E seed 脚本(票 11 刷新)——API 幂等造数 + DB 级联清残留
|
|
票 11 基线升级(在票 05 十二条矩阵之上):
|
|
- 关联客户走 API(票 04 customer/add + set-primary;a2 完整剧情:主→普通→切换主要)
|
|
- 团队成员走 API(票 04 team/add;a2/b1 完整团队:负责人+两角色成员)
|
|
- 部门专用禁领规则 1 条(冠军团队 allowFreeClaim=0,配合 D-09 禁领回归:b4 领取实测 66014)
|
|
- 长期暂缓样例(a3 预期重启 2027-01-15,前端按钮态联调)
|
|
- owner_dept_id 全量落值(创建=负责人主部门,服务端 ownerSnapshotResolver 解析;抛公海保留)
|
|
DB 豁免仅剩清理级联删一处(关联客户 API 化后,票 05 的补行豁免取消)。
|
|
|
|
用法: python seed-opportunity.py [--skip-db-clean]
|
|
产出: 控制台步骤日志 + seed-manifest.json + seed-data-manifest.md
|
|
原则: 全程走真实 API(DB 仅一处豁免:清理级联删,API 无商机删除端点)
|
|
"""
|
|
import json
|
|
import sys
|
|
import datetime as dt
|
|
|
|
import requests
|
|
import pymysql
|
|
|
|
if hasattr(sys.stdout, 'reconfigure'):
|
|
sys.stdout.reconfigure(encoding='utf-8')
|
|
sys.stderr.reconfigure(encoding='utf-8')
|
|
|
|
BASE = 'http://localhost:8080'
|
|
ADMIN_UID = '739564171091247104' # 罗伟健(管理员,无部门)
|
|
UID = {
|
|
'A': '744842565802524672', # 赖永利 · 特战团队(营销中心子)
|
|
'B': '744842566742048768', # 肖琴 · 冠军团队(营销中心子,A 兄弟部门)
|
|
'C': '744842318024015872', # 曾偲青 · 职员(跨中心)
|
|
}
|
|
NAME = {
|
|
ADMIN_UID: '罗伟健(管理员)',
|
|
'A': '赖永利(A·特战)', 'B': '肖琴(B·冠军)', 'C': '曾偲青(C·职员)',
|
|
}
|
|
DB = dict(host='8.129.84.155', port=3306, user='root', password='Itc@123456',
|
|
database='crm', charset='utf8mb4', autocommit=False)
|
|
|
|
PREFIX = 'e2e-'
|
|
TODAY = dt.date(2026, 8, 30)
|
|
NOW = '2026-08-30 10:00:00'
|
|
DEPT_CHAMPION = '744841308677341184' # 冠军团队(B 主部门,票 11 禁领规则专用对象)
|
|
|
|
defects = [] # 非预期响应收集(票面要求:任何一步非预期都记入缺陷草稿)
|
|
manifest = {'opportunities': [], 'pool_rules': [], 'scheme_templates': [],
|
|
'follows': [], 'surveys': [], 'scheme_cards': [], 'saved_views': [],
|
|
'customers': [], 'team_members': [], 'workplans': [],
|
|
'observations': [], 'checks': {}, 'skipped': []}
|
|
|
|
_seed_customers = {} # opp_id -> (customer_id, customer_name) DB 补的关联客户
|
|
|
|
|
|
def defect(step, msg):
|
|
line = f' ⚠ [{step}] {msg}'
|
|
print(line)
|
|
defects.append({'step': step, 'msg': msg})
|
|
|
|
|
|
def skip(step, msg):
|
|
print(f' ⊘ [{step}] {msg}')
|
|
manifest['skipped'].append({'step': step, 'msg': msg})
|
|
|
|
|
|
# ---------------- HTTP ----------------
|
|
|
|
def get_token(uid):
|
|
r = requests.get(f'{BASE}/api/auth/debug/token', params={'userId': uid}, timeout=30)
|
|
r.raise_for_status()
|
|
data = r.json().get('data')
|
|
tok = data if isinstance(data, str) else (data or {}).get('token')
|
|
assert tok, f'debug token 异常: {r.text[:200]}'
|
|
return tok
|
|
|
|
|
|
def sess(uid):
|
|
s = requests.Session()
|
|
s.headers['Authorization'] = f'Bearer {get_token(uid)}'
|
|
return s
|
|
|
|
|
|
def api(s, method, path, form=None, json_body=None, params=None, step=''):
|
|
"""统一请求。业务失败/非 200 记 defect 返回 None(不抛异常,由调用方决定降级)。"""
|
|
url = BASE + path
|
|
try:
|
|
if json_body is not None:
|
|
r = s.post(url, json=json_body, params=params, timeout=30)
|
|
elif form is not None:
|
|
r = s.post(url, data=form, params=params, timeout=30)
|
|
else:
|
|
r = s.request(method, url, params=params, timeout=30)
|
|
except Exception as e:
|
|
defect(step, f'{method} {path} 网络异常: {e}')
|
|
return None
|
|
if r.status_code != 200:
|
|
defect(step, f'{method} {path} HTTP {r.status_code}: {r.text[:200]}')
|
|
return None
|
|
try:
|
|
body = r.json()
|
|
except ValueError:
|
|
defect(step, f'{method} {path} 响应非 JSON: {r.text[:200]}')
|
|
return None
|
|
code = body.get('code')
|
|
if code not in (200, '200', 0, '0'):
|
|
defect(step, f'{method} {path} 业务失败 code={code}: {str(body.get("message"))[:200]}')
|
|
return None
|
|
return body.get('data')
|
|
|
|
|
|
def page_all(s, path, form, step):
|
|
"""PageResult 帮手:返回 (content, total)。"""
|
|
d = api(s, 'POST', path, form=form, step=step)
|
|
if not isinstance(d, dict):
|
|
return [], None
|
|
content = d.get('content') or []
|
|
return content, d.get('total')
|
|
|
|
|
|
def api_void(s, path, form=None, params=None, step=''):
|
|
"""Result<Void> 端点专用:code 通过即成功(data=null 不是失败)。
|
|
已踩坑:api() 对 data=null 返回 None,会被误判为失败。"""
|
|
url = BASE + path
|
|
try:
|
|
r = s.post(url, data=form, params=params, timeout=30)
|
|
except Exception as e:
|
|
defect(step, f'POST {path} 网络异常: {e}')
|
|
return False
|
|
if r.status_code != 200:
|
|
defect(step, f'POST {path} HTTP {r.status_code}: {r.text[:200]}')
|
|
return False
|
|
try:
|
|
body = r.json()
|
|
except ValueError:
|
|
defect(step, f'POST {path} 响应非 JSON: {r.text[:200]}')
|
|
return False
|
|
code = body.get('code')
|
|
if code not in (200, '200', 0, '0'):
|
|
defect(step, f'POST {path} 业务失败 code={code}: {str(body.get("message"))[:200]}')
|
|
return False
|
|
return True
|
|
|
|
|
|
# ---------------- DB 清理 ----------------
|
|
|
|
CHILD_TABLES = ['opportunity_follow', 'opportunity_site_survey', 'opportunity_attachment',
|
|
'opportunity_team', 'opportunity_customer', 'opportunity_stage_history',
|
|
'opportunity_status_history', 'opportunity_focus', 'opportunity_view_log',
|
|
'opportunity_oplog', 'opportunity_work_plan', 'opportunity_pending_notice']
|
|
|
|
|
|
def db_clean(cur):
|
|
"""票 04 决议:全量清存量商机。API 无商机删除端点(已核实 8 控制器)→ DB 级联删。
|
|
守卫:opportunity 行数 > 100 视为连错库/环境异常,中止。"""
|
|
cur.execute('SELECT COUNT(*) FROM opportunity')
|
|
n = cur.fetchone()[0]
|
|
print(f'[1/6] DB 清残留: opportunity 现有 {n} 行(票 04 决议=全量清 26 条)')
|
|
if n > 100:
|
|
raise SystemExit(f' ✋ 行数 {n} 超过守卫阈值 100,疑似连错库,中止(请人工确认 8.129.84.155/crm)')
|
|
if n == 0:
|
|
print(' 已是空表(重跑场景),跳过清理')
|
|
return
|
|
cur.execute('SELECT id FROM opportunity')
|
|
ids = [r[0] for r in cur.fetchall()]
|
|
ph = ','.join(['%s'] * len(ids))
|
|
|
|
def opp_col(table):
|
|
cur.execute(f'SHOW COLUMNS FROM {table}')
|
|
cols = [r[0] for r in cur.fetchall()]
|
|
for cand in ('opp_id', 'opportunity_id'):
|
|
if cand in cols:
|
|
return cand
|
|
return None
|
|
|
|
# 方案卡值表先于卡表删(按 card 外键)
|
|
card_col = opp_col('opportunity_scheme_card')
|
|
if card_col:
|
|
cur.execute(f'SELECT id FROM opportunity_scheme_card WHERE {card_col} IN ({ph})', ids)
|
|
card_ids = [r[0] for r in cur.fetchall()]
|
|
if card_ids:
|
|
cur.execute('SHOW COLUMNS FROM opportunity_scheme_card_value')
|
|
vcols = [r[0] for r in cur.fetchall()]
|
|
for cand in ('card_id', 'scheme_card_id'):
|
|
if cand in vcols:
|
|
ph2 = ','.join(['%s'] * len(card_ids))
|
|
cur.execute(f'DELETE FROM opportunity_scheme_card_value WHERE {cand} IN ({ph2})', card_ids)
|
|
break
|
|
else:
|
|
cur.execute('DELETE FROM opportunity_scheme_card_value')
|
|
print(f' ⚠ opportunity_scheme_card_value 无 card 外键列,整表清 {cur.rowcount} 行')
|
|
cur.execute(f'DELETE FROM opportunity_scheme_card WHERE {card_col} IN ({ph})', ids)
|
|
print(f' 清 opportunity_scheme_card {cur.rowcount} 行')
|
|
|
|
for t in CHILD_TABLES:
|
|
col = opp_col(t)
|
|
if col:
|
|
cur.execute(f'DELETE FROM {t} WHERE {col} IN ({ph})', ids)
|
|
else:
|
|
cur.execute(f'DELETE FROM {t}')
|
|
print(f' ⚠ {t} 无商机外键列,整表清 {cur.rowcount} 行')
|
|
print(f' 清 {t} {cur.rowcount} 行')
|
|
|
|
cur.execute('DELETE FROM opportunity')
|
|
print(f' 清 opportunity 主表 {cur.rowcount} 行')
|
|
cur.connection.commit()
|
|
|
|
|
|
# ---------------- 造数步骤 ----------------
|
|
|
|
def ensure_pool_rule(admin):
|
|
print('[2/6] 公海规则前置(F14 领取校验 + 抛公海开关依赖发布中版本)')
|
|
rows, _ = page_all(admin, '/api/rule/opp-pool-rule/page',
|
|
{'current': 1, 'size': 50}, step='pool-rule page')
|
|
published = [r for r in rows if r.get('status') == 2]
|
|
if published:
|
|
# 票 11:优先复用通用规则(applyScope=1);部门专用禁领规则由 ensure_dept_claim_rule 负责,
|
|
# 免得 page 按创建时间倒序拿到专用规则、manifest 记录语义跑偏
|
|
r0 = next((r for r in published if r.get('applyScope') == 1), published[0])
|
|
print(f" 复用发布中规则 id={r0['id']} {r0.get('ruleName')} V{r0.get('versionNo')}")
|
|
manifest['pool_rules'].append(r0)
|
|
return
|
|
form = dict(ruleName=PREFIX + 'seed公海规则', applyScope=1, isDefault=1,
|
|
allowManualPool=1, autoRecycleEnabled=1, recycleDays=30,
|
|
allowFreeClaim=1, recycleRemindEnabled=1, remindDays=7,
|
|
ruleDesc='票05 seed 造:允许手动转入/自由领取/30天回收/提前7天提醒')
|
|
ok = api_void(admin, '/api/rule/opp-pool-rule/publish', form=form, step='pool-rule publish')
|
|
if not ok:
|
|
skip('pool-rule', '发布失败,抛公海/领取步骤可能连带失败(defect 已记)')
|
|
return
|
|
rows, _ = page_all(admin, '/api/rule/opp-pool-rule/page',
|
|
{'current': 1, 'size': 50, 'keyword': PREFIX}, step='pool-rule recheck')
|
|
pub = next((r for r in rows if r.get('status') == 2), None)
|
|
if pub:
|
|
print(f" ✔ 新建并发布规则 id={pub['id']} {pub.get('ruleName')}")
|
|
manifest['pool_rules'].append(pub)
|
|
else:
|
|
defect('pool-rule', 'publish 返回成功但 page 查不到发布中版本')
|
|
|
|
|
|
def ensure_dept_claim_rule(admin):
|
|
"""票 11:部门专用禁领规则样例(applyScope=2 + 冠军团队 + allowFreeClaim=0)。
|
|
配合 D-09 禁领回归:b4 由 B 创建抛公海,E3 不清 owner_dept_id(保留冠军团队)→
|
|
领取时 ActionGuard 按 owner_dept_id 命中本规则 → 66014。
|
|
autoRecycleEnabled=0:只干禁领一件事,防 30 天自动回收把公海样例收走。
|
|
幂等:已有发布中部门专用规则则复用(不碰他人数据)。"""
|
|
print('[2b] 部门专用禁领规则前置(D-09 b4 禁领回归依赖发布中版本)')
|
|
rows, _ = page_all(admin, '/api/rule/opp-pool-rule/page',
|
|
{'current': 1, 'size': 50}, step='dept-claim-rule page')
|
|
pub = next((r for r in rows if r.get('status') == 2 and r.get('applyScope') == 2), None)
|
|
if pub:
|
|
print(f" 复用发布中部门专用规则 id={pub['id']} {pub.get('ruleName')}")
|
|
manifest['pool_rules'].append(pub)
|
|
return
|
|
form = dict(ruleName=PREFIX + 'seed冠军团队禁领', applyScope=2, isDefault=0,
|
|
allowManualPool=1, autoRecycleEnabled=0,
|
|
allowFreeClaim=0, recycleRemindEnabled=0,
|
|
deptIds=[DEPT_CHAMPION],
|
|
ruleDesc='票11 seed 造:冠军团队专用禁领(allowFreeClaim=0),不启用自动回收')
|
|
ok = api_void(admin, '/api/rule/opp-pool-rule/publish', form=form, step='dept-claim-rule publish')
|
|
if not ok:
|
|
skip('dept-claim-rule', '发布失败,b4 禁领回归实测可能连带失败(defect 已记)')
|
|
return
|
|
rows, _ = page_all(admin, '/api/rule/opp-pool-rule/page',
|
|
{'current': 1, 'size': 50, 'keyword': PREFIX}, step='dept-claim-rule recheck')
|
|
pub = next((r for r in rows if r.get('status') == 2 and r.get('applyScope') == 2), None)
|
|
if pub:
|
|
print(f" ✔ 新建并发布部门专用规则 id={pub['id']} {pub.get('ruleName')}(dept=冠军团队)")
|
|
manifest['pool_rules'].append(pub)
|
|
else:
|
|
defect('dept-claim-rule', 'publish 返回成功但 page 查不到发布中部门专用版本')
|
|
|
|
|
|
def ensure_scheme_template(admin):
|
|
print('[3/6] 方案卡模板前置(F09 建卡依赖发布中版本)')
|
|
rows, _ = page_all(admin, '/api/rule/opp-scheme-template/page',
|
|
{'current': 1, 'size': 50}, step='scheme-template page')
|
|
published = [r for r in rows if r.get('status') == 2]
|
|
if published:
|
|
r0 = published[0]
|
|
print(f" 复用发布中模板 id={r0['id']} {r0.get('templateName') or r0.get('code')} V{r0.get('versionNo')}")
|
|
manifest['scheme_templates'].append(r0)
|
|
return r0['id']
|
|
# 优先复用上次 seed copy 出的草稿(templateCode 非种子前缀),避免重跑堆积草稿
|
|
draft = next((r for r in rows if r.get('status') == 1
|
|
and not str(r.get('templateCode') or '').startswith('OPP_SCHEME_TPL_')), None)
|
|
if not draft:
|
|
src = next((r for r in rows if r.get('code') == 'OPP_SCHEME_TPL_01'), None) \
|
|
or (rows[0] if rows else None)
|
|
if not src:
|
|
skip('scheme-template', '库里无任何方案卡模板可 copy,方案卡样例跳过')
|
|
return None
|
|
new_id = api(admin, 'POST', '/api/rule/opp-scheme-template/copy',
|
|
params={'id': src['id']}, step='scheme-template copy')
|
|
if new_id is None:
|
|
skip('scheme-template', 'copy 失败,方案卡样例跳过')
|
|
return None
|
|
draft = {'id': new_id, 'copiedFrom': src['id']}
|
|
detail = api(admin, 'GET', '/api/rule/opp-scheme-template/detail',
|
|
params={'id': draft['id']}, step='scheme-template detail')
|
|
if not isinstance(detail, dict):
|
|
skip('scheme-template', '草稿 detail 失败,方案卡样例跳过')
|
|
return None
|
|
# publish=保存并发布:校验的是请求 DTO 里的 fields 列表(库里有字段不够)→
|
|
# 用 Spring MVC 索引绑定把 fields 逐项回传(fieldKey/isVisible/isRequired,入参按列表顺序重算)
|
|
form = {k: v for k, v in detail.items()
|
|
if k not in ('fields', 'deptNames', 'createBy', 'createTime', 'updateTime')
|
|
and not isinstance(v, (list, dict))}
|
|
if isinstance(detail.get('deptIds'), list):
|
|
form['deptIds'] = detail['deptIds']
|
|
for i, f0 in enumerate(detail.get('fields') or []):
|
|
form[f'fields[{i}].fieldKey'] = f0.get('fieldKey')
|
|
form[f'fields[{i}].isVisible'] = 0 if f0.get('isVisible') == 0 else 1
|
|
form[f'fields[{i}].isRequired'] = 0 if f0.get('isRequired') == 0 else 1
|
|
if not any(k.startswith('fields[') for k in form):
|
|
skip('scheme-template', 'detail 未回传 fields 列表,publish 会被 64022 拒,方案卡样例跳过')
|
|
return None
|
|
ok = api_void(admin, '/api/rule/opp-scheme-template/publish', form=form,
|
|
step='scheme-template publish')
|
|
if not ok:
|
|
skip('scheme-template', 'publish 失败,方案卡样例跳过')
|
|
return None
|
|
print(f" ✔ 模板发布 id={draft['id']}(fields {len(detail.get('fields') or [])} 项回传)")
|
|
manifest['scheme_templates'].append({'id': str(draft['id']), 'copiedFrom': draft.get('copiedFrom')})
|
|
return draft['id']
|
|
|
|
|
|
def create_opp(s, who, name, **extra):
|
|
form = dict(opportunityName=name, oppSource='opp_source_02', industryCode='gov',
|
|
localityType='locality_type_01', bidForm='bid_form_01',
|
|
# 票 09:D-14 生效后 create 六必填(province/city 未传会被 66001 拒)
|
|
provinceCode='510000', cityCode='510100',
|
|
partyAClear=1, partyA='E2E甲方·' + name.replace(PREFIX, ''),
|
|
remark='票05 seed 造数样例(前端联调数据)')
|
|
form.update(extra)
|
|
d = api(s, 'POST', '/api/opportunity', form=form, step=f'create {name}')
|
|
if d is None:
|
|
return None, None
|
|
opp_id = str(d)
|
|
det = api(s, 'GET', '/api/opportunity/detail', params={'id': d}, step=f'detail {name}')
|
|
st = det.get('oppStatus') if isinstance(det, dict) else '?'
|
|
print(f' ✔ {name} id={opp_id} 初始 oppStatus={st}(U01 观察)')
|
|
manifest['observations'].append(f'{name}: 新建后 oppStatus={st}')
|
|
row = {'id': opp_id, 'name': name, 'owner': who, 'ownerDept': None, 'status': st,
|
|
'samples': [], 'detail': det if isinstance(det, dict) else None}
|
|
if isinstance(det, dict):
|
|
row['ownerDept'] = det.get('ownerDeptId')
|
|
manifest['opportunities'].append(row)
|
|
return opp_id, det
|
|
|
|
|
|
def switch_stage(s, opp_id, seq_no, tag):
|
|
prog = api(s, 'GET', '/api/opportunity/stage/progress',
|
|
params={'oppId': opp_id}, step=f'progress {tag}')
|
|
if not isinstance(prog, dict):
|
|
skip('stage-switch', f'{tag} progress 失败,停在默认节点')
|
|
return
|
|
target = next((n for n in (prog.get('nodes') or [])
|
|
if n.get('seqNo') == seq_no and not n.get('isFixed')), None)
|
|
if not target:
|
|
defect('stage-switch', f'{tag} 找不到 seqNo={seq_no} 的普通节点: {prog.get("nodes")}')
|
|
return
|
|
ok = api_void(s, '/api/opportunity/stage/switch',
|
|
form={'oppId': opp_id, 'toStageId': target['nodeId'],
|
|
'remark': f'seed 切阶段到 seq{seq_no}'}, step=f'switch {tag}')
|
|
if ok:
|
|
print(f' ✔ {tag} 阶段切到 seq{seq_no}(nodeId={target["nodeId"]})')
|
|
|
|
|
|
def api_add_customer(s, opp_id, cust_id, cust_name, role='customer_role_01', primary=1, tag=''):
|
|
"""票 11:关联客户 API 化(票 04 customer/add,替掉票 05 的 DB 直插豁免)。
|
|
幂等:先 customer/list 查重(customer_id 已关联则直接返回已有行 id,--skip-db-clean 重跑不撞 66011)。
|
|
add 直设主要时服务端自动降原主要 + 刷主表 primary_customer_id 冗余列(D-17 判定口径)。"""
|
|
exist = api(s, 'GET', '/api/opportunity/customer/list', params={'oppId': opp_id},
|
|
step=f'customer-list {tag}')
|
|
if isinstance(exist, list):
|
|
hit = next((r for r in exist if str(r.get('customerId')) == str(cust_id)), None)
|
|
if hit:
|
|
print(f" ⊘ 关联客户已在册 opp={opp_id} ← {cust_name}(跳过 add)")
|
|
_seed_customers[str(opp_id)] = (cust_id, cust_name)
|
|
manifest['customers'].append({'oppId': str(opp_id), 'customerId': str(cust_id),
|
|
'name': cust_name, 'primary': primary})
|
|
return hit.get('id')
|
|
d = api(s, 'POST', '/api/opportunity/customer/add',
|
|
form={'oppId': opp_id, 'customerId': cust_id, 'customerNameSnapshot': cust_name,
|
|
'customerRole': role, 'isPrimaryIntended': primary}, step=f'customer-add {tag}')
|
|
if d is None:
|
|
return None
|
|
_seed_customers[str(opp_id)] = (cust_id, cust_name)
|
|
manifest['customers'].append({'oppId': str(opp_id), 'customerId': str(cust_id),
|
|
'name': cust_name, 'primary': primary})
|
|
print(f' ✔ 关联客户 opp={opp_id} ← {cust_name}(行 id={d},primary={primary})')
|
|
return d
|
|
|
|
|
|
def api_add_team(s, opp_id, user_ids, project_role, duty, permission, tag):
|
|
"""票 11:团队成员 API 化(票 04 team/add,DTO 批量成员统一角色/职责)。
|
|
幂等:先 team/list 查重,目标成员已在册则跳过;快照(姓名)服务端取。"""
|
|
exist = api(s, 'GET', '/api/opportunity/team/list', params={'oppId': opp_id},
|
|
step=f'team-list {tag}')
|
|
have = {str(r.get('userId')) for r in exist} if isinstance(exist, list) else set()
|
|
todo = [u for u in user_ids if str(u) not in have]
|
|
if not todo:
|
|
print(f' ⊘ 团队成员已在册 opp={opp_id} role={project_role}(跳过 add)')
|
|
return
|
|
d = api(s, 'POST', '/api/opportunity/team/add',
|
|
form={'oppId': opp_id, 'userIds': todo, 'projectRole': project_role,
|
|
'duty': duty, 'permission': permission}, step=f'team-add {tag}')
|
|
if d is not None:
|
|
print(f' ✔ 团队成员 opp={opp_id} +{len(todo)}人 role={project_role} perm={permission}')
|
|
manifest['team_members'].append({'oppId': str(opp_id), 'role': project_role,
|
|
'duty': duty, 'userIds': [str(u) for u in todo]})
|
|
|
|
|
|
def expect_fail(s, path, form, want_code, step):
|
|
"""票 11:负路径实测——业务失败且 code 命中期望 = 通过(记 checks);
|
|
意外成功或别的错误码都算 defect(D-09 禁领回归主助手)。"""
|
|
url = BASE + path
|
|
try:
|
|
r = s.post(url, data=form, timeout=30)
|
|
except Exception as e:
|
|
defect(step, f'POST {path} 网络异常: {e}')
|
|
return False
|
|
try:
|
|
body = r.json()
|
|
except ValueError:
|
|
defect(step, f'POST {path} 响应非 JSON: {r.text[:200]}')
|
|
return False
|
|
code = str(body.get('code'))
|
|
if r.status_code == 200 and code == str(want_code):
|
|
msg = str(body.get('message'))
|
|
print(f' ✔ 负路径实测 {step}: code={code}({msg[:60]})')
|
|
manifest['checks'][step] = {'code': code, 'message': msg[:120], 'pass': True}
|
|
return True
|
|
defect(step, f'期望 code={want_code} 实得 HTTP {r.status_code} code={code}: '
|
|
f'{str(body.get("message"))[:120]}')
|
|
return False
|
|
|
|
|
|
def seed_matrix(admin, A, B, C, cur):
|
|
print('[4/6] 造商机矩阵(12 条:待领取2 / 推进中6 / 暂缓2 / 已关闭2,跨 3 部门归属)')
|
|
made = {}
|
|
|
|
# --- A(特战团队)5 条 ---
|
|
a1, _ = create_opp(A, 'A', PREFIX + 'A-推进-智慧园区一期')
|
|
made['a1'] = a1
|
|
a2, _ = create_opp(A, 'A', PREFIX + 'A-推进-智慧园区二期',
|
|
customerName='园科集团(快照)')
|
|
made['a2'] = a2
|
|
if a2:
|
|
switch_stage(A, a2, 2, 'A-推进-智慧园区二期')
|
|
a3, _ = create_opp(A, 'A', PREFIX + 'A-暂缓-数据中心改造')
|
|
made['a3'] = a3
|
|
a4, _ = create_opp(A, 'A', PREFIX + 'A-公海-展厅多媒体项目')
|
|
made['a4'] = a4
|
|
a5, _ = create_opp(A, 'A', PREFIX + 'A-关闭-老机房UPS替换')
|
|
made['a5'] = a5
|
|
|
|
# --- B(冠军团队)5 条 ---
|
|
b1, _ = create_opp(B, 'B', PREFIX + 'B-推进-会议系统扩容')
|
|
made['b1'] = b1
|
|
b2, _ = create_opp(B, 'B', PREFIX + 'B-推进-报告厅音视频')
|
|
made['b2'] = b2
|
|
if b2:
|
|
switch_stage(B, b2, 3, 'B-推进-报告厅音视频')
|
|
b3, _ = create_opp(B, 'B', PREFIX + 'B-暂缓-剧场改造')
|
|
made['b3'] = b3
|
|
b4, _ = create_opp(B, 'B', PREFIX + 'B-公海-法院信息化')
|
|
made['b4'] = b4
|
|
b5, _ = create_opp(B, 'B', PREFIX + 'B-关闭-厂房广播改造')
|
|
made['b5'] = b5
|
|
|
|
# --- C(职员,跨中心)2 条 ---
|
|
c1, _ = create_opp(C, 'C', PREFIX + 'C-推进-高校实验室建设')
|
|
made['c1'] = c1
|
|
c2, _ = create_opp(C, 'C', PREFIX + 'C-推进-医院护理呼叫系统')
|
|
made['c2'] = c2
|
|
if c2:
|
|
switch_stage(C, c2, 2, 'C-推进-医院护理呼叫系统')
|
|
|
|
def row_of(key):
|
|
return next((r for r in manifest['opportunities'] if r['id'] == str(made[key])), None)
|
|
|
|
# --- 状态流转造 暂缓(3)/公海(1)/关闭(4) ---
|
|
# 票 11:a3 改长期暂缓样例(预期重启 2027-01-15 远期,前端联调按钮态);b3 保留近期对照
|
|
flows = [
|
|
('a3', 'pause', dict(pauseReason='pause_reason_01', pauseExpectedRestartDate='2027-01-15',
|
|
pauseRemark='客户预算未批复,长期暂缓样例(票 11:预期重启时间远期)'), A),
|
|
('a4', 'release-pool', dict(poolReason='pool_reason_01'), A),
|
|
('a5', 'close', dict(closeReason='close_reason_02', closeRemark='客户放弃,seed 关闭样例'), A),
|
|
('b3', 'pause', dict(pauseReason='pause_reason_02', pauseExpectedRestartDate='2026-10-15',
|
|
pauseRemark='预算冻结,seed 暂缓样例(近期重启对照)'), B),
|
|
('b4', 'release-pool', dict(poolReason='pool_reason_02'), B),
|
|
('b5', 'close', dict(closeReason='close_reason_03', closeRemark='竞争失败,seed 关闭样例'), B),
|
|
]
|
|
for key, action, form, s in flows:
|
|
if not made.get(key):
|
|
skip(action, f'{key} 未创建成功,流转跳过')
|
|
continue
|
|
ok = api_void(s, f'/api/opportunity/{action}', form={'id': made[key], **form},
|
|
step=f'{action} {key}')
|
|
if ok:
|
|
print(f' ✔ {key} → {action}')
|
|
row = row_of(key)
|
|
if row:
|
|
row['status'] = {'pause': 3, 'release-pool': 1, 'close': 4}[action]
|
|
|
|
# --- D-09 禁领回归实测(票 11 新增):b4 命中冠军团队禁领 → 66014;a4 往返对照放行 ---
|
|
if b4:
|
|
# b4:B 创建抛公海,E3 不清 owner_dept_id(保留冠军团队)→ A 领取按 owner_dept_id
|
|
# 命中部门专用规则 allowFreeClaim=0 → 66014(预检拦截,状态不变)
|
|
expect_fail(A, '/api/opportunity/claim', {'id': b4}, 66014, 'b4_claim_forbidden')
|
|
if a4:
|
|
# a4 对照:A 自己抛的公海,owner_dept_id 保留特战团队 → 走通用规则 allowFreeClaim=1 放行;
|
|
# 领取后立即抛回恢复公海样例(owner/dept 不变,状态复原,多留一条 claim+release 流转痕迹)
|
|
if api_void(A, '/api/opportunity/claim', params={'id': a4}, step='a4 claim roundtrip'):
|
|
print(' ✔ a4 领取放行(对照:特战团队走通用默认规则)')
|
|
manifest['checks']['a4_claim_allowed'] = {'pass': True}
|
|
api_void(A, '/api/opportunity/release-pool',
|
|
params={'id': a4, 'poolReason': 'pool_reason_01'},
|
|
step='a4 release-pool restore')
|
|
row = row_of('a4')
|
|
if row:
|
|
row['status'] = 1
|
|
row['samples'].append('禁领对照:领取放行后已抛回公海')
|
|
|
|
# --- 子表样例:跟进×2(customerId 必填但 A4 客户模块未建 → 占位实测) ---
|
|
if a2:
|
|
for i, (content, way) in enumerate([
|
|
('首次电话沟通,确认园区一期扩容意向,约下周现场拜访', 'follow_way_01'),
|
|
('上门拜访完成,客户明确预算区间,推进方案卡编制', 'follow_way_02')]):
|
|
d = api(A, 'POST', '/api/opportunity/follow/add',
|
|
form={'oppId': a2, 'followContent': content, 'followTime': NOW,
|
|
'followWay': way, 'customerId': 1, 'resultTag': 'result_tag_02'},
|
|
step=f'follow-add #{i+1}')
|
|
if d is None: # customerId=1 被拒 → 试 0
|
|
d = api(A, 'POST', '/api/opportunity/follow/add',
|
|
form={'oppId': a2, 'followContent': content, 'followTime': NOW,
|
|
'followWay': way, 'customerId': 0, 'resultTag': 'result_tag_02'},
|
|
step=f'follow-add #{i+1} retry customerId=0')
|
|
if d is not None:
|
|
print(f' ✔ 跟进记录 #{i+1} id={d}')
|
|
manifest['follows'].append({'id': str(d), 'oppId': str(a2)})
|
|
else:
|
|
skip('follow', f'跟进 #{i+1} 造数失败(customerId 必填 vs A4 未建,见 defect)')
|
|
|
|
# --- 勘察×1(A 的二期) ---
|
|
if a2:
|
|
d = api(A, 'POST', '/api/opportunity/site-survey/add',
|
|
form={'oppId': a2, 'surveyDate': '2026-08-27', 'engineerUserId': UID['A'],
|
|
'applyWay': 'apply_way_01', 'surveySeq': 'survey_seq_01',
|
|
'applyNo': 'KC-20260827-001', 'surveyDesc': '现场勘察:机房位置/供电/承重确认'},
|
|
step='site-survey add')
|
|
if d is not None:
|
|
print(f' ✔ 勘察记录 id={d}')
|
|
manifest['surveys'].append({'id': str(d), 'oppId': str(a2)})
|
|
|
|
# --- 关联客户(票 04 API 化,替掉票 05 DB 直插)+ 方案卡×1(A 的二期) ---
|
|
# a2 完整剧情:园科集团为主 → 建工设计院普通 → set-primary 切主要(原主要自动降普通,业务规则 6)
|
|
# customerId 为占位 id(A4 客户模块未建,add 对 customerId 无存在性校验,仅查重复关联 66011)
|
|
CUST_A1, CUST_A2 = 920000000000000100, 920000000000000200
|
|
a2_primary = None
|
|
if a2:
|
|
if api_add_customer(A, int(a2), CUST_A1, '园科集团(seed快照)', primary=1, tag='a2-园科'):
|
|
api_add_customer(A, int(a2), CUST_A2, '省建工设计院(seed快照)',
|
|
role='customer_role_04', primary=0, tag='a2-建工')
|
|
if api_void(A, '/api/opportunity/customer/set-primary',
|
|
params={'oppId': a2, 'customerId': CUST_A2}, step='customer set-primary a2'):
|
|
print(f' ✔ a2 主要意向客户切换 → 省建工设计院(customer_id={CUST_A2})')
|
|
manifest['checks']['a2_set_primary'] = {'primary': str(CUST_A2), 'pass': True}
|
|
a2_primary = CUST_A2
|
|
else:
|
|
defect('customer-add a2', 'a2 主关联客户失败,方案卡 customerId 回退占位值')
|
|
a2_primary = CUST_A1
|
|
# 票面口径:每条推进中商机 2-3 个关联客户、恰 1 个主要意向(A4 未建,customerId 用占位 id)
|
|
if a1:
|
|
api_add_customer(A, int(a1), 920000000000000500, '云谷科技园(seed快照)', primary=1, tag='a1')
|
|
api_add_customer(A, int(a1), 920000000000000600, '市规划设计院(seed快照)',
|
|
role='customer_role_04', primary=0, tag='a1')
|
|
if b1:
|
|
api_add_customer(B, int(b1), 920000000000000300, '峰会会展中心(seed快照)', primary=1, tag='b1')
|
|
api_add_customer(B, int(b1), 920000000000000310, '华堂展陈工程(seed快照)',
|
|
role='customer_role_05', primary=0, tag='b1')
|
|
if b2:
|
|
api_add_customer(B, int(b2), 920000000000000700, '滨江文体中心(seed快照)', primary=1, tag='b2')
|
|
api_add_customer(B, int(b2), 920000000000000800, '南方声学工程(seed快照)',
|
|
role='customer_role_05', primary=0, tag='b2')
|
|
if c1:
|
|
api_add_customer(C, int(c1), 920000000000000400, '省第一人民医院(seed快照)', primary=1, tag='c1')
|
|
api_add_customer(C, int(c1), 920000000000000410, '泽康医疗设备(seed快照)',
|
|
role='customer_role_02', primary=0, tag='c1')
|
|
if c2:
|
|
api_add_customer(C, int(c2), 920000000000000900, '仁济医院分院(seed快照)', primary=1, tag='c2')
|
|
api_add_customer(C, int(c2), 920000000000000950, '安捷机电安装(seed快照)',
|
|
role='customer_role_05', primary=0, tag='c2')
|
|
|
|
if a2 and a2_primary:
|
|
tpl_rows, _ = page_all(admin, '/api/rule/opp-scheme-template/page',
|
|
{'current': 1, 'size': 50}, step='scheme-template page for card')
|
|
pub = next((r for r in tpl_rows if r.get('status') == 2), None)
|
|
if not pub:
|
|
skip('scheme-card', '无发布中模板,方案卡样例跳过')
|
|
else:
|
|
sel = api(A, 'GET', '/api/opportunity/scheme-card/selectable-templates',
|
|
params={'oppId': a2}, step='selectable-templates')
|
|
tpl_id = None
|
|
if isinstance(sel, list) and sel:
|
|
tpl_id = sel[0].get('templateId') or sel[0].get('id')
|
|
if tpl_id is None:
|
|
# selectable 为空也允许直接拿发布中模板版本行 id 兜底实测
|
|
tpl_id = pub.get('id')
|
|
defect('selectable-templates', f'商机 {a2} 可选模板列表空(适用范围未命中?),用模板版本行 id={tpl_id} 兜底')
|
|
# valuesJson 的 fieldKey 只能取「绑定模板版本的显示字段」(66007 校验),
|
|
# field-defs 全库字段集比模板显示字段宽,不能用全库
|
|
dtl = api(admin, 'GET', '/api/rule/opp-scheme-template/detail',
|
|
params={'id': tpl_id}, step='scheme-template detail for card')
|
|
vis = [f for f in ((dtl or {}).get('fields') or []) if f.get('isVisible') == 1]
|
|
# 结构化字段(招标形式/介入阶段/方案预算)有主表列,禁止进 valuesJson(66007)
|
|
STRUCTURED = {'scheme_budget', 'bidding_form', 'enter_stage',
|
|
'schemeBudget', 'biddingForm', 'enterStage'}
|
|
dyn = [f for f in vis
|
|
if f.get('fieldKey') not in STRUCTURED
|
|
and not any(s in str(f.get('fieldKey')) for s in ('budget', 'bidd', 'stage_'))]
|
|
values = []
|
|
# 必填字段全填(submit 校验),非必填取 2 个点缀
|
|
req = [f for f in dyn if f.get('isRequired') == 1]
|
|
opt = [f for f in dyn if f.get('isRequired') != 1][:2]
|
|
for f in req + opt:
|
|
k = str(f.get('fieldKey'))
|
|
v = 'seed样例-' + str(f.get('fieldName') or k)
|
|
if 'date' in k or '时间' in str(f.get('fieldName') or ''):
|
|
v = '2026-09-15'
|
|
elif any(s in k for s in ('amount', 'budget', 'num', 'count')):
|
|
v = '100'
|
|
values.append({'fieldKey': f['fieldKey'], 'fieldValue': v})
|
|
d = api(A, 'POST', '/api/opportunity/scheme-card/save',
|
|
form={'oppId': a2, 'customerId': a2_primary,
|
|
'templateId': tpl_id, 'schemeBudget': '880000.00',
|
|
'biddingForm': 'bid_form_01', 'enterStage': 'opp_stage_04',
|
|
'valuesJson': json.dumps(values, ensure_ascii=False)},
|
|
step='scheme-card save')
|
|
if d is not None:
|
|
print(f' ✔ 方案卡草稿 id={d}(预算 88 万)')
|
|
manifest['scheme_cards'].append({'id': str(d), 'oppId': str(a2),
|
|
'templateId': str(tpl_id)})
|
|
if api_void(A, '/api/opportunity/scheme-card/submit',
|
|
params={'id': d}, step='scheme-card submit'):
|
|
print(f' ✔ 方案卡已提交 cardStatus=2')
|
|
|
|
# --- 团队成员(票 04 API 化):a2/b1 完整团队(负责人本人 + 两角色成员) ---
|
|
# 角色用 crm-dict「商机项目角色」字典 code(project_role_01~06);
|
|
# permission 档:1只读 2可写跟进 3可推进节点;快照(姓名)服务端取
|
|
if a2:
|
|
api_add_team(A, a2, [UID['B']], 'project_role_05', '方案设计与预算编制', 3, 'a2+B')
|
|
api_add_team(A, a2, [UID['C']], 'project_role_02', '现场勘察配合', 2, 'a2+C')
|
|
if b1:
|
|
api_add_team(B, b1, [UID['A']], 'project_role_04', '报价与投标支持', 2, 'b1+A')
|
|
api_add_team(B, b1, [UID['C']], 'project_role_06', '跨部门协同', 1, 'b1+C')
|
|
|
|
# --- 工作计划(票 12 API 化):a2 三形态样例(逾期/临期/已完成),逾期实时计算语义 ---
|
|
# 幂等:先 list 按 planContent 精确匹配复用(--skip-db-clean 重跑不重复 add);
|
|
# 复用时校准 deadline(相对当前时间刷新,逾期/临期语义保鲜)与 planStatus(登记完成走 update 回填 finishTime)
|
|
if a2:
|
|
now = dt.datetime.now()
|
|
wp_defs = [
|
|
('e2e-wp-逾期样例(整理现场勘察纪要并回传)', now - dt.timedelta(days=2), 0),
|
|
('e2e-wp-临期样例(本周内完成方案卡评审)', now + dt.timedelta(days=3), 0),
|
|
('e2e-wp-已完成样例(提交报价初稿)', now + dt.timedelta(days=1), 1),
|
|
]
|
|
exist_wp = api(A, 'GET', '/api/opportunity/workplan/list', params={'oppId': a2},
|
|
step='workplan list') or []
|
|
for wp_content, wp_deadline, wp_status in wp_defs:
|
|
hit = next((r for r in exist_wp if r.get('planContent') == wp_content), None)
|
|
dl = wp_deadline.strftime('%Y-%m-%d %H:%M:%S')
|
|
if hit:
|
|
upd = {'id': hit['id'], 'deadline': dl}
|
|
if hit.get('planStatus') != wp_status:
|
|
upd['planStatus'] = wp_status
|
|
if api_void(A, '/api/opportunity/workplan/update', form=upd,
|
|
step=f'workplan 校准 {wp_content[:14]}'):
|
|
print(f' ⊘ 工作计划已在册 opp={a2} ← {wp_content[:18]}(deadline/状态校准)')
|
|
else:
|
|
d = api(A, 'POST', '/api/opportunity/workplan/add',
|
|
form={'oppId': a2, 'planContent': wp_content, 'deadline': dl},
|
|
step=f'workplan add {wp_content[:14]}')
|
|
if d is not None:
|
|
if wp_status == 1:
|
|
api_void(A, '/api/opportunity/workplan/update',
|
|
form={'id': d, 'planStatus': 1}, step='workplan 登记完成')
|
|
hit = {'id': d}
|
|
print(f' ✔ 工作计划 opp={a2} ← {wp_content[:18]}(行 id={d},status={wp_status})')
|
|
if hit:
|
|
manifest['workplans'].append({'oppId': str(a2), 'id': str(hit['id']),
|
|
'content': wp_content, 'status': wp_status})
|
|
a2_row = row_of('a2')
|
|
if a2_row:
|
|
a2_row['samples'].append('工作计划×3(逾期/临期/已完成,票 12 Tab 数据源)')
|
|
|
|
# --- 关注×1:A 关注 B 的一条 ---
|
|
if b1:
|
|
if api_void(A, '/api/opportunity/focus', params={'oppId': b1}, step='focus'):
|
|
print(f' ✔ A 已关注 {b1}')
|
|
row_of('b1')['samples'].append('A(赖永利)已关注')
|
|
|
|
# --- 自定义视图×1(B 保存,F15 样例) ---
|
|
lv = api(B, 'GET', '/api/preference/view/list', params={'scopeKey': 'opportunity'}, step='view list')
|
|
existing = None
|
|
if isinstance(lv, list):
|
|
existing = next((v for v in lv if str(v.get('name', '')).startswith(PREFIX)), None)
|
|
body = {'name': PREFIX + '测试视图-我的推进', 'isDefault': True, 'seqNo': 99,
|
|
'sortField': 'createTime', 'sortDirection': 'desc',
|
|
'conditions': [{'field': 'opportunityName', 'operator': 'like', 'value': PREFIX},
|
|
{'field': 'oppStatus', 'operator': 'eq', 'value': '2'}]}
|
|
if existing:
|
|
body['viewId'] = existing.get('viewId')
|
|
d = api(B, 'POST', '/api/preference/view/save', params={'scopeKey': 'opportunity'},
|
|
json_body=body, step='saved-view save')
|
|
if d is not None:
|
|
print(f' ✔ 自定义视图 viewId={d}(scopeKey=opportunity)')
|
|
manifest['saved_views'].append({'viewId': str(d), 'owner': 'B', 'scopeKey': 'opportunity'})
|
|
|
|
return made
|
|
|
|
|
|
def verify(admin, A, B, C):
|
|
print('[5/6] 校验盘点(write checks → manifest)')
|
|
checks = manifest['checks']
|
|
|
|
_, t = page_all(admin, '/api/opportunity/page',
|
|
{'viewType': 'MANAGE', 'current': 1, 'size': 50}, step='verify MANAGE')
|
|
checks['MANAGE_total'] = t
|
|
_, t2 = page_all(admin, '/api/opportunity/page',
|
|
{'viewType': 'PUBLIC_POOL', 'current': 1, 'size': 50}, step='verify POOL')
|
|
checks['PUBLIC_POOL_total'] = t2
|
|
# 票 11:三账号 MINE 计数齐备(前端各账号工作台联调用)
|
|
for tag, s in (('A', A), ('B', B), ('C', C)):
|
|
_, tm = page_all(s, '/api/opportunity/page',
|
|
{'viewType': 'MINE', 'current': 1, 'size': 50}, step=f'verify {tag}-MINE')
|
|
checks[f'{tag}_MINE_total'] = tm
|
|
_, t4 = page_all(A, '/api/opportunity/page',
|
|
{'viewType': 'FOLLOWED', 'current': 1, 'size': 50}, step='verify A-FOLLOWED')
|
|
checks['A_FOLLOWED_total'] = t4
|
|
bs = api(admin, 'POST', '/api/opportunity/board/stage-summary',
|
|
form={'viewType': 'MANAGE'}, step='verify board-summary')
|
|
checks['board_stage_summary'] = bs
|
|
print(f" MANAGE={t} PUBLIC_POOL={t2} A_MINE={checks['A_MINE_total']} "
|
|
f"B_MINE={checks['B_MINE_total']} C_MINE={checks['C_MINE_total']} A_FOLLOWED={t4}")
|
|
print(f' board summary: {json.dumps(bs, ensure_ascii=False)[:200] if bs else "FAIL"}')
|
|
|
|
|
|
def spot_check_detail(s, opp_id):
|
|
"""票 11 验收抽查:A 登录看推进中商机详情(owner_dept_id 回显 + 团队 + 关联客户齐全)。"""
|
|
if not opp_id:
|
|
return
|
|
print('[5b] 前端联调视角抽查(A 登录 detail)')
|
|
det = api(s, 'GET', '/api/opportunity/detail', params={'id': opp_id}, step='spot detail')
|
|
if not isinstance(det, dict):
|
|
defect('spot detail', '详情拉取失败')
|
|
return
|
|
team = api(s, 'GET', '/api/opportunity/team/list', params={'oppId': opp_id}, step='spot team-list')
|
|
cust = api(s, 'GET', '/api/opportunity/customer/list', params={'oppId': opp_id}, step='spot customer-list')
|
|
checks = manifest['checks']
|
|
checks['spot_a2'] = {
|
|
'oppId': str(opp_id),
|
|
'ownerDeptId': det.get('ownerDeptId'),
|
|
'primaryCustomerId': det.get('primaryCustomerId'),
|
|
'team_members': len(team) if isinstance(team, list) else None,
|
|
'linked_customers': len(cust) if isinstance(cust, list) else None,
|
|
'pass': bool(det.get('ownerDeptId')) and isinstance(team, list) and isinstance(cust, list),
|
|
}
|
|
print(f" ✔ a2 详情:ownerDeptId={det.get('ownerDeptId')} "
|
|
f"团队 {checks['spot_a2']['team_members']} 人 "
|
|
f"关联客户 {checks['spot_a2']['linked_customers']} 个 "
|
|
f"primaryCustomer={checks['spot_a2']['primaryCustomerId']}")
|
|
|
|
|
|
def write_outputs():
|
|
print('[6/6] 落盘 manifest')
|
|
with open('.scratch/opportunity-e2e/seed-manifest.json', 'w', encoding='utf-8') as f:
|
|
json.dump(manifest, f, ensure_ascii=False, indent=2)
|
|
|
|
rows = '\n'.join(
|
|
f"| {r['id']} | {r['name']} | {NAME.get(r['owner'], r['owner'])} | "
|
|
f"{r.get('status')} | {';'.join(r['samples']) or '—'} |"
|
|
for r in manifest['opportunities'])
|
|
defects_md = ('\n'.join(f"- **[{d['step']}]** {d['msg']}" for d in defects)) or '(无)'
|
|
skipped_md = ('\n'.join(f"- **[{s['step']}]** {s['msg']}" for s in manifest['skipped'])) or '(无)'
|
|
md = f"""# 商机 E2E · 测试数据清单(seed-data-manifest)
|
|
|
|
> 票 `11-testdata-refresh` 刷新 · 20260830 · 由 `seed-opportunity.py` 生成(幂等可重跑,重跑=先清后造)。
|
|
> 票 `12-workplan-tab` 增补工作计划样例 · 20260831。
|
|
> 机读版:`seed-manifest.json`。机读/人读不一致时以 json 为准。
|
|
|
|
## 账号矩阵(debug token 用 userId)
|
|
|
|
| 代号 | 姓名 | userId | 部门(dept_id,crm_auth_user.dept_id 实锤) |
|
|
|---|---|---|---|
|
|
| 管理员 | 罗伟健 | `{ADMIN_UID}` | —(全量可见) |
|
|
| A | 赖永利 | `{UID['A']}` | 特战团队 `744841308564094976`(营销中心子) |
|
|
| B | 肖琴 | `{UID['B']}` | 冠军团队 `744841308677341184`(营销中心子) |
|
|
| C | 曾偲青 | `{UID['C']}` | 职员 `744841292483133440`(跨中心) |
|
|
|
|
## 商机矩阵({len(manifest['opportunities'])} 条)
|
|
|
|
| id | 名称 | 负责人 | status(1待领取/2推进/3暂缓/4关闭) | 附加样例 |
|
|
|---|---|---|---|---|
|
|
{rows}
|
|
|
|
## 规则前置
|
|
|
|
- 公海规则({len(manifest['pool_rules'])} 条发布中,含部门专用禁领):{json.dumps(manifest['pool_rules'], ensure_ascii=False)[:500]}
|
|
- 方案卡模板:{json.dumps(manifest['scheme_templates'], ensure_ascii=False)[:300]}
|
|
|
|
## 子表/偏好样例
|
|
|
|
- 跟进:{len(manifest['follows'])} 条(挂 A-推进-智慧园区二期;customerId 为占位值,A4 客户模块未建)
|
|
- 勘察:{len(manifest['surveys'])} 条
|
|
- 方案卡:{len(manifest['scheme_cards'])} 张(草稿/已提交,预算 88 万,customerId=主要意向客户)
|
|
- 自定义视图:{len(manifest['saved_views'])} 条(B 名下,scopeKey=opportunity)
|
|
- 关联客户:API 造 {len(manifest['customers'])} 行(票 04 customer/add;6 条推进中商机每条 2 个恰 1 主要,a2 另含 set-primary 切换剧情)
|
|
- 团队成员:API 造 {len(manifest['team_members'])} 条(票 04 team/add;a2/b1 完整团队:负责人+方案/现场/报价角色成员)
|
|
- 工作计划:API 造 {len(manifest['workplans'])} 条(票 12 workplan/add+update;挂 a2:逾期/临期/已完成三形态,逾期=未完成且 now>deadline 前端实时判)
|
|
- 禁领回归:b4 领取实测 66014(冠军团队专用规则 allowFreeClaim=0);a4 领取放行对照(走通用规则)
|
|
- 长期暂缓样例:a3(预期重启 2027-01-15);b3 近期重启对照(2026-10-15)
|
|
- 附件:MinIO SK 未配齐,本次未造(票 07 F08 补)
|
|
|
|
## 校验结果
|
|
|
|
```json
|
|
{json.dumps(manifest['checks'], ensure_ascii=False, indent=2)}
|
|
```
|
|
|
|
## U01 观察(新建初始状态)
|
|
|
|
{chr(10).join('- ' + o for o in manifest['observations']) or '(无)'}
|
|
|
|
## 缺陷速报(seed 过程非预期响应,喂票 09 报告)
|
|
|
|
{defects_md}
|
|
|
|
## 跳过项
|
|
|
|
{skipped_md}
|
|
|
|
## 重跑方式
|
|
|
|
```
|
|
python .scratch/opportunity-e2e/seed-opportunity.py # 先清后造(幂等)
|
|
python .scratch/opportunity-e2e/seed-opportunity.py --skip-db-clean # 只造不清
|
|
```
|
|
"""
|
|
with open('.scratch/opportunity-e2e/seed-data-manifest.md', 'w', encoding='utf-8') as f:
|
|
f.write(md)
|
|
print(' ✔ seed-manifest.json + seed-data-manifest.md')
|
|
|
|
|
|
def main():
|
|
skip_db_clean = '--skip-db-clean' in sys.argv
|
|
conn = pymysql.connect(**DB)
|
|
cur = conn.cursor()
|
|
try:
|
|
if not skip_db_clean:
|
|
db_clean(cur)
|
|
else:
|
|
print('[1/6] 跳过 DB 清理(--skip-db-clean)')
|
|
|
|
admin, A, B, C = sess(ADMIN_UID), sess(UID['A']), sess(UID['B']), sess(UID['C'])
|
|
ensure_pool_rule(admin)
|
|
ensure_dept_claim_rule(admin)
|
|
ensure_scheme_template(admin)
|
|
made = seed_matrix(admin, A, B, C, cur)
|
|
verify(admin, A, B, C)
|
|
spot_check_detail(A, made.get('a2'))
|
|
write_outputs()
|
|
finally:
|
|
cur.close()
|
|
conn.close()
|
|
|
|
print(f"\n===== seed 完成:商机 {len(manifest['opportunities'])} 条 · 缺陷速报 {len(defects)} 条 · 跳过 {len(manifest['skipped'])} 项 =====")
|
|
if defects:
|
|
print('缺陷速报(详情见 seed-data-manifest.md):')
|
|
for d in defects:
|
|
print(f" ⚠ [{d['step']}] {d['msg'][:120]}")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|
|
|