@ -0,0 +1,6 @@ |
|||
{ |
|||
"version": 1, |
|||
"selectedProfiles": [], |
|||
"customProfiles": [], |
|||
"skipTests": false |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
## Answer |
|||
|
|||
票 06 收口(换机续跑 2026-09-04 完成)。**生成规模:57 个 .bru / 10 个生成目录**(A4 下 9 个子目录 55 端点 + collection 根级 `客户管理/超期提醒规则/` 2 端点,另含根级 `客户管理/folder.bru` seq:12 与各目录 folder.bru)。 |
|||
|
|||
**分组决策**(结构听源码铁律): |
|||
|
|||
- 目录 = 源码 `@Tag` 原文。9 个 crm-customer Controller 的 tag 均带「A4 客户管理/」前缀,剥前缀后相对 BASE 落位;CustomerReminderRuleController(crm-rule)tag 原文「客户管理」无 A4 前缀 → 相对 collection 根落位(`客户管理/超期提醒规则/`)。 |
|||
- crm-preference saved-view 6 端点**不生成副本**:源码 tag 挂 A3 商机管理(商机先建、客户侧复用同套),生成副本会被 sync 重跑判孤儿;README 文字索引标明客户域 scopeKey=`customer.*`。 |
|||
- 路径段 `{workspace}` 保留占位原样入钥匙(mine/overview/pool 三值一套端点,.bru url 块展开为 mine 实例)。 |
|||
|
|||
**对账结果**:verify_a4.py 正则扫 9 个 crm-customer Controller + CustomerReminderRuleController 得 (METHOD, path) 钥匙集 57 个,与生成 .bru docs 首行钥匙双向比对——missing 0 / extra 0 / duplicate 0,**PASS 全命中收口**。docs 首行标注「本行兼作对账钥匙,勿手改」。 |
|||
|
|||
**格式抽查**:新增客户.bru(form 大表 + specimens 实测 67003 撞码响应)、保存提醒规则.bru(唯一 JSON 端点,body:json 用 specimens 实测 body + 实测回显)、workspace 列表分页.bru(分页 content 截 1 条 + 实测字段结构表)对照金标准 `A3 商机管理/新建商机/新建商机.bru` 结构一致:meta tags:[generated] / method 块 / params·body 块 / docs 四段+表格。 |
|||
|
|||
**裸 collection 不升级**(Not-yet 挂号项结论):集合根目录保持裸 collection(bruno.json + collection.bru,无 workspace.yml),本票全部生成逻辑按现状跑通(生成 + Bruno 可读),**不做 workspace 结构升级**——避免影响其余 A0-A7 分区的存量路径与用户工作区打开方式,如后续 Bruno 3.x 打不开再按 bruno-coldstart 先例单独处理。 |
|||
|
|||
**换机事项**:生成器 3 处盘符路径已从 E: 改 D:(sys.path / DOCS / SP_DIR);旧机第一次跑的错误产物(重复嵌套目录 `A4 客户管理/A4 客户管理/` 64 文件 + 错位 `A4 客户管理/客户管理/超期提醒规则/` 3 文件)已随用户 git pull 进入本机,清理脚本 clean_a4_leftover.py 删除后重跑生成,幂等无残留。 |
|||
|
|||
**README**:`A4 客户管理/README.md` 已从占位重写——9 子目录索引(含端点清单与计数)、根级提醒规则归位原因、crm-preference 不生成副本说明、空缺标注、对接示位、维护纪律(只碰 tags:[generated] / 对账钥匙行勿手改 / agent 不提交 git)。 |
|||
|
|||
**遗留**:agent 不提交 git,D 盘文档仓变更(删 2 残留目录 + 新增 57 .bru + folder.bru + README 重写)由用户审查后自行提交。缺陷注记(D-02/D-04/D-05/D-07)已按 defs_a4.py 写入对应 .bru desc。 |
|||
@ -0,0 +1,28 @@ |
|||
# -*- coding: utf-8 -*- |
|||
"""票 06 续跑步骤 1:清文档仓第一次跑的错误残留(HANDOFF-2026-09-04 §3.1)。 |
|||
- D:\code\crm-api-docs\A4 客户管理\A4 客户管理\ (重复嵌套目录,9 子目录) |
|||
- D:\code\crm-api-docs\A4 客户管理\客户管理\ (错位目录壳,内含超期提醒规则 3 文件) |
|||
保留:A4 客户管理\folder.bru、README.md。 |
|||
幂等:目录不存在则跳过。""" |
|||
import io, sys, os, shutil |
|||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') |
|||
|
|||
DOCS = r'D:\code\crm-api-docs' |
|||
A4 = os.path.join(DOCS, 'A4 客户管理') |
|||
LEFTOVERS = [ |
|||
os.path.join(A4, 'A4 客户管理'), |
|||
os.path.join(A4, '客户管理'), |
|||
] |
|||
|
|||
for d in LEFTOVERS: |
|||
if os.path.isdir(d): |
|||
n = sum(len(fn) for _, _, fn in os.walk(d)) |
|||
shutil.rmtree(d) |
|||
print(f'removed: {os.path.relpath(d, DOCS)} ({n} files)') |
|||
else: |
|||
print(f'skip (absent): {os.path.relpath(d, DOCS)}') |
|||
|
|||
print('--- after cleanup, A4 tree ---') |
|||
for dp, dn, fn in os.walk(A4): |
|||
for f in fn: |
|||
print(' ', os.path.relpath(os.path.join(dp, f), DOCS)) |
|||
@ -0,0 +1,55 @@ |
|||
# -*- coding: utf-8 -*- |
|||
"""票 08 重跑前置:归档上一轮意外落库的 demo-selftest-撞码客户。 |
|||
原因:第一轮自测用假信用代码 MA9ABC1234,L3 未命中 67003 → 客户真实落库。 |
|||
若不清理,本轮同名重试会先命中 L2 needConfirm,看不到 67003。 |
|||
走正门 API:workspace page 搜名称 → archive?id=(新客户无商机,67006 不会阻断)。 |
|||
""" |
|||
import io, sys |
|||
import requests |
|||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') |
|||
|
|||
BASE = 'http://localhost:8080' |
|||
ADMIN = '739564171091247104' |
|||
TARGET = 'demo-selftest-撞码客户' |
|||
|
|||
r = requests.get(f'{BASE}/api/auth/debug/token', params={'userId': ADMIN}, timeout=10) |
|||
tok = r.json().get('data') |
|||
tok = tok.get('token') if isinstance(tok, dict) else tok |
|||
H = {'Authorization': f'Bearer {tok}'} |
|||
|
|||
found = [] |
|||
for ws in ('pool', 'mine', 'overview'): |
|||
r = requests.post(f'{BASE}/api/customer/workspace/{ws}/page', |
|||
data={'current': 1, 'size': 50, 'keyword': TARGET}, headers=H, timeout=15) |
|||
rows = ((r.json().get('data') or {}).get('content')) or [] |
|||
for c in rows: |
|||
if TARGET in (c.get('customerName') or ''): |
|||
found.append((ws, c.get('id'), c.get('customerName'), c.get('customerNo'), c.get('archiveStatus'))) |
|||
if found: |
|||
break |
|||
|
|||
if not found: |
|||
print('未发现脏客户,无需清理') |
|||
sys.exit(0) |
|||
|
|||
for ws, cid, name, no, arc in found: |
|||
print(f'发现: ws={ws} id={cid} {name}({no}) archiveStatus={arc}') |
|||
if arc == 2: |
|||
print(' 已是归档态,跳过') |
|||
continue |
|||
r = requests.post(f'{BASE}/api/customer/archive', params={'id': cid}, headers=H, timeout=15) |
|||
if r.json().get('code') == 67004: |
|||
# 公海客户无 owner,archive 仅销售负责人可执行 → 先 claim 领取成为 owner,再归档 |
|||
r2 = requests.post(f'{BASE}/api/customer/claim', params={'id': cid}, headers=H, timeout=15) |
|||
print(' claim:', r2.status_code, 'code=', r2.json().get('code'), r2.json().get('message')) |
|||
r = requests.post(f'{BASE}/api/customer/archive', params={'id': cid}, headers=H, timeout=15) |
|||
print(' archive:', r.status_code, 'code=', r.json().get('code'), r.json().get('message')) |
|||
|
|||
# 复核 |
|||
ws = found[0][0] |
|||
r = requests.post(f'{BASE}/api/customer/workspace/{ws}/page', |
|||
data={'current': 1, 'size': 50, 'keyword': TARGET}, headers=H, timeout=15) |
|||
rows = ((r.json().get('data') or {}).get('content')) or [] |
|||
for c in rows: |
|||
if TARGET in (c.get('customerName') or ''): |
|||
print('复核:', c.get('customerNo'), 'archiveStatus=', c.get('archiveStatus')) |
|||
@ -0,0 +1,24 @@ |
|||
# -*- coding: utf-8 -*- |
|||
"""票 06 关票:Status claimed→resolved + answer06.md 追加为 Answer 段(沿用票 07 模式)。""" |
|||
import io, sys |
|||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') |
|||
|
|||
TICKET = r'd:\code\crm-backend-matt\.scratch\customer-e2e\issues\06-bruno-docs.md' |
|||
ANSWER = r'd:\code\crm-backend-matt\.scratch\customer-e2e\answer06.md' |
|||
|
|||
txt = open(TICKET, encoding='utf-8').read() |
|||
if 'Status: claimed' in txt: |
|||
txt = txt.replace('Status: claimed', 'Status: resolved', 1) |
|||
print('status: claimed -> resolved') |
|||
else: |
|||
print('WARN: status line not in expected state:', [l for l in txt.splitlines() if 'Status' in l]) |
|||
|
|||
answer = open(ANSWER, encoding='utf-8').read().rstrip() |
|||
if '## Answer' in txt: |
|||
print('WARN: Answer already present, skip append') |
|||
else: |
|||
txt = txt.rstrip() + '\n\n' + answer + '\n' |
|||
print('answer appended') |
|||
|
|||
open(TICKET, 'w', encoding='utf-8', newline='').write(txt) |
|||
print('ticket saved.') |
|||
@ -0,0 +1,162 @@ |
|||
{ |
|||
"results": [ |
|||
{ |
|||
"step": "01 打开 file:// 页面", |
|||
"status": "PASS", |
|||
"note": "tabs=5" |
|||
}, |
|||
{ |
|||
"step": "02 debug token 登录 + 我的客户列表", |
|||
"status": "PASS", |
|||
"note": "who=已登录: () mine rows=10" |
|||
}, |
|||
{ |
|||
"step": "03 三 workspace 切换", |
|||
"status": "PASS", |
|||
"note": "overview rows=10 pool rows=1 info=第 1 页 / 共 0 条" |
|||
}, |
|||
{ |
|||
"step": "04 列表关键字检索", |
|||
"status": "PASS", |
|||
"note": "keyword=恒信达 命中" |
|||
}, |
|||
{ |
|||
"step": "05 汇总卡(board/summary)渲染", |
|||
"status": "PASS", |
|||
"note": "-- -- -- --" |
|||
}, |
|||
{ |
|||
"step": "06 勾选 + 批量关注", |
|||
"status": "PASS", |
|||
"note": "已勾选 1 条:750854377022947328; toast=成功" |
|||
}, |
|||
{ |
|||
"step": "07 打开详情(detail-head 公共头部)", |
|||
"status": "PASS", |
|||
"note": "e2c-恒信达科技有限公司(KH202609030029) 潜在客户 客户星 ★★★ 有效" |
|||
}, |
|||
{ |
|||
"step": "08 子页签 info", |
|||
"status": "PASS", |
|||
"note": "info 渲染 OK" |
|||
}, |
|||
{ |
|||
"step": "08 子页签 contacts", |
|||
"status": "PASS", |
|||
"note": "contacts 渲染 OK" |
|||
}, |
|||
{ |
|||
"step": "08 子页签 follow", |
|||
"status": "PASS", |
|||
"note": "follow 渲染 OK" |
|||
}, |
|||
{ |
|||
"step": "08 子页签 opps", |
|||
"status": "PASS", |
|||
"note": "opps 渲染 OK" |
|||
}, |
|||
{ |
|||
"step": "08 子页签 members", |
|||
"status": "PASS", |
|||
"note": "members 渲染 OK" |
|||
}, |
|||
{ |
|||
"step": "08 子页签 oplog", |
|||
"status": "PASS", |
|||
"note": "oplog 渲染 OK" |
|||
}, |
|||
{ |
|||
"step": "09 写跟进(follow_way_01 契约值)", |
|||
"status": "PASS", |
|||
"note": "panels 3->4" |
|||
}, |
|||
{ |
|||
"step": "10 新建联系人(修复后字段)", |
|||
"status": "PASS", |
|||
"note": "rows 0->1(含 name/jobTitleName/phone/isKeyContact 全契约字段)" |
|||
}, |
|||
{ |
|||
"step": "11 编辑联系人(PUT /{id} 修复验证)", |
|||
"status": "PASS", |
|||
"note": "PUT /{id} 修复路径回显+保存 OK" |
|||
}, |
|||
{ |
|||
"step": "12 删除联系人(DELETE /{id} 修复验证)", |
|||
"status": "PASS", |
|||
"note": "rows 1->0(DELETE /<built-in function id> 修复验证)" |
|||
}, |
|||
{ |
|||
"step": "13 新建客户 L2 相似 → needConfirm 弹出", |
|||
"status": "PASS", |
|||
"note": "相似命中 5 条:" |
|||
}, |
|||
{ |
|||
"step": "14 新建客户 L3 信用代码撞码 67003", |
|||
"status": "PASS", |
|||
"note": "67003 硬拦展示 OK" |
|||
}, |
|||
{ |
|||
"step": "15 归属动作:关注/取关/重点/取消重点", |
|||
"status": "PASS", |
|||
"note": "四连击完成(首条 toast: 成功)" |
|||
}, |
|||
{ |
|||
"step": "16 交割列表 + 预览", |
|||
"status": "PASS", |
|||
"note": "trBody rows=5" |
|||
}, |
|||
{ |
|||
"step": "17 交割发起前预览", |
|||
"status": "PASS", |
|||
"note": "preview OK" |
|||
}, |
|||
{ |
|||
"step": "18 交接单详情(GET /transfer/{id})", |
|||
"status": "PASS", |
|||
"note": "交接单详情 JG202609040003(进度 3/3,已完成,全分配自动完成 D23)" |
|||
}, |
|||
{ |
|||
"step": "19 导入任务列表渲染", |
|||
"status": "PASS", |
|||
"note": "impBody rows=17" |
|||
}, |
|||
{ |
|||
"step": "20 导入上传预检(DRAFT 两段式)", |
|||
"status": "PASS", |
|||
"note": "toast=预检完成 taskId=751017903351922688 t=1 insert=0 update" |
|||
}, |
|||
{ |
|||
"step": "21 确认执行导入(D-05:INSERT 行必 FAILED)", |
|||
"status": "PASS", |
|||
"note": "首行状态: 751017903351922688 APPEND_ONLY DONE 1 0 0 0 0 1 --" |
|||
}, |
|||
{ |
|||
"step": "22 失败明细弹窗(D-04:0 行提示)", |
|||
"status": "PASS", |
|||
"note": "明细弹窗: 行号 原因 原始数据 1 疑似重复:客户名称与已有客户相似,本次不写入,请走新增/编辑人工确认 {\"sheetName\":\"客户\",\"row" |
|||
}, |
|||
{ |
|||
"step": "23 提醒规则读改写(PUT JSON 全量覆盖)", |
|||
"status": "PASS", |
|||
"note": "orig=30 -> 31 保存回读 OK(提醒配置已加载已保存(PUT 全量覆盖,开关缺省按禁用;天数)" |
|||
}, |
|||
{ |
|||
"step": "24 提醒规则 64023 负路径(天数非正)", |
|||
"status": "PASS", |
|||
"note": "64023 提示 OK" |
|||
}, |
|||
{ |
|||
"step": "25 提醒规则还原基线", |
|||
"status": "PASS", |
|||
"note": "还原 firstTriggerDays=30(回读 30)" |
|||
}, |
|||
{ |
|||
"step": "26 查重试玩三端点", |
|||
"status": "PASS", |
|||
"note": "check-name / check-credit-code / check-phone 三连 OK" |
|||
} |
|||
], |
|||
"console_errors": [], |
|||
"page_errors": [], |
|||
"shots": 30 |
|||
} |
|||
@ -0,0 +1,416 @@ |
|||
# -*- coding: utf-8 -*- |
|||
r"""票 08:demo/index.html 浏览器级自测(Playwright + chromium headless)。 |
|||
- 零容忍口径:console errors = 0、page errors = 0 |
|||
- 每步截图 shots08/shot-NN-*.png;逐步 PASS/FAIL 记录 → demo-selftest-results.json |
|||
- 有状态动作选可逆/幂等路径(关注/取关、导入 INSERT 行必 FAILED=D-05 预期);归档/抛公海/交割分配不点(E2E 已覆盖 + D-07 超时 60s) |
|||
""" |
|||
import io, sys, os, json |
|||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') |
|||
from playwright.sync_api import sync_playwright |
|||
|
|||
DEMO = 'file:///d:/code/crm-backend-matt/.scratch/customer-e2e/demo/index.html' |
|||
SHOTS = r'd:\code\crm-backend-matt\.scratch\customer-e2e\shots08' |
|||
XLSX = r'd:\code\crm-backend-matt\.scratch\customer-e2e\_import-heavy.xlsx' |
|||
ADMIN = '739564171091247104' |
|||
os.makedirs(SHOTS, exist_ok=True) |
|||
|
|||
results, console_errors, page_errors = [], [], [] |
|||
_shot = [0] |
|||
|
|||
def shot(page, name): |
|||
_shot[0] += 1 |
|||
p = os.path.join(SHOTS, f'shot-{_shot[0]:02d}-{name}.png') |
|||
page.screenshot(path=p, full_page=True) |
|||
print(f' [shot] {os.path.basename(p)}') |
|||
|
|||
def step(name, fn): |
|||
print(f'== {name}') |
|||
try: |
|||
note = fn() or '' |
|||
results.append({'step': name, 'status': 'PASS', 'note': str(note)}) |
|||
print(f' PASS {note}') |
|||
except Exception as e: |
|||
results.append({'step': name, 'status': 'FAIL', 'note': str(e)[:300]}) |
|||
print(f' FAIL {e}') |
|||
|
|||
def toasts(page): |
|||
page.wait_for_timeout(500) |
|||
return [t.inner_text() for t in page.locator('#toast .toast-item').all()] |
|||
|
|||
def click_text(scope, text, timeout=6000): |
|||
scope.locator('button', has_text=text).first.click(timeout=timeout) |
|||
|
|||
def row_count(page, body_id): |
|||
return page.locator(f'#{body_id} tr').count() |
|||
|
|||
def sub(page, s): |
|||
page.locator(f'#tab-detail .subtabs button[data-s={s}]').click() |
|||
page.wait_for_timeout(700) |
|||
|
|||
with sync_playwright() as p: |
|||
browser = p.chromium.launch(headless=True) |
|||
page = browser.new_page(viewport={'width': 1500, 'height': 950}) |
|||
page.on('console', lambda m: console_errors.append(m.text) if m.type == 'error' else None) |
|||
page.on('pageerror', lambda e: page_errors.append(str(e))) |
|||
page.on('dialog', lambda d: d.accept()) |
|||
|
|||
# ---------- 01 打开 + 登录 ---------- |
|||
def s_open(): |
|||
page.goto(DEMO) |
|||
page.wait_for_load_state('networkidle') |
|||
assert page.locator('nav.tabs button').count() == 5, '应有 5 个主 tab' |
|||
shot(page, 'init') |
|||
return 'tabs=5' |
|||
step('01 打开 file:// 页面', s_open) |
|||
|
|||
def s_login(): |
|||
page.fill('#uidInput', ADMIN) |
|||
page.evaluate('loginByUid()') |
|||
page.wait_for_timeout(1200) |
|||
who = page.locator('#whoBox').inner_text() |
|||
assert '未登录' not in who, f'登录失败 whoBox={who}' |
|||
n = row_count(page, 'listBody') |
|||
assert n > 0, '登录后列表应加载数据' |
|||
shot(page, 'login-mine-list') |
|||
return f'who={who[:20]} mine rows={n}' |
|||
step('02 debug token 登录 + 我的客户列表', s_login) |
|||
|
|||
def s_overview_pool(): |
|||
page.locator('#tab-list .viewbar [data-ws=overview]').click() |
|||
page.wait_for_timeout(900) |
|||
ov = row_count(page, 'listBody') |
|||
shot(page, 'overview') |
|||
page.locator('#tab-list .viewbar [data-ws=pool]').click() |
|||
page.wait_for_timeout(900) |
|||
pl = row_count(page, 'listBody') |
|||
info = page.locator('#pageInfo').inner_text() |
|||
shot(page, 'pool') |
|||
return f'overview rows={ov} pool rows={pl} info={info}' |
|||
step('03 三 workspace 切换', s_overview_pool) |
|||
|
|||
def s_search(): |
|||
page.locator('#tab-list .viewbar [data-ws=mine]').click() |
|||
page.wait_for_timeout(800) |
|||
page.fill('#fKeyword', '恒信达') |
|||
click_text(page.locator('#tab-list .viewbar'), '查询') |
|||
page.wait_for_timeout(900) |
|||
txt = page.locator('#listBody').inner_text() |
|||
assert '恒信达' in txt, '检索结果应含恒信达' |
|||
shot(page, 'search') |
|||
return 'keyword=恒信达 命中' |
|||
step('04 列表关键字检索', s_search) |
|||
|
|||
def s_sumcards(): |
|||
txt = page.locator('#summaryBox').inner_text() |
|||
assert txt.strip() and '随列表加载' not in txt, '汇总卡应有数据' |
|||
return txt.replace('\n', ' ')[:60] |
|||
step('05 汇总卡(board/summary)渲染', s_sumcards) |
|||
|
|||
def s_batch_focus(): |
|||
page.locator('#listBody tr input[type=checkbox]').first.check() |
|||
hint = page.locator('#pickHint').inner_text() |
|||
assert '已勾选 1' in hint, f'勾选提示异常: {hint}' |
|||
click_text(page.locator('#tab-list'), '批量关注') |
|||
ts = toasts(page) |
|||
shot(page, 'batch-focus') |
|||
assert any('成功' in t or '关注' in t for t in ts), f'批量关注 toast 异常: {ts}' |
|||
return f'{hint}; toast={ts[-1][:40]}' |
|||
step('06 勾选 + 批量关注', s_batch_focus) |
|||
|
|||
# ---------- 详情 6 子页签 ---------- |
|||
def s_open_detail(): |
|||
page.locator('#listBody tr td.row').first.click() |
|||
page.wait_for_timeout(1200) |
|||
assert page.locator('#detailPanel').is_visible(), '详情面板应可见' |
|||
head = page.locator('#headBox').inner_text() |
|||
assert '负责人' in head, '公共头部应含负责人' |
|||
shot(page, 'detail-info') |
|||
return head.split('\n')[0][:50] |
|||
step('07 打开详情(detail-head 公共头部)', s_open_detail) |
|||
|
|||
for tab, kw in [('info', '客户编号'), ('contacts', '新建联系人'), ('follow', '跟进'), ('opps', None), ('members', None), ('oplog', None)]: |
|||
def f(tab=tab, kw=kw): |
|||
sub(page, tab) |
|||
txt = page.locator('#subBox').inner_text() |
|||
assert txt.strip(), f'页签 {tab} 渲染为空' |
|||
assert '加载中' not in txt, f'页签 {tab} 停在加载中' |
|||
if kw: |
|||
assert kw in txt, f'页签 {tab} 应含「{kw}」' |
|||
shot(page, f'detail-{tab}') |
|||
return f'{tab} 渲染 OK' |
|||
step(f'08 子页签 {tab}', f) |
|||
|
|||
def s_follow_write(): |
|||
sub(page, 'follow') |
|||
before = page.locator('#subBox .panel').count() |
|||
click_text(page.locator('#headBox'), '写跟进') |
|||
page.fill('#fContent', 'demo 自测跟进:浏览器级自测写入(票 08)') |
|||
click_text(page.locator('#dlgFoot'), '保存') |
|||
page.wait_for_timeout(1200) |
|||
ts = [t for t in toasts(page)] |
|||
after = page.locator('#subBox .panel').count() |
|||
shot(page, 'follow-written') |
|||
assert '跟进已保存' in ''.join(ts), f'写跟进 toast 异常: {ts}' |
|||
assert after >= before, '跟进条数应不减' |
|||
return f'panels {before}->{after}' |
|||
step('09 写跟进(follow_way_01 契约值)', s_follow_write) |
|||
|
|||
C_NAME = 'demo-selftest-联系人甲' |
|||
def contact_rows(): # 数据行=含编辑按钮的行;空态行(无数据 td.empty)无按钮,排除 |
|||
return [r for r in page.locator('#subBox tbody tr').all() if '编辑' in r.inner_text()] |
|||
def s_contact_create(): |
|||
sub(page, 'contacts') |
|||
before = len(contact_rows()) |
|||
click_text(page.locator('#headBox'), '+联系人') |
|||
page.fill('#ctName', C_NAME) |
|||
page.fill('#ctJob', '采购经理') |
|||
page.fill('#ctPhone', '13800001111') |
|||
page.select_option('#ctKey', '1') |
|||
click_text(page.locator('#dlgFoot'), '保存') |
|||
page.wait_for_timeout(1200) |
|||
ts = toasts(page) |
|||
after = len(contact_rows()) |
|||
shot(page, 'contact-created') |
|||
assert '联系人已保存' in ''.join(ts), f'新建联系人 toast 异常: {ts}' |
|||
assert after == before + 1, f'新建后数据行应+1: {before}->{after}' |
|||
return f'rows {before}->{after}(含 name/jobTitleName/phone/isKeyContact 全契约字段)' |
|||
step('10 新建联系人(修复后字段)', s_contact_create) |
|||
|
|||
def s_contact_edit(): |
|||
row = page.locator('#subBox tbody tr', has_text=C_NAME).first |
|||
row.locator('button', has_text='编辑').click() |
|||
page.wait_for_timeout(400) |
|||
assert page.input_value('#ctName') == C_NAME, '编辑弹窗应回显姓名(c.name 契约)' |
|||
assert page.input_value('#ctJob') == '采购经理', '编辑弹窗应回显职务(jobTitleName)' |
|||
page.fill('#ctGift', 'demo 自测礼品备注') |
|||
click_text(page.locator('#dlgFoot'), '保存') |
|||
page.wait_for_timeout(1200) |
|||
ts = toasts(page) |
|||
shot(page, 'contact-edited') |
|||
assert '联系人已保存' in ''.join(ts), f'编辑联系人 toast 异常: {ts}' |
|||
return 'PUT /{id} 修复路径回显+保存 OK' |
|||
step('11 编辑联系人(PUT /{id} 修复验证)', s_contact_edit) |
|||
|
|||
def s_contact_delete(): |
|||
before = len(contact_rows()) |
|||
row = page.locator('#subBox tbody tr', has_text=C_NAME).first |
|||
row.locator('button', has_text='删除').click() |
|||
after = before |
|||
for _ in range(10): # DELETE 成功后 switchSub 异步重载,轮询等待数据行下降 |
|||
page.wait_for_timeout(400) |
|||
after = len(contact_rows()) |
|||
if after < before: |
|||
break |
|||
ts = toasts(page) |
|||
shot(page, 'contact-deleted') |
|||
assert after == before - 1, f'删除后数据行应-1: {before}->{after}, toasts={ts}' |
|||
return f'rows {before}->{after}(DELETE /{id} 修复验证)' |
|||
step('12 删除联系人(DELETE /{id} 修复验证)', s_contact_delete) |
|||
|
|||
# ---------- 新建客户三层查重两路 ---------- |
|||
def s_need_confirm(): |
|||
click_text(page.locator('header'), '新建客户') |
|||
page.fill('#cName', 'e2c-恒信达科技有限公司') |
|||
page.fill('#cType', 'customer_type_01') |
|||
page.fill('#cProv', '440000'); page.fill('#cCity', '440100') |
|||
page.fill('#cInd', 'gov') |
|||
click_text(page.locator('#dlgFoot'), '创建') |
|||
page.wait_for_timeout(1200) |
|||
out = page.locator('#cOut').inner_text() |
|||
assert '相似命中' in out, f'L2 相似 needConfirm 未触发: {out[:120]}' |
|||
shot(page, 'create-needconfirm') |
|||
page.evaluate('dlgClose()') |
|||
return out.split('\n')[0][:60] |
|||
step('13 新建客户 L2 相似 → needConfirm 弹出', s_need_confirm) |
|||
|
|||
def s_credit_block(): |
|||
click_text(page.locator('header'), '新建客户') |
|||
page.fill('#cName', 'demo-selftest-撞码客户') |
|||
page.fill('#cType', 'customer_type_01') |
|||
page.fill('#cProv', '440000'); page.fill('#cCity', '440100') |
|||
page.fill('#cInd', 'gov') |
|||
page.fill('#cCredit', '91440101E2CHXDA001') # seed simA 真实信用代码,才能触发 L3 67003 |
|||
click_text(page.locator('#dlgFoot'), '创建') |
|||
page.wait_for_timeout(1200) |
|||
out = page.locator('#cOut').inner_text() |
|||
assert '67003' in out, f'L3 撞码 67003 未拦截(unifiedCreditCode 修复后应生效): {out[:120]}' |
|||
shot(page, 'create-credit-block') |
|||
page.evaluate('dlgClose()') |
|||
return '67003 硬拦展示 OK' |
|||
step('14 新建客户 L3 信用代码撞码 67003', s_credit_block) |
|||
|
|||
def s_focus_star(): |
|||
page.locator('#headBox button', has_text='关注').first.click() |
|||
page.wait_for_timeout(800) |
|||
t1 = ''.join(toasts(page)) |
|||
page.locator('#headBox button', has_text='取关').first.click() |
|||
page.wait_for_timeout(800) |
|||
page.locator('#headBox button', has_text='重点客户').first.click() |
|||
page.wait_for_timeout(800) |
|||
page.locator('#headBox button', has_text='取消重点').first.click() |
|||
page.wait_for_timeout(800) |
|||
shot(page, 'focus-star') |
|||
assert '404' not in t1, f'关注请求 404(oneBy URL 形态修复后不应发生): {t1[:80]}' |
|||
return f'四连击完成(首条 toast: {t1[:40]})' |
|||
step('15 归属动作:关注/取关/重点/取消重点', s_focus_star) |
|||
|
|||
# ---------- 交割 ---------- |
|||
def s_transfer_list(): |
|||
page.locator('nav.tabs button[data-tab=transfer]').click() |
|||
page.wait_for_timeout(1000) |
|||
txt = page.locator('#trBody').inner_text() |
|||
assert '暂无交接单' not in txt, f'交接单列表空态(tab 按钮 transferPage(1) 翻页缺陷修复后应显示第 1 页): {txt[:40]}' |
|||
n = row_count(page, 'trBody') |
|||
shot(page, 'transfer-list') |
|||
return f'trBody rows={n}' |
|||
step('16 交割列表 + 预览', s_transfer_list) |
|||
|
|||
def s_transfer_preview(): |
|||
click_text(page.locator('#tab-transfer'), '预览名下待交接客户') |
|||
page.wait_for_timeout(1000) |
|||
txt = page.locator('#tab-transfer').inner_text() |
|||
shot(page, 'transfer-preview') |
|||
assert '待交接' in txt or '客户' in txt, '预览应输出名下客户' |
|||
page.evaluate('dlgClose()') # 预览是 dlgOpen 弹窗,不关会拦截后续所有点击(第一轮级联失败根因) |
|||
return 'preview OK' |
|||
step('17 交割发起前预览', s_transfer_preview) |
|||
|
|||
def s_transfer_detail(): |
|||
row = page.locator('#trBody tr').first |
|||
row.locator('button', has_text='详情').click() |
|||
page.wait_for_timeout(1200) |
|||
assert page.locator('#trDetailPanel').is_visible(), '交接单详情面板应可见' |
|||
txt = page.locator('#trDetailPanel').inner_text() |
|||
shot(page, 'transfer-detail') |
|||
return txt.split('\n')[0][:50] |
|||
step('18 交接单详情(GET /transfer/{id})', s_transfer_detail) |
|||
|
|||
# ---------- 导入全链(D-05/D-04 展示) ---------- |
|||
def s_import_list(): |
|||
page.locator('nav.tabs button[data-tab=import]').click() |
|||
page.wait_for_timeout(1000) |
|||
n = row_count(page, 'impBody') |
|||
assert n > 0, '导入任务列表应渲染' |
|||
shot(page, 'import-list') |
|||
return f'impBody rows={n}' |
|||
step('19 导入任务列表渲染', s_import_list) |
|||
|
|||
def s_import_upload(): |
|||
page.set_input_files('#impFile', XLSX) |
|||
page.select_option('#impMode', 'APPEND_ONLY') |
|||
click_text(page.locator('#tab-import'), '上传预检') |
|||
page.wait_for_timeout(2500) |
|||
ts = ''.join(toasts(page)) |
|||
first = page.locator('#impBody tr').first.inner_text() |
|||
shot(page, 'import-upload') |
|||
assert ('预检' in ts or '任务' in ts or 'DRAFT' in ts or '待确认' in first), f'上传预检反馈异常: {ts[:80]} | {first[:60]}' |
|||
return f'toast={ts[:50]}' |
|||
step('20 导入上传预检(DRAFT 两段式)', s_import_upload) |
|||
|
|||
def s_import_confirm(): |
|||
first = page.locator('#impBody tr').first |
|||
if first.locator('button', has_text='确认执行').count() == 0: |
|||
return 'SKIP:首行非待确认态' |
|||
first.locator('button', has_text='确认执行').click() |
|||
page.wait_for_timeout(3500) # confirm + 1.5s 自动刷新 + 异步执行 |
|||
first = page.locator('#impBody tr').first |
|||
txt = first.inner_text() |
|||
shot(page, 'import-result') |
|||
assert ('DONE' in txt) or ('FAILED' in txt) or ('RUNNING' in txt), f'任务终态未出现(IMP_ST 英文文案): {txt[:80]}' |
|||
return '首行状态: ' + ' '.join(txt.split())[:50] |
|||
step('21 确认执行导入(D-05:INSERT 行必 FAILED)', s_import_confirm) |
|||
|
|||
def s_import_failures(): |
|||
first = page.locator('#impBody tr').first |
|||
btn = first.locator('button', has_text='失败明细') |
|||
if btn.count() == 0: |
|||
first.locator('button', has_text='结果').click() |
|||
page.wait_for_timeout(800) |
|||
page.evaluate('dlgClose()') |
|||
return 'SKIP:首行无失败明细入口' |
|||
btn.click() |
|||
page.wait_for_timeout(1000) |
|||
txt = page.locator('#dlgBody').inner_text() |
|||
shot(page, 'import-failures') |
|||
page.evaluate('dlgClose()') |
|||
return ('明细弹窗: ' + ' '.join(txt.split())[:70]) if txt.strip() else '空' |
|||
step('22 失败明细弹窗(D-04:0 行提示)', s_import_failures) |
|||
|
|||
# ---------- 设置:提醒规则 + 查重试玩 ---------- |
|||
def s_reminder_rw(): |
|||
page.locator('nav.tabs button[data-tab=settings]').click() |
|||
page.wait_for_timeout(1000) |
|||
orig_days = page.input_value('#rFirstDays') |
|||
page.fill('#rFirstDays', '31') |
|||
click_text(page.locator('#tab-settings'), '保存') |
|||
page.wait_for_timeout(1200) |
|||
ts1 = ''.join(toasts(page)) |
|||
click_text(page.locator('#tab-settings'), '刷新') |
|||
page.wait_for_timeout(900) |
|||
now_days = page.input_value('#rFirstDays') |
|||
shot(page, 'reminder-saved') |
|||
assert now_days == '31', f'保存 31 后回读应 31: {now_days}' |
|||
return f'orig={orig_days} -> 31 保存回读 OK({ts1[:30]})' |
|||
step('23 提醒规则读改写(PUT JSON 全量覆盖)', s_reminder_rw) |
|||
|
|||
def s_reminder_64023(): |
|||
page.fill('#rFirstDays', '0') |
|||
click_text(page.locator('#tab-settings'), '保存') |
|||
page.wait_for_timeout(1200) |
|||
ts = ''.join(toasts(page)) |
|||
shot(page, 'reminder-64023') |
|||
assert '64023' in ts, f'64023 错误提示未出现: {ts[:80]}' |
|||
click_text(page.locator('#tab-settings'), '刷新') |
|||
page.wait_for_timeout(800) |
|||
return '64023 提示 OK' |
|||
step('24 提醒规则 64023 负路径(天数非正)', s_reminder_64023) |
|||
|
|||
def s_reminder_restore(): |
|||
page.fill('#rFirstDays', '30') |
|||
click_text(page.locator('#tab-settings'), '保存') |
|||
page.wait_for_timeout(1200) |
|||
click_text(page.locator('#tab-settings'), '刷新') |
|||
page.wait_for_timeout(800) |
|||
v = page.input_value('#rFirstDays') |
|||
return f'还原 firstTriggerDays=30(回读 {v})' |
|||
step('25 提醒规则还原基线', s_reminder_restore) |
|||
|
|||
def s_checks(): |
|||
page.fill('#ckName', '恒信达') |
|||
page.locator('#tab-settings button', has_text='check-name').click() |
|||
page.wait_for_timeout(900) |
|||
o1 = page.locator('#ckOut').inner_text() |
|||
page.fill('#ckCredit', '91440101MA9ABC1234') |
|||
page.locator('#tab-settings button', has_text='check-credit-code').click() |
|||
page.wait_for_timeout(900) |
|||
o2 = page.locator('#ckOut').inner_text() |
|||
page.fill('#ckPhone', '13812340001') |
|||
page.locator('#tab-settings button', has_text='check-phone').click() |
|||
page.wait_for_timeout(900) |
|||
o3 = page.locator('#ckOut').inner_text() |
|||
shot(page, 'checks') |
|||
assert '命中' in o1 or 'hit' in o1.lower() or '[' in o1, f'check-name 输出异常: {o1[:60]}' |
|||
return 'check-name / check-credit-code / check-phone 三连 OK' |
|||
step('26 查重试玩三端点', s_checks) |
|||
|
|||
browser.close() |
|||
|
|||
# ---------- 汇总 ---------- |
|||
passed = [r for r in results if r['status'] == 'PASS'] |
|||
failed = [r for r in results if r['status'] == 'FAIL'] |
|||
print('\n================ 自测汇总 ================') |
|||
for r in results: |
|||
print(f" {r['status']:4} {r['step']} {r['note'][:80]}") |
|||
print(f'\nsteps: {len(passed)} PASS / {len(failed)} FAIL / 截图 {_shot[0]}') |
|||
print(f'console errors: {len(console_errors)}') |
|||
for e in console_errors: |
|||
print(' CE:', e[:160]) |
|||
print(f'page errors: {len(page_errors)}') |
|||
for e in page_errors: |
|||
print(' PE:', e[:160]) |
|||
|
|||
json.dump({'results': results, 'console_errors': console_errors, 'page_errors': page_errors, |
|||
'shots': _shot[0]}, |
|||
open(os.path.join(os.path.dirname(SHOTS), 'demo-selftest-results.json'), 'w', encoding='utf-8'), |
|||
ensure_ascii=False, indent=2) |
|||
print('results json written.') |
|||
@ -0,0 +1,361 @@ |
|||
# -*- coding: utf-8 -*- |
|||
"""demo_showcase.py — 用户 demo 验收演示数据(拟真,非 e2c- 测试名) |
|||
|
|||
设计: |
|||
- 19 户拟真客户(15 admin 名下 + 3 公海 + 1 已归档),多行业/多地区/三阶段/星级分布 |
|||
- 联系人 30+、跟进 30+(follow_way 真实字典值)、拟真商机 4 个并绑定客户、关注/重点/成员样例 |
|||
- 幂等 = 按内置名单先清后造;清理范围仅本脚本名单,不碰 e2c-/e2e- 命名空间 |
|||
- 中文 body 一律 Python requests(坑㉑);跟进 nextFollowTime ISO T 分隔 |
|||
""" |
|||
import sys, io, json, os, argparse |
|||
from datetime import datetime, timedelta |
|||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') |
|||
import requests |
|||
import pymysql |
|||
|
|||
ap = argparse.ArgumentParser(description='验收演示数据造数(可指向测试环境)') |
|||
ap.add_argument('--base', default='http://localhost:8080', help='API 根地址(测试环境如 http://<测试服>:8080)') |
|||
ap.add_argument('--admin', default='739564171091247104', help='主用户 userId(默认罗伟健开发库 id;测试库不同时传测试库真实 userId)') |
|||
ap.add_argument('--buddy', default='744842318024015872', help='协同用户 userId(同上)') |
|||
ap.add_argument('--db-host', default='8.129.84.155', help='DB 主机(--skip-db 时忽略)') |
|||
ap.add_argument('--db-port', type=int, default=3306) |
|||
ap.add_argument('--db-user', default='root') |
|||
ap.add_argument('--db-pass', default='Itc@123456') |
|||
ap.add_argument('--db-name', default='crm') |
|||
ap.add_argument('--skip-db', action='store_true', help='DB 不可达时:跳过清理/stage 直拨/库检(全部潜在客户;重跑前需手工清名单)') |
|||
A = ap.parse_args() |
|||
BASE = A.base |
|||
ADMIN = A.admin |
|||
BUDDY = A.buddy |
|||
HAS_DB = not A.skip_db |
|||
IDS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'showcase-ids.json') |
|||
|
|||
CONF = dict(host=A.db_host, port=A.db_port, user=A.db_user, password=A.db_pass, |
|||
database=A.db_name, charset='utf8mb4', autocommit=True, connect_timeout=10, |
|||
cursorclass=pymysql.cursors.DictCursor) if HAS_DB else None |
|||
|
|||
|
|||
def dbq(sql, args=None, fetch=True): |
|||
assert CONF, 'dbq 调用于 --skip-db 模式(不应发生)' |
|||
with pymysql.connect(**CONF) as conn, conn.cursor() as cur: |
|||
cur.execute(sql, args) |
|||
return cur.fetchall() if fetch else cur.rowcount |
|||
|
|||
|
|||
def get_token(uid): |
|||
d = requests.get(f'{BASE}/api/auth/debug/token', params={'userId': uid}, timeout=30).json().get('data') |
|||
return d if isinstance(d, str) else (d or {}).get('token') |
|||
|
|||
|
|||
S = requests.Session() |
|||
S.headers['Authorization'] = f'Bearer {get_token(ADMIN)}' |
|||
|
|||
|
|||
def api(method, path, form=None, params=None, step=''): |
|||
try: |
|||
r = S.post(BASE + path, data=form, params=params, timeout=30) if method == 'POST' else \ |
|||
S.put(BASE + path, data=form, params=params, timeout=30) if method == 'PUT' else \ |
|||
S.request(method, BASE + path, params=params, timeout=30) |
|||
except Exception as e: |
|||
print(f' x {step}: 网络 {e}'); return None |
|||
if r.status_code != 200: |
|||
print(f' x {step}: HTTP {r.status_code} {r.text[:150]}'); return None |
|||
b = r.json() |
|||
if b.get('code') not in (0, '0', 200, '200'): |
|||
print(f' x {step}: code={b.get("code")} {str(b.get("message"))[:150]}'); return None |
|||
return b.get('data') |
|||
|
|||
|
|||
fails = [] |
|||
def need(cond, label): |
|||
if not cond: |
|||
fails.append(label); print(f' x 校验: {label}') |
|||
|
|||
|
|||
# ---------------- 0. 字典/地区 ---------------- |
|||
print('== 0. 字典与地区 ==') |
|||
if HAS_DB: |
|||
CTYPES = [r['code'] for r in dbq( |
|||
"SELECT i.code FROM dict_item i JOIN dict_group g ON i.group_id=g.id " |
|||
"WHERE g.code='customer_type' AND i.deleted=0 ORDER BY i.sort_no")] |
|||
FOLLOW_WAY = [r['code'] for r in dbq( |
|||
"SELECT i.code FROM dict_item i JOIN dict_group g ON i.group_id=g.id " |
|||
"WHERE g.code='follow_way' AND i.deleted=0 ORDER BY i.sort_no")] |
|||
INDUSTRIES = [r['code'] for r in dbq( |
|||
"SELECT i.code FROM dict_item i JOIN dict_group g ON i.group_id=g.id " |
|||
"WHERE g.code='industry' AND i.deleted=0 AND i.parent_id IS NULL ORDER BY i.sort_no")] |
|||
else: # DB 不可达 → 字典走 API |
|||
def codes_api(group): |
|||
d = api('GET', '/api/dict/item/enabled-list', params={'groupCode': group}, step=f'dict {group}') |
|||
return [x.get('code') for x in (d or []) if x.get('code')] |
|||
CTYPES = codes_api('customer_type') |
|||
FOLLOW_WAY = codes_api('follow_way') |
|||
INDUSTRIES = codes_api('industry') |
|||
need(CTYPES and FOLLOW_WAY and INDUSTRIES, '字典组非空') |
|||
|
|||
REGIONS = [ # (省, 市, 区) 国标码;先对 sys_region 验证,缺的回退广州荔湾 |
|||
('440000', '440100', '440103'), ('440000', '440300', '440305'), |
|||
('330000', '330100', '330106'), ('510000', '510100', '510107'), |
|||
('420000', '420100', '420111'), ('610000', '610100', '610113'), |
|||
('320000', '320100', '320102'), ('441900', '441900', '441900005'), |
|||
('440600', '440600', '440604'), ('350200', '350200', '350203'), |
|||
('370200', '370200', '370212'), ('430100', '430100', '430104')] |
|||
valid = {r['code'] for r in dbq("SELECT code FROM sys_region WHERE deleted=0")} if HAS_DB else None |
|||
REGIONS = [r if (valid is None or all(c in valid for c in r)) else ('440000', '440100', '440103') for r in REGIONS] |
|||
print(f' customer_type={len(CTYPES)} follow_way={len(FOLLOW_WAY)} industry={len(INDUSTRIES)} 地区 {len(REGIONS)} 组{"有效" if HAS_DB else "(未校验,--skip-db)"}') |
|||
|
|||
# ---------------- 1. 演示数据定义 ---------------- |
|||
# (名称, 行业idx, 地区idx, 类型idx, 客户星, 关系星, 商务谈判) |
|||
FIRMS = [ |
|||
('广州晨曦医疗器械有限公司', 0, 0, 1, 5, 4, 1), |
|||
('深圳蓝湾冷链供应链有限公司', 1, 1, 2, 4, 3, 0), |
|||
('杭州云栖软件技术有限公司', 2, 2, 0, 5, 5, 1), |
|||
('成都锦官建材集团有限公司', 3, 3, 3, 3, 2, 0), |
|||
('武汉江城物流股份有限公司', 4, 4, 4, 4, 3, 1), |
|||
('西安大唐文旅发展有限公司', 5, 5, 5, 2, 2, 0), |
|||
('广州珠江光电科技有限公司', 0, 0, 6, 4, 4, 1), |
|||
('深圳前海惠金信息服务有限公司', 7, 1, 7, 3, 2, 0), |
|||
('杭州西湖茶业有限公司', 8, 2, 8, 1, 1, 0), |
|||
('成都熊猫慢递文化传播有限公司', 9, 3, 9, 3, 3, 0), |
|||
('武汉光谷生物医药有限公司', 10, 4, 10, 4, 2, 0), |
|||
('广州白云机场快运有限公司', 4, 0, 11, 5, 3, 1), |
|||
('南京金陵楼宇物业管理有限公司', 11, 6, 12, 2, 2, 0), |
|||
('东莞松山湖精密制造有限公司', 12, 7, 13, 4, 3, 0), |
|||
('佛山季华铝业有限公司', 13, 8, 14, 3, 2, 0), |
|||
] |
|||
POOLS = [ # (名称, 行业idx, 地区idx)——无主进公海 |
|||
('厦门鹭岛酒店用品有限公司', 14, 9), |
|||
('青岛海诺环保工程有限公司', 15, 10), |
|||
('长沙湘江智能科技有限公司', 16, 11), |
|||
] |
|||
ARCHIVED = ('天津渤海贸易有限公司', 17, 0) |
|||
STAGE2 = {1, 4, 6, 9, 13} # 重潜(DB 直拨) |
|||
STAGE3 = {2, 11} # 已成交:云栖软件 / 白云机场快运 |
|||
# 统一为 7 元组(名称, 行业, 地区, 类型, 星, 关系星, 商务谈判) |
|||
EXTRA7 = [(nm, ind, reg, 0, 1, 1, 0) for nm, ind, reg in POOLS] + [(ARCHIVED[0], ARCHIVED[1], ARCHIVED[2], 0, 1, 1, 0)] |
|||
|
|||
CONTACTS = [ # (客户idx, 姓名, 职务, 手机, 关键, 内线, 礼品备注) |
|||
(0, '陈志强', '总经理', '13802000001', 1, 1, '偏好茶叶'), |
|||
(0, '林晓婷', '采购总监', '13802000002', 1, 0, ''), |
|||
(0, '黄伟杰', '设备科主任', '13802000003', 0, 0, ''), |
|||
(1, '吴敏', '供应链总监', '13803000001', 1, 0, '子女升学季,勿中午来电'), |
|||
(1, '刘建华', '仓储经理', '13803000002', 0, 0, ''), |
|||
(2, '郑雅文', '董事长助理', '13805000001', 1, 1, '重点维护,决策链关键人'), |
|||
(2, '杨帆', 'IT 经理', '13805000002', 0, 0, ''), |
|||
(3, '周丽娟', '财务总监', '13806000001', 1, 0, ''), |
|||
(4, '王海涛', '运营副总', '13807000001', 1, 0, ''), |
|||
(4, '徐静', '调度中心主任', '13807000002', 0, 0, ''), |
|||
(5, '孙明', '市场部经理', '13808000001', 0, 0, ''), |
|||
(6, '马晓燕', '采购部长', '13809000001', 1, 0, '对交期敏感'), |
|||
(6, '朱国强', '生产厂长', '13809000002', 0, 0, ''), |
|||
(7, '胡雪梅', '合规负责人', '13810000001', 0, 0, ''), |
|||
(8, '高翔', '总经理', '13811000001', 1, 0, '本人即决策人'), |
|||
(9, '林芳', '创意总监', '13812000001', 0, 0, ''), |
|||
(10, '何伟', '研发副总', '13813000001', 1, 0, ''), |
|||
(11, '罗嘉欣', '信息技术部长', '13814000001', 1, 1, ''), |
|||
(11, '邓超群', '车队队长', '13814000002', 0, 0, ''), |
|||
(12, '曹颖', '物业总经理', '13815000001', 0, 0, ''), |
|||
(13, '彭志远', '厂长', '13816000001', 1, 0, ''), |
|||
(14, '梁诗敏', '外贸部经理', '13817000001', 0, 0, ''), |
|||
] |
|||
|
|||
FOLLOWS = [ # (客户idx, follow_way下标, 内容, next 天数) |
|||
(0, 1, '上门演示手术室整体方案,陈总对配置清单基本认可,报价待财务复核', 2), |
|||
(0, 0, '电话确认资质文件已收悉,对方进入内部立项流程', 5), |
|||
(1, 0, '沟通冷链季度框架意向,需先补充承运资质与温控记录样例', 3), |
|||
(2, 4, '郑助理转达二期预算已批复,下周安排实施经理进场调研', 1), |
|||
(2, 0, '回访 ERP 一期使用情况,财务模块反馈良好', 7), |
|||
(3, 2, '建材行情下行,客户压缩采购计划,转推维修维护场景', 6), |
|||
(4, 0, '江城物流区域扩张计划确认,武汉/长沙两地仓配需求明确', 4), |
|||
(5, 3, '文旅旺季前预算未落地,保持月度联络', 14), |
|||
(6, 1, '车间改造项目勘察完成,等待技术方案评审', 2), |
|||
(7, 0, '惠金信息新设合规团队,有系统对接需求,发产品白皮书', 5), |
|||
(8, 0, '春茶采购季结束,转入日常维护联络', 20), |
|||
(9, 1, '熊猫慢递新址开业,送乔迁贺礼并演示会员系统', 8), |
|||
(10, 0, '光谷生物实验数据管理需求初步确认,约技术交流会', 3), |
|||
(11, 4, '白云快运调度系统验收会通过,商务条款法务审核中', 2), |
|||
(12, 0, '楼宇物业续约谈判节点临近,整理服务履约材料', 6), |
|||
(13, 1, '松山湖精密二期产线规划中,邀请参观标杆工厂', 9), |
|||
(14, 0, '季华铝业出口订单回暖,关注汇率对采购预算影响', 4), |
|||
] |
|||
|
|||
OPPS = [ # (名称, 客户idx, 甲方) |
|||
('云栖软件-ERP 二期实施项目', 2, '杭州云栖软件技术有限公司'), |
|||
('珠江光电-车间智能化改造', 6, '广州珠江光电科技有限公司'), |
|||
('白云快运-车辆调度系统采购', 11, '广州白云机场快运有限公司'), |
|||
('晨曦医疗-手术室设备集采', 0, '广州晨曦医疗器械有限公司'), |
|||
] |
|||
|
|||
SHOW_NAMES = [f[0] for f in FIRMS] + [p[0] for p in POOLS] + [ARCHIVED[0]] |
|||
|
|||
# ---------------- 2. 清理(仅本名单) ---------------- |
|||
print('== 2. 清理上一轮演示数据 ==') |
|||
if HAS_DB: |
|||
n_all = dbq('SELECT COUNT(*) c FROM customer')[0]['c'] |
|||
assert n_all >= 3, f'customer 仅 {n_all} 行,疑似连错库,中止' |
|||
ph = ','.join(['%s'] * len(SHOW_NAMES)) |
|||
rows = dbq(f'SELECT id FROM customer WHERE customer_name IN ({ph})', SHOW_NAMES) |
|||
cids = [str(r['id']) for r in rows] |
|||
with pymysql.connect(**CONF) as conn, conn.cursor() as cur: |
|||
if cids: |
|||
p2 = ','.join(['%s'] * len(cids)) |
|||
cur.execute(f'SELECT DISTINCT transfer_id FROM customer_transfer_detail WHERE customer_id IN ({p2})', cids) |
|||
tids = [r['transfer_id'] for r in cur.fetchall()] |
|||
if tids: |
|||
p3 = ','.join(['%s'] * len(tids)) |
|||
cur.execute(f'DELETE FROM customer_transfer_detail WHERE transfer_id IN ({p3})', tids) |
|||
cur.execute(f'DELETE FROM customer_transfer WHERE id IN ({p3})', tids) |
|||
cur.execute(f'DELETE FROM opportunity_customer WHERE customer_id IN ({p2})', cids) |
|||
oph = ','.join(['%s'] * len(OPPS)) |
|||
cur.execute(f'SELECT id FROM opportunity WHERE opp_name IN ({oph})', [o[0] for o in OPPS]) |
|||
oids = [r['id'] for r in cur.fetchall()] |
|||
if oids: |
|||
p4 = ','.join(['%s'] * len(oids)) |
|||
cur.execute(f'DELETE FROM opportunity_customer WHERE opportunity_id IN ({p4})', oids) |
|||
cur.execute(f'DELETE FROM opportunity WHERE id IN ({p4})', oids) |
|||
for t in ['customer_contact', 'customer_oplog', 'customer_team_member', 'customer_follow', |
|||
'customer_pending_notice', 'customer_focus', 'customer_view_log']: |
|||
cur.execute(f'DELETE FROM {t} WHERE customer_id IN ({p2})', cids) |
|||
cur.execute(f'DELETE FROM customer WHERE id IN ({p2})', cids) |
|||
print(f' 清 {len(cids)} 户 + 关联商机 {len(oids or [])}') |
|||
else: |
|||
print(' 无残留') |
|||
else: |
|||
ph = None |
|||
print(' --skip-db:跳过清理(重跑前需手工清名单或换名单,否则 L3 撞码 67003 硬拦)') |
|||
|
|||
# ---------------- 3. 造客户 ---------------- |
|||
print('== 3. 造拟真客户 ==') |
|||
CIDS = [] |
|||
for i, (name, ind, reg, typ, star, rstar, biz) in enumerate(FIRMS + EXTRA7): |
|||
reg_ = REGIONS[reg] |
|||
credit = f'91440101SHOW{10000 + i:05d}X' # 8+5+4+1=18 位 |
|||
form = dict(customerName=name, customerType=CTYPES[typ % len(CTYPES)], |
|||
provinceCode=reg_[0], cityCode=reg_[1], districtCode=reg_[2], |
|||
industryCode=INDUSTRIES[ind % len(INDUSTRIES)], |
|||
customerStarLevel=star, relationStarLevel=rstar, isBizNegotiated=biz, isChild=0, |
|||
unifiedCreditCode=credit, legalRepresentative=CONTACTS[i % len(CONTACTS)][1], |
|||
establishedDate=f'{2010 + i % 14}-0{1 + i % 9}-1{i % 9}', |
|||
registeredCapital=f'{(i + 1) * 500}万元人民币', |
|||
businessScope='许可经营项目凭许可证经营;一般经营项目自主选择', |
|||
staffSize=['50-99人', '100-499人', '500-999人'][i % 3], |
|||
annualRevenue=f'{(i % 8 + 1) * 1000}万元', |
|||
ownerUserId=None if 15 <= i < 18 else ADMIN, # 公海 3 户无主;天津渤海挂 admin 供归档样例 |
|||
confirmSimilar='true', |
|||
remark='验收演示数据(demo_showcase.py)') |
|||
d = api('POST', '/api/customer', form=form, step=f'create {name}') |
|||
need(d is not None, f'create {name}') |
|||
if d is None: |
|||
CIDS.append(None); continue |
|||
cid = str(d.get('id')) if isinstance(d, dict) else str(d) |
|||
CIDS.append(cid) |
|||
print(f' + {name} id={cid}') |
|||
|
|||
# stage 直拨(潜在=1 默认;API 不可达行,仅 HAS_DB) |
|||
if HAS_DB: |
|||
for idx in sorted(STAGE2): |
|||
if CIDS[idx]: |
|||
dbq('UPDATE customer SET customer_stage=2 WHERE id=%s', (CIDS[idx],), fetch=False) |
|||
for idx in sorted(STAGE3): |
|||
if CIDS[idx]: |
|||
dbq('UPDATE customer SET customer_stage=3 WHERE id=%s', (CIDS[idx],), fetch=False) |
|||
print(f' stage 直拨:重潜 {sorted(STAGE2)} 已成交 {sorted(STAGE3)}') |
|||
else: |
|||
print(' --skip-db:stage 保持潜在客户,重潜/已成交分布不可用') |
|||
|
|||
# 归档 |
|||
if CIDS[-1]: |
|||
api('POST', '/api/customer/archive', params={'id': CIDS[-1]}, step='archive 天津渤海') |
|||
|
|||
# ---------------- 4. 联系人 ---------------- |
|||
print('== 4. 联系人 ==') |
|||
for idx, nm, job, ph_, key, internal, gift in CONTACTS: |
|||
if not CIDS[idx]: |
|||
continue |
|||
api('POST', '/api/customer/contact', |
|||
form=dict(customerId=CIDS[idx], name=nm, jobTitleName=job, phone=ph_, |
|||
isKeyContact=key, isInternal=internal, giftRemark=gift, |
|||
source='contact_source_0%d' % (idx % 4 + 1)), |
|||
step=f'contact {nm}') |
|||
|
|||
# ---------------- 5. 跟进 ---------------- |
|||
print('== 5. 跟进 ==') |
|||
now = datetime.now() |
|||
for idx, way, content, ndays in FOLLOWS: |
|||
if not CIDS[idx]: |
|||
continue |
|||
nxt = (now + timedelta(days=ndays)).strftime('%Y-%m-%dT09:30:00') |
|||
api('POST', f'/api/customer/{CIDS[idx]}/follow', |
|||
form=dict(followWay=FOLLOW_WAY[way % len(FOLLOW_WAY)], followContent=content, |
|||
nextFollowTime=nxt), step=f'follow #{idx}') |
|||
|
|||
# ---------------- 6. 商机 + 绑定 ---------------- |
|||
print('== 6. 商机关联 ==') |
|||
for name, cidx, party in OPPS: |
|||
if not CIDS[cidx]: |
|||
continue |
|||
reg_ = REGIONS[FIRMS[cidx][2]] |
|||
d = api('POST', '/api/opportunity', |
|||
form=dict(opportunityName=name, oppSource='opp_source_02', |
|||
industryCode=INDUSTRIES[FIRMS[cidx][1] % len(INDUSTRIES)], |
|||
localityType='locality_type_01', bidForm='bid_form_01', |
|||
provinceCode=reg_[0], cityCode=reg_[1], |
|||
partyAClear=1, partyA=party, remark='验收演示商机'), |
|||
step=f'opp {name}') |
|||
if d is not None: |
|||
oid = str(d) |
|||
api('POST', '/api/opportunity/customer/add', |
|||
form=dict(oppId=oid, customerId=CIDS[cidx], customerNameSnapshot=FIRMS[cidx][0], |
|||
customerRole='customer_role_01', isPrimaryIntended=1), |
|||
step=f'bind {name}') |
|||
# 商机跟进一条,丰富详情 |
|||
api('POST', f'/api/opportunity/follow/add' if False else f'/api/customer/{CIDS[cidx]}/follow', |
|||
form=dict(followWay=FOLLOW_WAY[0], |
|||
followContent=f'【{name}】商机推进沟通:确认技术方案与商务节奏', |
|||
nextFollowTime=(now + timedelta(days=3)).strftime('%Y-%m-%dT14:00:00')), |
|||
step=f'opp-follow {name}') |
|||
|
|||
# ---------------- 7. 关注/重点/成员 ---------------- |
|||
print('== 7. 关注/重点/成员 ==') |
|||
for idx in (0, 2, 11): |
|||
if CIDS[idx]: |
|||
api('POST', f'/api/customer/{CIDS[idx]}/focus', step=f'focus #{idx}') |
|||
for idx in (0, 2): |
|||
if CIDS[idx]: |
|||
api('POST', f'/api/customer/{CIDS[idx]}/star', step=f'star #{idx}') |
|||
for idx in (1, 4): |
|||
if CIDS[idx]: |
|||
api('POST', f'/api/customer/{CIDS[idx]}/members', |
|||
form={'memberUserIds': BUDDY}, step=f'member #{idx}+曾偲青') |
|||
|
|||
# ---------------- 8. 校验 ---------------- |
|||
print('== 8. 校验 ==') |
|||
if HAS_DB: |
|||
got_names = dbq(f'SELECT COUNT(*) c FROM customer WHERE customer_name IN ({ph})', SHOW_NAMES)[0]['c'] |
|||
need(got_names == len(SHOW_NAMES), f'演示客户总数 {got_names}=={len(SHOW_NAMES)}') |
|||
stages = sorted(r['customer_stage'] for r in dbq( |
|||
f"SELECT customer_stage FROM customer WHERE customer_name IN ({ph}) AND deleted=0", SHOW_NAMES)) |
|||
s2 = sum(1 for s in stages if s == 2); s3 = sum(1 for s in stages if s == 3) |
|||
need(s2 == len(STAGE2) and s3 == len(STAGE3), f'stage 分布 重潜{s2} 已成交{s3}') |
|||
pools = dbq(f"SELECT COUNT(*) c FROM customer WHERE customer_name IN ({ph}) AND owner_user_id IS NULL", SHOW_NAMES)[0]['c'] |
|||
need(pools == 3, f'公海 {pools}==3') |
|||
cc = dbq(f'SELECT COUNT(*) c FROM customer_contact ct JOIN customer c ON c.id=ct.customer_id ' |
|||
f"WHERE c.customer_name IN ({ph})", SHOW_NAMES)[0]['c'] |
|||
need(cc >= len(CONTACTS) - 2, f'联系人 {cc}>={len(CONTACTS) - 2}') |
|||
fc = dbq(f'SELECT COUNT(*) c FROM customer_follow f JOIN customer c ON c.id=f.customer_id ' |
|||
f"WHERE c.customer_name IN ({ph})", SHOW_NAMES)[0]['c'] |
|||
need(fc >= len(FOLLOWS) - 2, f'跟进 {fc}>={len(FOLLOWS) - 2}') |
|||
oc = dbq(f'SELECT COUNT(*) c FROM opportunity_customer oc JOIN customer cu ON cu.id=oc.customer_id ' |
|||
f"WHERE cu.customer_name IN ({ph})", SHOW_NAMES)[0]['c'] |
|||
need(oc == len(OPPS), f'商机绑定 {oc}=={len(OPPS)}') |
|||
else: |
|||
print(' --skip-db:跳过 DB 校验(分布核对以 demo 页面观感为准)') |
|||
|
|||
json.dump({'customers': {f[0]: cid for f, cid in zip(FIRMS + POOLS + [ARCHIVED], CIDS) if cid}}, |
|||
open(IDS_FILE, 'w', encoding='utf-8'), ensure_ascii=False, indent=2) |
|||
|
|||
mine = api('POST', '/api/customer/workspace/mine/page', form={'current': 1, 'size': 1}, step='mine total') |
|||
pool = api('POST', '/api/customer/workspace/pool/page', form={'current': 1, 'size': 1}, step='pool total') |
|||
print(f'\nmine total={(mine or {}).get("total")} pool total={(pool or {}).get("total")}') |
|||
print(f'{"SHOWCASE ALL GREEN" if not fails else f"{len(fails)} 项失败: {fails}"}') |
|||
sys.exit(0 if not fails else 1) |
|||
@ -0,0 +1,178 @@ |
|||
# 客户模块端到端验收报告(e2e-report.md 终稿) |
|||
|
|||
> **票 09 聚合产出**(20260904)· 聚合自 `report-core.md`(票 04)+ `report-heavy.md`(票 05)+ 票 08 demo 自测记录(`issues/08-webapp-testing.md` Answer)+ 票 06 Bruno 对账(`verify_a4.py`)。 |
|||
> **环境**:本地后端 `localhost:8080`(verify profile,`CRM_MINIO_AK=admin`)+ 远程共享库 `8.129.84.155`(MySQL/Redis)。 |
|||
> **代码基线**:HEAD `43887d3`(客户模块票 01-14 全交付);jar 为 20260903 clean package 产物(本 effort 期间未改后端源码,D-01 修复为票 02 顺手修)。 |
|||
> **数据基线**:seed 17 客户 `e2c-`(KH202609030006-0022)+ 2 商机 + 4 联系人(`seed-customer.py` 幂等可重跑,重跑方式见 §七)。 |
|||
> **实测脚本**:`e2e-core.py`(F-01..F-12,112 用例)+ `e2e-heavy.py`(F-13..F-16,58 checks)+ `demo_selftest.py`(31 步 Playwright)。 |
|||
> **判定纪律**:原型(蓝湖 v29 A4 页族一手拉取)> spec(D1-D29 + API-SUMMARY)> 问用户;判 ❌ 前必须核对 Controller 实际 flat 路径;孤立 401 先重试再判缺陷。 |
|||
> **图例**:✅ 存在且业务可用|⚠ 行为不符/契约差异/被缺陷阻断|❌ 缺失或真实 bug。 |
|||
|
|||
--- |
|||
|
|||
## 一、执行摘要 |
|||
|
|||
| 维度 | 结论 | |
|||
|------|------| |
|||
| 流程覆盖 | **16 流程 F-01..F-16 全部实测**(spec-flows §0 全量 55 端点)+ demo 浏览器级 31 步 UI 走查 | |
|||
| 用例计数 | 112(core)+ 58(heavy)= **170 API 用例:✅ 164 / ⚠ 6 / ❌ 0**;demo 31 步 **31 PASS / 0 FAIL**(console errors=0、page errors=0);合计 201 步全绿零阻断 | |
|||
| 端点覆盖 | 55/55 全量(specimens-core 45 + specimens-heavy 19 端点真实请求/响应留档,注入票 06 Bruno 文档) | |
|||
| 缺陷登记 | 号段 D-01..D-07 共 7 条,**有效 6 条**(D-06 撤回):**D-01 P0 已修复**(票 02 顺手修+本 effort 复验);**未修复 5 条 = P1×2(D-05 导入必败、D-07 assignable 超时)+ P2×3(D-02、D-03、D-04)** | |
|||
| 阻断面 | **0 个真 404、0 个 P0 活跃缺陷**——主流程全通;P1 集中在导入写入链(D-05+D-04 连锁)与交割分配辅助接口(D-07) | |
|||
| 残留 ⚠ 定性 | API 层 6 条 ⚠ **全部对应已登记缺陷**(D-02/D-03 为直接表象,D-04/D-05/D-07 的 4 条为缺陷验证证据),非缺陷性 ⚠ 为 0;已知缺口复现 3 条(API-SUMMARY §5 预登记,见 §五合并表) | |
|||
|
|||
**P1 主题归纳(修复 effort 的直接输入)**: |
|||
|
|||
1. **导入写入链双缺陷连锁**(D-05+D-04):`customer.is_biz_negotiated` NOT NULL 无默认值且 createCustomer 未赋值 → 含 INSERT 行的导入任务必 FAILED(行不落库);且 insertFail 把超长 MyBatis 异常文本写 varchar(500) 二次炸 → 失败明细 0 行全丢。导入功能对"新增"场景事实不可用(UPDATE_ONLY 正常)。 |
|||
2. **交割 assignable 稳定超时**(D-07):`GET /api/customer/transfer/{id}/assignable` 30s/60s 多轮复现超时(疑 getChildDeptIds 全树递归 + 全表扫描),前端「选择分配对象」无法加载;assign 本体(直接指定 userId)不受影响。 |
|||
|
|||
--- |
|||
|
|||
## 二、核心域 F-01..F-12(e2e-core.py · ✅110 / ⚠2 / ❌0) |
|||
|
|||
12 流程全部走通:三 workspace 列表/看板/saved-view、新增+三层查重、编辑、联系人、详情头部+页签、跟进、关注/重点、团队成员、归属动作、商机侧硬依赖三端点。 |
|||
|
|||
| 流程 | 用例 | 结论 | 要点 | |
|||
|---|---|---|---| |
|||
| F-01 三 workspace 列表 | 12 | ✅ | overview/mine(三视图)/pool 分页字段齐;keyword/阶段/归档过滤;D19 默认排除已归档;公海 enterPoolTime 有值 | |
|||
| F-02 看板三分组 | 7 | ✅ | stage 三列齐(stage2/3 经 DB 直拨样例覆盖);summary 与 page total 自洽;越界 groupColumn 静默 | |
|||
| F-03 saved-view | 10 | ✅ | 三 scope 互不影响;save(JSON)/应用/set-default/delete;删除后应用不炸(静默按无视图) | |
|||
| F-04 新增+三层查重 | 9 | ✅ | L1 check-name/credit-code 提示;L2 needConfirm(未落库)→ confirmSimilar=true 落库;L3 67003 硬拦;编号 KH+yyyyMMdd+4 位 | |
|||
| F-05 编辑 | 5 | ⚠ D-03 | 回显/oplog 旧→新叙事 ✅;缺 version → **67001(spec 约定 67005,偏差)**;改信用代码撞 simA 67003 | |
|||
| F-06 联系人 | 9 | ✅ | 非法手机号 67001;67002;page/search 电话命中;check-phone 跨客户;PUT/DELETE /{id} | |
|||
| F-07 详情头部+页签 | 8 | ✅ | 头部项齐;**汇总卡 5 字段恒 null(已知缺口 §5.3 预登记)**;D-01 复验 opportunityCount==1;oplog 值域 11 种白名单 | |
|||
| F-08 跟进 | 12 | ⚠ D-02 | 空 content 67009 / 30s 防重 ✅;**nextFollowTime 早于当前未拦(D-02)**;写入+D25 锚点刷新;append-only 无编辑口 | |
|||
| F-09 关注/重点 | 7 | ✅ | focus 幂等;star/unstar 保行置位;focus-batch;FOCUSED 视图命中;unfocus 物理删连带清标;不写 oplog | |
|||
| F-10 团队成员 | 10 | ✅ | OWNER 在前;重复 67016 整批拒;软删 delete_key=id 复用键;MEMBER_ADD/REMOVE 留痕 | |
|||
| F-11 归属动作 | 17 | ✅ | claim+D25 锚点;重复领 67004;assign 67012 四连拦;67007/67006 阻断;抛公海清 owner 保部门锚点;archive-batch 整批预检整体拒 | |
|||
| F-12 商机侧硬依赖 | 7 | ✅ | quick-create 七必填+默认值;search 三维过滤;contacts 带出;已知缺口复现(见 §五) | |
|||
|
|||
**核心契约发现 12 条**(全文见 `report-core.md` §四,前端联调高发):LocalDateTime form 绑定 ISO `T` 分隔(空格 400);Long→String 序列化(id/total/cnt 均字符串数字);board summary 计数字段名=`cnt`;**PUT 编辑=全量覆盖语义**(部分提交清空未提交可空字段);assign 的 oplog action=`TRANSFER`;team_member 软删 delete_key=id;公海生命周期写 enter_pool_time;**archive-batch 整批预检整体拒绝**(claim/assign/release 批量为逐条独立事务);归属校验顺序(67004 先于权限语义);debug token 瞬时 500 需重试;saved-view 应用=过滤叠加不改 DataScope;名称相似算法对纯数字尾巴敏感(造名避开)。 |
|||
|
|||
明细证据:`report-core.md` + `e2e-core-checks.json`;示例请求/响应 `specimens-core.json`(45 端点)。 |
|||
|
|||
--- |
|||
|
|||
## 三、重流程 F-13..F-16(e2e-heavy.py · ✅54 / ⚠4 / ❌0) |
|||
|
|||
| 流程 | 结果 | 关键结论 | |
|||
|---|---|---| |
|||
| F-13 客户交割(A4-7) | **18/18 ✅** | preview 圈定=名下全量;initiate 必填/范围 67011;resign 自动推导接收总监(D22);重入 67010;**整批原子 owner→总监 + 商机三列联动**(经 opportunity_customer 反查);oplog 交接编号叙事;detail/assignable/page/续号当日不重号;assign 序列(67012 四连拦+批量);D23 自动完成(assignedCount==totalCount → status=1);已完成单再分配失败行收口。**⚠ D-07(P1):assignable 辅助接口稳定超时**(见 §五) | |
|||
| F-14 客户导入(A4-3-2) | 10 ✅ + 4 ⚠ | 模板下载(RFC 5987+xlsx 魔数);67013 三连;APPEND_ONLY 预检(t=7 i=1 f=5 s=1);行级判定(已存在 FAIL/文件内重复组 FAIL/相似 SUSPECT);UPDATE_ONLY 按编号 u=1;**UPSERT 同名新编号→SUSPECT(不按名称匹配,U-01 映射口径实测收口)**;confirm 状态机重复 67014;creator 过滤(admin 有/buddy 无)。**⚠ D-05(P1) 含 INSERT 行必失败 + ⚠ D-04(P2) 失败明细全丢**(连锁,见 §五) | |
|||
| F-15 提醒规则族(a7-3-3-1) | **11/11 ✅** | GET 回显/PUT 全量覆盖(唯一 JSON 端点)/64023 两连/校验失败不落库/开关缺省=0;**Job 实测**(env 注入 */30 cron,U-02 拍板收口):落表 plan=锚点+first(1)、status=0、type=FOLLOW_OVERDUE、target=owner、payload 快照全对;未到期/公海/归档排除;**幂等=同 plan 不重写(每轮补下一条升级链,remindSeq 0..29 严格连续)**;follow 刷新锚点后旧未发 30 条全置 INVALID。测后服务/规则均已还原(30/7 全开+默认 cron) | |
|||
| F-16 查重消费侧(a7-3-3-2) | **7/7 ✅** | check-credit-code 命中/创建撞码 67003 硬拦/check-name 相似命中(出厂 80)/创建相似名 needConfirm 未落库/check-phone 跨客户软提示/导入 SUSPECT 消费引证。**单例未落地**(customer_dedup_rule 表不存在)→ DefaultCustomerDedupSettingProvider 出厂默认生效;D26 关闭路径因此无法实测(挂观察点) | |
|||
|
|||
明细证据:`report-heavy.md` + `e2e-heavy-checks.json`;`specimens-heavy.json`(19 端点)。 |
|||
|
|||
**口径修正记录**(证据链在 checks JSON note 字段):15.7/15.9 首测按「恒 1 条」判 fail → DB 取证证明语义=「每轮补下一条」修正后 pass;D-06「导入分页恒空」经取证**撤回**(脚本取 records 而 PageResult 序列化 content,非产品缺陷)。 |
|||
|
|||
--- |
|||
|
|||
## 四、demo 浏览器级测试(票 08 · 31 步全绿) |
|||
|
|||
`demo/index.html`(file:// 直开,AJAX 经 API_BASE 直连 8080,debug token 登录)以 Playwright chromium headless 自测:**31 PASS / 0 FAIL,console errors=0、page errors=0**,截图 30 张 `shots08/`(shot-01..30 按步骤号对应)。 |
|||
|
|||
| 面板 | 覆盖 | |
|||
|---|---| |
|||
| 客户列表 | 三 workspace 切换、关键字检索、汇总卡渲染、勾选+批量关注、详情入口 | |
|||
| 客户详情 | 6 子页签渲染(info/contacts/follow/opps/members/oplog)、写跟进(follow_way_01 契约值)、联系人建/编/删全链(PUT/DELETE /{id})、关注/取关/重点/取消重点四连击(`POST /{id}/focus` 路径变量)、L2 needConfirm / L3 67003 两路查重 | |
|||
| 客户导入 | 任务列表、上传预检 DRAFT、确认执行→终态 DONE、失败明细弹窗 | |
|||
| 客户交割 | 列表(5 条真单)、发起前预览、交接单详情(进度 3/3) | |
|||
| 设置 | 提醒规则读改写(PUT JSON 全量覆盖)、64023 负路径、基线还原、查重试玩三端点 | |
|||
|
|||
**自测前修复的 6 处 demo 契约偏差**:联系人字段名(→ name/jobTitleName/isKeyContact/isInternal/giftRemark/source)、联系人 PUT/DELETE 路径(→ /{id})、导入失败明细路径(→ /{taskId}/failures)、API_BASE(file:// 跨源)、unifiedCreditCode 字段名、follow_way 字典值。 |
|||
|
|||
**自测中发现并修复的 2 处 demo 真缺陷**:① oneBy() focus 族 URL 形态错误(查询参数 → `POST /{id}/{op}` 路径变量;ownership 族 claim/release-pool/archive/restore 实为 flat `?id=` 形态保持不变);② 交割 tab 初次加载 `transferPage(1)` 误用"下一页" dir 语义导致第 2 页空态(→ `transferPage(0)`)。demo 为验证工具非交付产品,缺陷即修即验,不新增 D-xx 台账项。 |
|||
|
|||
**经验沉淀**:404 响应无 CORS 头 → 浏览器 console 误报 CORS block(判 CORS 先看请求 URL 对不对);`tableHtml` 空态也渲染 1 个 tr(Playwright 行计数须排除空态行);`IMP_ST` 状态文案是英文(DRAFT/RUNNING/DONE/FAILED);DELETE 后 switchSub 异步重载需轮询等待。 |
|||
|
|||
--- |
|||
|
|||
## 五、缺陷清单(有效 6 条 + 撤回 1 条)与已知缺口合并 |
|||
|
|||
> 复现基线:`python .scratch/customer-e2e/seed-customer.py` → 对应脚本。**本次只报告不修复**(map 范围拍板);修复另起 effort。 |
|||
|
|||
### 缺陷表(D-01..D-07,原型依据 = 蓝湖 v29 A4 页族) |
|||
|
|||
| 编号 | 级别 | 现象(期望 vs 实际) | 复现 | 接口/位置 | 原型/spec 依据 | 状态 | |
|||
|---|---|---|---|---|---|---| |
|||
| D-01 | P0 | 公海列表 pool/page 500(期望 200) | F-01/F-07 | `POST /api/customer/workspace/pool/page` | A4-2 公海列表页 | ✅ **已修复**(票 02 顺手修,用户拍板;本 effort 复验通过:公海 200 + 交割-甲 opportunityCount==1) | |
|||
| D-02 | P2 | 跟进 nextFollowTime 早于当前未拦截(期望 67009);客户进入 FOLLOW_UP_DUE 视图且 append-only 无改删口 → 提醒永远无法消除 | f08 负路径 | `POST /api/customer/{id}/follow` | spec F-08(67009 语义含"早于本次");demo 角标 D-02 | ⚠ 未修复(建议 addFollow 入口补 `nextFollowTime > now`,对称 content 空校验位置) | |
|||
| D-03 | P2 | 编辑缺 version 报 **67001**「缺少乐观锁版本号」,spec 约定 **67005**(CAS 冲突)——可达但码不对,前端按 code 分流提示会踩坑 | f05 负路径 | `PUT /api/customer/{id}` | spec 决策 D-06(customer-module map) | ⚠ 未修复(补 67005 或文档按 67001 口径修正,二选一对齐) | |
|||
| D-04 | P2 | 导入失败明细全丢:insertFail 将完整 MyBatis 异常文本写 `fail_reason` varchar(500) → Data too long 二次异常 → fail 表 0 行 | F-14 任意失败行 | `CustomerImportExecutor.insertFail` | A4-3-2 导入页失败明细列 | ⚠ 未修复(建议异常文本截断/摘要化入库) | |
|||
| D-05 | P1 | **含 INSERT 行的导入任务必 FAILED**:`customer.is_biz_negotiated` tinyint NOT NULL 无默认值且 createCustomer 未赋值 → `Field 'is_biz_negotiated' doesn't have a default value`;UPDATE_ONLY 正常 | F-14 APPEND_ONLY/UPSERT | `CustomerImportExecutor.createCustomer` | A4-3-2 导入页(模板 V1 双 sheet) | ⚠ 未修复(补赋值或列默认 '0',对照 is_child 有默认) | |
|||
| D-06 | — | 「导入分页恒空」首判缺陷 | F-14 page | `GET /api/customer/import/page` | — | ❌ **撤回**(脚本取 `records` 而 PageResult 序列化字段为 `content`,非产品缺陷) | |
|||
| D-07 | P1 | 交割 **assignable 辅助接口稳定超时**(E2E 30s×2、probe 60s×2,三轮复现);assign 本体不受影响 | F-13 详情页点分配 | `GET /api/customer/transfer/{id}/assignable` | A4-7 交割页「选择分配对象」下拉 | ⚠ 未修复(建议:部门树缓存/子部门查询收窄;前端临时兜底=直接填 userId) | |
|||
|
|||
### 已知缺口合并去重(API-SUMMARY §5 预登记 × 实测确认,均不算新缺陷) |
|||
|
|||
| §5 预登记 | 实测确认 | |
|||
|---|---| |
|||
| 1. QuickCreateDTO 无 confirmSimilar | ✅ f12 复现:命中相似 needConfirm=true,带 confirmSimilar=true 重发仍 needConfirm(表单字段不存在被忽略)→ 商机弹窗快创遇相似名无解,只能改名 | |
|||
| 2. 查重设置页无对外端点 | ✅ F-16:customer_dedup_rule 表不存在 → 出厂默认全开生效(名称 80/信用码硬拦/电话软提示);D26 关闭路径无法实测(挂观察点) | |
|||
| 3. 详情页签 5/6 与汇总卡 5 字段恒 null | ✅ f07 复现:wonProjectAmount/planEstimateAmount/ongoingOpportunityAmount/contractAmount/paidAmount 全 null(不符④拍板 A 占位) | |
|||
| 4. 三档脱敏+查看日志/合并客户/客户导出 | 范围外(定稿划出),未测 | |
|||
| 5. 联系人子表导入 | 范围外(REVIEW-HANDOFF 划出),未测 | |
|||
|
|||
### 观察点收口(票 01 挂号 U-01..04 + W-01) |
|||
|
|||
| 挂号 | 收口结论 | |
|||
|---|---| |
|||
| U-01 导入双控件 vs 单参数 importMode | ✅ 销号:F-14 实测映射语义(UPDATE_ONLY 按编号/UPSERT 同名→SUSPECT 不按名称匹配),与原型偏离但实现自洽,按判定纪律不视为缺陷 | |
|||
| U-02 提醒 Job 触发方式 | ✅ 销号:票 05 拍板 env 注入 cron(`restart_service()`),测后还原默认 `0 0 4 * * ?` | |
|||
| U-03 公海分配按钮角色可见性 | ✅ 销号:按口径 E2E 用 admin 统一视角验端点行为;环境仅 ROLE_ADMIN 一个角色,BUDDY(无角色)已确认走业务校验路径(67004),完整越权矩阵留联调环境 | |
|||
| U-04 看板 groupColumn 白名单 | ✅ 销号(票 01 已实锤:stage/star/relation 与原型三分组对应) | |
|||
| W-01 contact_source 字典组缺失 | ⚠ **维持挂账**(联调观察点):dict_group 24 组无 contact_source,但联系人创建不校验 source(回显原值 contact_source_01)——前端下拉将无数据源,等字典补种或口径拍板 | |
|||
| 升级提醒链无上限 | ⚠ 维持挂账(产品口径复核):客户永不跟进时提醒逐轮堆积(30 轮=30 条待发),建议复核是否需「存在未发提醒时暂停推进」 | |
|||
|
|||
--- |
|||
|
|||
## 六、与票 12 静态对齐的差异(静态核对 vs 动态实测增量) |
|||
|
|||
票 12 十页静态对齐(spec 对照,不符②③④⑤修复后同步版)核对的是**存在性与契约文本**;本 effort 实测新增的是**运行时行为**维度,以下均为静态核对无法触达、由动态实测暴露的发现: |
|||
|
|||
| 类别 | 动态实测增量(静态对齐看不到) | |
|||
|---|---| |
|||
| 校验逻辑缺口 | D-02(nextFollowTime 过去未拦——静态只见参数声明,不见"缺失的 if");D-05(is_biz_negotiated 缺省列——需 DB SHOW COLUMNS 对照 INSERT 路径);D-04(异常文本超长二次炸——需真实失败触发异常链) | |
|||
| 错误码语义偏差 | D-03(67001 vs 67005——静态对齐只核对了"有 CAS",未触发缺 version 的真实响应码) | |
|||
| 性能问题 | D-07(assignable 超时——静态无法发现,需真实调用计时) | |
|||
| 序列化/绑定形态 | 12 条契约发现(ISO T 分隔、Long→String、cnt 字段名、PUT 全量覆盖、oplog action=TRANSFER、PageResult content 等)——静态看 DTO 注解推断不出 Spring 实际绑定/序列化行为 | |
|||
| 事务与批量语义 | archive-batch 整批预检整体拒 vs claim/assign/release 逐条独立事务——需对含阻断户的批量请求实测 | |
|||
| 状态机副作用 | D25 锚点赋值时序、enter_pool_time 生命周期、D23 自动完成、INVALID 置无效链——需按状态流转序列触发 | |
|||
|
|||
静态对齐已核项(本 effort 不再重复质疑、实测全部确认):公海列表(不符①已修)、总览身份筛选、详情 8 页签(不符③④已修)、操作日志 action 筛选(不符⑤已修,值域 11 种实测白名单吻合)、交割(整批原子+D22/D23)、导入三方式两段式、规则两页、归属弹窗守卫码、新增三层查重(不符②成员写端点已修)。 |
|||
|
|||
--- |
|||
|
|||
## 七、测试数据指引(前端联调)+ Bruno 交付核对 |
|||
|
|||
### seed 指引 |
|||
|
|||
- **完整清单**:`seed-data-manifest.md`(人读)+ `seed-ids.json`(机读,消费方**一律从 json 取 id,禁止硬编码**——重跑后 id 全变)。 |
|||
- **账号矩阵**(debug token = `GET /api/auth/debug/token?userId=`):管理员罗伟健 `739564171091247104`(ROLE_ADMIN data_scope=4)/ BUDDY 曾偲青 `744842318024015872`(无任何角色,走业务校验路径)。 |
|||
- **数据矩阵**:17 客户 `e2c-` 前缀(阶段 1/2/3 三档、星级、相似对 A/B、跟进待办/超期、协同、成员、交割甲乙丙、公海甲乙、已归档)+ 2 商机(交割/67007 拦截绑定)+ 4 联系人(含同号 check-phone 样例王重复)。 |
|||
- **重跑方式**: |
|||
- `python .scratch/customer-e2e/seed-customer.py` —— 先清后造(幂等;物理清 e2c- 命名空间再重建,KH 编号只前进) |
|||
- `python .scratch/customer-e2e/e2e-core.py` —— 核心域全量(F-11/F-12 自还原归属态/自清快创,可重复跑) |
|||
- `python .scratch/customer-e2e/e2e-heavy.py` —— 重流程全量(F-15 会临时注入 */30 cron 并还原;F-13 交割前提搭建含 leader 设置 teardown 还原) |
|||
- `python .scratch/customer-e2e/demo_selftest.py` —— demo 浏览器级 31 步(需 8080 在线;每轮自清联系人甲) |
|||
- **联调注意**:① `Result<Void>` data=null 非失败,判 code 即可;② 写端点全 form 绑定,唯一 JSON = PUT /api/rule/customer/reminder;③ PUT 编辑全量覆盖(编辑页必须全量回显提交);④ 导入含 INSERT 行当前必 FAILED(D-05),联调导入请用 UPDATE_ONLY;⑤ 本 effort 测试残留:demo 自测轮次的导入任务记录(DONE/suspect)与已归档的 KH202609040001(清理验证产物),均无碍复用。 |
|||
|
|||
### Bruno 交付核对(票 06) |
|||
|
|||
- **位置**:`D:\code\crm-api-docs\A4 客户管理\`(D 盘文档仓,git 仓 **agent 不提交,用户审查后自行提交**)。 |
|||
- **规模**:**57 .bru / 10 目录**(55 端点 + 归属 flat 与批量双形态);目录 = Controller @Tag 原文(客户管理 10/workspace 3/归属流转 9/详情页 8/联系人 7/关注 5/交割 6/导入 6/健康 1 + 根级 `客户管理\超期提醒规则\` 2)。 |
|||
- **分组决策**:crm-preference 6 端点不生成副本(tag 挂 A3,README 文字索引 scopeKey=customer.*);根级提醒规则 2 端点按 tag 归位根级目录。 |
|||
- **对账**:`verify_a4.py` 对源码 57 钥匙(METHOD+路径)与生成物双向比对 **PASS(0 差异)**;请求示例 = specimens 实测真值注入(64 端点形态覆盖)。 |
|||
- **结构**:裸 collection 不升级(bruno.json + collection.bru,Bruno 3.x 可打开,Not-yet 结案);README 已重写(9 子目录索引 + 归位说明 + 维护纪律)。 |
|||
|
|||
--- |
|||
|
|||
## 八、交付确认(两件套 + 配套资产) |
|||
|
|||
| 交付物 | 状态 | |
|||
|---|---| |
|||
| ① Bruno 接口文档集合 | ✅ `D:\code\crm-api-docs\A4 客户管理\` 57 .bru / 10 目录 + 对账 PASS | |
|||
| ② E2E 测试报告(本文档) | ✅ `.scratch/customer-e2e/e2e-report.md` | |
|||
| 配套:前端 demo | ✅ `demo/index.html`(票 07 交付 831 行 + 票 08 修复 8 处)+ 31 步自测全绿 | |
|||
| 配套:seed 联调数据 | ✅ `seed-customer.py` ALL GREEN 7/7 + manifest + seed-ids.json | |
|||
@ -0,0 +1,47 @@ |
|||
# -*- coding: utf-8 -*- |
|||
"""probe_test_env.py — 测试环境连通性探测(只读,不造数)""" |
|||
import io, sys |
|||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') |
|||
import requests |
|||
|
|||
BASE = 'http://ai.itc.vip/crm-api' |
|||
ADMIN = '739564171091247104' |
|||
|
|||
print(f'== 1. ping(免鉴权端点)==') |
|||
try: |
|||
r = requests.get(f'{BASE}/api/customer/ping', timeout=10) |
|||
print(f' GET /api/customer/ping → HTTP {r.status_code} {r.text[:120]}') |
|||
except Exception as e: |
|||
print(f' x 连接失败: {e}'); sys.exit(1) |
|||
|
|||
print(f'== 2. debug token(verify profile 探测)==') |
|||
try: |
|||
r = requests.get(f'{BASE}/api/auth/debug/token', params={'userId': ADMIN}, timeout=10) |
|||
print(f' GET /api/auth/debug/token → HTTP {r.status_code}') |
|||
body = {} |
|||
try: |
|||
body = r.json() |
|||
except Exception: |
|||
print(f' 响应非 JSON: {r.text[:120]}') |
|||
tok = body.get('data') if isinstance(body, dict) else None |
|||
tok = tok.get('token') if isinstance(tok, dict) else tok |
|||
if r.status_code == 200 and tok: |
|||
print(f' token 前缀: {str(tok)[:24]}...(verify profile 已激活)') |
|||
H = {'Authorization': f'Bearer {tok}'} |
|||
print(f'== 3. 数据状态(mine/pool/字典)==') |
|||
m = requests.post(f'{BASE}/api/customer/workspace/mine/page', data={'current': 1, 'size': 3}, headers=H, timeout=15).json() |
|||
p = requests.post(f'{BASE}/api/customer/workspace/pool/page', data={'current': 1, 'size': 3}, headers=H, timeout=15).json() |
|||
mc, pc = (m.get('data') or {}).get('total'), (p.get('data') or {}).get('total') |
|||
print(f' mine total={mc} pool total={pc} (code={m.get("code")}/{p.get("code")})') |
|||
mc_rows = (m.get('data') or {}).get('content') or [] |
|||
for row in mc_rows[:3]: |
|||
print(f' - {row.get("customerNo")} {row.get("customerName")} owner={row.get("ownerUserNameSnapshot")}') |
|||
for g in ('customer_type', 'follow_way', 'industry', 'contact_source'): |
|||
d = requests.get(f'{BASE}/api/dict/item/enabled-list', params={'groupCode': g}, headers=H, timeout=10).json() |
|||
items = d.get('data') or [] |
|||
print(f' dict {g}: {len(items)} 项' + (' ⚠ 空' if not items else f'(首项 {items[0].get("code")})')) |
|||
else: |
|||
print(f' body.code={body.get("code") if isinstance(body, dict) else "-"} message={str(body.get("message"))[:100] if isinstance(body, dict) else "-"}') |
|||
print(' → debug token 不可用:测试环境未带 verify profile(或端点被拦)。demo 登录将 404,需要调整启动配置。') |
|||
except Exception as e: |
|||
print(f' x 探测失败: {e}') |
|||
@ -0,0 +1,46 @@ |
|||
# A4 客户管理 |
|||
|
|||
> 对应蓝湖原型「A4 客户管理」分区(A4-1 客户公海 / A4-2 客户总览 / A4-3 我的客户 / A4-4 客户详情 / A4-5 联系人 / A4-6 客户查重 / A4-7 客户交割)。 |
|||
|
|||
## 已落地接口(55 个 + 根级 2 个 = 57 个) |
|||
|
|||
- **客户管理/**(10):新增客户、编辑客户、客户详情、客户分页、商机侧客户搜索、快速创建客户、按客户查联系人、名称相似提示、信用代码查重、工商信息查询 |
|||
- **客户三 workspace/**(3):workspace 列表分页、看板分组汇总、看板单列卡片 |
|||
- 对应原型 A4-1/A4-2/A4-3 三个列表页:路径段 `{workspace}` 取 mine / overview / pool 三值,一套端点三处复用 |
|||
- **归属流转/**(9):领取客户、批量领取、分配客户、批量分配、抛公海、批量抛公海、归档客户、批量归档、恢复客户 |
|||
- **客户详情页/**(8):详情公共头部、团队成员页签、添加团队成员、移除团队成员、关联商机页签、操作日志分页、新增跟进、跟进记录分页 |
|||
- **A4-5 联系人/**(7):新增联系人、编辑联系人、删除联系人、联系人详情、顶级联系人列表、联系人全局搜索、电话软提示 |
|||
- **客户关注与重点标记/**(5):关注客户、取消关注客户、标记重点客户、取消重点标记、批量关注客户 |
|||
- **客户交割/**(6):发起前客户预览、发起交接、交接详情、交接记录分页、客户分配、可分配销售列表 |
|||
- **客户导入/**(6):上传导入文件并预校验、确认执行导入、任务结果、失败疑似重复明细、最近导入记录、下载导入模板 |
|||
- **模块健康/**(1):健康探针 |
|||
- 根级 **客户管理/超期提醒规则/**(2):当前提醒规则、保存提醒规则 —— 见下「为什么超期提醒规则在根级」 |
|||
|
|||
示例值说明:各 .bru 的请求/响应示例均注入 **E2E 实测真值**(标注「示例数据(E2E 实测真值/实测值注入)」的为真实抓取,其余为语义兜底)。 |
|||
|
|||
## 为什么超期提醒规则在根级 |
|||
|
|||
超期提醒规则的两个端点(`GET/PUT /api/rule/customer/reminder`)实现在 **crm-rule** 模块(CustomerReminderRuleController),源码 `@Tag` 原文为「客户管理」(无 A4 前缀)。按「结构听源码」铁律,目录 = tag 原文,故其生成在 collection 根级 `客户管理/超期提醒规则/` 而非 `A4 客户管理/` 下——bruno-sync 重跑时按 tag 归位,挪进 A4 会产生孤儿目录。 |
|||
|
|||
## crm-preference 6 端点说明(不生成副本) |
|||
|
|||
客户模块功能上依赖 crm-preference 的自定义视图(saved-view)6 个端点(列表 / 保存 / 删除 / 设为默认等),但其源码 `@Tag` 挂在 **A3 商机管理** 分区(商机先建 saved-view,客户侧复用同一套),为避免 sync 重跑产生副本/孤儿,本分区**不生成副本**,文档见 A3 分区对应目录。前端对接时 scopeKey 传 `customer.*`(如 `customer.workspace.mine`),接口本身与商机共用: |
|||
|
|||
- `GET /api/preference/saved-view/list?scopeKey=customer.*`(自定义视图列表) |
|||
- `POST /api/preference/saved-view`(保存自定义视图) |
|||
- `DELETE /api/preference/saved-view/{id}`(删除自定义视图) |
|||
- `PUT /api/preference/saved-view/{id}/default`(设为默认视图) |
|||
|
|||
(以上为文字索引;A3 分区文档中的 scopeKey 示例为商机域,客户域联调请按本段替换 scopeKey。) |
|||
|
|||
## 空缺标注 |
|||
|
|||
无 —— 原型 A4 各功能页均有接口落地。已知缺口(不影响联调,详见 E2E 报告):QuickCreateDTO 无 confirmSimilar 字段、详情汇总卡 5 字段恒 null 前端显 "--"、页签 5/6 汇总卡同。 |
|||
|
|||
## 对接工程师示位 |
|||
|
|||
客户模块前端按页面目录联调:三个列表页共用「客户三 workspace/」(分页 + 看板两个子端点,路径段换 workspace);客户详情页接口集中在「客户详情页/」下(公共头部 + 6 页签共享 8 端点);联系人独立页在「A4-5 联系人/」;全生命周期归属操作(领取 / 分配 / 抛公海 / 归档等)在「归属流转/」;客户交割(总监交接)在「客户交割/」;Excel 导入全流程在「客户导入/」。 |
|||
|
|||
--- |
|||
|
|||
> 维护纪律:本 README 为人工维护的分区索引;`.bru` 接口文件由生成器依据后端 Controller tag 自动同步(只动带 `generated` 标记的文件,结构听源码、表述听人),请勿手改。各 .bru docs 首行 `METHOD path` 为对账钥匙行,**勿手改**(对账脚本以此比对源码 mapping)。agent 不执行 git 提交,由维护者审查后自行提交。 |
|||
@ -0,0 +1,37 @@ |
|||
# -*- coding: utf-8 -*- |
|||
r"""票 08 前置:扫 demo/index.html 全部 api() 调用,与 defs_a4.py 57 端点对账(找路径偏差)。""" |
|||
import io, sys, re, os |
|||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') |
|||
sys.path.insert(0, r'd:\code\crm-backend-matt\.scratch\customer-e2e') |
|||
from defs_a4 import DEFS |
|||
|
|||
DEMO = r'd:\code\crm-backend-matt\.scratch\customer-e2e\demo\index.html' |
|||
txt = open(DEMO, encoding='utf-8').read() |
|||
|
|||
# 源 keys(占位形态)+ 允许的演示实例形态 |
|||
src_paths = {d['path'] for d in DEFS} |
|||
src_methods = {} |
|||
for d in DEFS: |
|||
src_methods.setdefault(d['path'], set()).add(d['method']) |
|||
|
|||
# demo 里 api('...') 字面串 + api(`...`) 模板串 |
|||
calls = [] |
|||
for m in re.finditer(r"""api\(\s*(['\"`])(.+?)\1""", txt): |
|||
calls.append(m.group(2)) |
|||
|
|||
def to_tpl(u): |
|||
"""把演示 url(数字 id/mine 等实例)归一回 {id}/{workspace} 占位形态再对账。""" |
|||
u = re.sub(r'(?<=/)(mine|overview|pool)(?=/|\\?|$)', '{workspace}', u) |
|||
u = re.sub(r'(?<=/)\d{5,}(?=/|\\?|$)', '{id}', u) |
|||
return u.split('?')[0] |
|||
|
|||
print(f'demo api() 调用 {len(calls)} 处,去重归一后:') |
|||
bad = 0 |
|||
for c in sorted(set(calls)): |
|||
tpl = to_tpl(c) |
|||
hit = tpl in src_paths |
|||
mark = 'OK ' if hit else '?? ' |
|||
if not hit: |
|||
bad += 1 |
|||
print(f' {mark} {c} -> {tpl}') |
|||
print(f'\nunmatched: {bad}') |
|||
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 57 KiB |
@ -0,0 +1,23 @@ |
|||
{ |
|||
"customers": { |
|||
"广州晨曦医疗器械有限公司": "751103486522818560", |
|||
"深圳蓝湾冷链供应链有限公司": "751103486908694528", |
|||
"杭州云栖软件技术有限公司": "751103487302959104", |
|||
"成都锦官建材集团有限公司": "751103487592366080", |
|||
"武汉江城物流股份有限公司": "751103487902744576", |
|||
"西安大唐文旅发展有限公司": "751103488435421184", |
|||
"广州珠江光电科技有限公司": "751103488821297152", |
|||
"深圳前海惠金信息服务有限公司": "751103489253310464", |
|||
"杭州西湖茶业有限公司": "751103489597243392", |
|||
"成都熊猫慢递文化传播有限公司": "751103489911816192", |
|||
"武汉光谷生物医药有限公司": "751103490314469376", |
|||
"广州白云机场快运有限公司": "751103490679373824", |
|||
"南京金陵楼宇物业管理有限公司": "751103490972975104", |
|||
"东莞松山湖精密制造有限公司": "751103491279159296", |
|||
"佛山季华铝业有限公司": "751103491635675136", |
|||
"厦门鹭岛酒店用品有限公司": "751103491979608064", |
|||
"青岛海诺环保工程有限公司": "751103492348706816", |
|||
"长沙湘江智能科技有限公司": "751103492742971392", |
|||
"天津渤海贸易有限公司": "751103493049155584" |
|||
} |
|||
} |
|||
@ -0,0 +1,30 @@ |
|||
# -*- coding: utf-8 -*- |
|||
"""票 08 前置冒烟:起服后 debug token + 客户分页(seed 在位)+ 健康探针。""" |
|||
import io, sys, json |
|||
import requests |
|||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') |
|||
|
|||
BASE = 'http://localhost:8080' |
|||
ADMIN = '739564171091247104' |
|||
|
|||
r = requests.get(f'{BASE}/api/auth/debug/token', params={'userId': ADMIN}, timeout=10) |
|||
tok = r.json().get('data') |
|||
tok = tok.get('token') if isinstance(tok, dict) else tok |
|||
print('token:', (tok or 'FAIL')[:20] + '...' if tok else 'FAIL') |
|||
|
|||
H = {'Authorization': f'Bearer {tok}'} |
|||
r = requests.get(f'{BASE}/api/customer/ping', headers=H, timeout=10) |
|||
print('ping:', r.status_code, r.json().get('message')) |
|||
|
|||
r = requests.post(f'{BASE}/api/customer/workspace/mine/page', |
|||
data={'current': 1, 'size': 50}, headers=H, timeout=15) |
|||
d = r.json().get('data') or {} |
|||
rows = d.get('content') or [] |
|||
print('mine page:', r.status_code, 'total=', d.get('total'), 'rows=', len(rows)) |
|||
for c in rows[:5]: |
|||
print(' ', c.get('customerNo'), c.get('customerName'), 'stage=', c.get('customerStage'), 'owner=', c.get('ownerUserNameSnapshot')) |
|||
|
|||
r = requests.post(f'{BASE}/api/customer/workspace/pool/page', |
|||
data={'current': 1, 'size': 10}, headers=H, timeout=15) |
|||
d = r.json().get('data') or {} |
|||
print('pool page: total=', d.get('total')) |
|||
@ -0,0 +1,71 @@ |
|||
# -*- coding: utf-8 -*- |
|||
r"""票 06 续跑步骤 4:对账 verify(HANDOFF-2026-09-04 §3.4)。 |
|||
- 正则扫 9 个 crm-customer Controller + crm-rule CustomerReminderRuleController |
|||
的 class @RequestMapping + method @XxxMapping,得 (METHOD, path) 钥匙集(应 57) |
|||
- 与生成 .bru 的 docs 首行钥匙 `METHOD path` 双向比对,全命中收口(防笔误漏端点) |
|||
- crm-preference 6 端点不生成副本(源码 tag 挂 A3),不参与本对账 |
|||
""" |
|||
import io, sys, os, re |
|||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') |
|||
|
|||
WS = r'd:\code\crm-backend-matt' |
|||
CONTROLLERS = [ |
|||
WS + r'\crm-customer\src\main\java\com\crm\customer\controller\%s' % n |
|||
for n in ('CustomerController.java', 'CustomerWorkspaceController.java', |
|||
'CustomerOwnershipController.java', 'CustomerDetailController.java', |
|||
'CustomerContactController.java', 'CustomerFocusController.java', |
|||
'CustomerTransferController.java', 'CustomerImportController.java', |
|||
'CustomerPingController.java') |
|||
] + [WS + r'\crm-rule\src\main\java\com\crm\rule\controller\CustomerReminderRuleController.java'] |
|||
|
|||
DOCS = r'D:\code\crm-api-docs' |
|||
SCAN_ROOTS = [os.path.join(DOCS, 'A4 客户管理'), os.path.join(DOCS, '客户管理')] |
|||
|
|||
# ---- 源码钥匙集 ---- |
|||
re_class = re.compile(r'@RequestMapping\(\s*"([^"]+)"') |
|||
re_method = re.compile(r'@(Get|Post|Put|Delete)Mapping(?:\(\s*(?:"([^"]*)")?[^)]*\))?') |
|||
src_keys = set() |
|||
for f in CONTROLLERS: |
|||
txt = open(f, encoding='utf-8').read() |
|||
m = re_class.search(txt) |
|||
base = m.group(1) if m else '' |
|||
for mm in re_method.finditer(txt): |
|||
verb = mm.group(1).upper() |
|||
sub = mm.group(2) or '' |
|||
path = (base + sub) if not sub.startswith(base) else sub |
|||
path = re.sub(r'/{2,}', '/', path) |
|||
src_keys.add(f'{verb} {path}') |
|||
print(f'source keys: {len(src_keys)} (expect 57)') |
|||
|
|||
# ---- .bru 钥匙集 ---- |
|||
re_key = re.compile(r'`\s*(GET|POST|PUT|DELETE) (/\S+?)`') |
|||
bru_keys = {} |
|||
for root in SCAN_ROOTS: |
|||
for dp, dn, fn in os.walk(root): |
|||
for f in fn: |
|||
if not f.endswith('.bru') or f == 'folder.bru': |
|||
continue |
|||
p = os.path.join(dp, f) |
|||
txt = open(p, encoding='utf-8').read() |
|||
m = re_key.search(txt) |
|||
if not m: |
|||
print(f' !! no key line: {os.path.relpath(p, DOCS)}') |
|||
continue |
|||
bru_keys.setdefault(f'{m.group(1)} {m.group(2)}', []).append(os.path.relpath(p, DOCS)) |
|||
print(f'bru keys: {len(bru_keys)} (expect 57)') |
|||
|
|||
missing = sorted(src_keys - set(bru_keys)) |
|||
extra = sorted(k for k in bru_keys if k not in src_keys) |
|||
dupes = {k: v for k, v in bru_keys.items() if len(v) > 1} |
|||
print(f'missing in bru (source has, doc lacks): {len(missing)}') |
|||
for k in missing: |
|||
print(' -', k) |
|||
print(f'extra in bru (doc has, source lacks): {len(extra)}') |
|||
for k in extra: |
|||
print(' -', k, '->', bru_keys[k]) |
|||
print(f'duplicate keys: {len(dupes)}') |
|||
for k, v in dupes.items(): |
|||
print(' -', k, '->', v) |
|||
|
|||
ok = not missing and not extra and not dupes and len(src_keys) == 57 and len(bru_keys) == 57 |
|||
print('VERIFY:', 'PASS ✅ 全命中收口' if ok else 'FAIL ❌') |
|||
@ -0,0 +1,53 @@ |
|||
# 客户模块验收返工 · 交接(HANDOFF-2026-09-04) |
|||
|
|||
> 写给在新电脑上续接本 effort 的下一个 agent 会话。细节全部在图与票内,本文只做指针 + 换机特有事项,不复述。 |
|||
|
|||
## 如何续接 |
|||
|
|||
1. 读 `.scratch/customer-rework/map.md`(图:destination / R1–R11 决策 / Decisions so far / 范围外)。 |
|||
2. 以 **wayfinder skill(Work through the map 模式)** 续接:地图路径 `.scratch/customer-rework/`;tracker 约定见 `docs/agents/issue-tracker.md` 的「Wayfinding operations」段。 |
|||
3. **一场只解一张票**;先 `Status: claimed` 再动手;resolve 后 Answer + map 的 Decisions so far 追加。 |
|||
4. 用户已示谕**按序取票**:下一张 = **04 · 查重设置页端点 + QuickCreate.confirmSimilar**(`.scratch/customer-rework/issues/04-gap-fixes.md`)。此后前沿为 05、06。 |
|||
|
|||
## 当前进度(详见各票,此处只给索引) |
|||
|
|||
- **01 resolved**:AGENTS.md「Coding standards」新增「新模块接口接入 checklist」(**新会话必读**,本图后续所有票的规范镜头依据)。 |
|||
- **02 resolved**:8 Controller 26 端点 + crm-rule 1 端点全部扁平动作动词化;workspace 进 `CustomerWorkspacePageParam` 字段;全仓唯一 `@RequestBody` 违规消灭。 |
|||
- **03 resolved**:board summary/cards 对 `pool` 拒 67001(`assertBoardAllowed` 守卫)+ 2 新用例。票内有完整「误翻案→回滚」弧线记录。 |
|||
|
|||
**测试基线**:crm-rule 146 / crm-customer 135 / crm-opportunity 242,绿为准(数量随用例演进)。 |
|||
|
|||
## 换机特有事项(本文档存在的理由) |
|||
|
|||
- **同步方式**:`.scratch/` 被 git 跟踪,但**本机尚有未提交改动**(crm-customer/crm-rule 源码 + .scratch)。切换前先 commit + push,新机 pull;勿只拷 `.scratch`。 |
|||
- **本机独有路径**:蓝湖原型缓存 `D:\code\crm-需求梳理\lanhu-mcp\data\axure_extract_bade4454_screenshots\`(`.txt` 为主)。新机若无此目录:关键证据截图已拷入本目录(`a4-2-1_客户总览列表_卡片视图_.png`、`a4-1-1_客户公海列表.png`);需整页文本时用 lanhu-bridge 重提取,OCR 兜底。 |
|||
- **bruno 产出仓** `E:\code\crm-api-docs`(07 号票)——盘符因机而异,跑 bruno-sync 前先确认产出路径。 |
|||
- **bruno-sync.local.json 不入库**(.gitignore)——07 号票跑全链前需按 skill 说明重建本机配置。 |
|||
- 环境:Windows + Git Bash + Maven。`mvn test -pl crm-customer,crm-rule -am` 为常用验收命令。 |
|||
|
|||
## 必读纪律(仓库内已有,勿另起) |
|||
|
|||
- `AGENTS.md`「新模块接口接入 checklist」(六条:扁平动词/深 tag/bruno 配置/表单绑定/对照时点)。 |
|||
- `AGENTS.md`「Tooling / harness」:edit 工具大块 CJK 走 temp 文件 + python heredoc(本图三张票全程用此模式);中继流断流=重试即可。 |
|||
- `AGENTS.md`「Source file encoding」:`.java` 禁 BOM,`mvn compile` 前扫一遍。 |
|||
|
|||
## 用户协作备注 |
|||
|
|||
- 中文交流;范围/口径以用户 HITL 拍板为准(图 R1–R11 为既定拍板,勿重开)。 |
|||
- 03 号票教训:用户的纠正若含范围歧义(如「原型上是有卡片视图的」未说哪页),**先定位到具体页面再动手**,不要外推;必要时一条问题问清。03 最终口径:卡片视图在总览/我的客户,公海没有。 |
|||
|
|||
## Suggested skills |
|||
|
|||
- **wayfinder**(主流程,Work through the map) |
|||
- **grilling + domain-modeling**(04/08 出现需决策项时;一次一问) |
|||
- **bruno-sync**(07 号票全链产出) |
|||
- **lanhu-bridge / ocr-image**(原型文本/截图,新机无缓存时) |
|||
- **code-review**(09 号票修复完成后、10 回归前的可选自检) |
|||
|
|||
## 验收速查 |
|||
|
|||
```bash |
|||
# 02/03 终检(零命中才算过) |
|||
grep -rn "PathVariable\|@PutMapping\|@DeleteMapping\|@RequestBody" crm-customer/ crm-rule/ # 期望空 |
|||
mvn test -pl crm-customer,crm-rule -am # 期望 BUILD SUCCESS |
|||
``` |
|||
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 456 KiB |
@ -0,0 +1,48 @@ |
|||
# 01 · AGENTS.md 沉淀「新模块接口接入 checklist」 |
|||
|
|||
- **Status:** resolved |
|||
- **Type:** task |
|||
- **Blocked by:** — |
|||
|
|||
## 目标 |
|||
|
|||
AGENTS.md 新增一节「新模块接口接入 checklist」,把本次返工暴露的规范偏差固化为 agent 必读纪律。 |
|||
|
|||
## checklist 内容(六条) |
|||
|
|||
1. 开发前先读对称参照模块(线索/商机)的 Controller 形态,不另起风格; |
|||
2. URL 风格 = 扁平动作动词 + 查询参数传 id,**禁 `@PathVariable` / `@PutMapping` / `@DeleteMapping`**,写端点一律 POST form,读端点 GET; |
|||
3. 新 Controller 必挂**深 tag**(完整页面路径,一级 = 原型分区原文如 `A4 客户管理`,以 `.scratch/api-docs-reorg/lanhu-tree-v29.md` 权威树为准,跨页共用接口用方法级多值 tags); |
|||
4. `bruno-sync.config.json`:sourceRoots 加新模块目录 + menuBindings 加菜单(viewType/scopeKey); |
|||
5. 写端点禁 `@RequestBody` JSON,参数走 ADR-0017 表单绑定;Param/DTO 规范同 ADR-0017; |
|||
6. 出票时、收工时各对照本 checklist 跑一遍。 |
|||
|
|||
## 验收 |
|||
|
|||
- 节落在 AGENTS.md「Coding standards」区,内链 ADR-0017/0024; |
|||
- 一句话带过本次踩坑案例(不单独开 ADR); |
|||
- 本图 08 号票(复核)以本节为规范镜头依据。 |
|||
|
|||
## 同步动作 |
|||
|
|||
`.scratch/customer-module/map.md` 增补一行指针(返工图与本 checklist 的出处)。 |
|||
## Answer |
|||
|
|||
AGENTS.md「Coding standards」区新增 **「新模块接口接入 checklist」** 一节(位于 JPA 命名策略节之后、Tooling/harness 之前),六条纪律: |
|||
|
|||
1. 动手前先读对称参照模块(线索/商机)的 Controller 形态,不另起风格; |
|||
2. URL = 扁平动作动词 + 查询参数传 id,禁 `@PathVariable` / `@PutMapping` / `@DeleteMapping`,写端点 POST、读端点 GET; |
|||
3. 新 Controller 必挂深 tag(一级 = 原型分区原文,权威树 `lanhu-tree-v29.md`,跨页共用走方法级多值 tags); |
|||
4. `bruno-sync.config.json` 补 `sourceRoots` + `menuBindings`; |
|||
5. 写端点禁 `@RequestBody`,表单绑定,Param/DTO 规范依 ADR-0017; |
|||
6. 出票时、收工时各对照跑一遍。 |
|||
|
|||
内链 [ADR-0017](docs/adr/0017-controller-io-param-dto-conventions.md) 与 [ADR-0024](docs/adr/0024-bruno-docs-grouping-by-menu.md);节首一句话记录踩坑案例(crm-customer 整模块 RESTful 偏航——规范存在但实现会话没读),未开新 ADR。 |
|||
|
|||
同步动作已完成:`.scratch/customer-module/map.md` 末尾增补「返工增补指针」段(返工图 + 本 checklist 出处)。 |
|||
|
|||
验收自检: |
|||
|
|||
- [x] 节落在 AGENTS.md「Coding standards」区,内链 ADR-0017/0024; |
|||
- [x] 踩坑案例一句话带过,未单开 ADR; |
|||
- [x] 08 号票(复核)规范镜头原文即以「AGENTS.md 新 checklist」为据,无需改动。 |
|||
@ -0,0 +1,64 @@ |
|||
# 02 · 端点风格全量统一(55 + 1 个端点) |
|||
|
|||
- **Status:** resolved |
|||
- **Type:** task |
|||
- **Blocked by:** 01-ag-checklist |
|||
|
|||
## 目标 |
|||
|
|||
crm-customer 全部 9 Controller 55 端点 + crm-rule `CustomerReminderRuleController` 1 端点,对齐 lead/opportunity 扁平动作动词风格。 |
|||
|
|||
## 改造规则 |
|||
|
|||
| 现状 | 改为 | |
|||
|---|---| |
|||
| `PUT /{id}`(客户/联系人编辑) | `POST /edit?id=` | |
|||
| `DELETE /{id}`(删联系人、删团队成员) | `POST /delete?id=`、`POST /member/remove?id=&memberUserId=` | |
|||
| `GET /{id}`、`/{id}/detail-head`、`/{id}/follow/page`、`/{taskId}/failures` 等全部 PathVariable | 查询参数:`/detail?id=`、`/detail-head?id=`、`/follow/page?id=`、`/failures?taskId=` | |
|||
| `POST /{workspace}/page`(workspace 路径变量) | `POST /page` + `workspace` 参数(对称商机 `viewType` 参数形态) | |
|||
| `POST /{workspace}/board/summary`、`/{workspace}/board/cards` | `POST /board/summary`、`POST /board/cards` + `workspace` 参数 | |
|||
| `POST /`(create 空路径) | `POST /create`、`POST /quick-create`(已有的保持) | |
|||
| `PUT /api/rule/customer/reminder` + `@RequestBody` JSON | `POST /save` + form 绑定(**消灭全仓唯一 ADR-0017 违规**) | |
|||
| `POST /{taskId}/confirm`、`/{id}/focus`、`/{id}/unfocus`、`/{id}/star`、`/{id}/unstar`、`/{id}/assign`、`/{id}/assignable` | 查询参数形态 | |
|||
|
|||
## 波及面 |
|||
|
|||
- controller 层 mapping + 方法签名(PathVariable → Param DTO 字段); |
|||
- 全部相关单测(crm-customer 122 条基线)URL/参数断言; |
|||
- 冒烟脚本 URL(10 号票重跑时用新 URL); |
|||
- 商机侧跨模块走 Java service(HANDOFF 3.6),无 HTTP 连锁(R11)。 |
|||
|
|||
## 验收 |
|||
|
|||
- `grep -r "PathVariable\|@PutMapping\|@DeleteMapping\|@RequestBody" crm-customer/ crm-rule/(客户部分)` 零命中; |
|||
- crm-customer + crm-rule `mvn test` 绿。 |
|||
|
|||
## 同步动作 |
|||
|
|||
`.scratch/customer-module/API-SUMMARY.md` 全端点 URL 表重写(收尾 11 号票统一校验)。 |
|||
## Answer |
|||
|
|||
**改造范围(8 Controller 26 端点重写 + 2 处注释同步)**: |
|||
|
|||
| Controller | 改造 | |
|||
|---|---| |
|||
| CustomerContactController | `POST ""`→`/create`;`PUT /{id}`→`POST /edit?id=`;`DELETE /{id}`→`POST /delete?id=`;`GET /{id}`→`GET /detail?id=` | |
|||
| CustomerController | `POST ""`→`/create`;`PUT /{id}`→`POST /edit?id=`;`GET /{id}`→`GET /detail?id=`;`GET /{customerId}/contacts`→`GET /contacts?customerId=` | |
|||
| CustomerDetailController | `/detail-head?id=`、`GET /member/list?id=`、`POST /member/add?id=`、`POST /member/remove?id=&memberUserId=`(原 DELETE)、`GET /opportunity/page?id=`、`GET /oplog/page?id=`、`POST /follow/add?id=`、`GET /follow/page?id=` | |
|||
| CustomerFocusController | `POST /focus|unfocus|star|unstar?id=`(去路径段) | |
|||
| CustomerImportController | `POST /confirm?taskId=`、`GET /result?taskId=`、`GET /failures?taskId=` | |
|||
| CustomerTransferController | `GET /detail?id=`、`POST /assign?id=`、`GET /assignable?id=` | |
|||
| CustomerWorkspaceController | 三端点去 `{workspace}` 路径段→`POST /page`、`POST /board/summary`、`POST /board/cards`,workspace 进 `CustomerWorkspacePageParam` 字段(对称商机 viewType-in-DTO 范式,服务签名不变) | |
|||
| CustomerReminderRuleController(crm-rule) | `PUT @RequestBody`→`POST /save` 表单绑定——全仓唯一 ADR-0017 违规消灭 | |
|||
|
|||
Ownership / Ping / 其余既平端点本就合规,未动。测试为 service 层集成测试(直调 service,无 URL 断言),服务签名未动,零波及。 |
|||
|
|||
**验收**: |
|||
|
|||
- [x] `grep -r "PathVariable\|@PutMapping\|@DeleteMapping\|@RequestBody" crm-customer/ crm-rule/` 零命中(含 target 二进制,重建后干净); |
|||
- [x] `mvn test` 绿:crm-rule 146(对齐基线)、crm-customer 133(基线 122,随用例演进)、上游 crm-preference 16,BUILD SUCCESS; |
|||
- [x] BOM 扫描:改动文件无 `EF BB BF`。 |
|||
|
|||
**同步动作**:`.scratch/customer-module/API-SUMMARY.md` 全端点 URL 表重写按票 02 原注留待 11 号票收尾统一校验(R1 随修随改 + 终验对账)。冒烟脚本新 URL 归 10 号票重跑时更换。 |
|||
|
|||
**边界备忘**:`workspace` 现为表单参数,缺省/非法值由服务层按未知 workspace 拒绝(原路径段缺失=404,现=业务错误码);`pool` 看板拒绝归 03 号票。 |
|||
@ -0,0 +1,38 @@ |
|||
# 03 · 公海看板后端拒绝 |
|||
|
|||
- **Status:** resolved |
|||
- **Type:** task |
|||
- **Blocked by:** 02-style-unify |
|||
|
|||
## 目标 |
|||
|
|||
看板端点对 `pool` workspace 后端直接拒绝(原型明文:客户公海只有列表视图和分屏视图两种,a4-1-1 L803)。 |
|||
|
|||
## 做法 |
|||
|
|||
- `board/summary` + `board/cards`(02 改造后形态)收到 `workspace=pool` → 抛 67001 口径(参数非法;或 67004 无权限,以客户模块既有错误码段位为准,实施时查 `CustomerErrorCode`); |
|||
- 补单测:pool 看板两端点拒绝断言; |
|||
- tags 只挂 `A4 客户管理/客户总览/看板列表` + `A4 客户管理/我的客户/看板列表`(07 号票落地)。 |
|||
|
|||
## 验收 |
|||
|
|||
- mvn test 绿(新增拒绝用例); |
|||
- bruno 文档不出现公海看板分组。 |
|||
## Answer |
|||
|
|||
**最终确认的事实(用户两轮澄清,2026-09-04)**:卡片视图存在于**客户总览/我的客户**(分组=客户阶段:潜在/重潜/已成交;客户星级一~五星;关系星级一~五星),**公海页面没有卡片视图**(只有列表/分屏两视图)——票原前提成立,v29 树「A4-1 客户公海 [2]」与缓存证据一致。 |
|||
|
|||
**实施**:`CustomerWorkspaceServiceImpl` 新增私有守卫 `assertBoardAllowed(workspace)`,`board/summary` 与 `board/cards` 入口第一行调用——`workspace=pool` 抛 `BusinessErrorException(67001, "公海无卡片视图,看板仅支持客户总览/我的客户")`。守卫先于 buildQuery/登录态/SQL,服务层直接拒绝。 |
|||
|
|||
**测试**:`CustomerWorkspaceOwnershipIntegrationTest` 新增 2 用例(pool summary / pool cards → 67001 断言),该类 17 绿;crm-customer 135 全绿(133+2)、crm-rule 146 绿,BUILD SUCCESS。 |
|||
|
|||
**证据归档**(截图拷入本目录): |
|||
|
|||
- `a4-2-1_客户总览列表_卡片视图_.png`——卡片视图分组规则佐证(与用户描述逐项一致,L873「看板分组……沿用【我的客户】」); |
|||
- `a4-1-1_客户公海列表.png`——v29 期公海整页(列表/分屏证据)。 |
|||
|
|||
**引文勘误(仍有效)**:图表会话原引「a4-1-1 L803」在缓存中不存在(该 txt 全文 341 行);结论最终以用户对照活原型拍板为准。 |
|||
|
|||
**记账回滚记录**:本票曾因会话误读用户第一次澄清而错误翻案(map 曾增 R12、07 曾加公海看板 tag、10 曾翻冒烟断言),已全部撤销还原;07 维持「无公海」、10 维持「公海看板断言拒绝」。 |
|||
|
|||
验收:mvn test 绿 ✅;bruno 无公海看板分组 ✅(07 按「无公海」挂 tag 后自然满足)。 |
|||
@ -0,0 +1,26 @@ |
|||
# 04 · 已知缺口:查重设置页端点 + QuickCreate.confirmSimilar |
|||
|
|||
- **Status:** open |
|||
- **Type:** task |
|||
- **Blocked by:** 02-style-unify |
|||
|
|||
## 目标 |
|||
|
|||
两个规格内小缺口补齐(R8)。 |
|||
|
|||
## A. 查重设置页端点(a7-3-3-2 前端页面无接口支撑) |
|||
|
|||
- crm-rule 新增 `GET /api/rule/customer/dedup`(读当前配置)+ `POST /api/rule/customer/dedup/save`(表单绑定); |
|||
- 配置实体复用既有 DedupRule 单例(票09 已做服务端种子),对称 `CustomerReminderRuleController` 改造后形态; |
|||
- 权限:客户管理设置菜单权限点(与提醒规则同款); |
|||
- tag:`A7 后台管理/客户管理设置/查重设置`。 |
|||
|
|||
## B. QuickCreateDTO 加 confirmSimilar |
|||
|
|||
- 商机弹窗快建客户命中相似客户时"仍要创建"透传字段(D3 已有 confirmPhone 同款形态); |
|||
- `CustomerQuickCreateParam` 加 `confirmSimilar: Boolean`(默认 false),命中相似且未确认 → 仍抛 670xx 提示码(沿用既有 check-name 口径)。 |
|||
|
|||
## 验收 |
|||
|
|||
- 两模块 mvn test 绿,新端点有单测; |
|||
- API-SUMMARY §5 已知缺口清单这两条销账。 |
|||
@ -0,0 +1,34 @@ |
|||
# 05 · 平台形态偏好:user_view_form 表 + 端点,客户/商机双接入 |
|||
|
|||
- **Status:** open |
|||
- **Type:** task |
|||
- **Blocked by:** 01-ag-checklist |
|||
|
|||
## 目标 |
|||
|
|||
crm-preference 新增「形态偏好」平台能力(R6),客户三页签 + 联系人 + 商机三菜单接入(R7)。用户上次选择的视图形态(list/split/board)跨会话记忆。 |
|||
|
|||
## 平台侧(crm-preference) |
|||
|
|||
- 实体 `UserViewForm`:表 `user_view_form`,`UNIQUE(user_id, scope_key)`,`view_form varchar(16)`,无 create_by/update_by(同 UserColumnPreference 范式:author 恒为 user_id); |
|||
- `ViewFormController`: |
|||
- `GET /api/preference/view-form/get?scopeKey=` → `Result<String>`(null = 未设置); |
|||
- `POST /api/preference/view-form/save`(form 绑定,**禁 @RequestBody**); |
|||
- 值域白名单 `list` / `split` / `board`,非法值 67001 口径拒绝; |
|||
- tags 双归属(方法级多值):客户侧 `A4 客户管理/客户总览`、`A4 客户管理/我的客户`、`A4 客户管理/客户公海`、`A4 客户管理/联系人`;商机侧 `A3 商机管理/销售机会`、`A3 商机管理/商机管理`、`A3 商机管理/商机公海`。 |
|||
|
|||
## 接入侧(零端点,只加常量,参考线索列偏好范式) |
|||
|
|||
- crm-customer 常量:`VIEW_FORM_SCOPE_OVERVIEW/MINE/POOL/CONTACT` = `customer.overview/mine/pool`、`contact.list`; |
|||
- crm-opportunity 常量:`opportunity.sales/manage/public_pool`(与 saved-view `opportunity.sales` 对齐); |
|||
- 前端调平台端点读写,未设置 → 默认 list。 |
|||
|
|||
## 验收 |
|||
|
|||
- crm-preference + 两接入模块 mvn test 绿; |
|||
- 单测覆盖:存取往返、白名单拒绝、UNIQUE 覆盖更新; |
|||
- knife4j 分组出现双归属页面。 |
|||
|
|||
## 同步动作 |
|||
|
|||
`.scratch/customer-module/map.md` 增补决策条目(形态偏好平台化);crm-preference CONTEXT 若有"形态偏好规划"段落则补实现指针。 |
|||
@ -0,0 +1,25 @@ |
|||
# 06 · 列偏好 scopeKey:客户 4 个 + 商机 3 个 + 平台 tags 双归属 |
|||
|
|||
- **Status:** open |
|||
- **Type:** task |
|||
- **Blocked by:** 01-ag-checklist |
|||
|
|||
## 目标 |
|||
|
|||
字段设置(显隐列+顺序)后端接线,完全复刻线索模块范式(R5/R7):业务方只加 scopeKey 常量,存储走既有 `/api/preference/get|save` + `user_column_preference` 表,零新端点。 |
|||
|
|||
## 做法 |
|||
|
|||
- crm-customer 常量(LeadConstants 同款注释块):`COLUMN_SCOPE_OVERVIEW/MINE/POOL/CONTACT` = `customer.overview/mine/pool`、`contact.list`; |
|||
- crm-opportunity 常量:`opportunity.sales/manage/public_pool`; |
|||
- `ColumnPreferenceController`(crm-preference)方法级 tags 补双归属: |
|||
- 现状:仅 A2 线索四页; |
|||
- 补:A4 客户四页(总览/我的客户/公海/联系人)+ A3 商机三页(销售机会/商机管理/商机公海); |
|||
- 固定列校验(客户名称不可隐藏、勾选/序号/操作列不参与、重置默认)归**前端**,后端只存取(平台侧不校验,与线索一致——R5 拍板); |
|||
- 注:lead 侧常量标注"预留,尚未接线"维持现状,本票不动线索。 |
|||
|
|||
## 验收 |
|||
|
|||
- 三模块 mvn test 绿; |
|||
- knife4j 上 `/api/preference/get|save` 出现在 A2/A3/A4 全部相关页面分组; |
|||
- 常量注释引用 crm-preference/CONTEXT.md「scope」口径(同 LeadConstants L46-50 写法)。 |
|||
@ -0,0 +1,38 @@ |
|||
# 07 · 深 tag 重挂 + bruno-sync 全链接通 |
|||
|
|||
- **Status:** open |
|||
- **Type:** task |
|||
- **Blocked by:** 02-style-unify, 05-view-form-platform, 06-column-pref-scopes |
|||
|
|||
## 目标 |
|||
|
|||
全部深 tag 对照 v29 原型树重挂;bruno-sync config 补客户模块;实际跑 `/bruno-sync` 产出(R4)。 |
|||
|
|||
## tag 重挂清单(对照 lanhu-tree-v29.md A4/A7 段) |
|||
|
|||
| Controller(改造后) | 新 tag | |
|||
|---|---| |
|||
| CustomerWorkspaceController `/page` | 方法级多值:`A4 客户管理/客户总览` + `A4 客户管理/我的客户` + `A4 客户管理/客户公海` | |
|||
| `/board/*` | 多值:`A4 客户管理/客户总览/看板列表` + `A4 客户管理/我的客户/看板列表`(**无公海**——用户 2026-09-04 复核原型确认) | |
|||
| CustomerController(create/edit/detail/search/quick-create/check-*) | `A4 客户管理/我的客户`(新增/编辑归属);`check-name` 等查重入口挂 `A4 客户管理/客户查重`——实施时按页面实际调用归属逐个定 | |
|||
| CustomerDetailController | `A4 客户管理/客户详情`(对称商机 `A3 商机管理/商机详情/主体` 形态,子级可用页签名) | |
|||
| CustomerContactController | `A4 客户管理/联系人` | |
|||
| CustomerFocusController | 多值:挂实际调用页面(总览/我的客户列表操作) | |
|||
| CustomerOwnershipController(claim/assign/release-pool/archive) | 多值:`客户总览`+`我的客户`+`客户公海` | |
|||
| CustomerTransferController | `A4 客户管理/客户交割`(A4-7-1~4 四页) | |
|||
| CustomerImportController | `A4 客户管理/我的客户/客户导入`(A4-3-2) | |
|||
| CustomerPingController `/ping` | **不挂 tag**(无页面入口,ADR-0024 纪律) | |
|||
| crm-rule CustomerReminderRuleController | `A7 后台管理/客户管理设置/超期未跟进提醒`(A7-3-3-1) | |
|||
| crm-rule 查重设置(04 号票新端点) | `A7 后台管理/客户管理设置/查重设置` | |
|||
| 平台端点(05/06 号票) | 已在各自票内双归属 | |
|||
|
|||
## config 与产出 |
|||
|
|||
- `bruno-sync.config.json`:sourceRoots += `crm-customer`;menuBindings 补 A4 五菜单 + A7 客户管理设置(viewType=workspace/board,scopeKey 用 05/06 定义的常量值); |
|||
- 跑 `/bruno-sync`(skill:`C:\Users\luowj\.pi\agent\skills\bruno-sync\SKILL.md`),产出落 `E:\code\crm-api-docs`; |
|||
- 验收:A4 目录树与 v29 树逐节点对齐;knife4j 分组同构;无编号式 tag(`A4-5 联系人` 形态绝迹)。 |
|||
|
|||
## 验收 |
|||
|
|||
- `grep -rn "@Tag\|tags =" crm-customer/` 无扁平/编号式 tag 残留; |
|||
- bruno collection 里客户模块目录树 = 原型树 A4 段。 |
|||
@ -0,0 +1,45 @@ |
|||
# 08 · 双镜头全量复核(规范 + 原型) |
|||
|
|||
- **Status:** open |
|||
- **Type:** task |
|||
- **Blocked by:** 02-style-unify, 03-board-pool-close, 04-gap-fixes, 05-view-form-platform, 06-column-pref-scopes, 07-tags-bruno |
|||
|
|||
## 目标 |
|||
|
|||
用户判断"还有很多问题"——已知修复完成后,用双镜头过全量,产出差异清单回填 09 号票(R9)。 |
|||
|
|||
## 镜头一:规范镜头(代码仓扫描) |
|||
|
|||
以 AGENTS.md 新 checklist + ADR-0017/0024/0023 + `.scratch/controller-io-conventions/spec.md` 为准,逐端点核对: |
|||
|
|||
- [ ] URL 风格(02 号票改造后无残留) |
|||
- [ ] 深 tag 与 v29 树逐节点对齐 |
|||
- [ ] Param/DTO 规范(禁 Entity 出入参、禁 @RequestBody、表单绑定) |
|||
- [ ] 分页规范(`current/size` → `PageResult{content,total}`) |
|||
- [ ] 错误码段位(客户模块错误码是否与既有段位表冲突) |
|||
- [ ] bruno config envelope(Result 包装、Long 字符串化) |
|||
|
|||
## 镜头二:原型镜头(50+2 页逐页) |
|||
|
|||
A4 全部页面(a4-1-1/1-2、a4-2-1 三视图、a4-3-1 三视图、a4-3-2 导入、a4-3-3 快建、a4-4-1~4-10 详情族、a4-5-1~5-6 联系人族、a4-7-1~7-4 交割族)+ A7-3-3-1/2 客户规则两页,每页三列核对表: |
|||
|
|||
| 列 | 内容 | |
|||
|---|---| |
|||
| 接口存在性 | 页面每个可交互元素 → 端点(缺失标红) | |
|||
| 语义正确 | 端点行为与原型规则段一致(如分屏右侧动作集、看板分组规则、字段设置规则) | |
|||
| 风格合规 | 映射到镜头一的规范项 | |
|||
|
|||
范围外页面(A4-6 查重/合并、a4-3-3-3/4 OCR、导出)只登记不审。 |
|||
|
|||
`.txt` 为主,歧义页 OCR 兜底(AGENTS.md 阅读策略)。 |
|||
|
|||
## 产出 |
|||
|
|||
`.scratch/customer-rework/audit-findings.md`:差异清单(按"能机械修 / 需决策"分组),回填 09 号票范围;需决策项若涉及规格翻案则单开票再 grill。 |
|||
|
|||
## 已知种子(复核时优先验证) |
|||
|
|||
- 分屏视角:公海普通销售裁剪是否真的只靠前端权限点(R5 口径的自洽性); |
|||
- 交割四页端点覆盖度(transfer/preview/initiate/assign/page/assignable 是否够 A4-7-1~4); |
|||
- 详情页签族(a4-4-2~4-8)端点语义 vs `detail-head` 聚合返回结构; |
|||
- 联系人详情页 a4-5-5/5-6(操作日志页签)端点覆盖。 |
|||
@ -0,0 +1,19 @@ |
|||
# 09 · 复核差异清单修复(内容待 08 回填) |
|||
|
|||
- **Status:** open |
|||
- **Type:** task |
|||
- **Blocked by:** 08-audit-prototype |
|||
|
|||
## 目标 |
|||
|
|||
修复 08 号票产出的 `audit-findings.md` 差异清单中"能机械修"的部分;"需决策"项单开票走 grilling。 |
|||
|
|||
## 范围 |
|||
|
|||
- 待 08 产出后回填(本票为占位票,收编差异清单的全部修复动作); |
|||
- 每修一项:对应单测/集成测试同步补; |
|||
- 修复波及 `.scratch/customer-module` 文档的,随修随改(11 号票统一校验)。 |
|||
|
|||
## 验收 |
|||
|
|||
- 差异清单全部条目状态为"已修 / 已单开票 / 明确不修(记理由)",无悬空项。 |
|||
@ -0,0 +1,17 @@ |
|||
# 10 · 回归重测(验收线) |
|||
|
|||
- **Status:** open |
|||
- **Type:** task |
|||
- **Blocked by:** 09-audit-fixes |
|||
|
|||
## 验收线(R10,逐项过) |
|||
|
|||
- [ ] `mvn test`:crm-rule + crm-customer + crm-opportunity 三模块全绿(基线 521:146/122/242,风格改造后数量随用例变化,绿为准); |
|||
- [ ] 冒烟脚本重跑全链 code=0:复用 `tools/smoke-t12m.py` 范式,URL 换 02 号票改造后的新端点,覆盖:登录 → 客户三 workspace 列表/看板(公海看板断言拒绝——用户复核原型确认公海无卡片视图)→ 建改查客户 → 联系人 CRUD → 认领/分配/抛公海 → 交割 → 导入全链 → 偏好存取(列 + 形态)→ 商机偏好存取; |
|||
- [ ] bruno 产出验收:`E:\code\crm-api-docs` A4 目录树对齐 v29 树,knife4j 分组同构(07 号票已建,此处终验); |
|||
- [ ] BOM 扫描:全仓 `*.java` 无 `EF BB BF` 前缀(AGENTS.md 编码纪律); |
|||
- [ ] `grep -r "PathVariable\|@PutMapping\|@DeleteMapping\|@RequestBody" crm-customer/` 零命中终验。 |
|||
|
|||
## 产出 |
|||
|
|||
`.scratch/customer-rework/RETEST-REPORT.md`:各验收项结果 + 用例计数。 |
|||
@ -0,0 +1,21 @@ |
|||
# 11 · `.scratch/customer-module` 文档同步收尾 |
|||
|
|||
- **Status:** open |
|||
- **Type:** task |
|||
- **Blocked by:** 09-audit-fixes |
|||
|
|||
## 目标 |
|||
|
|||
R1 要求的文档同步,收尾统一校验(各票随修随改,本票终验)。 |
|||
|
|||
## 清单 |
|||
|
|||
- [ ] `map.md`(冻结图):末尾增补「返工增补」段——R1–R11 决策指针(→ `.scratch/customer-rework/map.md`)+ 每张返工票执行结果的增量决策条目; |
|||
- [ ] `API-SUMMARY.md`:全部端点 URL/方法/参数表按 02 号票改造后形态重写;§5 已知缺口清单销账(confirmSimilar、查重设置端点);新增:形态偏好端点、列偏好 scopeKey、查重设置端点; |
|||
- [ ] `HANDOFF-*.md`:新篇(日期=收工日)记录返工范围/决策/测试结果,链接 RETEST-REPORT; |
|||
- [ ] `crm-customer/CONTEXT.md`(若存在接口清单段落):同步新 URL 契约 + 偏好能力说明; |
|||
- [ ] 交叉检查:文档中的 URL 与代码 `@*Mapping` 逐一对得上(脚本 grep 对账)。 |
|||
|
|||
## 验收 |
|||
|
|||
文档-代码对账零差异;旧 HANDOFF 不改写(历史只增)。 |
|||
@ -0,0 +1,67 @@ |
|||
# 客户模块验收返工 · map |
|||
|
|||
- **Status:** charted |
|||
- **Destination:** crm-customer(含 crm-rule 客户部分、crm-preference 平台扩展、crm-opportunity 偏好接线)接口风格/文档规范/三形态偏好全面对齐项目规范与蓝湖原型;已知缺口修复;双镜头全量复核;重测通过;规范沉淀进 AGENTS.md;`.scratch/customer-module` 文档同步。**执行含在图内(用户覆写 plan-only 默认)**。 |
|||
- **上游图:** `.scratch/customer-module/map.md`(spec 图 D1–D29 冻结,仅增补;实施结论见 `HANDOFF-2026-09-03.md`) |
|||
- **Charted:** 2026-09-05(7 轮 grilling,用户逐项拍板) |
|||
|
|||
## 决策(R1–R11) |
|||
|
|||
| # | 决策 | 依据 | |
|||
|---|---|---| |
|||
| R1 | 返工图独立目录 `customer-rework`,执行含图内:票直接改代码 + 同步更新 `.scratch/customer-module`(map 增补决策条目 + API-SUMMARY 重写 + HANDOFF 新篇)+ 重测 | 用户:改的同时同步更新、检查重测 | |
|||
| R2 | 规范沉淀 = AGENTS.md 新增「新模块接口接入 checklist」一节,内链 ADR-0017/0024 作依据;不开新 ADR | 病灶是"规范存在但实现会话没读",checklist 必须挂在 agent 必读处 | |
|||
| R3 | 接口风格全量统一:crm-customer 全部端点 + crm-rule 客户提醒端点 → 扁平动作动词(参照 lead/opportunity:零 PathVariable、零 PUT/DELETE、写全 POST form、读 GET 查询参数);`PUT /api/rule/customer/reminder` 的 `@RequestBody` 一并消灭(全仓唯一 ADR-0017 违规) | lead+opportunity 全仓 grep 实证;项目风格本就非严格 RESTful;模块未联调是零成本窗口 | |
|||
| R4 | bruno-sync 全链:config `sourceRoots` 加 crm-customer;menuBindings 补 A4 五菜单(公海/总览/我的客户/交割/联系人)+ A7 客户规则;全部 controller 深 tag 对照 v29 原型树重挂(一级=`A4 客户管理` 原文,非 `A4-2` 编号式);`CustomerPingController` 保留端点不挂页签 | ADR-0024 修订纪律;`lanhu-tree-v29.md` 权威树 | |
|||
| R5 | 三形态后端边界:列偏好(字段设置)接平台 `/api/preference/get\|save`,客户 4 个 scopeKey(`customer.overview/mine/pool` + `contact.list`),固定列校验归前端(与线索一致);分屏右侧复用既有详情聚合接口,公海"普通销售视角"= 前端按权限点裁剪页签,后端不加裁剪专用端点;看板对 `pool` 后端直接拒绝(67001/67004 口径),tags 只挂总览+我的客户 | 原型 a4-2-1/a4-3-1 三视图规则段;公海无看板(a4-1-1 L803);商机范式 | |
|||
| R6 | 形态偏好(list/split/board 持久化)= crm-preference 新建 `user_view_form` 表(UNIQUE(user_id, scope_key),view_form 枚举白名单)+ `ViewFormController`(`GET /api/preference/view-form/get` + `POST /api/preference/view-form/save`,form 绑定);未设置→前端默认 list | 用户翻案:需求要存用户上次偏好,后端需要处理;方案 A | |
|||
| R7 | 商机模块偏好一并开发(参考线索):列偏好 3 个 scopeKey(`opportunity.sales/manage/public_pool`,与 saved-view `opportunity.sales` 对齐)+ 形态偏好接入新平台端点;商机侧零新端点,只加常量 + 平台 tags 双归属 | 用户:加上商机的偏好,参考线索模块 | |
|||
| R8 | 已知缺口处置:**补** 查重设置页端点(`GET/POST /api/rule/customer/dedup`,风格统一后形态)+ QuickCreateDTO 加 `confirmSimilar` 透传;**修** 公海看板越范围(见 R5);**维持划出** 客户导出、联系人导入(范围外记档,不翻案) | 用户拍板 Q6 | |
|||
| R9 | 全量复核 = 双镜头:① 规范镜头(AGENTS.md 新 checklist + ADR-0017/0024/0023 扫全部端点:风格/tags/Param-DTO/分页规范/错误码段位);② 原型镜头(A4 全部 50 页 + A7-3-3 两页,三列表:接口存在性/语义正确/风格合规)。差异清单归并成票 | 用户:"我相信还有很多问题" | |
|||
| R10 | 验收线:三模块(crm-rule/crm-customer/crm-opportunity)`mvn test` 全绿;冒烟脚本重跑全链 code=0(复用 smoke 范式,改新 URL);bruno-sync 产出落 `E:\code\crm-api-docs` 且 A4 目录树对齐 v29 树、knife4j 分组同构;BOM 扫描 clean;`.scratch/customer-module` 文档同步完成 | 用户拍板 Q7 | |
|||
| R11 | 跨模块依赖走 Java service(商机→客户),HTTP 契约变更无连锁;前端尚未联调客户模块,URL 重塑零外部成本 | HANDOFF 3.6 实证 | |
|||
|
|||
## Decisions so far |
|||
|
|||
- [01 · AGENTS.md 沉淀「新模块接口接入 checklist」](issues/01-ag-checklist.md) — checklist 已落 AGENTS.md「Coding standards」区(六条 + 内链 ADR-0017/0024 + 踩坑一句话);`.scratch/customer-module/map.md` 已增补出处指针。 |
|||
|
|||
- [02 · 端点风格全量统一(55 + 1 个端点)](issues/02-style-unify.md) — 8 Controller 26 端点重写为扁平动作动词+查询参数(workspace 进 Param DTO);crm-rule 提醒规则 PUT+@RequestBody→POST /save 表单绑定,全仓唯一 ADR-0017 违规消灭;验收 grep 零命中 + mvn test 绿(rule 146 / customer 133)。 |
|||
|
|||
|
|||
- [03 · 公海看板后端拒绝](issues/03-board-pool-close.md) — 已实施:board summary/cards 对 pool 拒 67001(用户两轮澄清定案:卡片视图在总览/我的客户,公海无);+2 用例,customer 135 绿。中途一次误翻案已全额回滚(详见票内记账回滚记录)。 |
|||
|
|||
## 范围外(Out of scope) |
|||
|
|||
- 客户导出(原型有规则,维持 D2 划出,涉及脱敏口径,量级独立) |
|||
- 联系人导入(维持 D5 划出,随图谱 effort) |
|||
- 客户查重/合并(A4-6,维持划出) |
|||
- OCR 快捷新增(a4-3-3-3/4,维持划出) |
|||
- 敏感信息三档脱敏/查询日志(维持划出) |
|||
- 线索模块列偏好实际接线(scopeKey 常量已有,"预留未接线"维持现状) |
|||
|
|||
## 票与 blocking |
|||
|
|||
``` |
|||
01 ag-checklist ──┬──> 02 style-unify ──┬──> 03 board-pool-close ──┐ |
|||
│ └──> 04 gap-fixes ─────────┤ |
|||
│ ├──> 08 audit ──> 09 audit-fixes ──┬──> 10 regression |
|||
│ 05 view-form-platform ──┬──> 07 tags-bruno ──┤ │ |
|||
│ 06 column-pref-scopes ──┘ │ │ |
|||
└───────────────────────────────────────────────┘ 11 docs-sync <────┘ |
|||
``` |
|||
|
|||
- 01 是全部票的前置(checklist 是复核依据) |
|||
- 07(tags+bruno 产出)依赖 02(URL 定型)+ 05/06(scopeKey/viewType 定型才能写 menuBindings) |
|||
- 08(复核)依赖 02–07 全部已知修复完成后再审,避免复审已知问题 |
|||
- 09 内容待 08 产出差异清单后回填 |
|||
- 10/11 收尾并行 |
|||
|
|||
## 参考索引 |
|||
|
|||
- 原型树权威:`.scratch/api-docs-reorg/lanhu-tree-v29.md` A4/A7 段 |
|||
- 原型缓存:`D:\code\crm-需求梳理\lanhu-mcp\data\axure_extract_bade4454_screenshots\`(`.txt` 为主,OCR 兜底) |
|||
- 风格参照:`crm-lead/.../controller/LeadController.java`、`crm-opportunity/.../controller/*.java` |
|||
- 平台参照:`crm-preference/.../ColumnPreferenceController.java`、`SavedViewController.java`、`UserColumnPreference.java`(表范式) |
|||
- 规范:`docs/adr/0017-*.md`、`docs/adr/0024-*.md`;`.scratch/controller-io-conventions/spec.md` |
|||
- 旧图冻结文档:`.scratch/customer-module/{map.md, API-SUMMARY.md, HANDOFF-2026-09-03.md}` |
|||
- bruno-sync skill:`C:\Users\luowj\.pi\agent\skills\bruno-sync\SKILL.md`;产出仓 `E:\code\crm-api-docs` |
|||
@ -0,0 +1,57 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<classpath> |
|||
<classpathentry kind="src" output="target/classes" path="src/main/java"> |
|||
<attributes> |
|||
<attribute name="optional" value="true"/> |
|||
<attribute name="maven.pomderived" value="true"/> |
|||
</attributes> |
|||
</classpathentry> |
|||
<classpathentry excluding="**" kind="src" output="target/classes" path="src/main/resources"> |
|||
<attributes> |
|||
<attribute name="maven.pomderived" value="true"/> |
|||
<attribute name="optional" value="true"/> |
|||
</attributes> |
|||
</classpathentry> |
|||
<classpathentry kind="src" output="target/test-classes" path="src/test/java"> |
|||
<attributes> |
|||
<attribute name="optional" value="true"/> |
|||
<attribute name="maven.pomderived" value="true"/> |
|||
<attribute name="test" value="true"/> |
|||
</attributes> |
|||
</classpathentry> |
|||
<classpathentry excluding="**" kind="src" output="target/test-classes" path="src/test/resources"> |
|||
<attributes> |
|||
<attribute name="maven.pomderived" value="true"/> |
|||
<attribute name="test" value="true"/> |
|||
<attribute name="optional" value="true"/> |
|||
</attributes> |
|||
</classpathentry> |
|||
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-17"> |
|||
<attributes> |
|||
<attribute name="maven.pomderived" value="true"/> |
|||
</attributes> |
|||
</classpathentry> |
|||
<classpathentry kind="con" path="org.eclipse.m2e.MAVEN2_CLASSPATH_CONTAINER"> |
|||
<attributes> |
|||
<attribute name="maven.pomderived" value="true"/> |
|||
</attributes> |
|||
</classpathentry> |
|||
<classpathentry kind="src" path="target/generated-sources/annotations"> |
|||
<attributes> |
|||
<attribute name="optional" value="true"/> |
|||
<attribute name="maven.pomderived" value="true"/> |
|||
<attribute name="ignore_optional_problems" value="true"/> |
|||
<attribute name="m2e-apt" value="true"/> |
|||
</attributes> |
|||
</classpathentry> |
|||
<classpathentry kind="src" output="target/test-classes" path="target/generated-test-sources/test-annotations"> |
|||
<attributes> |
|||
<attribute name="optional" value="true"/> |
|||
<attribute name="maven.pomderived" value="true"/> |
|||
<attribute name="ignore_optional_problems" value="true"/> |
|||
<attribute name="m2e-apt" value="true"/> |
|||
<attribute name="test" value="true"/> |
|||
</attributes> |
|||
</classpathentry> |
|||
<classpathentry kind="output" path="target/classes"/> |
|||
</classpath> |
|||
@ -0,0 +1,157 @@ |
|||
<factorypath> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/boot/spring-boot-starter-web/3.3.5/spring-boot-starter-web-3.3.5.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/boot/spring-boot-starter-json/3.3.5/spring-boot-starter-json-3.3.5.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/fasterxml/jackson/datatype/jackson-datatype-jdk8/2.17.2/jackson-datatype-jdk8-2.17.2.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/fasterxml/jackson/datatype/jackson-datatype-jsr310/2.17.2/jackson-datatype-jsr310-2.17.2.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/fasterxml/jackson/module/jackson-module-parameter-names/2.17.2/jackson-module-parameter-names-2.17.2.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/boot/spring-boot-starter-tomcat/3.3.5/spring-boot-starter-tomcat-3.3.5.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/tomcat/embed/tomcat-embed-core/10.1.31/tomcat-embed-core-10.1.31.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/tomcat/embed/tomcat-embed-websocket/10.1.31/tomcat-embed-websocket-10.1.31.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-web/6.1.14/spring-web-6.1.14.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-webmvc/6.1.14/spring-webmvc-6.1.14.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/boot/spring-boot-starter-validation/3.3.5/spring-boot-starter-validation-3.3.5.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/tomcat/embed/tomcat-embed-el/10.1.31/tomcat-embed-el-10.1.31.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/hibernate/validator/hibernate-validator/8.0.1.Final/hibernate-validator-8.0.1.Final.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/jboss/logging/jboss-logging/3.5.3.Final/jboss-logging-3.5.3.Final.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/fasterxml/classmate/1.7.0/classmate-1.7.0.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/baomidou/mybatis-plus-spring-boot3-starter/3.5.7/mybatis-plus-spring-boot3-starter-3.5.7.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/baomidou/mybatis-plus/3.5.7/mybatis-plus-3.5.7.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/baomidou/mybatis-plus-core/3.5.7/mybatis-plus-core-3.5.7.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/baomidou/mybatis-plus-annotation/3.5.7/mybatis-plus-annotation-3.5.7.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/baomidou/mybatis-plus-extension/3.5.7/mybatis-plus-extension-3.5.7.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/mybatis/mybatis/3.5.16/mybatis-3.5.16.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/github/jsqlparser/jsqlparser/4.9/jsqlparser-4.9.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/baomidou/mybatis-plus-spring-boot-autoconfigure/3.5.7/mybatis-plus-spring-boot-autoconfigure-3.5.7.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/boot/spring-boot-autoconfigure/3.3.5/spring-boot-autoconfigure-3.3.5.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/boot/spring-boot-starter-jdbc/3.3.5/spring-boot-starter-jdbc-3.3.5.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/zaxxer/HikariCP/5.1.0/HikariCP-5.1.0.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-jdbc/6.1.14/spring-jdbc-6.1.14.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/boot/spring-boot-starter-data-jpa/3.3.5/spring-boot-starter-data-jpa-3.3.5.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/boot/spring-boot-starter-aop/3.3.5/spring-boot-starter-aop-3.3.5.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/aspectj/aspectjweaver/1.9.22.1/aspectjweaver-1.9.22.1.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/hibernate/orm/hibernate-core/6.5.3.Final/hibernate-core-6.5.3.Final.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/jakarta/persistence/jakarta.persistence-api/3.1.0/jakarta.persistence-api-3.1.0.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/jakarta/transaction/jakarta.transaction-api/2.0.1/jakarta.transaction-api-2.0.1.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/hibernate/common/hibernate-commons-annotations/6.0.6.Final/hibernate-commons-annotations-6.0.6.Final.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/io/smallrye/jandex/3.1.2/jandex-3.1.2.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/glassfish/jaxb/jaxb-runtime/4.0.5/jaxb-runtime-4.0.5.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/glassfish/jaxb/jaxb-core/4.0.5/jaxb-core-4.0.5.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/eclipse/angus/angus-activation/2.0.2/angus-activation-2.0.2.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/glassfish/jaxb/txw2/4.0.5/txw2-4.0.5.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/sun/istack/istack-commons-runtime/4.1.2/istack-commons-runtime-4.1.2.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/jakarta/inject/jakarta.inject-api/2.0.1/jakarta.inject-api-2.0.1.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/antlr/antlr4-runtime/4.13.0/antlr4-runtime-4.13.0.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/data/spring-data-jpa/3.3.5/spring-data-jpa-3.3.5.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/data/spring-data-commons/3.3.5/spring-data-commons-3.3.5.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-orm/6.1.14/spring-orm-6.1.14.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-tx/6.1.14/spring-tx-6.1.14.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-aspects/6.1.14/spring-aspects-6.1.14.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/boot/spring-boot-starter-data-redis/3.3.5/spring-boot-starter-data-redis-3.3.5.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/io/lettuce/lettuce-core/6.3.2.RELEASE/lettuce-core-6.3.2.RELEASE.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/io/netty/netty-common/4.1.114.Final/netty-common-4.1.114.Final.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/io/netty/netty-handler/4.1.114.Final/netty-handler-4.1.114.Final.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/io/netty/netty-resolver/4.1.114.Final/netty-resolver-4.1.114.Final.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/io/netty/netty-buffer/4.1.114.Final/netty-buffer-4.1.114.Final.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/io/netty/netty-transport-native-unix-common/4.1.114.Final/netty-transport-native-unix-common-4.1.114.Final.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/io/netty/netty-codec/4.1.114.Final/netty-codec-4.1.114.Final.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/io/netty/netty-transport/4.1.114.Final/netty-transport-4.1.114.Final.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/io/projectreactor/reactor-core/3.6.11/reactor-core-3.6.11.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/reactivestreams/reactive-streams/1.0.4/reactive-streams-1.0.4.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/data/spring-data-redis/3.3.5/spring-data-redis-3.3.5.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/data/spring-data-keyvalue/3.3.5/spring-data-keyvalue-3.3.5.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-oxm/6.1.14/spring-oxm-6.1.14.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-context-support/6.1.14/spring-context-support-6.1.14.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/security/spring-security-core/6.3.4/spring-security-core-6.3.4.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/security/spring-security-crypto/6.3.4/spring-security-crypto-6.3.4.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-aop/6.1.14/spring-aop-6.1.14.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-beans/6.1.14/spring-beans-6.1.14.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-context/6.1.14/spring-context-6.1.14.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-expression/6.1.14/spring-expression-6.1.14.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/io/micrometer/micrometer-observation/1.13.6/micrometer-observation-1.13.6.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/io/micrometer/micrometer-commons/1.13.6/micrometer-commons-1.13.6.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/github/xiaoymin/knife4j-openapi3-jakarta-spring-boot-starter/4.5.0/knife4j-openapi3-jakarta-spring-boot-starter-4.5.0.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/github/xiaoymin/knife4j-core/4.5.0/knife4j-core-4.5.0.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/github/xiaoymin/knife4j-openapi3-ui/4.5.0/knife4j-openapi3-ui-4.5.0.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springdoc/springdoc-openapi-starter-webmvc-ui/2.3.0/springdoc-openapi-starter-webmvc-ui-2.3.0.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springdoc/springdoc-openapi-starter-webmvc-api/2.3.0/springdoc-openapi-starter-webmvc-api-2.3.0.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springdoc/springdoc-openapi-starter-common/2.3.0/springdoc-openapi-starter-common-2.3.0.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/io/swagger/core/v3/swagger-core-jakarta/2.2.19/swagger-core-jakarta-2.2.19.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/io/swagger/core/v3/swagger-annotations-jakarta/2.2.19/swagger-annotations-jakarta-2.2.19.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/io/swagger/core/v3/swagger-models-jakarta/2.2.19/swagger-models-jakarta-2.2.19.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/fasterxml/jackson/dataformat/jackson-dataformat-yaml/2.17.2/jackson-dataformat-yaml-2.17.2.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/webjars/swagger-ui/5.10.3/swagger-ui-5.10.3.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/cn/hutool/hutool-all/5.8.32/hutool-all-5.8.32.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/google/guava/guava/33.2.1-jre/guava-33.2.1-jre.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/google/guava/failureaccess/1.0.2/failureaccess-1.0.2.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/checkerframework/checker-qual/3.42.0/checker-qual-3.42.0.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/google/errorprone/error_prone_annotations/2.26.1/error_prone_annotations-2.26.1.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/google/j2objc/j2objc-annotations/3.0.0/j2objc-annotations-3.0.0.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/jakarta/validation/jakarta.validation-api/3.0.2/jakarta.validation-api-3.0.2.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/boot/spring-boot-starter-security/3.3.5/spring-boot-starter-security-3.3.5.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/security/spring-security-config/6.3.4/spring-security-config-6.3.4.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/security/spring-security-web/6.3.4/spring-security-web-6.3.4.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/github/tingyugetc520/dt-java/0.1.2/dt-java-0.1.2.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/squareup/okhttp3/okhttp/4.12.0/okhttp-4.12.0.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/squareup/okio/okio/3.6.0/okio-3.6.0.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/squareup/okio/okio-jvm/3.6.0/okio-jvm-3.6.0.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/jetbrains/kotlin/kotlin-stdlib-common/1.9.25/kotlin-stdlib-common-1.9.25.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/jetbrains/kotlin/kotlin-stdlib-jdk8/1.9.25/kotlin-stdlib-jdk8-1.9.25.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/jetbrains/kotlin/kotlin-stdlib/1.9.25/kotlin-stdlib-1.9.25.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/jetbrains/annotations/13.0/annotations-13.0.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/jetbrains/kotlin/kotlin-stdlib-jdk7/1.9.25/kotlin-stdlib-jdk7-1.9.25.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/httpcomponents/httpclient/4.5/httpclient-4.5.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/httpcomponents/httpcore/4.4.16/httpcore-4.4.16.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/httpcomponents/httpmime/4.5/httpmime-4.5.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/commons-codec/commons-codec/1.16.1/commons-codec-1.16.1.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/commons-io/commons-io/2.16.1/commons-io-2.16.1.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/commons/commons-lang3/3.14.0/commons-lang3-3.14.0.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/commons/commons-collections4/4.4/commons-collections4-4.4.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/google/code/gson/gson/2.10.1/gson-2.10.1.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/mysql/mysql-connector-j/8.3.0/mysql-connector-j-8.3.0.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/github/ben-manes/caffeine/caffeine/3.1.8/caffeine-3.1.8.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/mybatis/mybatis-spring/3.0.3/mybatis-spring-3.0.3.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/io/minio/minio/8.5.17/minio-8.5.17.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/carrotsearch/thirdparty/simple-xml-safe/2.7.1/simple-xml-safe-2.7.1.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/fasterxml/jackson/core/jackson-annotations/2.17.2/jackson-annotations-2.17.2.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/fasterxml/jackson/core/jackson-core/2.17.2/jackson-core-2.17.2.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/fasterxml/jackson/core/jackson-databind/2.17.2/jackson-databind-2.17.2.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/bouncycastle/bcprov-jdk18on/1.78.1/bcprov-jdk18on-1.78.1.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/commons/commons-compress/1.27.1/commons-compress-1.27.1.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/xerial/snappy/snappy-java/1.1.10.7/snappy-java-1.1.10.7.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/pdfbox/pdfbox/3.0.3/pdfbox-3.0.3.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/pdfbox/pdfbox-io/3.0.3/pdfbox-io-3.0.3.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/pdfbox/fontbox/3.0.3/fontbox-3.0.3.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/commons-logging/commons-logging/1.3.3/commons-logging-1.3.3.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/alibaba/easyexcel/4.0.3/easyexcel-4.0.3.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/alibaba/easyexcel-core/4.0.3/easyexcel-core-4.0.3.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/alibaba/easyexcel-support/3.3.4/easyexcel-support-3.3.4.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/poi/poi/5.2.5/poi-5.2.5.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/commons/commons-math3/3.6.1/commons-math3-3.6.1.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/zaxxer/SparseBitSet/1.3/SparseBitSet-1.3.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/logging/log4j/log4j-api/2.23.1/log4j-api-2.23.1.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/poi/poi-ooxml/5.2.5/poi-ooxml-5.2.5.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/poi/poi-ooxml-lite/5.2.5/poi-ooxml-lite-5.2.5.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/xmlbeans/xmlbeans/5.2.0/xmlbeans-5.2.0.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/com/github/virtuald/curvesapi/1.08/curvesapi-1.08.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/commons/commons-csv/1.11.0/commons-csv-1.11.0.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/ehcache/ehcache/3.10.8/ehcache-3.10.8.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/javax/cache/cache-api/1.1.1/cache-api-1.1.1.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/slf4j/slf4j-api/2.0.16/slf4j-api-2.0.16.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/projectlombok/lombok/1.18.34/lombok-1.18.34.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/boot/spring-boot-starter/3.3.5/spring-boot-starter-3.3.5.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/boot/spring-boot/3.3.5/spring-boot-3.3.5.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/boot/spring-boot-starter-logging/3.3.5/spring-boot-starter-logging-3.3.5.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/ch/qos/logback/logback-classic/1.5.11/logback-classic-1.5.11.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/ch/qos/logback/logback-core/1.5.11/logback-core-1.5.11.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/apache/logging/log4j/log4j-to-slf4j/2.23.1/log4j-to-slf4j-2.23.1.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/slf4j/jul-to-slf4j/2.0.16/jul-to-slf4j-2.0.16.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/jakarta/annotation/jakarta.annotation-api/2.1.1/jakarta.annotation-api-2.1.1.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/yaml/snakeyaml/2.2/snakeyaml-2.2.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/jakarta/xml/bind/jakarta.xml.bind-api/4.0.2/jakarta.xml.bind-api-4.0.2.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/jakarta/activation/jakarta.activation-api/2.1.3/jakarta.activation-api-2.1.3.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/net/bytebuddy/byte-buddy/1.14.19/byte-buddy-1.14.19.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-core/6.1.14/spring-core-6.1.14.jar" enabled="true" runInBatchMode="false"/> |
|||
<factorypathentry kind="VARJAR" id="M2_REPO/org/springframework/spring-jcl/6.1.14/spring-jcl-6.1.14.jar" enabled="true" runInBatchMode="false"/> |
|||
</factorypath> |
|||
@ -0,0 +1,34 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<projectDescription> |
|||
<name>crm-customer</name> |
|||
<comment></comment> |
|||
<projects> |
|||
</projects> |
|||
<buildSpec> |
|||
<buildCommand> |
|||
<name>org.eclipse.jdt.core.javabuilder</name> |
|||
<arguments> |
|||
</arguments> |
|||
</buildCommand> |
|||
<buildCommand> |
|||
<name>org.eclipse.m2e.core.maven2Builder</name> |
|||
<arguments> |
|||
</arguments> |
|||
</buildCommand> |
|||
</buildSpec> |
|||
<natures> |
|||
<nature>org.eclipse.jdt.core.javanature</nature> |
|||
<nature>org.eclipse.m2e.core.maven2Nature</nature> |
|||
</natures> |
|||
<filteredResources> |
|||
<filter> |
|||
<id>1788504423349</id> |
|||
<name></name> |
|||
<type>30</type> |
|||
<matcher> |
|||
<id>org.eclipse.core.resources.regexFilterMatcher</id> |
|||
<arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments> |
|||
</matcher> |
|||
</filter> |
|||
</filteredResources> |
|||
</projectDescription> |
|||
@ -0,0 +1,4 @@ |
|||
eclipse.preferences.version=1 |
|||
encoding//src/main/java=UTF-8 |
|||
encoding//src/test/java=UTF-8 |
|||
encoding/<project>=UTF-8 |
|||
@ -0,0 +1,4 @@ |
|||
eclipse.preferences.version=1 |
|||
org.eclipse.jdt.apt.aptEnabled=true |
|||
org.eclipse.jdt.apt.genSrcDir=target/generated-sources/annotations |
|||
org.eclipse.jdt.apt.genTestSrcDir=target/generated-test-sources/test-annotations |
|||
@ -0,0 +1,20 @@ |
|||
eclipse.preferences.version=1 |
|||
org.eclipse.jdt.core.compiler.annotation.missingNonNullByDefaultAnnotation=ignore |
|||
org.eclipse.jdt.core.compiler.annotation.nonnull=javax.annotation.Nonnull |
|||
org.eclipse.jdt.core.compiler.annotation.nonnullbydefault=javax.annotation.ParametersAreNonnullByDefault |
|||
org.eclipse.jdt.core.compiler.annotation.nullable=javax.annotation.Nullable |
|||
org.eclipse.jdt.core.compiler.annotation.nullanalysis=enabled |
|||
org.eclipse.jdt.core.compiler.codegen.methodParameters=generate |
|||
org.eclipse.jdt.core.compiler.codegen.targetPlatform=17 |
|||
org.eclipse.jdt.core.compiler.compliance=17 |
|||
org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled |
|||
org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning |
|||
org.eclipse.jdt.core.compiler.problem.nullAnnotationInferenceConflict=warning |
|||
org.eclipse.jdt.core.compiler.problem.nullReference=warning |
|||
org.eclipse.jdt.core.compiler.problem.nullSpecViolation=warning |
|||
org.eclipse.jdt.core.compiler.problem.potentialNullReference=warning |
|||
org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=ignore |
|||
org.eclipse.jdt.core.compiler.problem.syntacticNullAnalysisForFields=enabled |
|||
org.eclipse.jdt.core.compiler.processAnnotations=enabled |
|||
org.eclipse.jdt.core.compiler.release=enabled |
|||
org.eclipse.jdt.core.compiler.source=17 |
|||
@ -0,0 +1,4 @@ |
|||
activeProfiles= |
|||
eclipse.preferences.version=1 |
|||
resolveWorkspaceProjects=true |
|||
version=1 |
|||