Browse Source

commit

master
luoweijian 1 day ago
parent
commit
1b85082c41
  1. 61
      .scratch/customer-demo-ref/HANDOFF-2026-09-12.md
  2. 22
      .scratch/customer-demo-ref/_quickcheck.py
  3. 81
      .scratch/customer-demo-ref/_quickcheck12.py
  4. 33
      .scratch/customer-demo-ref/_quickcheck12b.py
  5. 36
      .scratch/customer-demo-ref/_quickcheck12c.py
  6. 36
      .scratch/customer-demo-ref/_quickcheck12d.py
  7. 77
      .scratch/customer-demo-ref/_quickcheck13.py
  8. 19
      .scratch/customer-demo-ref/_scan.py
  9. 13
      .scratch/customer-demo-ref/_t12scan.py
  10. 529
      .scratch/customer-demo-ref/assets/_fa-core.md
  11. 50
      .scratch/customer-demo-ref/assets/acceptance-selfcheck.md
  12. 6
      .scratch/customer-demo-ref/assets/smoke-plan.md
  13. 44
      .scratch/customer-demo-ref/assets/uncommitted-changes.md
  14. BIN
      .scratch/customer-demo-ref/demo/__pycache__/_t14d7.cpython-313.pyc
  15. BIN
      .scratch/customer-demo-ref/demo/__pycache__/smoke.cpython-313.pyc
  16. 335
      .scratch/customer-demo-ref/demo/_jarbuild.log
  17. 58
      .scratch/customer-demo-ref/demo/_run7.log
  18. 56
      .scratch/customer-demo-ref/demo/_run8.log
  19. 0
      .scratch/customer-demo-ref/demo/_server.log
  20. 35
      .scratch/customer-demo-ref/demo/_t14api.py
  21. 15
      .scratch/customer-demo-ref/demo/_t14cols.py
  22. 79
      .scratch/customer-demo-ref/demo/_t14d7.py
  23. 15
      .scratch/customer-demo-ref/demo/_t14db.py
  24. 32
      .scratch/customer-demo-ref/demo/_t14diag.py
  25. 17
      .scratch/customer-demo-ref/demo/_t14org.py
  26. 21
      .scratch/customer-demo-ref/demo/_t14org2.py
  27. 17
      .scratch/customer-demo-ref/demo/_t14org3.py
  28. 29
      .scratch/customer-demo-ref/demo/_t14scan.py
  29. 708
      .scratch/customer-demo-ref/demo/index.html
  30. BIN
      .scratch/customer-demo-ref/demo/shots/s01-list.png
  31. BIN
      .scratch/customer-demo-ref/demo/shots/s02-pool-i06.png
  32. BIN
      .scratch/customer-demo-ref/demo/shots/s07-board.png
  33. BIN
      .scratch/customer-demo-ref/demo/shots/s11-head.png
  34. BIN
      .scratch/customer-demo-ref/demo/shots/s19-oplog.png
  35. BIN
      .scratch/customer-demo-ref/demo/shots/s20-import.png
  36. BIN
      .scratch/customer-demo-ref/demo/shots/s24-transfer.png
  37. BIN
      .scratch/customer-demo-ref/demo/shots/s30-ledger.png
  38. BIN
      .scratch/customer-demo-ref/demo/shots/s40-final.png
  39. BIN
      .scratch/customer-demo-ref/demo/shots/t10-quick-pool.png
  40. BIN
      .scratch/customer-demo-ref/demo/shots/t11-quick-detail.png
  41. BIN
      .scratch/customer-demo-ref/demo/shots/t12-quick-contacts.png
  42. BIN
      .scratch/customer-demo-ref/demo/shots/t13-quick-settings.png
  43. 444
      .scratch/customer-demo-ref/demo/smoke-report.json
  44. 1099
      .scratch/customer-demo-ref/demo/smoke.py
  45. BIN
      .scratch/customer-demo-ref/demo/tmp_xlsx/cont.xlsx
  46. BIN
      .scratch/customer-demo-ref/demo/tmp_xlsx/cust.xlsx
  47. BIN
      .scratch/customer-demo-ref/demo/tmp_xlsx/tpl_cust.xlsx
  48. 30
      .scratch/customer-demo-ref/issues/07-field-alignment-checklist.md
  49. 20
      .scratch/customer-demo-ref/issues/10-core-list.md
  50. 23
      .scratch/customer-demo-ref/issues/11-core-detail.md
  51. 17
      .scratch/customer-demo-ref/issues/12-contacts-opp-entry.md
  52. 14
      .scratch/customer-demo-ref/issues/13-flow-rules-tail.md
  53. 22
      .scratch/customer-demo-ref/issues/14-smoke-script.md
  54. 10
      .scratch/customer-demo-ref/issues/15-report-handover.md
  55. 32
      .scratch/customer-demo-ref/map.md
  56. 6136
      logs/crm-app.log
  57. BIN
      logs/crm-app.log.2026-09-11.0.gz

61
.scratch/customer-demo-ref/HANDOFF-2026-09-12.md

@ -0,0 +1,61 @@
# HANDOFF — customer-demo-ref wayfinder 全图收官(2026-09-12)
> 写给接续 agent:地图已 **done**,无剩余开发工作。本文档只覆盖「状态快照 + 用户回来后的可能动作 + 复跑速查 + 本会话新踩的坑」。细节一律引用落盘工件,不重复。
## 1. 状态快照(一句话)
`e:\code\crm-backend-matt\.scratch\customer-demo-ref\` 的 15 票全部 resolved,map.md Status=done;票 14 冒烟第八跑 **40/0 ALL_PASS(176.4s)**;票 15 四标准自检与双仓清单已落盘;**唯一待办 = 用户审查双仓未提交变更并自行提交**(agent 纪律不碰 git)。
- 权威状态:`map.md`(Decisions 15 行 + Destination 验收对照块)
- 各票结论:`issues/01..15-*.md` 的 `## Answer` 节(全部已写)
- 验收自检:`assets/acceptance-selfcheck.md`(四标准逐条 ✓)
- 未提交清单:`assets/uncommitted-changes.md`
## 2. 本段会话(收尾段)完成了什么
1. **票 14 第七/八轮**:诊断脚本 `demo/_t14d7.py` 实锤三大根因(seed 联系人职务不在字典→batchEdit 被校验拦截;quickAdd 后 _contacts 旧快照;pool+ASSIGNED 视图与公海口径恒互斥)→ 第七轮 39/1;第八轮实锤 **demo onclick 陷阱**并修复 → 40/0 全绿。全程记录在 `issues/14-smoke-script.md` Answer。
2. **票 15**:`assets/acceptance-selfcheck.md` + `assets/uncommitted-changes.md` 落盘 + map 收口(Status=done、验收对照块、Not yet specified 清空、Decisions 补 09/10/11/14 四行)。
3. **环境**:重建 fat jar 并重启 8080(旧 jar 落后源码导致 project/page 404);构建通道与 jar 文件锁坑已沉淀到记忆「crm-backend-matt 构建与脚本执行通道」。
## 3. 用户回来后最可能的动作(按概率排序)
| 动作 | 做法 |
|------|------|
| 审查未提交变更 | 打开 `assets/uncommitted-changes.md`:backend 51 行全属 `.scratch/customer-demo-ref`(M10+??37,其中诊断脚本/日志标注可删);文档仓 `E:\code\crm-api-docs` 仅 2 个前序迁移遗留目录。**提交由用户执行** |
| 试跑 Demo | 直接浏览器打开 `demo/index.html?api=http://localhost:8080`(服务若已停,见 §4 起服);登录页 uid 填 `739564171091247104`(admin 罗伟健)debug 直登;验收指北同 `assets/acceptance-selfcheck.md` §4 |
| 复跑冒烟 | 见 §4 速查;脚本幂等(sweep→seed 自举),可重复执行 |
| 处置 8080 服务 | 本会话起的服务仍在后台运行(新 jar);若不需要可 taskkill,不影响任何落盘工件 |
## 4. 复跑速查(冒烟/起服)
- 解释器(唯一可用组合):`E:\code\crm-backend-matt\.scratch\customer-e2e\.venv10\Scripts\python.exe -X utf8 .scratch/customer-demo-ref/demo/smoke.py`(playwright+pymysql+openpyxl 齐备;长任务**必须前台跑**,后台进程树会被会话压缩杀死)。
- 起服:`SPRING_PROFILES_ACTIVE=verify` + MinIO env(AK/SK 凭据已内置 `crm-app/src/main/resources/application.yml` 默认值,无需明文传入)+ `java -jar crm-app\target\crm-app-1.0.0-SNAPSHOT.jar`(corretto-17)。
- DB=远程共享库(凭据同上内置);seed 幂等自举(sweep 先于 ensure_seeds,e2c-dr\* 用后硬删;2d 把查重模式归位 FUZZY(2));报告落 `demo/smoke-report.json`
- **fat jar 重建**:先 taskkill 占 8080 的 java(文件锁→repackage rename 失败),删 target 旧 jar,再走 IDEA bundled mvn(沙箱需越权;通道详见记忆「crm-backend-matt 构建与脚本执行通道」)。
## 5. 本会话新踩/复用的坑(未沉淀进记忆的部分)
- **demo onclick 陷阱**:`dlgOpen` 把 `fn` 直接挂 `btn.onclick` → 浏览器把 PointerEvent 作为首参传入。任何 `async function doX(confirmSimilar)` 形态的按钮回调都会把事件对象序列化进表单(如 `confirmSimilar=[object PointerEvent]` → 后端 400)。修法=首参防御:`(cf === true || cf === 'true') ? true : undefined`(qs 会跳过 undefined)。doEdit/doCreate 已修;**给新对话框按钮回调加参数时必踩,先想这个**。
- **edit 的 L2 needConfirm 假成功**:后端 edit 相似命中时返回 code=0 + `needConfirm:true` 不落库——demo 侧必须显式处理(doEdit 已补 simbox 分支,同 doCreate 契约),否则「toast 成功但 DB 没变」。
- **pool 工作台视图互斥**:`fViewType` 默认 ASSIGNED 与公海 owner IS NULL 恒冲突(CustomerMapper L83)——demo pool 页签在默认视图下恒空,冒烟侧先呈现现象再清视图。
- **seed 自举顺序**:sweep 必须先于 ensure_seeds,否则上轮 seed 被 find 命中不重建、随即被 sweep 清掉,整轮查重基线缺失(冒烟步 9/27 假阴)。
- **中文 URL**:任何拼接中文查询参数必须 `urllib.parse.quote`(本 effort 已三次踩)。
- **诊断脚本临时文件**:`demo/_t14d7.py`(DB 职务+pool+edit 三合一取证)保留作证据,其余 `_quickcheck*/_q.py` 类可删。
## 6. Suggested skills(按场景)
| 场景 | skill |
|------|-------|
| 用户要求把本次成果同步成 Bruno 文档/新 .bru | `bruno-sync`(注意 config sourceRoots 需含目标模块目录) |
| 用户要继续下一张 wayfinder 地图/大块工作 | `wayfinder` |
| 用户报 demo/后端行为异常要求诊断 | `diagnosing-bugs`;浏览器层验证用 `webapp-testing` |
| 用户要求 review 本次全部改动 | `code-review`(review_scope=explicit_target,指定 `.scratch/customer-demo-ref`) |
| 需要看图/截图(非多模态会话) | `vision-mcp`(AGENTS.md 有调用规范) |
## 7. 关键文件索引
- 地图/票/资产:`e:\code\crm-backend-matt\.scratch\customer-demo-ref\`(map.md、issues/、assets/、demo/)
- 冒烟报告:`demo/smoke-report.json`(第八跑 40/0 + api_urls 91 路径 + http_fails 空)
- 取证脚本:`demo/_t14d7.py`、`demo/_run7.log`、`demo/_run8.log`、`demo/_jarbuild.log`
- 契约权威:`.scratch/customer-module/API-SUMMARY.md`(85 端点);原型树:`.scratch/api-docs-reorg/lanhu-tree-v29.md`
- 缺陷台账:`.scratch/customer-frontend-handover/gap-report.md` + `customer-defectfix/FIX-REPORT.md`

22
.scratch/customer-demo-ref/_quickcheck.py

@ -0,0 +1,22 @@
import json
from playwright.sync_api import sync_playwright
URL = 'file:///E:/code/crm-backend-matt/.scratch/customer-demo-ref/demo/index.html'
errors = []
with sync_playwright() as p:
b = p.chromium.launch()
pg = b.new_page(viewport={'width': 1720, 'height': 1080})
pg.on('pageerror', lambda e: errors.append(str(e)))
pg.goto(URL)
pg.wait_for_timeout(800)
n_sub = pg.eval_on_selector_all('#detailPanel .subtabs button', 'els => els.length')
projects_btn = pg.eval_on_selector_all('#detailPanel .subtabs button[data-s="projects"]', 'els => els.length')
g5 = pg.eval_on_selector_all('[data-defect="G5"]', 'els => els.length')
# 打开缺陷面板,统计台账行数
pg.click('[data-defect-panel]')
pg.wait_for_timeout(300)
panel_rows = pg.eval_on_selector_all('[data-defect-row]', 'els => els.length')
pg.screenshot(path='E:/code/crm-backend-matt/.scratch/customer-demo-ref/demo/shots/t11-quick-detail.png')
b.close()
print(json.dumps({'pageerror': errors, 'subtabs': n_sub, 'projects_tab': projects_btn,
'g5_badge': g5, 'panel_rows': panel_rows}, ensure_ascii=False))

81
.scratch/customer-demo-ref/_quickcheck12.py

@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
"""票 12 快验:联系人顶级菜单 + 商机侧三入口(UI 层 + 登录链路尽力)"""
import io, json, sys
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
from playwright.sync_api import sync_playwright
URL = 'file:///E:/code/crm-backend-matt/.scratch/customer-demo-ref/demo/index.html'
errors = []
out = {}
with sync_playwright() as p:
b = p.chromium.launch()
pg = b.new_page(viewport={'width': 1720, 'height': 1080})
pg.on('pageerror', lambda e: errors.append(str(e)))
pg.goto(URL)
pg.wait_for_timeout(800)
# 1) nav tabs = 6,联系人按钮存在
out['tabs'] = pg.eval_on_selector_all('nav.tabs button', 'els => els.map(e => e.dataset.tab)')
# 2) 切联系人 tab
pg.click('nav.tabs button[data-tab="contacts"]')
pg.wait_for_timeout(300)
out['contacts_section_visible'] = pg.eval_on_selector('#tab-contacts', 'e => e.style.display !== "none"')
out['g5_badge'] = pg.eval_on_selector_all('#tab-contacts [data-defect="G5"]', 'els => els.length')
out['cform_options'] = pg.eval_on_selector('#cForm', 'e => e.options.length')
# 3) 未登录 hint(尽力;登录后此值会被覆盖)
out['ct_hint_before_login'] = pg.eval_on_selector('#ctPageInfo', 'e => e.textContent')
# 4) 登录链路(远程 debug token;失败不阻塞 UI 断言)
try:
pg.fill('#uidInput', '739564171091247104')
pg.evaluate('loginByUid()')
pg.wait_for_timeout(2500)
out['token_set'] = pg.evaluate('!!localStorage.getItem("e2c_token")')
if out['token_set']:
pg.click('nav.tabs button[data-tab="contacts"]')
pg.evaluate('loadContactPage()')
pg.wait_for_timeout(2000)
out['ct_rows'] = pg.eval_on_selector_all('#ctPageBody tr', 'els => els.length')
out['ct_head'] = pg.eval_on_selector('#ctPageHead', 'e => e.textContent.slice(0, 120)')
out['ct_info'] = pg.eval_on_selector('#ctPageInfo', 'e => e.textContent')
out['phone_masked'] = pg.evaluate('''() => {
const tds = [...document.querySelectorAll('#ctPageBody td')];
const phone = tds.map(t => t.textContent).find(t => /^\\d{3}\\*{4}\\d{4}$/.test(t));
return phone || null;
}''')
# 行点开详情弹窗(contact/detail 回显 + G6 空态)
has_row = pg.eval_on_selector_all('#ctPageBody tr td.row', 'els => els.length')
if has_row:
pg.click('#ctPageBody tr td.row')
pg.wait_for_timeout(1200)
out['detail_dlg'] = pg.eval_on_selector('dialog#dlg[open]', 'e => !!e')
out['ct_info_kv'] = pg.eval_on_selector_all('#ctPaneInfo .kv', 'els => els.length')
pg.click('#ctTabBtn1')
pg.wait_for_timeout(200)
out['g6_log'] = pg.eval_on_selector_all('#ctPaneLog[data-defect="G6"]', 'els => els.length')
pg.keyboard.press('Escape')
pg.wait_for_timeout(200)
except Exception as e:
out['login_error'] = str(e)[:200]
# 5) 商机侧三入口 UI
pg.click('nav.tabs button[data-tab="list"]')
pg.wait_for_timeout(200)
out['opp_entry_buttons'] = pg.evaluate('''() => ({
quick: [...document.querySelectorAll('button')].some(b => b.textContent.includes('快建客户')),
search3d: [...document.querySelectorAll('button')].some(b => b.textContent.includes('三维搜索')),
})''')
# 快建弹窗打开(7 必填控件)
pg.evaluate('dlgQuickCreate()')
pg.wait_for_timeout(200)
out['quick_fields'] = pg.evaluate('''() => ['qName','qType','qProv','qCity','qInd','qStar','qRStar'].filter(id => !!document.getElementById(id)).length''')
pg.keyboard.press('Escape')
pg.wait_for_timeout(200)
# 全局搜索弹窗
pg.click('nav.tabs button[data-tab="contacts"]')
pg.evaluate('dlgContactSearch()')
pg.wait_for_timeout(200)
out['search_dlg'] = pg.eval_on_selector('dialog#dlg[open]', 'e => !!e')
pg.keyboard.press('Escape')
pg.screenshot(path='E:/code/crm-backend-matt/.scratch/customer-demo-ref/demo/shots/t12-quick-contacts.png', full_page=False)
b.close()
out['pageerror'] = errors
print(json.dumps(out, ensure_ascii=False, indent=1))

33
.scratch/customer-demo-ref/_quickcheck12b.py

@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
"""票 12 补验:contact/search + customer/search 真请求(只读端点)"""
import io, json, sys
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
from playwright.sync_api import sync_playwright
URL = 'file:///E:/code/crm-backend-matt/.scratch/customer-demo-ref/demo/index.html'
errors = []
out = {}
with sync_playwright() as p:
b = p.chromium.launch()
pg = b.new_page(viewport={'width': 1720, 'height': 1080})
pg.on('pageerror', lambda e: errors.append(str(e)))
pg.goto(URL)
pg.wait_for_timeout(600)
pg.fill('#uidInput', '739564171091247104')
pg.evaluate('loginByUid()')
pg.wait_for_timeout(2500)
# contact/search:keyword=张关键(seed 联系人)
r1 = pg.evaluate('''async () => {
const arr = await api('/api/customer/contact/search?keyword=' + encodeURIComponent('张关键') + '&limit=20');
return { n: (arr || []).length, first: arr && arr[0] ? { name: arr[0].name, phone: arr[0].phone, customerName: arr[0].customerName, source: arr[0].source } : null };
}''')
out['contact_search'] = r1
# customer/search:keyword=e2c 三维模糊 + excludeCustomerIds 重复键
r2 = pg.evaluate('''async () => {
const d = await api('/api/customer/search', { method: 'POST', body: 'keyword=e2c&archiveStatus=1&excludeCustomerIds=750844487390986240' });
return { total: d && d.total, row0: d && d.content && d.content[0] ? { customerNo: d.content[0].customerNo, ownerName: d.content[0].ownerName, customerStage: d.content[0].customerStage } : null };
}''')
out['customer_search'] = r2
b.close()
out['pageerror'] = errors
print(json.dumps(out, ensure_ascii=False, indent=1))

36
.scratch/customer-demo-ref/_quickcheck12c.py

@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
"""票 12 补验 r2:取 contact/page 首行真值,回查 contact/search/customer/search"""
import io, json, sys
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
from playwright.sync_api import sync_playwright
URL = 'file:///E:/code/crm-backend-matt/.scratch/customer-demo-ref/demo/index.html'
errors = []
out = {}
with sync_playwright() as p:
b = p.chromium.launch()
pg = b.new_page(viewport={'width': 1720, 'height': 1080})
pg.on('pageerror', lambda e: errors.append(str(e)))
pg.goto(URL)
pg.wait_for_timeout(600)
pg.fill('#uidInput', '739564171091247104')
pg.evaluate('loginByUid()')
pg.wait_for_timeout(2500)
out['page0'] = pg.evaluate('''async () => {
const d = await api('/api/customer/contact/page?current=1&size=3');
return (d && d.content || []).map(r => ({ name: r.name, phone: r.phone, source: r.source, customerName: r.customerName }));
}''')
out['search_by_row_name'] = pg.evaluate('''async () => {
const d = await api('/api/customer/contact/page?current=1&size=1');
const nm = d && d.content && d.content[0] && d.content[0].name;
if (!nm) return 'no-row';
const arr = await api('/api/customer/contact/search?keyword=' + encodeURIComponent(nm) + '&limit=20');
return { kw: nm, n: (arr || []).length, phone0: arr && arr[0] ? arr[0].phone : null };
}''')
out['ws_page0'] = pg.evaluate('''async () => {
const d = await api('/api/customer/workspace/page', { method: 'POST', body: 'workspace=mine&current=1&size=3&archiveStatus=1' });
return { total: d && d.total, rows: (d && d.content || []).map(r => ({ customerName: r.customerName, ownerNameSnapshot: r.ownerNameSnapshot })) };
}''')
b.close()
out['pageerror'] = errors
print(json.dumps(out, ensure_ascii=False, indent=1))

36
.scratch/customer-demo-ref/_quickcheck12d.py

@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
"""票 12 补验 r3:customer/search 用库内真名(三维模糊含联系人名)"""
import io, json, sys
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
from playwright.sync_api import sync_playwright
URL = 'file:///E:/code/crm-backend-matt/.scratch/customer-demo-ref/demo/index.html'
errors = []
out = {}
with sync_playwright() as p:
b = p.chromium.launch()
pg = b.new_page(viewport={'width': 1720, 'height': 1080})
pg.on('pageerror', lambda e: errors.append(str(e)))
pg.goto(URL)
pg.wait_for_timeout(600)
pg.fill('#uidInput', '739564171091247104')
pg.evaluate('loginByUid()')
pg.wait_for_timeout(2500)
out['by_customer_name'] = pg.evaluate('''async () => {
const d = await api('/api/customer/search', { method: 'POST', body: 'keyword=' + encodeURIComponent('佛山季华铝业') });
return { total: d && d.total, row0: d && d.content && d.content[0] ? { customerNo: d.content[0].customerNo, customerType: d.content[0].customerType, ownerDeptId: d.content[0].ownerDeptId, archiveStatus: d.content[0].archiveStatus } : null };
}''')
out['by_contact_name_3d'] = pg.evaluate('''async () => {
// 三维模糊验证用联系人姓名梁诗敏应命中其所属客户
const d = await api('/api/customer/search', { method: 'POST', body: 'keyword=' + encodeURIComponent('梁诗敏') });
return { total: d && d.total, name0: d && d.content && d.content[0] ? d.content[0].customerName : null };
}''')
out['exclude_dup_key'] = pg.evaluate('''async () => {
// excludeCustomerIds 重复键排除后 total 应减少
const a = await api('/api/customer/search', { method: 'POST', body: 'keyword=' + encodeURIComponent('佛山') });
const d = await api('/api/customer/search', { method: 'POST', body: 'keyword=' + encodeURIComponent('佛山') + '&excludeCustomerIds=' + (a.content && a.content[0] ? a.content[0].id : '0') });
return { before: a && a.total, after: d && d.total };
}''')
b.close()
out['pageerror'] = errors
print(json.dumps(out, ensure_ascii=False, indent=1))

77
.scratch/customer-demo-ref/_quickcheck13.py

@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
"""票 13 快验:工商联想 NO_HIT / 导入过滤 / POOL-CFG 占位卡 / P3-3 / D-G1 注记"""
import io, json, sys
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
from playwright.sync_api import sync_playwright
URL = 'file:///E:/code/crm-backend-matt/.scratch/customer-demo-ref/demo/index.html'
errors = []
out = {}
with sync_playwright() as p:
b = p.chromium.launch()
pg = b.new_page(viewport={'width': 1720, 'height': 1080})
pg.on('pageerror', lambda e: errors.append(str(e)))
pg.goto(URL)
pg.wait_for_timeout(600)
pg.fill('#uidInput', '739564171091247104')
pg.evaluate('loginByUid()')
pg.wait_for_timeout(2500)
out['token'] = pg.evaluate('!!localStorage.getItem("e2c_token")')
# 1) 工商联想 NO_HIT(#24 真请求:stub 恒空数组)
out['lookup_raw'] = pg.evaluate('''async () => {
const arr = await api('/api/customer/company-lookup?companyName=' + encodeURIComponent('恒信达'));
return Array.isArray(arr) ? arr : ('type=' + typeof arr);
}''')
pg.evaluate('dlgCreate()')
pg.wait_for_timeout(200)
pg.fill('#cLk', '恒信达')
pg.evaluate('doLookup()')
pg.wait_for_timeout(1200)
out['lookup_no_hit'] = pg.eval_on_selector('#cOut', 'e => e.textContent.includes("未查询到匹配企业")')
out['lookup_stub_note'] = pg.eval_on_selector_all('#cOut [data-defect="STUB-LOOKUP"]', 'els => els.length')
pg.keyboard.press('Escape')
pg.wait_for_timeout(200)
# 2) 导入任务过滤(#63 真请求)
pg.click('nav.tabs button[data-tab="import"]')
pg.evaluate('loadImportPage()')
pg.wait_for_timeout(1200)
out['imp_all'] = pg.eval_on_selector_all('#impBody tr', 'els => els.length')
pg.select_option('#impFStatus', '2')
pg.wait_for_timeout(1200)
out['imp_status2_rows'] = pg.evaluate('''() => {
const trs = [...document.querySelectorAll('#impBody tr')];
return { n: trs.length, allDone: trs.every(tr => tr.textContent.includes('已完成') || tr.textContent.includes('暂无')) };
}''')
pg.select_option('#impFStatus', '')
pg.select_option('#impFMode', 'UPSERT')
pg.wait_for_timeout(1200)
out['imp_mode_upsert'] = pg.evaluate('''() => {
const trs = [...document.querySelectorAll('#impBody tr')];
return { n: trs.length, allUpsert: trs.every(tr => tr.textContent.includes('UPSERT') || tr.textContent.includes('暂无')) };
}''')
out['p3_3_note'] = pg.eval_on_selector_all('#tab-import [data-defect="P3-3"]', 'els => els.length')
# 3) POOL-CFG 占位卡(设置页)
pg.click('nav.tabs button[data-tab="settings"]')
pg.wait_for_timeout(200)
out['pool_cfg'] = pg.eval_on_selector_all('#tab-settings [data-defect="POOL-CFG"]', 'els => els.length')
out['pool_disabled'] = pg.evaluate('''() => {
const inputs = [...document.querySelectorAll('#tab-settings [data-defect="POOL-CFG"]')];
const panel = inputs[0] ? inputs[0].closest('.panel') : null;
return panel ? [...panel.querySelectorAll('input')].every(i => i.disabled && i.readOnly) : false;
}''')
# 4) D-G1 注记(详情页签联系人工具栏)
pg.click('nav.tabs button[data-tab="detail"]')
pg.wait_for_timeout(200)
out['dg1_note'] = pg.eval_on_selector_all('[data-defect="D-G1"]', 'els => els.length')
# 5) F6 stub 钩子(列表 tab)
pg.click('nav.tabs button[data-tab="list"]')
pg.wait_for_timeout(200)
out['f6_stub_hook'] = pg.eval_on_selector_all('[data-defect="STUB-LOOKUP"]', 'els => els.length')
pg.screenshot(path='E:/code/crm-backend-matt/.scratch/customer-demo-ref/demo/shots/t13-quick-settings.png')
b.close()
out['pageerror'] = errors
print(json.dumps(out, ensure_ascii=False, indent=1))

19
.scratch/customer-demo-ref/_scan.py

@ -0,0 +1,19 @@
import sys, re
sys.stdout.reconfigure(encoding='utf-8')
p = r'e:\code\crm-backend-matt\.scratch\customer-demo-ref\demo\index.html'
lines = open(p, encoding='utf-8').read().splitlines()
mode = sys.argv[1] if len(sys.argv) > 1 else 'func'
if mode == 'func':
for i, l in enumerate(lines, 1):
if i <= 705 and ('function ' in l or 'async ' in l):
print(i, l.rstrip()[:130])
elif mode == 'id':
lo, hi = int(sys.argv[2]), int(sys.argv[3])
for i, l in enumerate(lines, 1):
if lo <= i <= hi and re.search(r"id=[\"']", l):
print(i, l.strip()[:150])
elif mode == 'range':
lo, hi = int(sys.argv[2]), int(sys.argv[3])
for i, l in enumerate(lines, 1):
if lo <= i <= hi:
print(i, l.rstrip())

13
.scratch/customer-demo-ref/_t12scan.py

@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
"""票12 前侦察:扫描 demo/index.html 全部 function / section / dialog / 关键 id 行号"""
import io, re, sys
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
p = r'e:\code\crm-backend-matt\.scratch\customer-demo-ref\demo\index.html'
lines = open(p, 'r', encoding='utf-8').read().splitlines()
pat = re.compile(r'^(async )?function (\w+)|<section id="([\w-]+)"|<dialog id="(\w+)"|id="(dlg\w+|tab\w*)"')
for i, ln in enumerate(lines, 1):
m = pat.search(ln)
if m:
print(f'{i}: {ln.strip()[:150]}')

529
.scratch/customer-demo-ref/assets/_fa-core.md

@ -1,529 +0,0 @@
# 客户 Demo · 核心域字段逐入口对齐表(_fa-core)
> 用途:`.scratch/customer-demo-ref/demo/index.html` 的冒烟断言 + 人工验收双用。入口编号对齐 `assets/coverage-matrix.md`(缺口动作≠豁免)。
> 契约权威优先级:**Bruno `.bru` 出参示例 > DTO/Param 源码 > API-SUMMARY(下称 AS,`.scratch/customer-module/API-SUMMARY.md`)**。字段名逐字取自源码/`.bru`;「示例」列取 `.bru` E2E 实测真值或任务书 seed。
> **envelope**:统一 `Result<T> = {code, success, message, data}`(code=0 成功,错误码见 AS §1);本表只写 `data` 内字段。
## 通用口径(各入口共用,正文不再重复)
- **分页请求**(`BaseParam`,crm-base):`current`(默认 1)/ `size`(默认 10,上限 500)/ `keyword` / `orderBy` / `asc`(默认 false=倒序)。
- **分页出参**(`PageResult`):`content[]` / `total` / `size` / `current` / `pages` / `empty`(=total==0)。
- **Long 序列化**:雪花 id 与 Long 计数 JSON 序列化为**字符串**(E2E 实测 `"opportunityCount":"0"`、`"total":"5"`);前端勿做 Number 运算,判空按 `=== null` / `!= null`
- **时间格式**:LocalDateTime `yyyy-MM-dd HH:mm:ss`;LocalDate `yyyy-MM-dd`;表单提交 LocalDateTime 用 ISO `T` 分隔(空格分隔报 400,见 #40 follow/add)。
- **空值"--"口径**:demo 渲染 `value || '--'`(grid 兜底,旧 demo L1124-1135 同);契约 null 字段显 `--`,不显 0/空串。
- **脱敏总述**:核心域唯一脱敏位=查重两 DTO 的 `masked` 布尔标志——无权命中时 `customerId/customerName/ownerName/customerNo` 等**业务字段不透出(null)+ masked=true**,前端只渲染「存在匹配记录」(check-name)/「系统已存在该企业」(check-credit-code)。客户列表/详情 V1 **无字段级脱敏**:`unifiedCreditCode`、`presidentClassPhone` 等明文回显(E2E 实测 `"13800000001"`;敏感信息三档脱敏定稿划出,AS §0/§5.4)。联系人电话脱敏 + `contact/reveal` 明文端点属 contact 域,不在本表。
- **冒烟 seed**(e2e 种子库):admin `userId=739564171091247104`(罗伟健);客户 `750844477165273088`(e2c-全字段-科技,编号 KH202609030010,负责人=admin);公海客户 `750844487390986240`(e2c-公海-甲,无主);联系人 `750844500171030528`(张关键)。
---
### #1 workspace 列表分页 — `POST /api/customer/workspace/page`(mine/overview/pool 三 workspace 共用一份契约)
- 出处:`A4 客户管理\我的客户\workspace 列表分页.bru`(客户总览/客户公海同名 .bru 各一件)+ `crm-customer/.../domain/param/CustomerWorkspacePageParam.java``domain/dto/CustomerListRowDTO.java`(出参 SQL 见 `mapper/CustomerMapper.java` pageWorkspace)
- 数据范围:overview=裸表+`@DataScope`(管理员可见全部);mine=负责人**或**协同人(子查询包裹);pool=owner 空且按部门可见范围(子查询包裹)。
**请求参数**(form 表单绑定;含 BaseParam 五参):
| 名 | 类型 | 必填 | 示例 | 口径 |
|---|---|---|---|---|
| workspace | string | 是 | `mine` | `mine`/`overview`/`pool`;未知值 67001「未知 workspace」 |
| viewType | string | 否 | `ASSIGNED` | 内置五视图(硬编码不落库),空=基础集;适配矩阵见下 |
| savedViewId | string | 否 | | 叠加自定义视图(#7)检索条件于数据集之上,不改 @DataScope 边界;**RECENT 视图不接**;视图不存在静默不叠加;**board/cards 不接** |
| archiveStatus | number | 否 | `1` | 1 有效 / 2 已归档;默认 1(D19 归档=删除语义) |
| customerStage | number | 否 | `1` | 1 潜在 / 2 重潜 / 3 已成交 |
| customerType | string | 否 | `customer_type_01` | customer_type 字典 code |
| customerStarLevel | number | 否 | `3` | 1~5 |
| relationStarLevel | number | 否 | `3` | 1~5 |
| industryCode | string | 否 | `gov` | 行业一级 code |
| provinceCode | string | 否 | `440000` | 省国标 code |
| groupColumn | string | 否 | `stage` | 仅看板两端点有意义:白名单 `stage`/`star`/`relation`,越界静默忽略 |
| groupValue | number | 否 | `1` | 仅 board/cards(与 groupColumn 配对) |
| current / size / keyword / orderBy / asc | — | 否 | `1` / `50` / `恒信达` | BaseParam;keyword 按名称/编号等业务列模糊 |
**viewType 内置五视图 × workspace 适配矩阵**(越界视图不硬拒,SQL 自然空集;F7 口径:ownerOnly 等布尔未做,单 viewType 参数承载五视图):
| viewType | 文案 | mine | overview | pool |
|---|---|---|---|---|
| ASSIGNED | 我负责的 | ✅ | ✅ | ❌ |
| COLLABORATING | 我协同的 | ✅ | ✅ | ❌ |
| FOLLOW_UP_DUE | 待跟进(next_follow_time 到期未跟) | ✅ | ❌ | ❌ |
| FOCUSED | 我关注的 | ✅ | ✅ | ✅ |
| RECENT | 最近访问(customer_view_log 倒序锚点) | ✅ | ✅ | ✅(不接 saved-view) |
**出参**(`PageResult<CustomerListRowDTO>`,data 内 24 字段;示例值=E2E 公海行 + mine 行):
| 字段路径 | 类型 | 口径/文案 | 脱敏 | 示例 |
|---|---|---|---|---|
| content[].id | Long→str | 客户 ID | - | `"750844487390986240"` |
| content[].customerNo | string | 客户编号 KH+yyyyMMdd+4 位 | - | `"KH202609030020"` |
| content[].customerName | string | 客户名称(固定列不可隐藏) | - | `"e2c-公海-甲"` |
| content[].customerType | string | 字典 code(前端字典缓存翻译,**后端不回显名**) | - | `"customer_type_01"` |
| content[].provinceCode / cityCode / districtCode | string | 国标 code(名称前端字典渲染) | - | `"440000"` / `"440100"` / `"440103"` |
| content[].industryCode / industryChildCode | string | 行业一级/二级 code | - | `"gov"` / `"gov_1"` |
| content[].customerStage | number | 1 潜在 2 重潜 3 已成交 | - | `1` |
| content[].customerStarLevel / relationStarLevel | number | 1~5 | - | `3` / `3` |
| content[].strategicAgreementLevel | number | 战略协议等级快照,null=未签 | - | `null` |
| content[].vipCustomerLevel | number | 已成交 VIP 客户等级快照,null=无 | - | `null` |
| content[].opportunityCount | Long→str | **真算**:SQL 标量子查询 `opportunity_customer join opportunity`(oc.delete_key=0 且 o.deleted=0) | - | `"0"` |
| content[].projectCount | Long→str | **恒空**:SQL `SELECT 0 AS project_count`(A5 未接);AS §2.1 写「恒 null 显--」、DTO @Schema 注「恒 0」、E2E 实测 `"0"`——demo 按「恒空显 --」渲染即可,值不具业务含义 | - | `"0"` |
| content[].lastValidFollowTime | string | 最近有效跟进时间(超期锚点) | - | `"2026-09-03 22:55:18"` |
| content[].ownerUserId | Long→str | 销售负责人 ID;**pool 行恒 null** | - | `null`(pool)/ `"739564171091247104"`(mine) |
| content[].ownerUserNameSnapshot | string | 负责人姓名快照 | - | `null` / `"罗伟健"` |
| content[].ownerDeptId | Long→str | 销售部门 ID 快照 | - | `"744841292348915712"` |
| content[].ownerDeptNameSnapshot | string | 部门名快照 | - | `"广东保伦电子股份有限公司"` |
| content[].enterPoolTime | string | 进入公海时间;**pool 固定列可排序**(D29 pool 默认排序=enter_pool_time 倒序;其余 workspace 默认 create_time 倒序) | - | `"2026-09-11 11:45:49"` / `null` |
| content[].archiveStatus | number | 1 有效 2 已归档 | - | `1` |
| content[].lastViewTime | string | 最近访问时间;**仅 RECENT 视图非 null**(同时是 RECENT 排序锚点) | - | `null` |
| content[].focusFlag | number | 是否关注:customer_focus 行存在=1(**按当前登录用户**),0=未关注;三 workspace 通用 | - | `0` |
| total / size / current / pages | Long→str | 分页元信息 | - | `"5"` / `"1"` / `"1"` / `"5"` |
| empty | boolean | total==0 | - | `false` |
- 备注:原型差异列=mine/overview 加负责人+部门四件,pool 加 enterPoolTime 且无负责人列;demo 现 11 列,类型/行业/战略协议等级/VIP 等级/进入公海时间列未渲染(矩阵 #11 字段级挂 07 票)。字典列后端一律回 code 不回名(CustomerListRowDTO 类注释明示)。
---
### #9 看板分组汇总 — `POST /api/customer/workspace/board/summary`
- 出处:`A4 客户管理\我的客户\看板列表\看板分组汇总.bru`(客户总览同名一件;**公海无看板 .bru**)+ `domain/dto/CustomerBoardSummaryDTO.java``service/impl/CustomerWorkspaceServiceImpl.java`
- 请求参数:复用 `CustomerWorkspacePageParam`(同 #1),**groupColumn 必填**(stage/star/relation),**无需 groupValue**;分页/keyword 意义有限(同 WHERE 口径)。示例载荷:`workspace=mine&groupColumn=stage&current=1&size=1&keyword=恒信达`
- 出参(`List<CustomerBoardSummaryDTO>`,数组元素两字段):
| 字段路径 | 类型 | 口径/文案 | 示例 |
|---|---|---|---|
| [].groupValue | number | 分组取值(stage 1~3 / star 1~5) | `1` |
| [].cnt | Long→str | 该分组客户数(**仅返回有数据分组**,空列骨架前端补全;本期重潜/已成交恒空=D20 预期) | `"28"` |
- 备注(**demo 旧代码字段名漂移**):旧 demo `loadSummary`(L590-592)读 `it.count` / `it.name`——**契约字段是 `cnt` / `groupValue`,旧读法必显 "--"**,改造时按 `groupValue`→文案、`cnt`→数字渲染。E2E 实测:`[{"groupValue":1,"cnt":"28"},{"groupValue":2,"cnt":"6"},{"groupValue":3,"cnt":"3"}]`。
### #9 看板单列卡片 — `POST /api/customer/workspace/board/cards`
- 出处:`A4 客户管理\我的客户\看板列表\看板单列卡片.bru`(总览同名)+ 同上源码
- 请求参数:同 #1 + `groupColumn`(stage/star/relation)+ **`groupValue` 必填**(缺省 67001「看板取卡必须指定分组值」)。示例载荷:`groupColumn=stage&groupValue=2&workspace=mine&current=1&size=20&keyword=恒信达`
- 出参:`PageResult<CustomerListRowDTO>`——**与列表 #1 逐字段同构**(24 字段,E2E 卡片样例:customerStage=2、customerStarLevel=4、ownerUserId=`"739564171091247104"`、total=`"6"`)。
- 备注:仅 mine/overview 可用;**pool 调看板两端口一律 67001「公海无卡片视图,看板仅支持客户总览/我的客户」**(源码 `assertBoardAllowed` 守卫在,AS §2.1 文档口径一致;GR §六.1 记 jar 实测 code=0 漂移待 04 票核实);board/cards 不接 saved-view;summary 与 page/cards 同一 WHERE 口径,数字自洽。
---
### #7 preference 自定义视图四端点(saved-view) — `GET|POST /api/preference/view/list|save|delete|set-default`
- 出处:**A4 无 saved-view `.bru` 文档**(契约源=AS §2.14 + crm-preference 源码 `controller/SavedViewController.java`、`domain/dto/SavedView.java`、`SavedViewCondition.java`);示例形态借 `A3 商机管理\销售机会\自定义视图\*.bru` 四件(字段结构同源,商机域 scopeKey 实测 `opportunity`
- **scopeKey**(客户域,`CustomerConstants` L67-69):`customer.mine` / `customer.overview` / `customer.pool`;userId 取登录态,不接受前端传入。
- 端点逐个:
| 端点 | 请求参数 | 出参 data |
|---|---|---|
| `GET /api/preference/view/list?scopeKey=` | scopeKey | `List<SavedView>`,按 seqNo 排序;出参不回显 scopeKey |
| `POST /api/preference/view/save?scopeKey=` | scopeKey(query 表单参)+ **`@RequestBody SavedView` JSON**——全平台唯一 JSON 例外(ADR-0017 范围外) | `String` viewId(新建/覆盖后服务端生成) |
| `POST /api/preference/view/delete?scopeKey=&viewId=` | scopeKey、viewId | `null` |
| `POST /api/preference/view/set-default?scopeKey=&viewId=` | scopeKey、viewId(单值互斥,设默认自动取消原默认) | `null` |
- **SavedView 字段**(JSON body):
| 字段 | 类型 | 必填 | 口径 |
|---|---|---|---|
| viewId | string | 新建可空;编辑/删除/设默认须回传 | 服务端生成 32 位 hex(样例 `"e7b9595f7ea54641b093e9d6bfb096f9"`) |
| name | string | 是 | 检索名称(用户填) |
| conditions | array | 否 | 本期前端只提交 1 组(契约按列表预留复合扩展) |
| conditions[].field | string | - | 业务方字段池稳定 code(如 `industryCode`) |
| conditions[].operator | string | - | 业务方自定义:等于/不等于/包含/不包含/为空/不为空(demo 用 eq/like 等) |
| conditions[].value | string | - | 为空/不为空操作符时可为 null |
| sortField / sortDirection | string | 否 | 排序字段 key / `asc`|`desc` |
| isDefault | boolean | 否 | 默认视图图钉,单值互斥 |
| seqNo | number | 否 | 列表排序序号 |
- 备注:内置五视图(viewType)不走本接口;workspace/page 的 `savedViewId` 消费本接口存的 viewId(#1);视图为用户私有。
### #8 preference 视图形态两件 — `GET|POST /api/preference/view-form/get|save`
- 出处:`A4 客户管理\我的客户\读取视图形态.bru` + `保存视图形态.bru`(总览/公海同名共 6 件)+ `ViewFormController.java`、`ViewFormServiceImpl.java`
- `GET /api/preference/view-form/get?scopeKey=``String`:`list`/`split`/`board`;**null=未设置,前端默认 list**(E2E:`"data": "list"`)
- `POST /api/preference/view-form/save`(form:`scopeKey` + `viewForm`)→ `null`;白名单 {list,split,board},非法 68001(平台段位,不在 67xxx)
- 备注:scopeKey 同 #7 三码位(`customer.mine/overview/pool`)。文档口径「pool 仅 list/split,board 被拒 680xx」**实现侧无 pool 特判——pool 保存 board 实回 code=0**(AS §2.14 ⚠ I-06 r2 漂移),前端不得依赖拒绝行为。
---
### #13-19 归属/关注/重点 动作族(合并表,每端点一行;全 POST,id/ids 走查询参数)
- 出处:`crm-customer/.../controller/CustomerOwnershipController.java`、`CustomerFocusController.java`;.bru 逐件:`客户公海\领取客户.bru`、`批量领取.bru`、`分配客户.bru`、`批量分配.bru`、`抛公海.bru`、`批量抛公海.bru`、`归档客户.bru`、`批量归档.bru`、`恢复客户.bru`、`关注客户.bru`、`取消关注客户.bru`、`批量关注客户.bru`、`客户详情\标记重点客户.bru`、`取消重点标记.bru`
- 示例值:单条 `id=750844487390986240`;批量 `ids=750844487390986240`(重复键 `ids=..&ids=..`);分配目标 `userId=744842318024015872`
| 端点 | 参数 | 返回 data | 主要错误码 | 口径 |
|---|---|---|---|---|
| `POST /api/customer/focus?id=` | id | `null`(Result<Void>) | 67002 | 幂等;行存在性=关注态;不写 oplog |
| `POST /api/customer/unfocus?id=` | id | `null` | — | 幂等;物理删行,连带清重点标记 |
| `POST /api/customer/focus-batch?ids=` | ids[] | **`null`(注意:非 BatchResult**——controller 循环逐个 focus 后恒 success) | 67002 | 逐个幂等关注,对称商机 |
| `POST /api/customer/star?id=` | id | `null` | 67002 | 幂等;未关注自动建行 starred=1;不写 oplog |
| `POST /api/customer/unstar?id=` | id | `null` | — | 幂等;保留行仅置 starred=0 |
| `POST /api/customer/assign?id=&userId=` | id、userId | `null` | 67002/67012 | 目标人员启用在职+非原负责人;不校验进行中商机 |
| `POST /api/customer/assign-batch?ids=&userId=` | ids[]、userId | `BatchResult` | 67002/67012 | 目标人员统一校验一次(无效**整批拒绝**);其余逐条独立事务 |
| `POST /api/customer/claim?id=` | id | `null` | 67002/67004 | 校验仍在公海;D25 领取即赋 last_valid_follow_time 锚点(为空时赋当下) |
| `POST /api/customer/claim-batch?ids=` | ids[] | `BatchResult` | 67002/67004 | 逐条独立事务,部分成功 |
| `POST /api/customer/release-pool?id=` | id | `null` | 67002/67007 | 清 owner+协同人(部门锚点保留),软删 team_member;进行中商机 67007 阻断 |
| `POST /api/customer/release-pool-batch?ids=` | ids[] | `BatchResult` | 67002/67007 | 逐条独立事务,部分成功 |
| `POST /api/customer/archive?id=` | id | `null` | 67002/67004/67006 | 仅销售负责人;归档=删除语义(D19);进行中商机 67006 阻断 |
| `POST /api/customer/archive-batch?ids=` | ids[] | `BatchResult` | 67002/67004/67006 | **整批预检:任一失败整批不执行**(预检失败=顶层非 0,E2E 实测顶层 `code:67006`);预检过后逐条独立事务 |
| `POST /api/customer/restore?id=` | id | `null` | 67002/67004 | 仅销售负责人(已归档→有效) |
- **BatchResult 结构**(`crm-base/.../result/BatchResult.java`):`{total:int, successCount:int, failCount:int, failures:[{id:Long→str, reason:string, message:string}]}`。`reason` 语义枚举(`CustomerBatchFailReason`):`NOT_FOUND / STATUS_NOT_ALLOWED / CONCURRENT_MODIFIED / ARCHIVE_BLOCKED / POOL_BLOCKED / ASSIGN_INVALID / UNKNOWN`。E2E 失败行样例:`{"id":"753612204858671104","reason":"STATUS_NOT_ALLOWED","message":"客户已被领取,不在公海"}`。
- **错误码含义**(AS §1):67004 当前状态不允许(归档/恢复/抛公海守卫);67006 归档被阻断:存在进行中商机;67007 抛公海被阻断:存在进行中商机;67012 分配非法(明细已分配/对象越权/为发起人本人/为当前负责人)。
- 单条动作 CAS 乐观锁 + 写 oplog(TRANSFER/CLAIM/POOL/ARCHIVE/RESTORE 均入 #45 oplog 值域);关注/重点族不写 oplog。
---
### #21 新建客户 — `POST /api/customer/create`(form 绑定 `CustomerCreateDTO`,禁 @RequestBody
- 出处:`A4 客户管理\我的客户\新增客户.bru` + `domain/dto/CustomerCreateDTO.java`;出参 `CustomerSaveResultDTO.java` + `CustomerSimilarHitDTO.java`
- 三层查重内建:L1 停输防抖(check-name,只提示)→ L2 名称相似 needConfirm 弹窗(未落库)→ L3 信用代码硬拦(67003)。`ownerUserId` 空=直接进公海并记 `enter_pool_time`(D25,锚点 NULL);有值=直接指派(快照服务端补齐,不信任前端传部门)。
- 请求参数(DTO 全字段,按区块分组;「必填」以 .bru 参数表+DTO @Schema 为准):
| 名 | 类型 | 必填 | 示例 | 值域/口径 |
|---|---|---|---|---|
| customerName | string | 是 | `e2c-查重-撞码-003045` | 停输防抖触发相似查重 |
| customerType | string | 是 | `customer_type_01` | customer_type 字典(6 值,见字典节) |
| provinceCode / cityCode | string | 是 | `440000` / `440100` | 国标 code |
| districtCode | string | 否 | `440103` | 国标 code |
| industryCode | string | 是 | `gov` | 行业一级 code |
| industryChildCode | string | 否 | `gov_1` | 行业二级 code |
| customerStarLevel / relationStarLevel | number | 是 | `3` | 1~5 |
| networkUnits | string | 否 | | 人脉关系单位 JSON 数组(标签名) |
| ownerUserId | string(Long) | 否 | `739564171091247104` | 空=进公海;无效/停用 67012 |
| companyDecisionMaker | string | 否 | `王总` | 公司决策人 |
| isBizNegotiated | number | 是 | `0` | 1 是 / 0 否 |
| directorVisitTime | string | 否 | | 总监近期拜访时间 yyyy-MM-dd HH:mm:ss |
| isChild | number | 是 | `0` | 1 是 / 0 否(默认 0) |
| parentCustomerId | string(Long) | 否 | | isChild=1 时必填,服务端防环校验 |
| cooperationSystem | string | 否 | `OA对接` | 合作系统 |
| nonCoopReason | string | 否 | | 不合作原因 |
| joinedPresidentClass | number | 否 | `1` | 1/0/null(未选) |
| presidentClassPerson / presidentClassPhone | string | 条件 | `王总` / `13800000001` | joinedPresidentClass=1 时 Person 必填 |
| joinedProductClass | number | 否 | | 1/0/null |
| productClassPerson / productClassPhone | string | 条件 | | joinedProductClass=1 时 Person 必填 |
| unifiedCreditCode | string | 否 | `91440101E2CHXDA001` | 18 位数字或大写字母;撞码 L3 硬拦 67003 |
| legalRepresentative | string | 否 | `王测试` | |
| establishedDate | string | 否 | `2018-05-20` | yyyy-MM-dd,不得晚于当前日期 |
| registeredCapital / businessScope / staffSize / annualRevenue / businessAddress | string | 否 | `1000万元人民币` 等 | 工商信息区文本 |
| bizNameFromLookup | string | 否 | | 企查查确认的工商名称(只存不覆盖客户名称) |
| remark | string | 否 | `e2e 实测备注` | ≤500 字 |
| confirmSimilar | boolean | 否 | `true` | L2 弹窗后「仍要创建」重发置 true |
- 出参(`CustomerSaveResultDTO`,data 内 5 字段):`id`(Long→str,落库成功时)、`needConfirm`(Boolean,true=名称相似需确认**未落库**)、`similarHits[]`(见 #22)、`customerNo`(落库成功时,`"KH202609110010"`)、`customerName`。
- 错误码:67001 入参非法 / 67003 信用代码撞码 / 67012 销售负责人无效 / 67015 查重设置单例缺失。
- E2E 落库成功样例:`{"id":"753584451841163264","needConfirm":null,"similarHits":null,"customerNo":"KH202609110010","customerName":"e2c-cg-A-098932"}`。
### #22 名称相似查重 — `GET /api/customer/check-name?name=&excludeId=`
- 出处:`A4 客户管理\客户查重\名称相似提示.bru` + `CustomerSimilarHitDTO.java`(L1 停输防抖 / L2 弹窗共用载荷;create/quick-create 内建查重同款)
- 请求参数:`name`(string,必填,待查客户名称);`excludeId`(string,可选,编辑页传自身排除)。
- 出参(`List<CustomerSimilarHitDTO>`,data 内 6 字段/行):
| 字段路径 | 类型 | 口径 | 脱敏 | 示例 |
|---|---|---|---|---|
| [].customerId | Long→str | 命中客户 ID | **无权命中=null** | `"750844478243209216"` |
| [].customerName | string | 命中客户名称 | 无权命中=null | `"e2c-恒信达科技有限公司"` |
| [].ownerName | string | 命中客户负责人 | 无权命中=null | `"罗伟健"` |
| [].customerStage | number | 1 潜在 2 重潜 3 已成交 | 无权命中=null | `1` |
| [].archiveStatus | number | 1 有效 2 已归档 | 无权命中=null | `1` |
| [].masked | boolean | 无权命中=true | **true 时以上五字段全 null,前端只渲染「存在匹配记录」** | `false` |
- 备注:**masked 脱敏形态(契约=DTO 注释,E2E 无 masked=true 样例)**:不是打星号字符串,而是「字段置 null + masked=true + 固定文案」。匹配方式/阈值走查重设置(AS §2.13:nameMatchMode 1 精确 / 2 模糊,similarityThreshold 0~100 出厂 80;.bru docs 提 ngram 阈值 70/80/90 为旧口径,以查重设置单例为准)。错误码 67015(单例未落地时走出厂默认不报)。
### #23 信用代码查重 — `GET /api/customer/check-credit-code?creditCode=`
- 出处:`A4 客户管理\客户查重\信用代码查重.bru` + `CreditCodeCheckDTO.java`
- 请求参数:`creditCode`(string,必填,18 位)。示例 `91440101MA9ABC1234`
- 出参(`CreditCodeCheckDTO`,data 内 6 字段):
| 字段路径 | 类型 | 口径 | 脱敏 | 示例 |
|---|---|---|---|---|
| exists | boolean | 是否已存在同码客户 | - | `true` |
| masked | boolean | 无权命中=true | **true 时只提示「系统已存在该企业」** | `false` |
| customerId | Long→str | 命中客户 ID | 有权可见时给 | `"750844478243209216"` |
| customerName | string | 命中客户名称 | 有权可见时给 | `"e2c-恒信达科技有限公司"` |
| customerNo | string | 命中客户编号 | 有权可见时给 | `"KH202609030011"` |
| ownerName | string | 命中客户负责人 | 有权可见时给 | `"罗伟健"` |
- 备注:L1 即时校验(失焦/企查查回填后调用);L3 保存时硬拦 67003;系统强制无开关(AS §2.13)。
### #24 工商联想 — `GET /api/customer/company-lookup?companyName=`(**参数名是 companyName 不是 name**)
- 出处:`A4 客户管理\我的客户\工商信息查询.bru` + `port/CompanyLookupPort.java`
- 请求参数:`companyName`(string,必填,公司名称关键字)。示例 `恒信达`
- 出参(`List<CompanyCandidate>`,9 字段/候选):`bizName` / `unifiedCreditCode` / `legalRepresentative` / `establishedDate`(LocalDate)/ `registeredCapital` / `businessScope` / `staffSize` / `annualRevenue` / `address`
- 备注:**一期 stub 恒返回空数组**(E2E:`"data": []`),前端按「未查询到匹配企业」渲染,可手工录入;真实企查查接入后自动激活。前端职责:只回填当前为空的字段,已填字段再选须弹「受影响字段确认」;回填后立即调 check-credit-code;无结果/失败/超时均不阻断手工提交。
### #25 编辑客户 — `POST /api/customer/edit?id=`(form 绑定 `CustomerUpdateDTO` + CAS)
- 出处:`A4 客户管理\客户详情\编辑客户.bru` + `CustomerUpdateDTO.java`
- 请求参数:查询参数 `id` + 表单(与 #21 create 的差异):
- **多**:`version`(number,**必传**,乐观锁 CAS,不匹配报 **67005**「乐观锁冲突」;编辑回显取 `GET /detail``version` 带回)。
- **少**:无 `ownerUserId`(**归属字段不可改**——负责人/部门「✅(新增时)」;服务端忽略任何归属入参,归属变更走 assign/transfer)。
- 其余 33 字段与 create 同名同义(customerName…remark + confirmSimilar),必填区相同(isBizNegotiated/isChild 必填)。
- 出参:同 `CustomerSaveResultDTO`(needConfirm 相似确认流同样适用,`confirmSimilar=true` 重发)。
- 错误码:67001 / 67002 / 67003 / 67005。
- 备注(AS §2.3 edit 行注):冒烟实测需一并提交 `version`+`isBizNegotiated`+`isChild`,缺省因 DB NOT NULL 无默认而失败(关联 AS §5 D-05 已修,冒烟口径保守保留)。E2E 反例样例:`{"code":67005,"success":false,"message":"缺少乐观锁版本号","data":null}`。
### #47 快速创建客户 — `POST /api/customer/quick-create`(`CustomerQuickCreateDTO` 最小集)
- 出处:`A4 客户管理\我的客户\快速创建客户.bru` + `CustomerQuickCreateDTO.java`
- 请求参数(7+1 字段,DTO `requiredMode=REQUIRED` 全 7 必填):
| 名 | 类型 | 必填 | 示例 |
|---|---|---|---|
| customerName | string | 是 | `e2c-快创-448049` |
| customerType | string | 是 | `customer_type_01` |
| provinceCode / cityCode | string | 是 | `440000` / `440100` |
| industryCode | string | 是 | `gov` |
| customerStarLevel / relationStarLevel | number | 是 | `2` |
| confirmSimilar | boolean | 否(默认 false) | 命中相似确认后重发置 true(返工票 04 补齐) |
- 出参:`CustomerSaveResultDTO`(同 #21)。E2E needConfirm 样例:`{"needConfirm":true,"similarHits":[{"customerId":"753620871771324416","customerName":"e2c-t05qc-相似源甲科技有限公司","ownerName":"罗伟健","customerStage":1,"archiveStatus":1,"masked":false}],"customerNo":null,"customerName":null}`。
- 错误码:67001 / 67003。
- 备注(**两处 .bru 漂移,以源码/AS 为准**):① .bru 参数表把 industryCode/两星级标「否」且**漏 `confirmSimilar`**——DTO 源码有该字段(返工票 04 已补);② .bru docs 文本「QuickCreateDTO 无 confirmSimilar 字段」为陈旧描述。服务端默认:owner=当前用户、is_child=否、isBizNegotiated=否;查重三层与编号生成复用主表 create。
### #48 商机侧客户搜索 — `POST /api/customer/search`(form 绑定 `CustomerSearchParam`
- 出处:**无 A4 `.bru`**(契约源=源码 `CustomerSearchParam.java` + `CustomerSearchItemDTO.java` + AS §2.4);A3 侧演示形态待 03 票
- 请求参数:BaseParam 五参(keyword=**名称/联系人/电话三维模糊**)+ `customerType`(string,字典 code 精确,可空)+ `archiveStatus`(number,1/2,可空=全部)+ `excludeCustomerIds`(Long 数组,重复键;商机候选池排除已关联客户,可空)。
- 出参(`PageResult<CustomerSearchItemDTO>`,行 9 字段):
| 字段路径 | 类型 | 口径 | 示例 |
|---|---|---|---|
| content[].id | Long→str | 客户 id | — |
| content[].customerNo | string | 客户编号 | — |
| content[].customerName | string | 客户名称 | — |
| content[].customerType | string | 字典 code | — |
| content[].ownerUserId | Long→str | 销售负责人 id | — |
| content[].ownerName | string | 负责人姓名(快照) | — |
| content[].ownerDeptId | Long→str | 负责人部门 id | — |
| content[].customerStage | number | 1 潜在/2 重潜/3 已成交 | — |
| content[].archiveStatus | number | 1 有效/2 已归档 | — |
- 备注:轻量出参;选中后走 `GET /api/customer/detail?id=` 带出全量;DataScope 注入可见范围。
---
### #29 详情公共头部 — `GET /api/customer/detail-head?id=`
- 出处:`A4 客户管理\客户详情\详情公共头部.bru` + `domain/dto/CustomerDetailHeadDTO.java`
- 请求参数:`id`(string,必填,查询参数)。示例 `750844477165273088`
- **副作用**:upsert `customer_view_log` 刷新「最近访问」锚点(F7 viewTouch 收口;UNIQUE(user_id,customer_id) 去重;高频行为不落 oplog)——冒烟断言 RECENT 视图排序前先打一次详情头。
- 出参(`CustomerDetailHeadDTO`,data 内 24 字段;示例=E2E 客户甲):
| 字段路径 | 类型 | 口径/文案 | 示例 |
|---|---|---|---|
| id | Long→str | 客户 ID | `"750844477165273088"` |
| customerNo | string | 客户编号 | `"KH202609030010"` |
| customerName | string | 客户名称 | `"e2c-全字段-科技"` |
| customerStage | number | 1 潜在 2 重潜 3 已成交(只读条,不可手动改) | `1` |
| customerStageName | string | 阶段名回显(CustomerStageEnum) | `"潜在客户"` |
| customerStarLevel / relationStarLevel | number | 1~5 | `3` / `3` |
| strategicAgreementLevel | number | 快照,null=未签 | `null` |
| vipCustomerLevel | number | 已成交 VIP 等级快照,null=无 | `null` |
| archiveStatus | number | 1 有效 2 已归档(已归档态 badge 数据源) | `1` |
| ownerUserId | Long→str | 销售负责人 ID(公海 null) | `"739564171091247104"` |
| ownerUserNameSnapshot | string | 负责人姓名快照 | `"罗伟健"` |
| ownerDeptNameSnapshot | string | 部门名快照 | `"广东保伦电子股份有限公司"` |
| lastFollowSummary | string | 最近跟进摘要(最新一条跟进内容) | `"e2c-F08 跟进实测:电话沟通年度合作意向"` |
| lastFollowTime | string | 最近跟进时间 | `"2026-09-11 11:45:33"` |
| nextFollowTime | string | 待跟进提醒(未来最近一条下次跟进时间,null=无待跟进) | `"2026-09-12 22:34:06"` |
| opportunityCount | Long→str | 关联商机数(外部反查,失败前端显 "--") | `"0"` |
| projectCount | Long→str | DTO @Schema=「恒 null 显 --」;E2E 实测 `"0"`——按恒空渲染 | `"0"` |
| wonProjectAmount | BigDecimal | 汇总卡①已中标项目总额——**恒 null 显 "--"**(A5/投标 seam 未接入) | `null` |
| planEstimateAmount | BigDecimal | 汇总卡②跟进中方案预估总额——恒 null 显 "--"(方案卡 seam) | `null` |
| ongoingOpportunityAmount | BigDecimal | 汇总卡③进行中商机总额——恒 null 显 "--"(可经 port 扩展后切真值) | `null` |
| contractAmount | BigDecimal | 汇总卡④合同总额——恒 null 显 "--"(合同模块未建) | `null` |
| paidAmount | BigDecimal | 汇总卡④累计回款——恒 null 显 "--"(与合同总额同卡) | `null` |
- 备注:**金额 5 字段恒 null 显 "--" 是 grill Q18 契约:前端渲染 4 张汇总卡、显 "--" 不显 0**(矩阵 #29=P3-6 改造点:demo 未渲染卡)。错误码 67002。
### #30 客户详情(页签1 客户信息 / 编辑页回显同源) — `GET /api/customer/detail?id=`
- 出处:`A4 客户管理\客户详情\客户详情.bru` + `domain/dto/CustomerDetailDTO.java`(extends BaseDTO)
- 请求参数:`id`(string,必填,查询参数)。错误码 67002。
- 出参(`CustomerDetailDTO`,data 内 48 字段=BaseDTO 3 + 本类 45;示例=E2E 客户甲):
| 字段路径 | 类型 | 口径/文案 | 脱敏 |
|---|---|---|---|
| id | Long→str | 主键 | - |
| createTime / updateTime | string | **经 BaseDTO 带出,E2E 实测有值** `"2026-09-03 22:27:53"`(见下方 G4 备注) | - |
| customerNo | string | KH+yyyyMMdd+4 位序,只读 | - |
| customerStage | number | 1 潜在 2 重潜 3 已成交(只读条) | - |
| archiveStatus | number | 1 有效 2 已归档 | - |
| version | number | 乐观锁版本号(**编辑回传**,E2E `3`) | - |
| customerName | string | 客户名称 | - |
| customerType / customerTypeName | string | code + **字典名回显**(`customer_type_01`/`总包`) | - |
| provinceCode / cityCode / districtCode | string | 国标 code(名称前端字典渲染,后端不回名) | - |
| industryCode / industryName | string | `gov`/`政府机关`(一级字典名回显) | - |
| industryChildCode / industryChildName | string | `gov_1`/`人大政协`(二级字典名回显) | - |
| customerStarLevel / relationStarLevel | number | 1~5 | - |
| networkUnits | string | 人脉关系单位 JSON 数组 | - |
| ownerUserId | Long→str | 销售负责人 ID(公海 null) | - |
| ownerUserNameSnapshot / ownerDeptId / ownerDeptNameSnapshot | — | 负责人姓名/部门 ID/部门名快照 | - |
| enterPoolTime | string | 进入公海时间(公海客户才有值) | - |
| lastValidFollowTime | string | 最近有效跟进时间(超期锚点) | - |
| strategicAgreementLevel / vipCustomerLevel | number | 快照(null=未签/无) | - |
| companyDecisionMaker | string | 公司决策人 | - |
| isBizNegotiated | number | 是否商机勾兑 1/0 | - |
| directorVisitTime | string | 总监近期拜访时间 | - |
| isChild / parentCustomerId / parentCustomerNameSnapshot | — | 子客户标志/父客户 ID/父客户名快照 | - |
| cooperationSystem / nonCoopReason | string | 合作系统/不合作原因 | - |
| joinedPresidentClass / presidentClassPerson / presidentClassPhone | — | 总裁班三件(**电话明文**,E2E `"13800000001"`) | **无脱敏** |
| joinedProductClass / productClassPerson / productClassPhone | — | 产品班三件(电话明文) | **无脱敏** |
| unifiedCreditCode | string | 统一社会信用代码(E2E `91440101E2CTEST001` 明文) | **无脱敏(V1 三档脱敏划出)** |
| legalRepresentative / establishedDate / registeredCapital / businessScope / staffSize / annualRevenue / businessAddress / bizNameFromLookup | — | 工商信息区(establishedDate=LocalDate) | - |
| remark | string | 备注 | - |
- 备注(**G4 校准**):矩阵 #30 记档「系统信息四件套 creatorId/createTime/updaterId/updateTime 不在 DTO 恒"–"」——**逐字核对源码后应修正为:`creatorId`/`updaterId` 确实不在 CustomerDetailDTO(无字段);但 `createTime`/`updateTime` 由 BaseDTO 继承带出且 E2E 实测有值**。demo 现渲染四件套恒 "--" 是保守可行口径;若要显示创建/更新时间可直接取值,只有操作人两件必须 "--"。
### #40 跟进记录分页 — `GET /api/customer/follow/page?id=` + 新增跟进 — `POST /api/customer/follow/add?id=`
- 出处:`A4 客户管理\客户详情\跟进记录\跟进记录分页.bru` + `新增跟进.bru``domain/param/FollowPageParam.java`、`domain/dto/FollowCreateDTO.java`、`domain/entity/CustomerFollow.java`(出参=实体直出)
- **page 请求参数**(GET query):`id`(必填)+ `followWay`(string,follow_way 字典 code 精确,例 `follow_way_01`)+ `startTime`/`endTime`(string,yyyy-MM-dd HH:mm:ss 含端)+ BaseParam 五参。
- **G1 标注**:原型有「操作人」筛选,`FollowPageParam` **无 operatorUserId 参数**——后端无此过滤能力,demo 侧豁免该筛选(P2-G1 记档)。
- **page 出参**(`PageResult<CustomerFollow>`,行 13 字段,实体直出含审计四件套):
| 字段路径 | 类型 | 口径 | 示例 |
|---|---|---|---|
| content[].id / creatorId / createTime / updaterId / updateTime / deleted | — | BaseEntity 审计字段(E2E 实测序列化;`"deleted":false`) | `"750854396027338752"` |
| content[].customerId | Long→str | 所属客户 ID | `"750844477165273088"` |
| content[].followWay | string | follow_way 字典 code(5 值见字典节;**后端不回名**) | `"follow_way_01"` |
| content[].followContent | string | 跟进内容(长文本) | `"e2c-F08 跟进实测:电话沟通年度合作意向"` |
| content[].nextFollowTime | string | 下次跟进时间(有值进「待跟进」筛选) | `"2026-09-10 23:07:18"` |
| content[].followBy | Long→str | 跟进人用户 ID | `"739564171091247104"` |
| content[].followByName | string | 跟进人姓名(服务端快照,前端不传) | `"罗伟健"` |
| content[].followDeptName | string | 跟进人部门名 | `"广东保伦电子股份有限公司"` |
- **add 请求参数**(form):`followWay`(必填,5 值域)、`followContent`(必填,空→67009)、`nextFollowTime`(选填,**ISO `T` 分隔** `2026-09-10T10:00:00`;空格分隔报 400;有值进待跟进)。
- **add 出参**:`Result<Long>` 跟进 id(E2E `"753583108837605376"`)。
- 备注:append-only 提交后不可改删(纠错=新增补充);同客户同内容 30 秒窗口防重复提交;写库刷新 `last_valid_follow_time` 超期锚点(旧未发提醒自动置已失效)。错误码 67002/67009。已知缺陷 D-02(P2):nextFollowTime 早于当前未拦截(.bru docs 记档)。
### #45 操作日志分页 — `GET /api/customer/oplog/page?id=`
- 出处:`A4 客户管理\客户详情\操作日志\操作日志分页.bru` + `domain/param/OplogPageParam.java`、`domain/entity/CustomerOplog.java`(出参=实体直出)
- **请求参数**(GET query):`id`(必填)+ `startTime`/`endTime`(yyyy-MM-dd HH:mm:ss 含端)+ `action`(string,操作类型精确)+ BaseParam 五参(keyword 匹配 `detail` 叙事)。
- **action 值域**:权威=`CustomerConstants` 全部 ACTION_* 常量,**18 种**:`CREATE / UPDATE / FOLLOW / ARCHIVE / RESTORE / TRANSFER / CLAIM / POOL / STAGE_CHANGE / MEMBER_ADD / MEMBER_REMOVE / CONTACT_ADD / CONTACT_EDIT / CONTACT_DELETE / AGREEMENT_ADD / AGREEMENT_EDIT / AGREEMENT_DELETE / GRAPH_EDIT`。(漂移标注:AS §3 写「17 种」未含 GRAPH_EDIT,未同步;demo 筛选下拉按 18 种渲染。)
- **出参**(`PageResult<CustomerOplog>`,行 12 字段):
| 字段路径 | 类型 | 口径 | 示例 |
|---|---|---|---|
| content[].id / creatorId / createTime / updaterId / updateTime / deleted | — | BaseEntity 审计字段 | `"750854380575522816"` |
| content[].customerId | Long→str | 所属客户 ID | `"750844477165273088"` |
| content[].action | string | 18 种值域(见上) | `"UPDATE"` |
| content[].detail | string | 叙事形态 `把 {字段} 从 {旧} 修改为 {新}`(D13;多字段变更串联一条) | `"把人脉关系单位从[...]修改为空;把公司决策人从王总修改为空;…"` |
| content[].operatorId | Long→str | 操作人用户 ID(**系统动作为 null**,如阶段自动流转/导入) | `"739564171091247104"` |
| content[].operatorName | string | 操作人姓名(系统动作记「系统」) | `"罗伟健"` |
| content[].operatorDeptName | string | 操作人部门名快照 | `"广东保伦电子股份有限公司"` |
- 备注:append-only 审计、无限期保留;时间倒序;日期分组由前端渲染。错误码 67002。导出按钮无端点=豁免(P3-4)。
### #41 关联商机页签 — `GET /api/customer/opportunity/page?id=`(port 反查 crm-opportunity)
- 出处:`A4 客户管理\客户详情\关联商机\关联商机页签.bru` + `port/CustomerOpportunityQueryPort.java`(record OpportunityItem,7 字段真名逐字如下)
- 请求参数:`id`(必填)+ BaseParam 五参。
- 出参(`PageResult<OpportunityItem>`,**契约 7 字段**;G2:原型 13 列 vs 契约 7 字段,demo 现 5 列):
| 字段路径 | 类型 | 口径 | 示例 |
|---|---|---|---|
| content[].id | Long→str | 商机 ID | `"750844498195513344"` |
| content[].oppName | string | 商机名称 | `"e2c-联动-交割"` |
| content[].stageName | string | 商机阶段名(已回显名) | `"客户圈定"` |
| content[].estimateAmount | BigDecimal | 预计金额 | `null` |
| content[].ownerUserId | Long→str | 销售负责人 ID | `"739564171091247104"` |
| content[].ownerNameSnapshot | string | 负责人姓名快照(可 null→"--") | `null` |
| content[].updateTime | string | 最近更新时间 | `"2026-09-03 22:27:58"` |
- 备注:port 失败前端显 "--" 不显 0(issues-07 §A);列清单以 03 票实现实返为准收口 G2。错误码 67002。
### #43 战略协议五端点 — `CustomerAgreementController /api/customer/agreement/*`
- 出处:`A4 客户管理\战略协议\*.bru` 五件 + `domain/dto/AgreementDTO.java`(ADR-0017 Route A:写入参/出参双向同类)
- **AgreementDTO 字段**(双向):
| 字段 | 类型 | 必填(写) | 口径 |
|---|---|---|---|
| customerId | Long→str | 新增必填;**编辑传入忽略**(归属不可换客户,以库内为准) | 所属客户 |
| agreementLevel | number | 是 | 1~3=一级/二级/三级;越界 67017 |
| amount | string | 否 | 签订金额**文本**(数字/区间/含单位,E2E `"500万"`、`"128000"`) |
| remark | string | 否 | 备注 |
| fileFileId | string | 否 | 附件 fileId(crm-file 引用字符串,单文件可空);**demo 以纯文本框呈现**(附件上传位豁免 D2),下载由前端走 crm-file 通用端点 |
| customerName | string | 仅出参 | 所属客户名称(卡片带出) |
| id / createTime / updateTime | — | 仅出参 | BaseDTO(E2E detail 实测有值) |
| 端点 | 参数 | 出参 data | 错误码 |
|---|---|---|---|
| `GET /api/customer/agreement/list?customerId=` | customerId | `List<AgreementDTO>`(页签卡片,时间倒序**非分页**;E2E 空列表 `[]`) | 67002 |
| `GET /api/customer/agreement/detail?id=` | id | `AgreementDTO` 全量 | 67017/67002 |
| `POST /api/customer/agreement/create`(form) | AgreementDTO | `Long` 协议 id | 67001/67017/67002 |
| `POST /api/customer/agreement/edit?id=`(form) | id + AgreementDTO | `null` | 67001/67017/67002 |
| `POST /api/customer/agreement/delete?id=` | id | `null`(软删) | 67002/67017 |
- 备注(**快照联动**):新增/编辑/删除协议同步刷新客户主表 `strategic_agreement_level`(删除=删空后快照回 NULL=未签);oplog 记 `AGREEMENT_ADD/EDIT/DELETE`;67017 语义=等级缺失或越界/协议不存在或无权访问。权限继承所属客户(无独立 ACL,无权 67002/67017)。
### #44 团队成员三端点 — `GET member/list?id=``POST member/add?id=``POST member/remove?id=&memberUserId=`
- 出处:`A4 客户管理\客户详情\团队成员\*.bru` 三件 + `domain/dto/CustomerMemberDTO.java`、`MemberAddDTO.java`
- `GET /api/customer/member/list?id=` 出参(`List<CustomerMemberDTO>`,**data 内仅 4 字段**;负责人 ROLE_OWNER 在前):
| 字段路径 | 类型 | 口径 | 示例 |
|---|---|---|---|
| [].userId | Long→str | 成员用户 ID | `"739564171091247104"` |
| [].userName | string | 姓名(负责人=主表快照,协同人=团队成员快照) | `"罗伟健"` |
| [].deptName | string | 部门名(负责人=主表快照,协同人=**实时回显**) | `"广东保伦电子股份有限公司"` |
| [].role | string | 角色枚举:`OWNER` 负责人 / `COLLABORATOR` 协同人(**字段名是 role,不是 memberRole**) | `"OWNER"` |
- **G3 标注**:原型另有职务/加入时间两列——`memberRole`、`createTime` **均不在此 DTO**(不暴露),demo 恒 "--" 渲染=记档口径(P2-G3,字段级挂 07 票)。
- `POST /api/customer/member/add?id=`:form 绑定 `MemberAddDTO{memberUserIds: List<Long>}`(**重复键** `memberUserIds=..&memberUserIds=..`,示例 `744842318024015872`)→ `null`。两阶段校验**整批拒绝**:名单空/含销售负责人/重复加入/用户停用→67016;批内幂等去重;姓名快照;oplog `MEMBER_ADD`。E2E 反例:`{"code":67016,"message":"成员名单不能为空"}`。
- `POST /api/customer/member/remove?id=&memberUserId=`:查询参数两件 → `null`。软删保留历史(delete_key 复用键,可重复加入);oplog `MEMBER_REMOVE`;错误码 67002/67016。
### #42 关联项目页签 — 占位(demo 无端点调用,内容恒 "--")
- 矩阵 #42 口径:**demo 侧无入口调用、页签占位显 "--"**(「新增占位页签」动作)。漂移提示:契约面实际已存在 `GET /api/customer/project/page?id=`(`CustomerDetailController.pageProjects`,`CustomerProjectQueryPort.ProjectItem` 9 字段),且 `A4 客户管理\客户详情\关联项目\关联项目页签.bru` 已有 E2E 真值(projectName=e2c-r3d-n3-项目甲、stageName=冲突处理、statusName=进行中、filingStatusName=报备审核中)——AS §5.3「A5 未建恒 --」记档已过时;demo 维持占位,切真值属后续票,本表不展开。
---
## 字典与枚举(值域 + 文案 + 出处)
| 字典/枚举 | 值域与文案 | 出处 |
|---|---|---|
| customer_stage | 1 潜在客户 / 2 重潜客户 / 3 已成交客户(三态只前进,CAS;**本期两个跃迁均不触发,实际只有「潜在」**,D20 seam) | `domain/enums/CustomerStageEnum.java`;detail-head `customerStageName` 回显「潜在客户」;AS §3 |
| customer_star_level | 1~5 整数,无文案字典(星级组件渲染) | DTO @Schema;AS §2.1 |
| relation_star_level | 1~5 整数(导入 INSERT 缺省补 0=未评估占位,D-05 修复口径);看板 groupColumn=`relation` 分组 1~5 | AS §5.7/§2.1 |
| customer_type | `customer_type_01` 总包 / `_02` 工程商 / `_03` 投资方 / `_04` 设计院 / `_05` 集成商 / `_06` 投标公司 | `crm-dict/.../config/DictDataInitializer.java` L325-332 |
| industryCode | 运行时字典接口/字典树核对(industry 分组两级行业树,客户用二级/商机用一级);E2E 样例 `gov`=政府机关、`gov_1`=人大政协 | DictDataInitializer L95;E2E detail 实测 |
| follow_way | `follow_way_01` 电话沟通 / `_02` 上门拜访 / `_03` 微信对接 / `_04` 线上会议 / `_05` 展会沟通 | DictDataInitializer L293-299;FollowCreateDTO @Schema |
| viewType(内置五视图) | `ASSIGNED` 我负责的 / `COLLABORATING` 我协同的 / `FOLLOW_UP_DUE` 待跟进 / `FOCUSED` 我关注的 / `RECENT` 最近访问;适配矩阵见 #1 | AS §2.1/§3 |
| archiveStatus | 1 有效 / 2 已归档(无合并态,D19 归档=删除语义) | AS §3 |
| view-form | `list` / `split` / `board`(白名单外 68001;pool 拒 board 文档口径未实现=I-06 漂移) | ViewFormController/Impl;.bru 保存视图形态.bru;AS §2.14 |
| oplog action | 18 种(含 `GRAPH_EDIT`),逐值见 #45;AS §3「17 种」为未同步漂移 | `CustomerConstants` L97-114 |
| BatchFailReason | `NOT_FOUND / STATUS_NOT_ALLOWED / CONCURRENT_MODIFIED / ARCHIVE_BLOCKED / POOL_BLOCKED / ASSIGN_INVALID / UNKNOWN` | `domain/enums/CustomerBatchFailReason.java` |
| member role | `OWNER` / `COLLABORATOR` | CustomerMemberDTO 常量 |
| transferReason(交割,本表外引用) | `resign` 离职 / `transfer_post` 岗位调动 / `region_adjust` 区域调整 | AS §3 |
| job_title | 运行时两级字典树(demo 动态拉取),无静态值域,不落本表 | AS §2.7(jobTitleId/Name/Category/Level 联动 D1/D2) |
---
## 来源文件清单
**Bruno 仓 `D:\code\crm-api-docs\`**(相对根):
- `A4 客户管理\我的客户\workspace 列表分页.bru`、`新增客户.bru`、`快速创建客户.bru`、`编辑客户.bru`(客户详情\)、`工商信息查询.bru`
- `A4 客户管理\客户总览\workspace 列表分页.bru`、`看板列表\看板分组汇总.bru`、`看板单列卡片.bru`(我的客户同名)
- `A4 客户管理\客户查重\名称相似提示.bru`、`信用代码查重.bru`
- `A4 客户管理\客户详情\详情公共头部.bru`、`客户详情.bru`、`标记重点客户.bru`、`取消重点标记.bru`、`跟进记录\跟进记录分页.bru`、`跟进记录\新增跟进.bru`、`操作日志\操作日志分页.bru`、`关联商机\关联商机页签.bru`、`关联项目\关联项目页签.bru`、`团队成员\团队成员页签.bru`、`团队成员\添加团队成员.bru`、`团队成员\移除团队成员.bru`
- `A4 客户管理\客户公海\关注客户.bru`、`取消关注客户.bru`、`批量关注客户.bru`、`领取客户.bru`、`批量领取.bru`、`分配客户.bru`、`批量分配.bru`、`抛公海.bru`、`批量抛公海.bru`、`归档客户.bru`、`批量归档.bru`、`恢复客户.bru`、`读取视图形态.bru`、`保存视图形态.bru`(我的客户/客户总览同名)
- `A4 客户管理\战略协议\战略协议页签列表.bru`、`战略协议详情.bru`、`新增战略协议.bru`、`编辑战略协议.bru`、`删除战略协议.bru`
- `A3 商机管理\销售机会\自定义视图\自定义视图列表.bru`、`保存自定义视图.bru`(saved-view 形态佐证,A4 无同名 .bru)
**后端源码 `D:\code\crm-backend-matt\`**(绝对路径根同上):
- `crm-customer\src\main\java\com\crm\customer\domain\dto\`:CustomerListRowDTO、CustomerBoardSummaryDTO、CustomerCreateDTO、CustomerUpdateDTO、CustomerQuickCreateDTO、CustomerSaveResultDTO、CustomerSimilarHitDTO、CreditCodeCheckDTO、CustomerSearchItemDTO、CustomerDetailHeadDTO、CustomerDetailDTO、FollowCreateDTO、AgreementDTO、CustomerMemberDTO、MemberAddDTO、CustomerBatchFailItem
- `crm-customer\...\domain\param\`:CustomerWorkspacePageParam、CustomerSearchParam、FollowPageParam、OplogPageParam
- `crm-customer\...\domain\enums\`:CustomerStageEnum、CustomerBatchFailReason;`constant\CustomerConstants.java`
- `crm-customer\...\port\`:CustomerOpportunityQueryPort、CompanyLookupPort;`mapper\CustomerMapper.java`(pageWorkspace SQL:opportunityCount 真算/projectCount 恒 0/focusFlag/lastViewTime);`service\impl\CustomerWorkspaceServiceImpl.java`(board 守卫/排序/viewType);`controller\`:CustomerWorkspaceController、CustomerOwnershipController、CustomerFocusController、CustomerController、CustomerDetailController、CustomerAgreementController
- `crm-preference\src\main\java\com\crm\preference\`:controller\SavedViewController、controller\ViewFormController、domain\dto\SavedView、domain\dto\SavedViewCondition
- `crm-base\src\main\java\com\crm\base\domain\`:param\BaseParam、dto\BaseDTO、result\PageResult、result\BatchResult、result\Result
- `crm-dict\src\main\java\com\crm\dict\config\DictDataInitializer.java`(customer_type/follow_way 种子)
**仓内文档**:
- `.scratch\customer-module\API-SUMMARY.md`(§0/§1/§2.1-2.6/§2.9/§2.14/§3/§5)
- `.scratch\customer-demo-ref\assets\coverage-matrix.md`(入口编号/G1-G6/P3-x 依据)
- `.scratch\customer-frontend-handover\mapping-a4-pages.md`(原型列文案)
- `.scratch\customer-e2e\demo\index.html`(fork 基线渲染口径:`|| '--'`、maskPhone/reveal、旧 loadSummary 读 count/name 的漂移证据)

50
.scratch/customer-demo-ref/assets/acceptance-selfcheck.md

@ -0,0 +1,50 @@
# 交付四标准自检表(票 15)
> 对照 map.md Destination 四标准逐条自检;证据均落盘可复查。2026-09-12,票 14 第八跑(40/0 全绿)为验收时点。
## 结论速览
| # | 标准 | 结论 | 核心证据 |
|---|------|------|----------|
| 1 | 功能覆盖 | **达标** | 69 入口对账零缺口(33 免改+9 新增+16 改造全落地)+ 冒烟唯一路径 91 |
| 2 | 字段对齐 | **达标** | field-alignment.md #1-#69 三表 + K1-K11 校准全落 demo |
| 3 | 流程闭环 | **达标** | 冒烟步 9-25 全链 PASS(create→edit CAS→archive/restore→release→claim→assign→交割→oplog) |
| 4 | 交付物齐备 | **达标** | demo/index.html + smoke.py + smoke-report.json + 4 assets + shots 9 张 + 双仓清单 |
## 1. 功能覆盖(原型可见入口全部可点且触发对应端点)
- 对账基线:蓝湖 v29 原型 A4 页族 × API-SUMMARY 85 端点 → [assets/coverage-matrix.md](coverage-matrix.md) 69 入口逐行:无缺口 33 / 新增 9 / 改造 16 / 豁免 14(均有台账依据:01 号票 Answer + gap-report/defectfix/F-15)。
- 实施对账:16 改造+9 新增入口全部出票落地(09 基建 → 10 列表层 → 11 详情层 → 12 联系人菜单+商机侧 → 13 重流程收尾),无遗留半成品;豁免 14 项在 demo「已知缺陷」面板 C 区静态明示。
- 端点触达实证:第八跑冒烟 api_urls **唯一路径 91 条**(GET 147 + POST 79),含 workspace/page、pool/board、交割三段、导入三段式、规则族读写、saved-view、focus/star 等全部动作族。
- 缺陷呈现:14 开放现象(G1-G6/P3-*/D-G1/I-06/POOL-CFG/STUB-LOOKUP)三档呈现(渲染即现象+data-defect 角标+面板),冒烟步 30/31/35 断言角标 11 类目 87 处。
## 2. 字段对齐(与 Bruno 出参一致,含脱敏与 "--" 口径)
- 权威对齐表:[assets/field-alignment.md](field-alignment.md)(1091 行,核心域 #1-#49 / 联系人族+重流程+规则族 #31-#69),逐入口请求参数/出参字段/空值"--"与枚举字典口径三表,每表附 .bru+DTO 出处。
- K1-K11 校准注记(07 号票收口回写):G4 收窄、关联项目真端点 Noop、看板 summary=cnt/groupValue、oplog action 18 种、contact_source 字典真值、focus-batch Void、quick-create 七必填、电话脱敏 138\*\*\*\*1234、transfer assign_status 契约——demo 冒烟全部按 K 口径断言通过(步 11 金额卡 4 卡恒 "--"、步 16 列头无「负责人」+明文/脱敏切换、步 22/24 交割明细 assignStatus)。
- 冒烟侧 40 步的 note 全部携带字段级实证值(qaRet successCount/failedRows、import result 计数、pool total、dedup mode/threshold),报告落盘可追溯。
## 3. 流程闭环(新增→归档/抛公海全链路 + 操作日志留痕)
- 主链(冒烟步 8-25 连续 PASS):登录→新建(L2 相似 needConfirm→confirmSimilar 重发)→编辑 CAS(version 0→1 名字-改落库)→详情 8 页签→归档/恢复(archive_status 2→1→2 + oplog≥2)→导入三段式(预检 1 失败行→确认执行→失败明细)→联系人 quickAdd/batchEdit/check-phone/delete→抛公海 release→pool 勾选→claim→archive→restore→再 archive→assign(successCount=1)。
- 商机联动链(步 23-24):从客户发起建商机→抛公海被进行中商机 67007 阻断→关闭商机→放行→交割 preview→initiate→detail assignStatus 变化(assign 目标动态取 assignable[0],排除发起人与总监本人)。
- oplog 留痕:ARCHIVE/RESTORE/CREATE action 断言(步 19 action=CREATE 筛选、步 25 oplog=3)。
- 规则族回写还原(步 26-28):dedup 阈值 80→90→80 还原+匹配方式不被污染(mode==2)、reminder 非法 0→64023→还原 30/7——共享库无污染退出。
## 4. 交付物齐备
| 交付物 | 路径 | 状态 |
|--------|------|------|
| 单文件 Demo | `.scratch/customer-demo-ref/demo/index.html`(2925+ 行) | OK |
| 冒烟脚本 | `demo/smoke.py`(40 步,py_compile 通过,无 BOM) | OK |
| PASS/FAIL 报告 | `demo/smoke-report.json`(ALL_PASS=true,40/0,176.4s) | OK |
| 冒烟截图 | `demo/shots/`(9 张关键现场) | OK |
| 对账矩阵 | `assets/coverage-matrix.md` | OK |
| 字段对齐清单 | `assets/field-alignment.md` | OK |
| 冒烟步骤表 | `assets/smoke-plan.md` | OK |
| 商机依赖清单 | `assets/opp-deps.md` | OK |
| 四标准自检 | `assets/acceptance-selfcheck.md`(本文件) | OK |
| 双仓未提交变更清单 | `assets/uncommitted-changes.md` | OK |
- 纪律自证:零 PUT/DELETE(方法分布 GET 147+POST 79)、console error=0、http_fails=0、4xx 走业务弹窗通道(67003/64023/67007 三处实证)。
- 环境观察(非缺陷,面板 D 区):共享库存量 source 旧 code 未迁移;e2c seed 需冒烟重建(票 14 ensure_seeds 幂等自举解决)。

6
.scratch/customer-demo-ref/assets/smoke-plan.md

@ -47,8 +47,8 @@ py -X utf8 .scratch/customer-demo-ref/demo/smoke.py # 系统 Python 3.13 +
| 10 | 编辑客户:回显(version CAS)→改备注→保存 | edit?id= 成功;列表刷新生效 | UI+NET |
| 11 | 详情头部:detail-head 渲染 + 金额卡 | 4 张金额卡恒"--"(P3-6 data-defect)〔D〕;计数/负责人/跟进摘要有值 | UI |
| 12 | 8 页签全遍历可切换 | 信息/联系人/跟进/关联商机/关联项目/战略协议/团队成员/操作日志 8 页签 | UI |
| 13 | 页签1 信息:16 字段栅格 | 系统信息四件套恒"--"(G4 data-defect)〔D〕;creditCode 脱敏形态断言 | UI |
| 14 | 页签4/5:关联商机 7 列真名 + 关联项目恒"--" | 商机列头数=7(G2 data-defect)〔D〕;项目页签"--"(#42 占位) | UI |
| 13 | 页签1 信息:16 字段栅格 | 创建人/更新人恒"--"、创建/更新时间实测有值(G4 data-defect)〔D〕;creditCode 明文回显断言(K10 核心域无字段级脱敏) | UI |
| 14 | 页签4/5:关联商机 7 列真名 + 关联项目恒"--" | 商机列头数=7(G2 data-defect)〔D〕;项目页签"--"(K2 真端点 CustomerProjectQueryPort·Noop 空态) | UI+NET |
| 15 | 页签7 团队成员:添加 BUDDY→渲染→移除 | member/add·remove 成功;**无职务/加入时间列**(G3 data-defect)〔D〕 | UI+DB |
| 16 | 页签2 联系人:quickAdd 2 行→列表 8 列→编辑→reveal | 列表**无负责人列**(G5)〔D〕;reveal 明文+DB reveal_log;电话脱敏形态 | UI+NET+DB |
| 17 | 联系人 batchEdit(含 D-G1 注记)+ check-phone + 删除 | batchEdit 空串提交=不改(D-G1 注记可见)〔D〕;check-phone 命中王重复;删除双刷 | UI+DB |
@ -83,7 +83,7 @@ py -X utf8 .scratch/customer-demo-ref/demo/smoke.py # 系统 Python 3.13 +
| 31 | 角标钩子扫描 | `[data-defect]` 计数≥12(G1-G6/P3-3/P3-4/P3-5/P3-6/D-G1/I-06/POOL-CFG/STUB-LOOKUP) | UI |
| 32 | I-06 双现象注记(pool workspace) | pool 汇总条注记文本含「67001」+「记忆成功」;pool 形态下拉无 board 选项 | UI |
| 33 | 豁免入口不出现 | 换负责人按钮/操作日志导出/完整金额开关/导入触发校验复选框 count=0 | UI |
| 34 | G 系列现象复核(合并断言) | 跟进筛选无操作人项(G1)、商机 7 列(G2)、成员 2 列缺(G3)、四件套"--"(G4)、联系人无负责人列(G5)、联系人日志空态(G6) | UI |
| 34 | G 系列现象复核(合并断言) | 跟进筛选无操作人项(G1)、商机 7 列(G2)、成员 2 列缺(G3)、创建人/更新人"--"(G4)、联系人无负责人列(G5)、联系人日志空态(G6) | UI |
| 35 | 金额卡与页签5 "--" 口径 | P3-6 四卡"--";关联项目页签"--" | UI |
| 36 | company-lookup NO_HIT 现象 | 表单联想无命中提示+F6 批量更新按钮 stub 文案 | UI |

44
.scratch/customer-demo-ref/assets/uncommitted-changes.md

@ -0,0 +1,44 @@
# 双仓未提交变更清单(票 15)
> 实测时点:2026-09-12(票 14 第八跑全绿后)。**agent 不提交 git**——本清单仅供用户审查后自行处置。
> 勘误:Bruno 文档仓实际路径 = **`E:\code\crm-api-docs`**(map.md Notes 与票 15 Scope 中的 `D:\code\crm-api-docs` 为换机前旧路径,已实测不存在)。
## 一、backend 仓(e:\code\crm-backend-matt)
`git status --porcelain` 共 51 行,**全部属于本 effort(.scratch/customer-demo-ref)与运行时日志;前序 effort 的 java/.bru 变更已不在工作区(用户已提交)**。
### 本 effort 修改(M/D,10 项)
| 状态 | 文件 | 说明 |
|------|------|------|
| D | `.scratch/customer-demo-ref/assets/_fa-core.md` | 票 07 收口:中间分片已并入 field-alignment.md 后删除 |
| M | `.scratch/customer-demo-ref/assets/smoke-plan.md` | 票 08/14 迭代:40 步表随冒烟实跑校准 |
| M | `.scratch/customer-demo-ref/demo/index.html` | 票 09-13 改造 + 票 14 doEdit/doCreate needConfirm 与 onclick 修复 |
| M | `.scratch/customer-demo-ref/issues/07-field-alignment-checklist.md` | K1-K11 校准注记回写 |
| M | `.scratch/customer-demo-ref/issues/10-core-list.md` | Answer + resolved |
| M | `.scratch/customer-demo-ref/issues/11-core-detail.md` | Answer + resolved |
| M | `.scratch/customer-demo-ref/issues/12-contacts-opp-entry.md` | Answer + resolved |
| M | `.scratch/customer-demo-ref/issues/13-flow-rules-tail.md` | Answer + resolved |
| M | `.scratch/customer-demo-ref/issues/14-smoke-script.md` | Answer + resolved |
| M | `.scratch/customer-demo-ref/map.md` | Decisions 09/10/11/14 回填 + 收口 |
### 本 effort 新增(??,交付物与取证)
- **核心交付**:`demo/smoke.py`、`demo/smoke-report.json`、`demo/shots/`(15 张:s01-s40 现场图 + t10-t13 快验图)、`assets/acceptance-selfcheck.md`、`assets/uncommitted-changes.md`(本文件)
- **诊断/扫描临时脚本**(可删):`demo/_t14*.py`(diag/db/org/org2/org3/api/cols/scan)、`_quickcheck*.py`×6、`_scan.py`、`_t12scan.py`、`demo/_q.py`
- **运行日志/临时**(可删):`demo/_jarbuild.log`、`_run7.log`、`_run8.log`、`_server.log`、`demo/tmp_xlsx/`、`demo/__pycache__/`
### 前序遗留 / 运行时
- `logs/crm-app.log`(M)与 `logs/crm-app.log.2026-09-11.0.gz`(??):本地起服运行日志,非源码变更。
## 二、Bruno 文档仓(E:\code\crm-api-docs)
`git status --porcelain` 共 2 行,**均为前序 effort(customer-frontend-handover 收官时 A4 目录迁移)遗留,非本 effort 产物**;本 effort 未改动文档仓任何文件。
| 状态 | 路径(八进制转义已还原) | 说明 |
|------|------|------|
| ?? | `A4 客户管理/客户详情/战略协议/` | frontend-handover 票 05 迁移产物:战略协议 .bru 自客户详情根部迁入独立目录(未 git add) |
| ?? | `A4 客户管理/联系人/联系人导入/` | 同上:联系人导入 .bru 迁入「联系人」子目录(未 git add) |
> 前序记忆(frontend-handover 收官时)为 M3+D11+??11——当前仅余上述 2 个 untracked 目录,其余变更用户已在收官后提交。

BIN
.scratch/customer-demo-ref/demo/__pycache__/_t14d7.cpython-313.pyc

Binary file not shown.

BIN
.scratch/customer-demo-ref/demo/__pycache__/smoke.cpython-313.pyc

Binary file not shown.

335
.scratch/customer-demo-ref/demo/_jarbuild.log

@ -0,0 +1,335 @@
[INFO] Scanning for projects...
[WARNING]
[WARNING] Some problems were encountered while building the effective model for com.crm:crm-auth:jar:1.0.0-SNAPSHOT
[WARNING] 'dependencies.dependency.version' for org.projectlombok:lombok:jar is either LATEST or RELEASE (both of them are being deprecated) @ line 60, column 22
[WARNING]
[WARNING] Some problems were encountered while building the effective model for com.crm:crm-file:jar:1.0.0-SNAPSHOT
[WARNING] 'dependencies.dependency.(groupId:artifactId:type:classifier)' must be unique: org.projectlombok:lombok:jar -> duplicate declaration of version (?) @ line 49, column 21
[WARNING]
[WARNING] It is highly recommended to fix these problems because they threaten the stability of your build.
[WARNING]
[WARNING] For this reason, future Maven versions might no longer support building such malformed projects.
[WARNING]
[INFO] ------------------------------------------------------------------------
[INFO] Reactor Build Order:
[INFO]
[INFO] crm [pom]
[INFO] crm-base [jar]
[INFO] crm-file [jar]
[INFO] crm-auth [jar]
[INFO] crm-dict [jar]
[INFO] crm-rule [jar]
[INFO] crm-preference [jar]
[INFO] crm-lead [jar]
[INFO] crm-customer [jar]
[INFO] crm-opportunity [jar]
[INFO] crm-project [jar]
[INFO] crm-log [jar]
[INFO] crm-app [jar]
[INFO]
[INFO] ----------------------------< com.crm:crm >-----------------------------
[INFO] Building crm 1.0.0-SNAPSHOT [1/13]
[INFO] from pom.xml
[INFO] --------------------------------[ pom ]---------------------------------
[INFO]
[INFO] --------------------------< com.crm:crm-base >--------------------------
[INFO] Building crm-base 1.0.0-SNAPSHOT [2/13]
[INFO] from crm-base\pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ crm-base ---
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-base\src\main\resources
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-base\src\main\resources
[INFO]
[INFO] --- compiler:3.13.0:compile (default-compile) @ crm-base ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- resources:3.3.1:testResources (default-testResources) @ crm-base ---
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-base\src\test\resources
[INFO]
[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ crm-base ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- surefire:3.2.5:test (default-test) @ crm-base ---
[INFO] Tests are skipped.
[INFO]
[INFO] --- jar:3.4.2:jar (default-jar) @ crm-base ---
[INFO]
[INFO] --------------------------< com.crm:crm-file >--------------------------
[INFO] Building crm-file 1.0.0-SNAPSHOT [3/13]
[INFO] from crm-file\pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ crm-file ---
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-file\src\main\resources
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-file\src\main\resources
[INFO]
[INFO] --- compiler:3.13.0:compile (default-compile) @ crm-file ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- resources:3.3.1:testResources (default-testResources) @ crm-file ---
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-file\src\test\resources
[INFO]
[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ crm-file ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- surefire:3.2.5:test (default-test) @ crm-file ---
[INFO] Tests are skipped.
[INFO]
[INFO] --- jar:3.4.2:jar (default-jar) @ crm-file ---
[INFO]
[INFO] --------------------------< com.crm:crm-auth >--------------------------
[INFO] Building crm-auth 1.0.0-SNAPSHOT [4/13]
[INFO] from crm-auth\pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ crm-auth ---
[INFO] Copying 1 resource from src\main\resources to target\classes
[INFO] Copying 0 resource from src\main\resources to target\classes
[INFO]
[INFO] --- compiler:3.13.0:compile (default-compile) @ crm-auth ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- resources:3.3.1:testResources (default-testResources) @ crm-auth ---
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-auth\src\test\resources
[INFO]
[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ crm-auth ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- surefire:3.2.5:test (default-test) @ crm-auth ---
[INFO] Tests are skipped.
[INFO]
[INFO] --- jar:3.4.2:jar (default-jar) @ crm-auth ---
[INFO]
[INFO] --------------------------< com.crm:crm-dict >--------------------------
[INFO] Building crm-dict 1.0.0-SNAPSHOT [5/13]
[INFO] from crm-dict\pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ crm-dict ---
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-dict\src\main\resources
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-dict\src\main\resources
[INFO]
[INFO] --- compiler:3.13.0:compile (default-compile) @ crm-dict ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- resources:3.3.1:testResources (default-testResources) @ crm-dict ---
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-dict\src\test\resources
[INFO]
[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ crm-dict ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- surefire:3.2.5:test (default-test) @ crm-dict ---
[INFO] Tests are skipped.
[INFO]
[INFO] --- jar:3.4.2:jar (default-jar) @ crm-dict ---
[INFO]
[INFO] --------------------------< com.crm:crm-rule >--------------------------
[INFO] Building crm-rule 1.0.0-SNAPSHOT [6/13]
[INFO] from crm-rule\pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ crm-rule ---
[INFO] Copying 0 resource from src\main\resources to target\classes
[INFO] Copying 1 resource from src\main\resources to target\classes
[INFO]
[INFO] --- compiler:3.13.0:compile (default-compile) @ crm-rule ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- resources:3.3.1:testResources (default-testResources) @ crm-rule ---
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-rule\src\test\resources
[INFO]
[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ crm-rule ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- surefire:3.2.5:test (default-test) @ crm-rule ---
[INFO] Tests are skipped.
[INFO]
[INFO] --- jar:3.4.2:jar (default-jar) @ crm-rule ---
[INFO]
[INFO] -----------------------< com.crm:crm-preference >-----------------------
[INFO] Building crm-preference 1.0.0-SNAPSHOT [7/13]
[INFO] from crm-preference\pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ crm-preference ---
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-preference\src\main\resources
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-preference\src\main\resources
[INFO]
[INFO] --- compiler:3.13.0:compile (default-compile) @ crm-preference ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- resources:3.3.1:testResources (default-testResources) @ crm-preference ---
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-preference\src\test\resources
[INFO]
[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ crm-preference ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- surefire:3.2.5:test (default-test) @ crm-preference ---
[INFO] Tests are skipped.
[INFO]
[INFO] --- jar:3.4.2:jar (default-jar) @ crm-preference ---
[INFO]
[INFO] --------------------------< com.crm:crm-lead >--------------------------
[INFO] Building crm-lead 1.0.0-SNAPSHOT [8/13]
[INFO] from crm-lead\pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ crm-lead ---
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-lead\src\main\resources
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-lead\src\main\resources
[INFO]
[INFO] --- compiler:3.13.0:compile (default-compile) @ crm-lead ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- resources:3.3.1:testResources (default-testResources) @ crm-lead ---
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-lead\src\test\resources
[INFO]
[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ crm-lead ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- surefire:3.2.5:test (default-test) @ crm-lead ---
[INFO] Tests are skipped.
[INFO]
[INFO] --- jar:3.4.2:jar (default-jar) @ crm-lead ---
[INFO]
[INFO] ------------------------< com.crm:crm-customer >------------------------
[INFO] Building crm-customer 1.0.0-SNAPSHOT [9/13]
[INFO] from crm-customer\pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ crm-customer ---
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-customer\src\main\resources
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-customer\src\main\resources
[INFO]
[INFO] --- compiler:3.13.0:compile (default-compile) @ crm-customer ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- resources:3.3.1:testResources (default-testResources) @ crm-customer ---
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-customer\src\test\resources
[INFO]
[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ crm-customer ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- surefire:3.2.5:test (default-test) @ crm-customer ---
[INFO] Tests are skipped.
[INFO]
[INFO] --- jar:3.4.2:jar (default-jar) @ crm-customer ---
[INFO]
[INFO] ----------------------< com.crm:crm-opportunity >-----------------------
[INFO] Building crm-opportunity 1.0.0-SNAPSHOT [10/13]
[INFO] from crm-opportunity\pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ crm-opportunity ---
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-opportunity\src\main\resources
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-opportunity\src\main\resources
[INFO]
[INFO] --- compiler:3.13.0:compile (default-compile) @ crm-opportunity ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- resources:3.3.1:testResources (default-testResources) @ crm-opportunity ---
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-opportunity\src\test\resources
[INFO]
[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ crm-opportunity ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- surefire:3.2.5:test (default-test) @ crm-opportunity ---
[INFO] Tests are skipped.
[INFO]
[INFO] --- jar:3.4.2:jar (default-jar) @ crm-opportunity ---
[INFO]
[INFO] ------------------------< com.crm:crm-project >-------------------------
[INFO] Building crm-project 1.0.0-SNAPSHOT [11/13]
[INFO] from crm-project\pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ crm-project ---
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-project\src\main\resources
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-project\src\main\resources
[INFO]
[INFO] --- compiler:3.13.0:compile (default-compile) @ crm-project ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- resources:3.3.1:testResources (default-testResources) @ crm-project ---
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-project\src\test\resources
[INFO]
[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ crm-project ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- surefire:3.2.5:test (default-test) @ crm-project ---
[INFO] Tests are skipped.
[INFO]
[INFO] --- jar:3.4.2:jar (default-jar) @ crm-project ---
[INFO]
[INFO] --------------------------< com.crm:crm-log >---------------------------
[INFO] Building crm-log 1.0.0-SNAPSHOT [12/13]
[INFO] from crm-log\pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ crm-log ---
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-log\src\main\resources
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-log\src\main\resources
[INFO]
[INFO] --- compiler:3.13.0:compile (default-compile) @ crm-log ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- resources:3.3.1:testResources (default-testResources) @ crm-log ---
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-log\src\test\resources
[INFO]
[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ crm-log ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- surefire:3.2.5:test (default-test) @ crm-log ---
[INFO] Tests are skipped.
[INFO]
[INFO] --- jar:3.4.2:jar (default-jar) @ crm-log ---
[INFO]
[INFO] --------------------------< com.crm:crm-app >---------------------------
[INFO] Building crm-app 1.0.0-SNAPSHOT [13/13]
[INFO] from crm-app\pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ crm-app ---
[INFO] Copying 2 resources from src\main\resources to target\classes
[INFO] Copying 0 resource from src\main\resources to target\classes
[INFO]
[INFO] --- compiler:3.13.0:compile (default-compile) @ crm-app ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- resources:3.3.1:testResources (default-testResources) @ crm-app ---
[INFO] skip non existing resourceDirectory E:\code\crm-backend-matt\crm-app\src\test\resources
[INFO]
[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ crm-app ---
[INFO] No sources to compile
[INFO]
[INFO] --- surefire:3.2.5:test (default-test) @ crm-app ---
[INFO] Tests are skipped.
[INFO]
[INFO] --- jar:3.4.2:jar (default-jar) @ crm-app ---
[INFO]
[INFO] --- spring-boot:3.3.5:repackage (repackage) @ crm-app ---
[INFO] Replacing main artifact E:\code\crm-backend-matt\crm-app\target\crm-app-1.0.0-SNAPSHOT.jar with repackaged archive, adding nested dependencies in BOOT-INF/.
[INFO] The original artifact has been renamed to E:\code\crm-backend-matt\crm-app\target\crm-app-1.0.0-SNAPSHOT.jar.original
[INFO] ------------------------------------------------------------------------
[INFO] Reactor Summary for crm 1.0.0-SNAPSHOT:
[INFO]
[INFO] crm ................................................ SUCCESS [ 0.002 s]
[INFO] crm-base ........................................... SUCCESS [ 1.312 s]
[INFO] crm-file ........................................... SUCCESS [ 0.182 s]
[INFO] crm-auth ........................................... SUCCESS [ 0.253 s]
[INFO] crm-dict ........................................... SUCCESS [ 0.119 s]
[INFO] crm-rule ........................................... SUCCESS [ 0.185 s]
[INFO] crm-preference ..................................... SUCCESS [ 0.118 s]
[INFO] crm-lead ........................................... SUCCESS [ 0.145 s]
[INFO] crm-customer ....................................... SUCCESS [ 0.197 s]
[INFO] crm-opportunity .................................... SUCCESS [ 0.176 s]
[INFO] crm-project ........................................ SUCCESS [ 0.144 s]
[INFO] crm-log ............................................ SUCCESS [ 0.138 s]
[INFO] crm-app ............................................ SUCCESS [ 1.167 s]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 4.467 s
[INFO] Finished at: 2026-09-12T00:11:07+08:00
[INFO] ------------------------------------------------------------------------

58
.scratch/customer-demo-ref/demo/_run7.log

@ -0,0 +1,58 @@
sweep 娓呯悊娈嬬暀 0 琛?
seed 鑷妇锛坖obc=chairman锛?..
[seed] 鏌ラ噸鍖归厤鏂瑰紡褰掍綅 FUZZY(2)
[seed] 鏌ラ噸鍩虹嚎鍛戒腑鏁?1
python.exe : E:\code\crm-backend-matt\.scratch\customer-e2e\.venv10\Lib\site-packages\openpyxl\styles\stylesheet.py:237
: UserWarning: Workbook contains no default style, apply openpyxl's default
所在位置 行:1 字符: 1
+ & "E:\code\crm-backend-matt\.scratch\customer-e2e\.venv10\Scripts\pyt ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (E:\code\crm-bac...npyxl's default:String) [], RemoteException
+ FullyQualifiedErrorId : NativeCommandError
warn("Workbook contains no default style, apply openpyxl's default")
xlsx 灏辩华: e:\code\crm-backend-matt\.scratch\customer-demo-ref\demo\tmp_xlsx\cust.xlsx / e:\code\crm-backend-matt\.scratch\customer-demo-ref\demo\tmp_xlsx\cont.xlsx
PASS 01 鎵撳紑Demo+uid鐧诲綍+棣栧睆鍒楄〃娓叉煋 | rows=10
PASS 02 涓墂orkspace鍒囨崲;pool涓嶅彂board+I-06娉ㄨ[D] | overview璇锋眰OK=True;pool鏂拌姹?鏉?i06鍙=True
PASS 03 鍐呯疆瑙嗗浘涓嬫媺浜斿€?鍒嘑OLLOW_UP_DUE | opts=['ASSIGNED', 'COLLABORATING', 'FOLLOW_UP_DUE', 'FOCUSED', 'RECENT']
PASS 04 鍒楃瓫閫夐樁娈?宸叉垚浜? | 璇锋眰鍙傛暟 customerStage=3 宸插懡涓?
PASS 05 褰掓。鎬佸紑鍏?e2c-宸插綊妗e彲瑙? | archiveStatus=2鍛戒腑=True;鏍蜂緥鍙=True
PASS 06 saved-view 鏂板缓鈫掗€変腑鏌ヨ鈫掑垹闄? | save=True;閫変腑甯avedViewId=True;鍒犲悗娑堝け=True;body=POST /api/preference/view/save?scopeKey=customer.mine # {"name":"e2c-dr-瑙嗗浘","isDefault":false,"seqNo":10,"conditions":[{"field":"keyword","operator":"
PASS 07 褰㈡€佸亸濂給verview鍒囩湅鏉库啋娓叉煋鈫掕繕鍘焞ist | 鐪嬫澘鍒?3;cards璇锋眰=True;杩樺師=True
PASS 08 宸ュ晢鑱旀兂(companyName)+NO_HIT鐜拌薄+STUB-LOOKUP娉ㄨ[D] | 鏈煡璇㈠埌鍖归厤浼佷笟锛堜竴鏈熶紒鏌ユ煡 port 涓?stub 鎭?NO_HIT锛屽瀹炲憟鐜帮紱鍙墜宸ュ綍鍏ワ紝鐪熷疄婧愭帴鍏ュ悗鑷姩婵€娲伙級銆?
PASS 09 鍚嶇О鏌ラ噸needConfirm+similarHits鈫抍onfirmSimilar閲嶅彂鈫掑垱寤烘垚鍔? | simbox=鐩镐技鍛戒腑 1 鏉★細e2c-dr-鎭掍俊杈剧鎶€鏈夐檺璐d换鍏徃锛堣礋璐d汉=缃椾紵鍋ワ紝娼滃湪瀹㈡埛锛屾湁鏁堬級浠嶈鍒涘缓锛?纭鍒涘缓锛坈onfirmSimilar=;toast=鍒涘缓鎴愬姛 id=753770502094323712;澶瑰叿A=753770502094323712
FAIL 10 缂栬緫瀹㈡埛鍥炴樉+CAS淇濆瓨(鍚浉浼肩‘璁ら噸鍙? | 鍥炴樉鍚?e2c-dr-鎭掍俊杈剧鎶€鏈夐檺璐d换鍏徃;simbox=-;toast=;db=e2c-dr-鎭掍俊杈剧鎶€鏈夐檺璐d换鍏徃 v0
PASS 11 璇︽儏澶撮儴娓叉煋+4閲戦鍗℃亽--[D] | cards=['--', '--', '--', '-- / --'];P3-6瑙掓爣=1
PASS 12 8椤电鍏ㄩ亶鍘?鑱旂郴浜洪《绾ц彍鍗昪ontact/page | subs=['info', 'contacts', 'follow', 'opps', 'projects', 'agreements', 'members', 'oplog'];contact/page琛?10;鏂板errs=[]
PASS 13 淇℃伅鏍呮牸16瀛楁+G4鍒涘缓/鏇存柊浜?-[D]+creditCode鏄庢枃 | kv=18;鍒涘缓浜?--;淇$敤浠g爜=91440101E2CTEST001
PASS 14 鍟嗘満7鍒?G2-D)+椤圭洰椤电Noop绌烘€?contact/search鍛戒腑 | 鍟嗘満鍒?['鍟嗘満ID', '鍟嗘満鍚嶇О', '闃舵', '棰勮閲戦', '璐熻矗浜篒D', '璐熻矗浜?, '鏈€杩戞洿鏂?];Noop绌烘€?True;search=濮撳悕鑱屽姟鐢佃瘽鍏抽敭鑱旂郴浜烘潵婧愭墍灞炲鎴峰鎴风紪鍙峰紶鍏抽敭钁d簨闀?38****0001鏄痗ontact_source_01e2c-
PASS 15 鎴愬憳鍔燘UDDY鈫掓覆鏌撯啋绉婚櫎(G3-D鏃犺亴鍔?鍔犲叆鏃堕棿鍒? | 鍒楀ご=['鎴愬憳', '瑙掕壊', '鎿嶄綔'];DB鎴愬憳琛?27;memberUserIds閲嶅閿?True
PASS 16 鑱旂郴浜簈uickAdd2琛?G5鏃犺礋璐d汉鍒梉D]+鐢佃瘽鑴辨晱+reveal鏄庢枃+DB鍩嬬偣 | 鑴辨晱=True;DB鑱旂郴浜?5;reveal_log=7;鏄庢枃=True;鍒楀ご=['', '#', '濮撳悕', '鑱屽姟', '鑱旂郴鐢佃瘽', '鍏抽敭', '鍐呯嚎', '鏉ユ簮', '绀煎搧澶囨敞', '鎿嶄綔'];qaRet={"successCount":2,"failedRows":[]}
PASS 17 batchEdit绌轰覆=DB涓嶆敼(D-G1)[D]+check-phone鍛戒腑+鍒犻櫎鍙屽埛 | DB phone=13812340001;D-G1瑙掓爣=1;check-phone=瀹㈡埛缂栧彿--瀹㈡埛鍚嶇О--瀹㈡埛绫诲瀷--闃舵--瀹㈡埛鏄熺骇--鍏崇郴鏄熺骇--鎴樼暐鍗忚--;鍒犲悗琛?4(鍒犲墠5,del_ok=True,errs+0);lid=753770624404422656;names=鏉庡啋鐑?璧靛啋鐑?鏉庢櫘閫?鐜嬮噸澶?寮犲叧閿?
PASS 18 鍐欒窡杩?鏃ユ湡鍒嗙粍+followWay绛涢€? | way閫夐」=6(绌?5);鏃ユ湡鍒嗙粍=5;绛涢€?True
PASS 19 鍏虫敞/閲嶇偣鏍囪+oplog鐣欑棔+action绛涢€? | action=CREATE绛涢€夊惈鏂板瀹㈡埛=True;action=FOLLOW绛涢€?True;DB璺熻繘=23
PASS 20 瀹㈡埛瀵煎叆涓夋寮?缂虹渷浠介妫€FAIL鈫扗ONE璁℃暟=棰勬鈫掓槑缁嗗惢鍚? | 棰勬 t=2 ins=1 fail=1;taskId=753770723947839488;result={'taskId': '753770723947839488', 'importMode': 'APPEND_ONLY', 'templateVersion': 'V1', 'status': 2, 'totalCount': 2, 'insertCount': 1, 'updateCount': 0, 'unchangedCount': 0, 'failCount': 1, 'suspectCount': 0, 'failReason': None, 'creatorName': Non
PASS 21 瀵煎叆浠诲姟鍒楄〃+status/importMode杩囨护+P3-3娉ㄨ[D] | status杩囨护=True;mode杩囨护=True;P3-3=1
PASS 22 鑱旂郴浜哄鍏ヤ笁娈靛紡+P3-3鏃犳牎楠屽閫夋[D] | 棰勬=棰勬瀹屾垚锛堜换鍔?753770764947161088 寰呯‘璁わ級锛氬叡 2 琛岋紝棰勮鏂板 2銆佹洿鏂?0銆佸け璐?0銆佺枒浼奸噸澶?;result={'taskId': '753770764947161088', 'customerId': '753770502094323712', 'importMode': 'APPEND_ONLY', 'templateVersion': 'V1', 'status': 2, 'totalCount': 2, 'insertCount': 2, 'updateCount': 0, 'unchangedCount': 0, 'failCount': 0, 'su
PASS 23 鍟嗘満鑱斿姩閾?UI寤哄晢鏈衡啋鎶涘叕娴锋嫤鎴啋鍏冲晢鏈衡啋鏀捐) | 鍟嗘満=753770801903173632;鐘舵€?鈫?;涓€娆elease鎷︽埅=True(toast=鍟嗘満宸插垱寤?id=753770801903173632锛涘鎴疯鎯呫€屽叧鑱斿晢鏈恒€嶉〉绛惧彲鍙嶆煡 || {"total":1,"s);杩涘叕娴?True
PASS 24 浜ゅ壊(preview鍦堝畾鈫抜nitiate鈫抪age鈫抎etail鈫抋ssignable鈫抋ssign鏄庣粏鎬佸彉鍖?F-13娓卨eftover=0) | 鍗曞彿=JG202609120001;鍦堝畾鍚す鍏?True;鏄庣粏=1鈫抋ssign鍚庡彉鍖?True(assign瀵硅薄=99021002);UI琛?10
PASS 25 褰掑睘鍔ㄤ綔鏃?release鈫抍laim鈫抋rchive鈫抮estore鈫掑啀archive+oplog+assign) | 杩涘叕娴?True(pool榛樿瑙嗗浘琛?0,娓呰鍥惧悗琛?1);claim=True({"total":1,"successCount":1,"failCount":);archive=True({"total":1,"successCount":1,"failCount":);assign=True(success=1 fail=0);鎭㈠1鈫掑啀褰掓。2;oplog=3
PASS 26 鏌ラ噸璁剧疆璇烩啋鏀?0鈫抯ave鐢熸晥鈫掕繕鍘?鍚尮閰嶆柟寮忎笉琚薄鏌? | 鍘?80鈫?0鈫掕繕鍘?80;mode=2(鍘?)
PASS 27 check-name鍛戒腑e2c-鎭掍俊杈?淇$敤浠g爜鎾炵爜67003(L3纭嫤)+check-credit-code L1 | check-name=check-name锛歔{"customerId":"753770361971015680","customerName;err=缁熶竴绀句細淇$敤浠g爜宸茶鍗犵敤锛氱郴缁熷凡瀛樺湪璇ヤ紒涓?(code=67003);creditCode.exists=True
PASS 28 瓒呮湡鎻愰啋save闈炴硶0鈫?4023toast鈫掕繕鍘?0/7 | 64023=True;杩樺師=30/7(鍘?0/7)
PASS 29 鍏捣姹犻厤缃崰浣嶅崱鍙[D] | 瑙掓爣=1;绂佺敤杈撳叆=2
PASS 30 缂洪櫡鍙拌处闈㈡澘(A鈮?4/B4/C14/D1+鍥哄畾澹版槑) | A=14 B=4 C=14 D=1;娓叉煋琛?33
PASS 31 data-defect瑙掓爣鈮?2涓斿叧閿被鐩綈鍏?澶氶〉绛剧疮璁? | 绱=87;绫荤洰=['D-G1', 'G1', 'G2', 'G3', 'G4', 'G5', 'I-06', 'P3-3', 'P3-6', 'POOL-CFG', 'STUB-LOOKUP']
PASS 32 I-06鍙岀幇璞?pool鏃燽oard閫夐」+娉ㄨ鍚?7001/璁板繂鎴愬姛) | fForm閫夐」=['list', 'split']
PASS 33 璞佸厤鍏ュ彛涓嶅嚭鐜?鎹㈣礋璐d汉/鏃ュ織瀵煎嚭/瀹屾暣閲戦寮€鍏?瀵煎叆鏍¢獙澶嶉€夋) | 鍛戒腑=0
PASS 34 G绯诲垪鍚堝苟澶嶆牳(G1鏃犳搷浣滀汉绛涢€?G2涓冨垪/G3涓ゅ垪缂?G4--[D]/G5鏃犺礋璐d汉鍒?G6绌烘€? | G1=True G2=True G3=True G4=True G5=True G6=True
PASS 35 閲戦鍗3-6鍥涘崱--鍙e緞+鍏宠仈椤圭洰Noop绌烘€? | cards=['--', '--', '--', '-- / --']
PASS 36 company-lookup NO_HIT+F6 stub鏂囨 | NO_HIT=True;F6stub=True
PASS 37 console/pageerror=0 | 鏃犻敊璇?
PASS 38 闆禤UT/DELETE(ADR-0017) | 杩濅緥=[]
PASS 39 绔偣瑕嗙洊骞垮害(鍞竴璺緞鈮?0+鐭╅樀娓呭崟50鏉? | 鍞竴璺緞=92;缂哄け=[]
PASS 40 澶瑰叿姝i棬娓呮壂+瑙勫垯/褰㈡€佸亸濂借繕鍘熸牳瀵? | 娓呮壂=4琛?娈嬬暀=0;dedup=80(鍘?0);reminder=30(鍘?0);overview褰㈡€?list
==== summary ==== 40 steps, 1 failed (178.6s) 鈫?e:\code\crm-backend-matt\.scratch\customer-demo-ref\demo\smoke-report.json
FAIL 10 缂栬緫瀹㈡埛鍥炴樉+CAS淇濆瓨(鍚浉浼肩‘璁ら噸鍙? | 鍥炴樉鍚?e2c-dr-鎭掍俊杈剧鎶€鏈夐檺璐d换鍏徃;simbox=-;toast=;db=e2c-dr-鎭掍俊杈剧鎶€鏈夐檺璐d换鍏徃 v0
ALL_PASS: False

56
.scratch/customer-demo-ref/demo/_run8.log

@ -0,0 +1,56 @@
sweep 娓呯悊娈嬬暀 0 琛?
seed 鑷妇锛坖obc=chairman锛?..
[seed] 鏌ラ噸鍩虹嚎鍛戒腑鏁?1
python.exe : E:\code\crm-backend-matt\.scratch\customer-e2e\.venv10\Lib\site-packages\openpyxl\styles\stylesheet.py:237
: UserWarning: Workbook contains no default style, apply openpyxl's default
所在位置 行:1 字符: 1
+ & "E:\code\crm-backend-matt\.scratch\customer-e2e\.venv10\Scripts\pyt ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (E:\code\crm-bac...npyxl's default:String) [], RemoteException
+ FullyQualifiedErrorId : NativeCommandError
warn("Workbook contains no default style, apply openpyxl's default")
xlsx 灏辩华: e:\code\crm-backend-matt\.scratch\customer-demo-ref\demo\tmp_xlsx\cust.xlsx / e:\code\crm-backend-matt\.scratch\customer-demo-ref\demo\tmp_xlsx\cont.xlsx
PASS 01 鎵撳紑Demo+uid鐧诲綍+棣栧睆鍒楄〃娓叉煋 | rows=10
PASS 02 涓墂orkspace鍒囨崲;pool涓嶅彂board+I-06娉ㄨ[D] | overview璇锋眰OK=True;pool鏂拌姹?鏉?i06鍙=True
PASS 03 鍐呯疆瑙嗗浘涓嬫媺浜斿€?鍒嘑OLLOW_UP_DUE | opts=['ASSIGNED', 'COLLABORATING', 'FOLLOW_UP_DUE', 'FOCUSED', 'RECENT']
PASS 04 鍒楃瓫閫夐樁娈?宸叉垚浜? | 璇锋眰鍙傛暟 customerStage=3 宸插懡涓?
PASS 05 褰掓。鎬佸紑鍏?e2c-宸插綊妗e彲瑙? | archiveStatus=2鍛戒腑=True;鏍蜂緥鍙=True
PASS 06 saved-view 鏂板缓鈫掗€変腑鏌ヨ鈫掑垹闄? | save=True;閫変腑甯avedViewId=True;鍒犲悗娑堝け=True;body=POST /api/preference/view/save?scopeKey=customer.mine # {"name":"e2c-dr-瑙嗗浘","isDefault":false,"seqNo":10,"conditions":[{"field":"keyword","operator":"
PASS 07 褰㈡€佸亸濂給verview鍒囩湅鏉库啋娓叉煋鈫掕繕鍘焞ist | 鐪嬫澘鍒?3;cards璇锋眰=True;杩樺師=True
PASS 08 宸ュ晢鑱旀兂(companyName)+NO_HIT鐜拌薄+STUB-LOOKUP娉ㄨ[D] | 鏈煡璇㈠埌鍖归厤浼佷笟锛堜竴鏈熶紒鏌ユ煡 port 涓?stub 鎭?NO_HIT锛屽瀹炲憟鐜帮紱鍙墜宸ュ綍鍏ワ紝鐪熷疄婧愭帴鍏ュ悗鑷姩婵€娲伙級銆?
PASS 09 鍚嶇О鏌ラ噸needConfirm+similarHits鈫抍onfirmSimilar閲嶅彂鈫掑垱寤烘垚鍔? | simbox=鐩镐技鍛戒腑 1 鏉★細e2c-dr-鎭掍俊杈剧鎶€鏈夐檺璐d换鍏徃锛堣礋璐d汉=缃椾紵鍋ワ紝娼滃湪瀹㈡埛锛屾湁鏁堬級浠嶈鍒涘缓锛?纭鍒涘缓锛坈onfirmSimilar=;toast=鍒涘缓鎴愬姛 id=753771843382411264;澶瑰叿A=753771843382411264
PASS 10 缂栬緫瀹㈡埛鍥炴樉+CAS淇濆瓨(鍚浉浼肩‘璁ら噸鍙? | 鍥炴樉鍚?e2c-dr-鎭掍俊杈剧鎶€鏈夐檺璐d换鍏徃;simbox=鍛戒腑;toast=淇濆瓨鎴愬姛;db=e2c-dr-鎭掍俊杈剧鎶€鏈夐檺璐d换鍏徃-鏀?v1
PASS 11 璇︽儏澶撮儴娓叉煋+4閲戦鍗℃亽--[D] | cards=['--', '--', '--', '-- / --'];P3-6瑙掓爣=1
PASS 12 8椤电鍏ㄩ亶鍘?鑱旂郴浜洪《绾ц彍鍗昪ontact/page | subs=['info', 'contacts', 'follow', 'opps', 'projects', 'agreements', 'members', 'oplog'];contact/page琛?10;鏂板errs=[]
PASS 13 淇℃伅鏍呮牸16瀛楁+G4鍒涘缓/鏇存柊浜?-[D]+creditCode鏄庢枃 | kv=18;鍒涘缓浜?--;淇$敤浠g爜=91440101E2CTEST001
PASS 14 鍟嗘満7鍒?G2-D)+椤圭洰椤电Noop绌烘€?contact/search鍛戒腑 | 鍟嗘満鍒?['鍟嗘満ID', '鍟嗘満鍚嶇О', '闃舵', '棰勮閲戦', '璐熻矗浜篒D', '璐熻矗浜?, '鏈€杩戞洿鏂?];Noop绌烘€?True;search=濮撳悕鑱屽姟鐢佃瘽鍏抽敭鑱旂郴浜烘潵婧愭墍灞炲鎴峰鎴风紪鍙峰紶鍏抽敭钁d簨闀?38****0001鏄痗ontact_source_01e2c-
PASS 15 鎴愬憳鍔燘UDDY鈫掓覆鏌撯啋绉婚櫎(G3-D鏃犺亴鍔?鍔犲叆鏃堕棿鍒? | 鍒楀ご=['鎴愬憳', '瑙掕壊', '鎿嶄綔'];DB鎴愬憳琛?28;memberUserIds閲嶅閿?True
PASS 16 鑱旂郴浜簈uickAdd2琛?G5鏃犺礋璐d汉鍒梉D]+鐢佃瘽鑴辨晱+reveal鏄庢枃+DB鍩嬬偣 | 鑴辨晱=True;DB鑱旂郴浜?5;reveal_log=8;鏄庢枃=True;鍒楀ご=['', '#', '濮撳悕', '鑱屽姟', '鑱旂郴鐢佃瘽', '鍏抽敭', '鍐呯嚎', '鏉ユ簮', '绀煎搧澶囨敞', '鎿嶄綔'];qaRet={"successCount":2,"failedRows":[]}
PASS 17 batchEdit绌轰覆=DB涓嶆敼(D-G1)[D]+check-phone鍛戒腑+鍒犻櫎鍙屽埛 | DB phone=13812340001;D-G1瑙掓爣=1;check-phone=瀹㈡埛缂栧彿--瀹㈡埛鍚嶇О--瀹㈡埛绫诲瀷--闃舵--瀹㈡埛鏄熺骇--鍏崇郴鏄熺骇--鎴樼暐鍗忚--;鍒犲悗琛?4(鍒犲墠5,del_ok=True,errs+0);lid=753771972256595968;names=鏉庡啋鐑?璧靛啋鐑?鏉庢櫘閫?鐜嬮噸澶?寮犲叧閿?
PASS 18 鍐欒窡杩?鏃ユ湡鍒嗙粍+followWay绛涢€? | way閫夐」=6(绌?5);鏃ユ湡鍒嗙粍=5;绛涢€?True
PASS 19 鍏虫敞/閲嶇偣鏍囪+oplog鐣欑棔+action绛涢€? | action=CREATE绛涢€夊惈鏂板瀹㈡埛=True;action=FOLLOW绛涢€?True;DB璺熻繘=24
PASS 20 瀹㈡埛瀵煎叆涓夋寮?缂虹渷浠介妫€FAIL鈫扗ONE璁℃暟=棰勬鈫掓槑缁嗗惢鍚? | 棰勬 t=2 ins=1 fail=1;taskId=753772071518994432;result={'taskId': '753772071518994432', 'importMode': 'APPEND_ONLY', 'templateVersion': 'V1', 'status': 2, 'totalCount': 2, 'insertCount': 1, 'updateCount': 0, 'unchangedCount': 0, 'failCount': 1, 'suspectCount': 0, 'failReason': None, 'creatorName': Non
PASS 21 瀵煎叆浠诲姟鍒楄〃+status/importMode杩囨护+P3-3娉ㄨ[D] | status杩囨护=True;mode杩囨护=True;P3-3=1
PASS 22 鑱旂郴浜哄鍏ヤ笁娈靛紡+P3-3鏃犳牎楠屽閫夋[D] | 棰勬=棰勬瀹屾垚锛堜换鍔?753772113634000896 寰呯‘璁わ級锛氬叡 2 琛岋紝棰勮鏂板 2銆佹洿鏂?0銆佸け璐?0銆佺枒浼奸噸澶?;result={'taskId': '753772113634000896', 'customerId': '753771843382411264', 'importMode': 'APPEND_ONLY', 'templateVersion': 'V1', 'status': 2, 'totalCount': 2, 'insertCount': 2, 'updateCount': 0, 'unchangedCount': 0, 'failCount': 0, 'su
PASS 23 鍟嗘満鑱斿姩閾?UI寤哄晢鏈衡啋鎶涘叕娴锋嫤鎴啋鍏冲晢鏈衡啋鏀捐) | 鍟嗘満=753772150371909632;鐘舵€?鈫?;涓€娆elease鎷︽埅=True(toast=鍟嗘満宸插垱寤?id=753772150371909632锛涘鎴疯鎯呫€屽叧鑱斿晢鏈恒€嶉〉绛惧彲鍙嶆煡 || {"total":1,"s);杩涘叕娴?True
PASS 24 浜ゅ壊(preview鍦堝畾鈫抜nitiate鈫抪age鈫抎etail鈫抋ssignable鈫抋ssign鏄庣粏鎬佸彉鍖?F-13娓卨eftover=0) | 鍗曞彿=JG202609120001;鍦堝畾鍚す鍏?True;鏄庣粏=1鈫抋ssign鍚庡彉鍖?True(assign瀵硅薄=99021002);UI琛?10
PASS 25 褰掑睘鍔ㄤ綔鏃?release鈫抍laim鈫抋rchive鈫抮estore鈫掑啀archive+oplog+assign) | 杩涘叕娴?True(pool榛樿瑙嗗浘琛?0,娓呰鍥惧悗琛?1);claim=True({"total":1,"successCount":1,"failCount":);archive=True({"total":1,"successCount":1,"failCount":);assign=True(success=1 fail=0);鎭㈠1鈫掑啀褰掓。2;oplog=3
PASS 26 鏌ラ噸璁剧疆璇烩啋鏀?0鈫抯ave鐢熸晥鈫掕繕鍘?鍚尮閰嶆柟寮忎笉琚薄鏌? | 鍘?80鈫?0鈫掕繕鍘?80;mode=2(鍘?)
PASS 27 check-name鍛戒腑e2c-鎭掍俊杈?淇$敤浠g爜鎾炵爜67003(L3纭嫤)+check-credit-code L1 | check-name=check-name锛歔{"customerId":"753771720497692672","customerName;err=缁熶竴绀句細淇$敤浠g爜宸茶鍗犵敤锛氱郴缁熷凡瀛樺湪璇ヤ紒涓?(code=67003);creditCode.exists=True
PASS 28 瓒呮湡鎻愰啋save闈炴硶0鈫?4023toast鈫掕繕鍘?0/7 | 64023=True;杩樺師=30/7(鍘?0/7)
PASS 29 鍏捣姹犻厤缃崰浣嶅崱鍙[D] | 瑙掓爣=1;绂佺敤杈撳叆=2
PASS 30 缂洪櫡鍙拌处闈㈡澘(A鈮?4/B4/C14/D1+鍥哄畾澹版槑) | A=14 B=4 C=14 D=1;娓叉煋琛?33
PASS 31 data-defect瑙掓爣鈮?2涓斿叧閿被鐩綈鍏?澶氶〉绛剧疮璁? | 绱=87;绫荤洰=['D-G1', 'G1', 'G2', 'G3', 'G4', 'G5', 'I-06', 'P3-3', 'P3-6', 'POOL-CFG', 'STUB-LOOKUP']
PASS 32 I-06鍙岀幇璞?pool鏃燽oard閫夐」+娉ㄨ鍚?7001/璁板繂鎴愬姛) | fForm閫夐」=['list', 'split']
PASS 33 璞佸厤鍏ュ彛涓嶅嚭鐜?鎹㈣礋璐d汉/鏃ュ織瀵煎嚭/瀹屾暣閲戦寮€鍏?瀵煎叆鏍¢獙澶嶉€夋) | 鍛戒腑=0
PASS 34 G绯诲垪鍚堝苟澶嶆牳(G1鏃犳搷浣滀汉绛涢€?G2涓冨垪/G3涓ゅ垪缂?G4--[D]/G5鏃犺礋璐d汉鍒?G6绌烘€? | G1=True G2=True G3=True G4=True G5=True G6=True
PASS 35 閲戦鍗3-6鍥涘崱--鍙e緞+鍏宠仈椤圭洰Noop绌烘€? | cards=['--', '--', '--', '-- / --']
PASS 36 company-lookup NO_HIT+F6 stub鏂囨 | NO_HIT=True;F6stub=True
PASS 37 console/pageerror=0 | 鏃犻敊璇?
PASS 38 闆禤UT/DELETE(ADR-0017) | 杩濅緥=[]
PASS 39 绔偣瑕嗙洊骞垮害(鍞竴璺緞鈮?0+鐭╅樀娓呭崟50鏉? | 鍞竴璺緞=91;缂哄け=[]
PASS 40 澶瑰叿姝i棬娓呮壂+瑙勫垯/褰㈡€佸亸濂借繕鍘熸牳瀵? | 娓呮壂=4琛?娈嬬暀=0;dedup=80(鍘?0);reminder=30(鍘?0);overview褰㈡€?list
==== summary ==== 40 steps, 0 failed (176.4s) 鈫?e:\code\crm-backend-matt\.scratch\customer-demo-ref\demo\smoke-report.json
ALL_PASS: True

0
.scratch/customer-demo-ref/demo/_server.log

35
.scratch/customer-demo-ref/demo/_t14api.py

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
"""票14 API 复现:check-name 对库内存量同名客户的真实行为。"""
import io, sys, json, urllib.request, urllib.parse
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
BASE = "http://localhost:8080"
ADMIN = "739564171091247104"
def get_json(url, token=None):
req = urllib.request.Request(url)
if token: req.add_header("Authorization", "Bearer " + token)
with urllib.request.urlopen(req, timeout=20) as r:
return json.loads(r.read().decode())
tok = get_json(f"{BASE}/api/auth/debug/token?userId={ADMIN}").get("data")
if isinstance(tok, dict): tok = tok.get("token")
print("token:", (tok or "")[:20], "...")
def ck(name):
u = f"{BASE}/api/customer/check-name?name={urllib.parse.quote(name)}"
try:
d = get_json(u, tok)
print(f"check-name[{name}] code={d.get('code')} data={json.dumps(d.get('data'), ensure_ascii=False)[:300]}")
except Exception as e:
print(f"check-name[{name}] EXC {e}")
# 1) 库内现存 13 条同名:EXACT 模式应命中
ck("e2c-恒信达科技有限公司")
# 2) sweep 已清的 seed 名:应空(无同名)
ck("e2c-dr-恒信达科技有限责任公司")
# 3) 部分词:EXACT 模式不命中
ck("e2c-恒信达")
# 4) dedup 设置现状
d = get_json(f"{BASE}/api/rule/customer/dedup", tok)
print("dedup:", json.dumps(d.get("data"), ensure_ascii=False))

15
.scratch/customer-demo-ref/demo/_t14cols.py

@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
import pymysql, io, sys
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
conn = pymysql.connect(host='8.129.84.155', port=3306, user='root', password='Itc@123456',
database='crm', charset='utf8mb4', cursorclass=pymysql.cursors.DictCursor)
with conn.cursor() as cur:
for t in ("customer", "customer_contact", "customer_member", "customer_follow",
"customer_transfer", "customer_transfer_detail", "import_task", "contact_import_task"):
try:
cur.execute("SHOW COLUMNS FROM " + t)
cols = [r["Field"] for r in cur.fetchall()]
print(t, "", cols)
except Exception as e:
print(t, "ERR", e)
conn.close()

79
.scratch/customer-demo-ref/demo/_t14d7.py

@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
"""第七轮诊断:①seed 联系人 jobTitleName ②pool+viewType 复现 ③edit CAS 复现"""
import io, sys, json, urllib.request, urllib.parse
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', line_buffering=True)
sys.path.insert(0, r"e:\code\crm-backend-matt\.scratch\customer-demo-ref\demo")
import pymysql
BASE = "http://localhost:8080"
ADMIN = "739564171091247104"
def get_json(url, token=None):
req = urllib.request.Request(url)
if token: req.add_header("Authorization", "Bearer " + token)
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read().decode())
def post(url, data, token=None):
req = urllib.request.Request(url, data=urllib.parse.urlencode(data).encode(), method="POST")
req.add_header("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8")
if token: req.add_header("Authorization", "Bearer " + token)
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read().decode())
def db():
return pymysql.connect(host='8.129.84.155', port=3306, user='root', password='Itc@123456',
database='crm', charset='utf8mb4', autocommit=True, connect_timeout=10,
cursorclass=pymysql.cursors.DictCursor)
tok = get_json(f"{BASE}/api/auth/debug/token?userId={ADMIN}").get("data")
if isinstance(tok, dict): tok = tok.get("token")
print("== ① seed 联系人 jobTitleName ==")
with db() as conn, conn.cursor() as cur:
cur.execute("SELECT id, name, job_title_id, job_title_name, phone FROM customer_contact "
"WHERE customer_id=750844477165273088 AND deleted=0 ORDER BY id")
for r in cur.fetchall():
print(" ", r)
print("== ② pool+viewType 复现(对 e2c-dr-imp 客户) ==")
with db() as conn, conn.cursor() as cur:
cur.execute("SELECT id, customer_name, owner_user_id, archive_status, version FROM customer "
"WHERE customer_name LIKE 'e2c-dr%' AND deleted=0")
rows = cur.fetchall()
for r in rows: print(" ", r)
def wsp(view, kw="e2c-dr-imp"):
body = {"workspace": "pool", "current": "1", "size": "50", "viewType": view, "keyword": kw, "archiveStatus": "1"}
return post(f"{BASE}/api/customer/workspace/page", body, tok)
for v in ("ASSIGNED", "", "COLLABORATING"):
try:
d = wsp(v)
c = (d.get("data") or {})
rows2 = c.get("content") or []
print(" pool viewType=%r -> total=%s rows=%s" % (v, c.get("total"), [r.get("customerName") for r in rows2][:5]))
except Exception as e:
print(" pool viewType=%r -> ERR %s" % (v, e))
print("== ③ edit CAS 复现(对恒信达 seed) ==")
with db() as conn, conn.cursor() as cur:
cur.execute("SELECT id, customer_name, version FROM customer WHERE customer_name='e2c-dr-恒信达科技有限责任公司' AND deleted=0")
rows3 = cur.fetchall()
for r in rows3: print(" before:", r)
if rows3:
cid = str(rows3[0]["id"]); ver = str(rows3[0]["version"])
det = get_json(f"{BASE}/api/customer/detail?id={cid}", tok)
dv = ((det.get("data") or {}).get("detail") or {}).get("version")
print(" detail.version =", dv, "| db.version =", ver)
body = {"customerName": "e2c-dr-恒信达科技有限责任公司-改", "customerType": "customer_type_01",
"provinceCode": "440000", "cityCode": "440100", "districtCode": "440103",
"industryCode": "I", "version": ver}
ret = post(f"{BASE}/api/customer/edit?id={cid}", body, tok)
print(" edit ret:", json.dumps(ret, ensure_ascii=False)[:200])
with db() as conn, conn.cursor() as cur:
cur.execute("SELECT id, customer_name, version FROM customer WHERE id=%s", (int(cid),))
for r in cur.fetchall(): print(" after:", r)
# 还原
cur.execute("UPDATE customer SET customer_name='e2c-dr-恒信达科技有限责任公司' WHERE id=%s", (int(cid),))
print(" (已还原名字)")

15
.scratch/customer-demo-ref/demo/_t14db.py

@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
import pymysql, io, sys
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', line_buffering=True)
conn = pymysql.connect(host='8.129.84.155', port=3306, user='root', password='Itc@123456',
database='crm', charset='utf8mb4', cursorclass=pymysql.cursors.DictCursor)
cur = conn.cursor()
cur.execute("SELECT id, customer_name, archive_status, owner_user_id FROM customer WHERE customer_name LIKE 'e2c-dr-imp%'")
for r in cur.fetchall(): print(r)
print('---- oplog of e2c-dr-imp% customers ----')
cur.execute("SELECT customer_id, action, detail FROM customer_oplog WHERE customer_id IN (SELECT id FROM customer WHERE customer_name LIKE %s) ORDER BY id DESC LIMIT 8", ('e2c-dr-imp%',))
for r in cur.fetchall(): print(r)
print('---- transfer bills (recent) ----')
cur.execute("SELECT id, transfer_no, status, reason, to_director_id, total_count, assigned_count, create_time FROM customer_transfer ORDER BY id DESC LIMIT 3")
for r in cur.fetchall(): print(r)
conn.close()

32
.scratch/customer-demo-ref/demo/_t14diag.py

@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
"""票14 取证第二轮:transfer_detail 列结构 / 查重设置现状 / 撞码 seed 客户。"""
import io, sys
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
import pymysql
def db():
return pymysql.connect(host='8.129.84.155', port=3306, user='root', password='Itc@123456',
database='crm', charset='utf8mb4', autocommit=True,
connect_timeout=10, cursorclass=pymysql.cursors.DictCursor)
def q(sql, args=None):
with db() as c, c.cursor() as cur:
cur.execute(sql, args); return cur.fetchall()
print("== 1) customer_transfer_detail 列结构 ==")
for r in q("SHOW COLUMNS FROM customer_transfer_detail"):
print(r["Field"], r["Type"], r["Null"], r["Default"])
print("== 2) customer_dedup_rule 现状(查重设置是否被污染)==")
for r in q("SELECT * FROM customer_dedup_rule"):
print(r)
print("== 3) 最近 3 张交割单明细(SELECT *)==")
for r in q("SELECT id, transfer_no, status FROM customer_transfer ORDER BY id DESC LIMIT 3"):
print(r)
for d in q("SELECT * FROM customer_transfer_detail WHERE transfer_id=%s", (r["id"],)):
print(" 明细:", d)
print("== 4) 信用代码 91440101E2CTEST001 占用者 ==")
for r in q("SELECT id, customer_name, archive_status, deleted FROM customer WHERE unified_credit_code=%s", ("91440101E2CTEST001",)):
print(r)

17
.scratch/customer-demo-ref/demo/_t14org.py

@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
import pymysql, io, sys
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', line_buffering=True)
conn = pymysql.connect(host='8.129.84.155', port=3306, user='root', password='Itc@123456',
database='crm', charset='utf8mb4', cursorclass=pymysql.cursors.DictCursor)
cur = conn.cursor()
cur.execute("SHOW TABLES LIKE 'sys%%'")
print('sys tables:', [r[list(r)[0]] for r in cur.fetchall()])
try:
cur.execute("SELECT id, name, dept_id, enabled, employment_status FROM sys_user WHERE id IN (739564171091247104, 744842318024015872)")
for r in cur.fetchall(): print('user:', r)
except Exception as e: print('sys_user ERR', e)
try:
cur.execute("SELECT id, dept_name, leader_user_id FROM sys_dept")
for r in cur.fetchall(): print('dept:', r)
except Exception as e: print('sys_dept ERR', e)
conn.close()

21
.scratch/customer-demo-ref/demo/_t14org2.py

@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
import pymysql, io, sys
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', line_buffering=True)
conn = pymysql.connect(host='8.129.84.155', port=3306, user='root', password='Itc@123456',
database='crm', charset='utf8mb4', cursorclass=pymysql.cursors.DictCursor)
cur = conn.cursor()
cur.execute("SHOW TABLES")
tabs = [r[list(r)[0]] for r in cur.fetchall()]
print('all tables:', tabs)
utabs = [t for t in tabs if 'user' in t.lower()]
for t in utabs:
try:
cur.execute("SHOW COLUMNS FROM " + t)
print(t, 'cols:', [r['Field'] for r in cur.fetchall()])
except Exception as e: print(t, 'ERR', e)
for t in utabs:
try:
cur.execute("SELECT * FROM " + t + " WHERE user_id IN (739564171091247104, 744842318024015872) OR id IN (739564171091247104, 744842318024015872)")
for r in cur.fetchall(): print(t, r)
except Exception as e: pass
conn.close()

17
.scratch/customer-demo-ref/demo/_t14org3.py

@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
import pymysql, io, sys
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', line_buffering=True)
conn = pymysql.connect(host='8.129.84.155', port=3306, user='root', password='Itc@123456',
database='crm', charset='utf8mb4', cursorclass=pymysql.cursors.DictCursor)
cur = conn.cursor()
cur.execute("SELECT id, username, account, dept_id, enabled, employment_status FROM crm_auth_user WHERE id IN (739564171091247104, 744842318024015872)")
for r in cur.fetchall(): print('user:', r)
buddy_dept = None
cur.execute("SELECT dept_id FROM crm_auth_user WHERE id=744842318024015872")
row = cur.fetchone()
if row: buddy_dept = row['dept_id']
print('buddy dept_id =', buddy_dept)
if buddy_dept:
cur.execute("SELECT id, dept_name, leader_user_id FROM sys_dept WHERE id=%s", (buddy_dept,))
for r in cur.fetchall(): print('buddy dept:', r)
conn.close()

29
.scratch/customer-demo-ref/demo/_t14scan.py

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
"""票 14 前置扫描:demo/index.html 函数签名 + 控件 id + 关键 JS 段行号"""
import io, re, sys
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
P = r"e:\code\crm-backend-matt\.scratch\customer-demo-ref\demo\index.html"
txt = open(P, encoding='utf-8').read()
lines = txt.split('\n')
# 1) 全部函数签名(行号)
print("== FUNCTIONS ==")
for i, ln in enumerate(lines, 1):
for m in re.finditer(r'(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(([^)]*)\)', ln):
print(f"L{i} {m.group(1)}({m.group(2)[:60]})")
# 2) HTML/JS 控件 id
ids = sorted(set(re.findall(r'id="([^"]+)"', txt)))
print("\n== IDS(%d) ==" % len(ids))
print(", ".join(ids))
# 3) 关键锚点行号(辅助定向阅读)
anchors = ["dlgCreate", "dlgOppCreate", "doOppCreate", "impUpload", "impConfirm",
"cimpUpload", "cimpConfirm", "transfer/", "dlgTransferEntry", "dedup",
"reminder", "check-name", "check-phone", "reveal", "defect-panel",
"svDelete", "saveForm", "loadForm", "errbox", "memberUserIds",
"batchUnarchive", "unarchive", "openQa", "qaSubmit", "dlgFollow", "act("]
print("\n== ANCHORS ==")
for a in anchors:
hits = [i for i, ln in enumerate(lines, 1) if a in ln][:8]
print(f"{a}: {hits}")

708
.scratch/customer-demo-ref/demo/index.html

@ -177,6 +177,7 @@ tr.err-row td { background:#fef2f2 !important; }
<nav class="tabs">
<button data-tab="list" class="on" onclick="switchTab('list')">客户列表</button>
<button data-tab="detail" onclick="openDetailTab()">客户详情</button>
<button data-tab="contacts" onclick="switchTab('contacts');loadContactPage();loadContactForm()">联系人</button>
<button data-tab="import" onclick="switchTab('import');loadImportPage()">客户导入</button>
<button data-tab="transfer" onclick="switchTab('transfer');transferPage(0)">客户交割</button>
<button data-tab="settings" onclick="switchTab('settings');loadReminder();loadDedup()">设置</button>
@ -190,24 +191,62 @@ tr.err-row td { background:#fef2f2 !important; }
<button data-ws="overview" onclick="switchWs('overview')">客户总览</button>
<button data-ws="pool" onclick="switchWs('pool')">客户公海</button>
<span class="sp"></span>
<select id="fViewType" onchange="S.page=1;loadList()">
<select id="fForm" onchange="saveForm(this.value)" title="视图形态偏好(view-form/get|save,scopeKey=customer.*,持久化)"></select>
<select id="fViewType" onchange="S.page=1;S.savedViewId=null;svPaint();loadList()">
<option value="ASSIGNED">我负责的</option>
<option value="COLLABORATING">我协同的</option>
<option value="FOLLOW_UP_DUE">待跟进</option>
<option value="FOCUSED">我关注的</option>
<option value="RECENT">最近访问</option>
</select>
<input id="fKeyword" placeholder="客户名称/编号" style="width:170px">
<select id="svSel" onchange="svPick(this.value)" title="saved-view:叠加自定义视图(scopeKey=customer.mine/overview/pool);RECENT 视图不接"><option value="">自定义视图(未叠加)</option></select>
<button onclick="svNew()">存为视图</button>
<button onclick="svSetDefault()">设默认</button>
<button onclick="svDelete()">删除视图</button>
<input id="fKeyword" placeholder="客户名称/编号" style="width:150px">
<button onclick="S.page=1;loadList()">查询</button>
<span class="hint" id="listHint"></span>
</div>
<table><thead><tr>
<th>关注</th><th>客户名称</th><th>编号</th><th>阶段</th><th>星级</th><th>负责人</th><th>所属部门</th>
<th>商机数</th><th>项目数</th><th>最近跟进</th><th>操作</th>
</tr></thead><tbody id="listBody"></tbody></table>
<div class="pager">
<div class="viewbar">
<select id="fArchive" onchange="S.page=1;loadList()" title="archiveStatus:1 有效 / 2 已归档(D19 归档=删除语义)">
<option value="1">有效客户</option><option value="2">已归档</option>
</select>
<select id="fStage" onchange="S.page=1;loadList()">
<option value="">全部阶段</option><option value="1">潜在</option><option value="2">重潜</option><option value="3">已成交</option>
</select>
<select id="fType" onchange="S.page=1;loadList()">
<option value="">全部类型</option><option value="customer_type_01">总包</option><option value="customer_type_02">工程商</option><option value="customer_type_03">投资方</option><option value="customer_type_04">设计院</option><option value="customer_type_05">集成商</option><option value="customer_type_06">投标公司</option>
</select>
<select id="fStar" onchange="S.page=1;loadList()">
<option value="">客户星级</option><option value="1">★1</option><option value="2">★2</option><option value="3">★3</option><option value="4">★4</option><option value="5">★5</option>
</select>
<select id="fRelStar" onchange="S.page=1;loadList()">
<option value="">关系星级</option><option value="1">★1</option><option value="2">★2</option><option value="3">★3</option><option value="4">★4</option><option value="5">★5</option>
</select>
<input id="fIndustry" placeholder="行业 code(gov)" style="width:120px" onchange="S.page=1;loadList()">
<input id="fProvince" placeholder="省份 code(440000)" style="width:130px" onchange="S.page=1;loadList()">
<button onclick="fltReset()">重置筛选</button>
<span class="defect-note" id="i06Note" data-defect="I-06" style="display:none">公海无看板(数据端点 67001);形态偏好接口对 pool+board 记忆成功(I-06 契约漂移,非缺陷)——本 Demo 公海形态不提供 board 选项</span>
</div>
<div id="listTable">
<table><thead id="listHead"></thead><tbody id="listBody"></tbody></table>
<div class="pager" id="pager">
<button onclick="loadList(-1)">上一页</button><span id="pageInfo">-</span><button onclick="loadList(1)">下一页</button>
</div>
</div>
<div class="panel"><b>汇总卡(当前 workspace + 视图)</b><div id="summaryBox" class="sumcards hint">随列表加载</div></div>
<div id="boardBox" style="display:none">
<div class="viewbar">
<span>分组列</span>
<select id="bCol" onchange="S.bCol=this.value;loadBoard()">
<option value="stage">客户阶段</option><option value="star">客户星级</option><option value="relation">关系星级</option>
</select>
<span class="hint">看板数据=board/summary(显式传 groupColumn;字段 cnt/groupValue)+ board/cards(groupValue 必传,不接 saved-view);仅 mine/overview 提供入口</span>
</div>
<div id="boardRow" style="display:flex;gap:10px;align-items:flex-start;overflow-x:auto"></div>
</div>
<div class="hint" id="splitHint" style="display:none">分屏形态(占位):点击左表「客户名称」,下方出该行卡片预览(列表行子集)。</div>
</div>
<div class="panel" id="splitPanel" style="display:none"><b>行卡片预览</b><div id="splitCard" class="hint">点击左表客户名称</div></div>
<div class="panel">
<b>选中客户批量操作</b> <span class="hint">列表行点击勾选;操作作用于勾选行或勾选行第一条</span>
<div class="toolbar">
@ -216,11 +255,19 @@ tr.err-row td { background:#fef2f2 !important; }
<button onclick="batchRelease()">批量抛公海</button>
<button onclick="batchArchive()">批量归档</button>
<button onclick="batchFocus()">批量关注</button>
<button onclick="batchCompanyUpdate()">批量工商更新 <span class="mark" title="F6:逐客户按名称查工商源只补空缺字段,单次上限 100,行级五种状态软处置;一期 stub 恒 NO_HIT">F6</span></button>
<button data-defect="STUB-LOOKUP" onclick="batchCompanyUpdate()">批量工商更新 <span class="mark" title="F6:逐客户按名称查工商源只补空缺字段,单次上限 100,行级五种状态软处置;一期 stub 恒 NO_HIT(STUB-LOOKUP 豁免)">F6 stub</span></button>
<button onclick="dlgTransferEntry()">发起交割 <span class="mark" title="A4-7:离职/调动整批移交,接收总监名下客户整批原子划转">A4-7</span></button>
</div>
<div class="hint" id="pickHint">未勾选</div>
</div>
<div class="panel">
<b>商机侧演示入口</b> <span class="hint">票 12:A3 商机表单三件套(形态按 03 票 opp-deps §4;API-SUMMARY §2.4「商机弹窗快建」= 反向快建客户)</span>
<div class="toolbar">
<button onclick="dlgQuickCreate()">快建客户(quick-create)</button>
<button onclick="dlgCustSearch()">候选客户三维搜索(customer/search)</button>
</div>
<div class="hint">「从客户发起建商机」在列表行操作「建商机」按钮(预填 customerId/customerName → POST /api/opportunity;customerId 非空时 isPrimaryIntended+customerRole 必填)。</div>
</div>
</section>
<!-- ========== Tab 2 客户详情 ========== -->
@ -232,6 +279,7 @@ tr.err-row td { background:#fef2f2 !important; }
<button data-s="contacts" onclick="switchSub('contacts')">联系人</button>
<button data-s="follow" onclick="switchSub('follow')">跟进记录 <span class="mark" title="D-02(P2):写跟进允许 nextFollowTime 早于现在(原型要求拦截)">D-02</span></button>
<button data-s="opps" onclick="switchSub('opps')">关联商机</button>
<button data-s="projects" onclick="switchSub('projects')">关联项目</button>
<button data-s="agreements" onclick="switchSub('agreements')">战略协议 <span class="mark" title="页签 6(返工票 12):列表/新增/编辑/删除/详情五端点,写后同步客户协议等级快照">A4-4-6</span></button>
<button data-s="members" onclick="switchSub('members')">团队成员</button>
<button data-s="oplog" onclick="switchSub('oplog')">操作日志</button>
@ -248,6 +296,7 @@ tr.err-row td { background:#fef2f2 !important; }
<button id="btnEditList" onclick="editListToggle()">批量编辑</button>
<button id="btnSaveList" onclick="saveListAll()" style="display:none">全部保存</button>
<button id="btnResetList" onclick="btnResetListClick()" style="display:none">放弃修改</button>
<span class="defect-note" data-defect="D-G1" title="GR D-G1(contact-graph):batchEdit「空串=清空」不可达——空串绑定成 null=不修改">D-G1:空值提交=不修改(清空语义豁免)</span>
<button id="btnRevealAll" onclick="toggleRevealAll()">明文查看</button>
<button onclick="exportContacts()">导出</button>
<button onclick="openCimp()">导入</button>
@ -257,6 +306,7 @@ tr.err-row td { background:#fef2f2 !important; }
<div class="viewbar" style="margin-bottom:6px">
<button id="pillList" class="on" onclick="switchContactView('list')">列表视图</button>
<button id="pillGraph" onclick="switchContactView('graph')">图谱视图</button>
<span class="dbadge" data-defect="G5" title="ContactDTO 未暴露负责人快照(G5),列表无负责人列">⚠G5</span>
<span class="hint" id="contactCount"></span>
</div>
<div id="cListView">
@ -296,6 +346,26 @@ tr.err-row td { background:#fef2f2 !important; }
</section>
<!-- ========== Tab 3 客户导入 ========== -->
<!-- ========== Tab 联系人(票 12:#50 顶级列表 / #51 详情 / #52 全局搜索 / #53 日志空态 G6 / contact.list 偏好) ========== -->
<section id="tab-contacts" style="display:none">
<div class="panel">
<div class="viewbar">
<input id="cKw" placeholder="姓名/电话/所属客户" style="width:180px">
<button onclick="CT.page=1;loadContactPage()">查询</button>
<select id="cForm" onchange="saveContactForm(this.value)" title="形态偏好 view-form/get|save,scopeKey=contact.list(持久化)"></select>
<button onclick="dlgCpEdit()">列偏好</button>
<span class="sp"></span>
<button onclick="dlgContactSearch()">全局搜索(商机选联系人场景)</button>
<span class="defect-note" data-defect="G5" title="07 票定稿:ContactDTO 无 owner 字段,联系人无负责人列">G5:联系人无负责人列(ContactDTO 无 owner 字段)</span>
</div>
<table><thead id="ctPageHead"></thead><tbody id="ctPageBody"></tbody></table>
<div class="pager">
<button onclick="loadContactPage(-1)">上一页</button><span id="ctPageInfo">-</span><button onclick="loadContactPage(1)">下一页</button>
</div>
<div class="hint">contact/page(GET,BaseParam+keyword 匹配姓名/电话/所属客户名,客户可见性收口);电话全读路径服务端脱敏 138****1234(K10);来源=contact_source 字典驱动(K6:group_meeting/group_activity/referral/other)。</div>
</div>
</section>
<section id="tab-import" style="display:none">
<div class="panel">
<b>两段式导入<span class="mark" title="D-05(P1):customer.is_biz_negotiated NOT NULL 无默认值且 createCustomer 未赋值——含 INSERT 行的导入必 FAILED(行不落库)。D-04(P2):行异常文本超长致失败明细写不进 fail 表(0 行)">D-05 / D-04</span></b>
@ -314,8 +384,17 @@ tr.err-row td { background:#fef2f2 !important; }
<button onclick="impUpload()">上传预检</button>
</div>
<div class="hint">上传后生成 DRAFT 任务并返回行级预检计数(响应回显 duplicateStrategy);确认后异步执行,任务列表可轮询结果/失败明细。67013=预检失败(mode/策略非法/表头/空文件/行数),67014=状态机不允许(重复 confirm)。F8:duplicateStrategy 仅作用文件内重复组,不触名称相似 SUSPECT 判定。</div>
<div class="defect-note" data-defect="P3-3" title="GR P3-3:原型控件幻影(矩阵 #64 豁免)">P3-3:原型「触发校验规则后确认执行」复选框为幻影控件——实现语义=确认即执行,上传预检已含规则校验</div>
</div>
<div class="panel"><b>导入任务(仅本人可见,creator 过滤;#63 状态/模式过滤已接 ImportPageParam)</b>
<div class="viewbar">
<select id="impFStatus" onchange="loadImportPage()" title="status:0 待确认 / 1 处理中 / 2 已完成 / 3 已失败">
<option value="">全部状态</option><option value="0">待确认</option><option value="1">处理中</option><option value="2">已完成</option><option value="3">已失败</option>
</select>
<select id="impFMode" onchange="loadImportPage()" title="importMode:APPEND_ONLY / UPDATE_ONLY / UPSERT">
<option value="">全部模式</option><option value="APPEND_ONLY">仅新增</option><option value="UPDATE_ONLY">仅更新</option><option value="UPSERT">新增或更新</option>
</select>
</div>
<div class="panel"><b>导入任务(仅本人可见,creator 过滤)</b>
<table><thead><tr>
<th>任务ID</th><th>模式</th><th>状态</th><th>总数</th><th>新增</th><th>更新</th><th>未变化</th><th>失败</th><th>疑似</th><th>失败原因</th><th>提交时间</th><th>操作</th>
</tr></thead><tbody id="impBody"></tbody></table>
@ -377,6 +456,14 @@ tr.err-row td { background:#fef2f2 !important; }
</div>
<div class="toolbar"><button onclick="loadDedup()">刷新</button><button class="primary" onclick="saveDedup()" style="background:#409eff;color:#fff;border-color:#409eff">保存</button></div>
</div>
<div class="panel">
<b>客户公海池配置 <span class="defect-note" data-defect="POOL-CFG" title="决策票 02 核实:后端 crm-rule 客户域仅查重+提醒两控制器、.bru 客户规则下无公海池文档;线索域 /api/rule/pool 不属客户域">POOL-CFG(未实现 · 只读占位)</span></b>
<div class="dbody" style="padding:8px 0">
<label><b>准入规则</b><input value="固定准入规则(虚拟池,不可配置)" readonly disabled style="background:#f5f7fa;color:#999"></label>
<label><b>回收/退出规则</b><input value="未实现——后端无端点、v29 原型无此配置页" readonly disabled style="background:#f5f7fa;color:#999"></label>
<div class="hint">POOL-CFG(决策票 02 核实):客户公海池配置后端未实现且原型无此页;客户公海=固定准入规则的虚拟池,非可配置池。线索域公海池配置(/api/rule/pool)不属客户域,不得混入。</div>
</div>
</div>
<div class="panel">
<b>查重试玩(消费侧试玩 · 配置走上方查重设置单例;信用代码恒开无配置)</b>
<div class="toolbar">
@ -530,7 +617,7 @@ async function loginByUid() {
localStorage.setItem('e2c_me', JSON.stringify(me));
paintWho();
toast('登录成功: ' + (me.userName || me.nickName || uid), 'ok');
S.page = 1; loadList(); loadSummary();
switchWs(S.ws);
} catch (e) { toast('登录异常: ' + e.message, 'err'); }
}
function switchUser(uid) { $('uidInput').value = uid; loginByUid(); }
@ -546,11 +633,32 @@ function switchTab(t) {
}
/* ================= 客户列表 ================= */
const S = { ws: 'mine', page: 1, size: 10, total: 0, rows: [], picked: new Set() };
function switchWs(ws) {
S.ws = ws; S.page = 1; S.picked.clear(); paintPick();
const S = { ws: 'mine', page: 1, size: 10, total: 0, rows: [], picked: new Set(), form: 'list', savedViewId: null, savedViews: [], bCol: 'stage' };
const CUST_TYPE = { customer_type_01: '总包', customer_type_02: '工程商', customer_type_03: '投资方', customer_type_04: '设计院', customer_type_05: '集成商', customer_type_06: '投标公司' }; // field-alignment 字典专节(DictDataInitializer 种子同值)
const BOARD_GROUP = { stage: { 1: '潜在', 2: '重潜', 3: '已成交' }, star: { 1: '★1', 2: '★2', 3: '★3', 4: '★4', 5: '★5' }, relation: { 1: '★1', 2: '★2', 3: '★3', 4: '★4', 5: '★5' } };
const wsScope = () => 'customer.' + S.ws;
function typeName(code) { return CUST_TYPE[code] || code || '--'; }
function industryName(code) {
if (!code) return '--';
for (const g of INDUSTRY) {
if (g.code === code) return g.name;
for (const c of (g.children || [])) if (c.code === code) return c.name;
}
return code;
}
async function switchWs(ws) {
S.ws = ws; S.page = 1; S.picked.clear(); S.savedViewId = null; paintPick();
document.querySelectorAll('#tab-list .viewbar [data-ws]').forEach(b => b.classList.toggle('on', b.dataset.ws === ws));
loadList(); loadSummary();
paintFormOptions();
$('i06Note').style.display = ws === 'pool' ? '' : 'none';
loadSavedViews();
loadForm();
loadList();
}
function fltReset() {
['fStage', 'fType', 'fStar', 'fRelStar', 'fIndustry', 'fProvince'].forEach(id => { $(id).value = ''; });
$('fArchive').value = '1';
S.page = 1; loadList();
}
async function loadList(dir) {
if (!TOKEN()) { $('listHint').textContent = '请先登录'; return; }
@ -560,12 +668,19 @@ async function loadList(dir) {
try {
const d = await api('/api/customer/workspace/page', {
method: 'POST',
body: qs({ workspace: S.ws, current: S.page, size: S.size, viewType: $('fViewType').value, keyword: $('fKeyword').value.trim() })
body: qs({
workspace: S.ws, current: S.page, size: S.size, viewType: $('fViewType').value, keyword: $('fKeyword').value.trim(),
archiveStatus: $('fArchive').value, customerStage: $('fStage').value, customerType: $('fType').value,
customerStarLevel: $('fStar').value, relationStarLevel: $('fRelStar').value,
industryCode: $('fIndustry').value.trim(), provinceCode: $('fProvince').value.trim(),
savedViewId: ($('fViewType').value === 'RECENT' ? '' : (S.savedViewId || ''))
})
});
S.rows = (d && d.content) || [];
S.total = (d && d.total) || 0;
if (!S.rows.length && S.page > 1) { S.page = 1; return loadList(); }
$('listBody').innerHTML = S.rows.length ? S.rows.map(rowHtml).join('') : '<tr><td colspan="12" class="empty">无数据</td></tr>';
paintHead();
$('listBody').innerHTML = S.rows.length ? S.rows.map(rowHtml).join('') : '<tr><td colspan="' + (listCols().length + 1) + '" class="empty">无数据</td></tr>';
$('pageInfo').textContent = '第 ' + S.page + ' 页 / 共 ' + S.total + ' 条';
$('listHint').textContent = '';
paintPick();
@ -573,34 +688,158 @@ async function loadList(dir) {
}
function badge(txt, cls) { return '<span class="badge ' + (cls || 'b1') + '">' + esc(txt) + '</span>'; }
function star(n) { return n ? '★'.repeat(Number(n)) : '--'; }
/* 列组随 workspace 切换(原型差异:mine/overview 有负责人+部门,pool 无负责人列、加进入公海时间固定列) */
function listCols() {
const base = ['关注', '客户名称', '编号', '阶段', '星级', '类型', '行业', '协议等级', 'VIP等级'];
const tail = ['商机数', '项目数', '最近跟进', '操作'];
return S.ws === 'pool' ? base.concat(['进入公海时间'], tail) : base.concat(['负责人', '所属部门'], tail);
}
function paintHead() {
$('listHead').innerHTML = '<tr><th style="width:34px"></th>' + listCols().map(h => '<th>' + h + '</th>').join('') + '</tr>';
}
function rowHtml(r) {
const ck = S.picked.has(String(r.id)) ? ' checked' : '';
return '<tr>' +
'<td><input type="checkbox" ' + ck + ' onclick="event.stopPropagation();pick(\'' + r.id + '\')"></td>' +
'<td><span class="star-link' + (Number(r.focusFlag) ? '' : ' off') + '" title="关注/取关" onclick="event.stopPropagation();toggleFocus(\'' + r.id + '\',' + (Number(r.focusFlag) ? 0 : 1) + ')">' + (Number(r.focusFlag) ? '★' : '☆') + '</span></td>' +
'<td class="row" onclick="openDetail(\'' + r.id + '\')">' + esc(r.customerName) + '</td>' +
'<td>' + esc(r.customerNo) + '</td>' +
'<td>' + badge(STAGE[r.customerStage] || r.customerStage, r.customerStage === 3 ? 'b2' : 'b1') + '</td>' +
'<td>' + star(r.customerStarLevel) + '</td>' +
'<td>' + esc(r.ownerNameSnapshot || r.ownerUserNameSnapshot || r.ownerUserId) + '</td>' +
'<td>' + esc(r.ownerDeptNameSnapshot || '--') + '</td>' +
'<td>' + (r.opportunityCount == null ? '--' : r.opportunityCount) + '</td>' +
'<td>--</td>' +
'<td>' + esc(r.lastValidFollowTime || '--') + '</td>' +
'<td><button onclick="event.stopPropagation();openDetail(\'' + r.id + '\')">详情</button></td></tr>';
const nameClick = (S.form === 'split' ? 'splitPick' : 'openDetail') + '(\'' + r.id + '\')';
const c = [];
c.push('<td><input type="checkbox" ' + ck + ' onclick="event.stopPropagation();pick(\'' + r.id + '\')"></td>');
c.push('<td><span class="star-link' + (Number(r.focusFlag) ? '' : ' off') + '" title="关注/取关" onclick="event.stopPropagation();toggleFocus(\'' + r.id + '\',' + (Number(r.focusFlag) ? 0 : 1) + ')">' + (Number(r.focusFlag) ? '★' : '☆') + '</span></td>');
c.push('<td class="row" onclick="' + nameClick + '">' + esc(r.customerName) + '</td>');
c.push('<td>' + esc(r.customerNo) + '</td>');
c.push('<td>' + badge(STAGE[r.customerStage] || r.customerStage, r.customerStage === 3 ? 'b2' : 'b1') + '</td>');
c.push('<td>' + star(r.customerStarLevel) + '</td>');
c.push('<td>' + esc(typeName(r.customerType)) + '</td>');
c.push('<td>' + esc(industryName(r.industryCode)) + '</td>');
c.push('<td>' + (r.strategicAgreementLevel == null ? '--' : esc(r.strategicAgreementLevel)) + '</td>');
c.push('<td>' + (r.vipCustomerLevel == null ? '--' : esc(r.vipCustomerLevel)) + '</td>');
if (S.ws === 'pool') c.push('<td>' + esc(r.enterPoolTime || '--') + '</td>');
else {
c.push('<td>' + esc(r.ownerNameSnapshot || r.ownerUserNameSnapshot || '--') + '</td>');
c.push('<td>' + esc(r.ownerDeptNameSnapshot || '--') + '</td>');
}
c.push('<td>' + (r.opportunityCount == null ? '--' : esc(r.opportunityCount)) + '</td>');
c.push('<td>' + (r.projectCount == null ? '--' : esc(r.projectCount)) + '</td>'); // K5:三方漂移收敛=按实返回,null 兜底 "--"
c.push('<td>' + esc(r.lastValidFollowTime || '--') + '</td>');
c.push('<td><button onclick="event.stopPropagation();openDetail(\'' + r.id + '\')">详情</button> <button title="从客户发起建商机(预填 customerId/customerName → POST /api/opportunity)" onclick="event.stopPropagation();dlgOppCreate(\'' + r.id + '\')">建商机</button></td>');
return '<tr>' + c.join('') + '</tr>';
}
function pick(id) { const k = String(id); if (S.picked.has(k)) S.picked.delete(k); else S.picked.add(k); paintPick(); }
function paintPick() { $('pickHint').textContent = S.picked.size ? '已勾选 ' + S.picked.size + ' 条:' + [...S.picked].join(', ') : '未勾选'; }
const pickedIds = () => [...S.picked];
async function loadSummary() {
const box = $('summaryBox');
if (!TOKEN()) { box.textContent = '请先登录'; return; }
/* ---- saved-view 自定义视图(票 10;scopeKey=customer.mine/overview/pool;RECENT 不接) ----
契约(field-alignment #7):save 为全平台唯一 @RequestBody JSON 例外(ADR-0017 范围外);
本期前端只提交 1 组条件(keyword like),conditions 为空数组=基础集。 */
async function loadSavedViews() {
if (!TOKEN()) { S.savedViews = []; svPaint(); return; }
try { S.savedViews = (await api('/api/preference/view/list?scopeKey=' + wsScope())) || []; }
catch (e) { S.savedViews = []; }
svPaint();
}
function svPaint() {
const dis = $('fViewType').value === 'RECENT';
$('svSel').disabled = dis;
$('svSel').innerHTML = '<option value="">自定义视图(未叠加)</option>' +
S.savedViews.map(v => '<option value="' + esc(v.viewId) + '"' + (S.savedViewId === v.viewId ? ' selected' : '') + '>' + esc(v.name) + (v.isDefault ? ' ⚑' : '') + '</option>').join('');
}
async function svNew() {
if ($('fViewType').value === 'RECENT') return toast('RECENT 视图不接 saved-view', 'err');
const name = prompt('视图名称(存入 customer.' + S.ws + ' 作用域)', '');
if (!name) return;
const kw = $('fKeyword').value.trim();
const body = { name: name, isDefault: false, seqNo: (S.savedViews.length + 1) * 10,
conditions: kw ? [{ field: 'keyword', operator: 'like', value: kw }] : [] };
try {
const viewId = await api('/api/preference/view/save?scopeKey=' + wsScope(), { method: 'POST', headers: { 'Content-Type': 'application/json;charset=UTF-8' }, body: JSON.stringify(body) });
toast('视图已保存' + (viewId ? ': ' + viewId : ''), 'ok');
S.savedViewId = viewId || null;
await loadSavedViews();
S.page = 1; loadList();
} catch (e) { toast('保存视图失败: ' + e.message + codeSuffix(e), 'err'); }
}
function svPick(v) { S.savedViewId = v || null; S.page = 1; loadList(); }
async function svSetDefault() {
if (!S.savedViewId) return toast('请先在下拉选中一个视图', 'err');
try { await api('/api/preference/view/set-default?scopeKey=' + wsScope() + '&viewId=' + S.savedViewId, { method: 'POST' }); toast('已设为默认视图', 'ok'); loadSavedViews(); }
catch (e) { toast('失败: ' + e.message + codeSuffix(e), 'err'); }
}
async function svDelete() {
if (!S.savedViewId) return toast('请先在下拉选中一个视图', 'err');
try { await api('/api/preference/view/delete?scopeKey=' + wsScope() + '&viewId=' + S.savedViewId, { method: 'POST' }); toast('视图已删除', 'ok'); S.savedViewId = null; loadSavedViews(); }
catch (e) { toast('失败: ' + e.message + codeSuffix(e), 'err'); }
}
/* ---- 形态偏好 view-form(票 10;list/split/board;pool 不给 board 选项=I-06 注记口径) ---- */
function paintFormOptions() {
$('fForm').innerHTML = '<option value="list">列表形态</option><option value="split">分屏形态</option>' + (S.ws === 'pool' ? '' : '<option value="board">看板形态</option>');
}
async function loadForm() {
try { S.form = (await api('/api/preference/view-form/get?scopeKey=' + wsScope())) || 'list'; }
catch (e) { S.form = 'list'; }
if (S.form === 'board' && S.ws === 'pool') S.form = 'list'; // I-06:保存侧对 pool+board 记忆成功,渲染侧不提供
paintForm();
}
function paintForm() {
const f = S.form === 'board' ? 'board' : (S.form === 'split' ? 'split' : 'list');
$('fForm').value = f;
$('listTable').style.display = f === 'board' ? 'none' : '';
$('boardBox').style.display = f === 'board' ? '' : 'none';
$('splitPanel').style.display = f === 'split' ? '' : 'none';
$('splitHint').style.display = f === 'split' ? '' : 'none';
if (f === 'board') loadBoard();
}
async function saveForm(v) {
if (!v) return;
if (S.ws === 'pool' && v === 'board') { paintForm(); return; } // pool 下拉无 board 选项,兜底不发送
try {
await api('/api/preference/view-form/save', { method: 'POST', body: qs({ scopeKey: wsScope(), viewForm: v }) });
S.form = v; toast('形态已保存: ' + v, 'ok');
} catch (e) { toast('保存形态失败: ' + e.message + codeSuffix(e), 'err'); }
paintForm();
}
/* ---- 看板视图(票 10;summary 显式 groupColumn,字段 cnt/groupValue(K3);cards groupValue 必传) ---- */
function cardHtml(r) {
return '<div class="panel" style="margin:0 0 6px;padding:6px 10px;cursor:pointer" onclick="openDetail(\'' + r.id + '\')">' +
'<b>' + esc(r.customerName || '--') + '</b> <span class="hint">' + esc(r.customerNo || '--') + '</span><br>' +
'<span class="hint">' + esc(STAGE[r.customerStage] || '--') + ' · ' + star(r.customerStarLevel) + ' · ' + esc(r.ownerNameSnapshot || r.ownerUserNameSnapshot || '无主') + '</span></div>';
}
async function loadBoard() {
const box = $('boardRow');
if (!TOKEN()) { box.innerHTML = '<span class="hint">请先登录</span>'; return; }
box.innerHTML = '<span class="loading">看板加载中...</span>';
const col = S.bCol;
try {
const d = await api('/api/customer/workspace/board/summary', { method: 'POST', body: qs({ workspace: S.ws, viewType: $('fViewType').value }) });
const items = Array.isArray(d) ? d : Object.entries(d || {}).map(([k, v]) => ({ key: k, count: (v && typeof v === 'object') ? (v.count != null ? v.count : v.total) : v }));
box.className = 'sumcards';
box.innerHTML = items.length ? items.map(it => '<div class="card"><b>' + esc(it.count != null ? it.count : '--') + '</b>' + esc(it.name || it.key || '') + '</div>').join('') : '<span class="hint"></span>';
} catch (e) { box.className = 'sumcards hint'; box.textContent = '汇总加载失败: ' + e.message; }
const sums = await api('/api/customer/workspace/board/summary', { method: 'POST', body: qs({ workspace: S.ws, viewType: $('fViewType').value, groupColumn: col }) });
const map = {};
(Array.isArray(sums) ? sums : []).forEach(it => { map[String(it.groupValue)] = it.cnt; });
const defs = Object.keys(BOARD_GROUP[col] || {}); // 空列骨架前端补全(summary 仅返回有数据分组)
const cardLists = await Promise.all(defs.map(gv =>
api('/api/customer/workspace/board/cards', { method: 'POST', body: qs({ workspace: S.ws, viewType: $('fViewType').value, groupColumn: col, groupValue: gv, current: 1, size: 20 }) }).catch(e => ({ err: e }))));
box.innerHTML = defs.map((gv, i) => {
const d = cardLists[i];
const rows = (d && d.content) || [];
const cards = d && d.err ? '<div class="hint">加载失败: ' + esc(d.err.message) + '</div>'
: (rows.length ? rows.map(cardHtml).join('') : '<div class="hint">空列</div>');
return '<div style="min-width:230px;flex:1;border:1px solid #e4e7ed;border-radius:6px;background:#fafbfc;padding:8px">' +
'<b>' + BOARD_GROUP[col][gv] + '</b> <span class="badge b1">' + esc(map[String(gv)] != null ? map[String(gv)] : '0') + '</span>' +
'<div style="margin-top:6px">' + cards + '</div></div>';
}).join('');
} catch (e) { box.innerHTML = '<span class="hint">看板加载失败: ' + esc(e.message) + codeSuffix(e) + '</span>'; }
}
/* ---- 分屏形态占位(票 10;卡片=列表行子集) ---- */
function splitPick(id) {
const r = S.rows.find(x => String(x.id) === String(id));
if (!r) return;
$('splitCard').innerHTML = '<b>' + esc(r.customerName || '--') + '</b> <span class="hint">' + esc(r.customerNo || '--') + '</span>' +
'<div class="grid" style="margin-top:8px">' +
'<div class="kv"><b>阶段</b>' + esc(STAGE[r.customerStage] || '--') + '</div>' +
'<div class="kv"><b>星级</b>' + star(r.customerStarLevel) + '</div>' +
'<div class="kv"><b>类型</b>' + esc(typeName(r.customerType)) + '</div>' +
'<div class="kv"><b>行业</b>' + esc(industryName(r.industryCode)) + '</div>' +
'<div class="kv"><b>负责人</b>' + esc(r.ownerNameSnapshot || r.ownerUserNameSnapshot || '--') + '</div>' +
'<div class="kv"><b>最近跟进</b>' + esc(r.lastValidFollowTime || '--') + '</div>' +
'</div><div class="hint" style="margin-top:6px">「详情」按钮进入完整详情页。</div>';
}
/* ================= 联系人图谱/页签增强(票02 收编自 customer-contact-graph demo 修订⑧版) ================= */
@ -622,6 +861,7 @@ const C = { list: [], query: { keyword: '', internal: '' }, checked: {}, editing
let revealed = {}; // id -> true(单点临时明文;列表/图谱共享)
let JOB_DICT = []; // [{cat, code, jobs:[{name, code, level}]}]
let SOURCE_DICT = []; // [{code, name}]
let INDUSTRY = []; // 行业两级树 [{code, name, children:[{code, name}]}](列表行业列字典翻译用)
let qaRows = [];
const wrap = $('graphWrap'), canvasInner = $('canvasInner'), edgeSvg = $('edgeSvg');
const custName = () => D.cname || ('客户 ' + (D.id || ''));
@ -639,10 +879,12 @@ async function ensureDicts() {
if (dictLoaded) return;
const pair = await Promise.all([
api('/api/dict/item/tree?groupCode=job_title').catch(() => []),
api('/api/dict/item/enabled-list?groupCode=contact_source').catch(() => [])
api('/api/dict/item/enabled-list?groupCode=contact_source').catch(() => []),
api('/api/dict/item/tree?groupCode=industry').catch(() => [])
]);
JOB_DICT = (pair[0] || []).map(g => ({ cat: g.name, code: g.code, jobs: (g.children || []).map(j => ({ name: j.name, code: j.code, level: j.level == null ? null : Number(j.level) })) }));
SOURCE_DICT = (pair[1] || []).map(s => ({ code: s.code, name: s.name }));
INDUSTRY = pair[2] || [];
dictLoaded = true;
}
/* 层级变化后一致性清理(用户拍板:同级不应有线;方向规则:上级层级必须 > 下级):
@ -927,7 +1169,7 @@ const DEFECT_LEDGER = {
{ id: 'G1', level: 'P2', ph: '跟进筛选无「操作人」', loc: '详情·页签3 筛选区', how: '①+②', copy: '后端 FollowPageParam 无操作人参数(G1),操作人筛选暂不可用' },
{ id: 'G2', level: 'P2', ph: '关联商机 7 字段 vs 原型 13 列', loc: '详情·页签4', how: '①+②', copy: '原型期望 13 列,后端契约 7 字段(G2,挂账跨模块票);本页按实际契约渲染,缺列不补' },
{ id: 'G3', level: 'P2', ph: '团队成员缺职务/加入时间两列', loc: '详情·页签7', how: '①+②', copy: '后端 CustomerMemberDTO 未暴露(G3);真源在实体,待 DTO 补列' },
{ id: 'G4', level: 'P2', ph: '系统信息四件套(创建/更新时间·恒 "--"', loc: '详情·页签1 系统信息区', how: '①+②', copy: '后端 CustomerDetailDTO 未暴露(G4),恒显 --' },
{ id: 'G4', level: 'P2', ph: '系统信息创建/更新人恒 "--"(创建/更新时间实测有值)', loc: '详情·页签1 系统信息区', how: '①+②', copy: '后端 CustomerDetailDTO 未暴露创建人/更新人(G4);创建/更新时间经 BaseDTO 实测有值,如实渲染' },
{ id: 'G5', level: 'P2', ph: '联系人列表缺「负责人」列', loc: '详情·页签2 列表', how: '①+②', copy: 'ContactDTO 未暴露负责人快照(G5)' },
{ id: 'G6', level: 'P2', ph: '联系人维度操作日志无数据源', loc: '联系人页·日志子页签', how: '①+②', copy: '后端 CustomerOplog 无 contact 维度列(G6),本页签空态' },
{ id: 'P3-6', level: 'P3', ph: '头部金额汇总卡 5 字段恒 null', loc: '详情头部·金额卡', how: '①+②', copy: '金额字段装配未接(P3-6),恒显 --' },
@ -1012,6 +1254,8 @@ const formVal = id => { const el = $(id); return el ? el.value.trim() : ''; };
function dlgCreate() {
dlgOpen('新建客户(三层查重内建:L2 名称相似 → needConfirm 确认后重发)', `
<label><b>客户名称*</b><input id="cName" placeholder="e2c-demo-客户"></label>
<label><b>工商联想(#24)</b><input id="cLk" placeholder="公司名称关键字(参数名 companyName)" style="width:220px"><button onclick="doLookup()">联想</button>
<span class="defect-note" data-defect="STUB-LOOKUP" title="一期企查查 port 为 stub 恒空数组(NO_HIT);seam 已接线,真实源接入后自动激活">STUB-LOOKUP:一期 stub 恒 NO_HIT</span></label>
<label><b>客户类型*</b><input id="cType" placeholder="字典 code"></label>
<label><b>省/市/区*</b><input id="cProv" placeholder="provinceCode" style="width:100px"><input id="cCity" placeholder="cityCode" style="width:100px"><input id="cDist" placeholder="districtCode" style="width:100px"></label>
<label><b>行业一级*</b><input id="cInd" placeholder="industryCode"></label>
@ -1032,8 +1276,41 @@ function createForm() {
isBizNegotiated: formVal('cBiz'), isChild: formVal('cChild'), ownerUserId: formVal('cOwner'),
};
}
async function doCreate(confirmSimilar) {
/* ---- #24 工商联想(company-lookup;一期 stub 恒空数组=NO_HIT):只回填当前为空的字段,回填后立即 check-credit-code ---- */
async function doLookup() {
const nm = formVal('cLk');
if (!nm) { $('cOut').innerHTML = '<div class="errbox">companyName 必填</div>'; return; }
try {
const arr = await api('/api/customer/company-lookup?companyName=' + encodeURIComponent(nm));
const list = Array.isArray(arr) ? arr : [];
if (!list.length) {
$('cOut').innerHTML = '<div class="hint" data-defect="STUB-LOOKUP">未查询到匹配企业(一期企查查 port 为 stub 恒 NO_HIT,如实呈现;可手工录入,真实源接入后自动激活)。</div>';
return;
}
window._lookupHits = list;
$('cOut').innerHTML = '<div class="hint">命中 ' + list.length + ' 条(点「采用」只回填当前为空的字段,已填不覆盖):</div>' +
list.map((c, i) => '<div class="kv"><b>' + esc(c.bizName || ('候选' + (i + 1))) + '</b><span>' +
esc(c.unifiedCreditCode || '--') + ' / 法人=' + esc(c.legalRepresentative || '--') + ' / ' + esc(c.establishedDate || '--') +
' <button onclick="applyLookup(' + i + ')">采用</button></span></div>').join('');
} catch (e) { $('cOut').innerHTML = errHtml(e); }
}
async function applyLookup(i) {
const c = (window._lookupHits || [])[i];
if (!c) return;
const put = (id, v) => { const el = $(id); if (el && v != null && v !== '' && !el.value.trim()) el.value = v; };
put('cName', c.bizName); put('cCredit', c.unifiedCreditCode);
if (formVal('cCredit')) {
try {
const hit = await api('/api/customer/check-credit-code?creditCode=' + encodeURIComponent(formVal('cCredit')));
$('cOut').innerHTML = '<div class="hint">已回填空字段;check-credit-code(回填即查重):' + esc(JSON.stringify(hit)) + '</div>';
return;
} catch (e) { $('cOut').innerHTML = '已回填空字段;check-credit-code 失败: ' + errHtml(e); return; }
}
toast('已回填空字段(已填不覆盖)', 'ok');
}
async function doCreate(cf) {
try {
const confirmSimilar = (cf === true || cf === 'true') ? true : undefined; // 同 doEdit:onclick 首参是事件对象,非显式 true 一律 undefined
const d = await api('/api/customer/create', { method: 'POST', body: qs(Object.assign(createForm(), { confirmSimilar: confirmSimilar })) });
if (d && d.needConfirm) {
const hits = d.similarHits || [];
@ -1063,15 +1340,25 @@ async function dlgEdit() {
window._editBiz = d.isBizNegotiated; window._editChild = d.isChild; // 冒烟实证:edit 必带 isBizNegotiated+isChild(DB NOT NULL 无默认,D-05 关联)
} catch (e) { toast(e.message, 'err'); }
}
async function doEdit() {
async function doEdit(cf) {
/* dlgOpen 把 fn 直接挂 onclick(浏览器传 PointerEvent 作首参)→ 非显式 true 一律 undefined(qs 跳过) */
const confirmSimilar = (cf === true || cf === 'true') ? true : undefined;
const id = window._editId;
try {
await api('/api/customer/edit?id=' + id, { method: 'POST', body: qs({
const d = await api('/api/customer/edit?id=' + id, { method: 'POST', body: qs({
customerName: formVal('eName'), customerType: formVal('eType'),
provinceCode: formVal('eProv'), cityCode: formVal('eCity'), districtCode: formVal('eDist'),
industryCode: formVal('eInd'), customerStarLevel: formVal('eStar'), relationStarLevel: formVal('eRStar'),
version: window._editVer, isBizNegotiated: window._editBiz, isChild: window._editChild,
confirmSimilar: confirmSimilar,
}) });
if (d && d.needConfirm) { // L2 名称相似(与服务端 needConfirm 契约同 create;不确认不落库)
const hits = d.similarHits || [];
$('eOut').innerHTML = '<div class="simbox">相似命中 ' + hits.length + ' 条:<ul>' + hits.map(h =>
'<li>' + esc(h.customerName) + '(负责人=' + esc(h.ownerName) + ')</li>').join('') +
'</ul>仍要保存? <button onclick="doEdit(\'true\')">确认保存(confirmSimilar=true)</button></div>';
return;
}
toast('保存成功', 'ok'); dlgClose(); loadList(); loadHead(); switchSub('info');
} catch (e) { $('eOut').innerHTML = errHtml(e); }
}
@ -1102,17 +1389,22 @@ async function toggleFocus(id, target) {
try {
await api('/api/customer/' + (target ? 'focus' : 'unfocus') + '?id=' + id, { method: 'POST' });
toast(target ? '已关注' : '已取关', 'ok');
loadList(); loadSummary();
loadList();
} catch (e) { toast('失败: ' + e.message + codeSuffix(e), 'err'); }
}
function dlgAssign() {
const id = pickedIds()[0];
if (!id) return toast('请先勾选客户行', 'err');
dlgOpen('分配给销售', '<label><b>客户 id</b><input value="' + id + '" disabled></label>' +
const ids = pickedIds();
if (!ids.length) return toast('请先勾选客户行(支持整批分配)', 'err');
dlgOpen('分配给销售(整批 ' + ids.length + ' 条 · assign-batch)', '<label><b>客户 ids</b><input value="' + esc(ids.join(',')) + '" disabled></label>' +
'<label><b>目标 userId</b><input id="asUser" placeholder="分配目标用户 id"></label><div id="asOut"></div>',
[{ text: '分配', ok: true, fn: async () => {
try { await act('/api/customer/assign?id=' + id + '&userId=' + formVal('asUser')); dlgClose(); }
catch (e) { $('asOut').innerHTML = errHtml(e); }
try {
const r = await api('/api/customer/assign-batch?ids=' + ids.join(',') + '&userId=' + formVal('asUser'), { method: 'POST' });
const fails = (r && r.failures) || [];
$('asOut').innerHTML = '<div class="hint">总 ' + esc(r && r.total != null ? r.total : ids.length) + ' · 成功 ' + esc(r && r.successCount != null ? r.successCount : '-') + ' · 失败 ' + esc(r && r.failCount != null ? r.failCount : '-') + '</div>' +
(fails.length ? tableHtml(['客户 id', '原因', '说明'], fails.map(f => [esc(f.id || '--'), esc(f.reason || '--'), '<span style="white-space:normal">' + esc(f.message || '--') + '</span>'])) : '');
loadList();
} catch (e) { $('asOut').innerHTML = errHtml(e) + '<div class="hint">目标人员校验失败=整批拒绝(assign-batch 预检语义)。</div>'; loadList(); }
} }]);
}
function dlgTransferEntry() { switchTab('transfer'); transferPage(0); }
@ -1157,7 +1449,15 @@ async function loadHead() {
'|最近跟进:' + esc(d.lastFollowSummary || '--') + '(' + esc(d.lastFollowTime || '--') + ')' +
'|下次跟进:' + esc(d.nextFollowTime || '--') +
'|商机 ' + (d.opportunityCount == null ? '--' : d.opportunityCount) +
'|项目 ' + (d.projectCount == null ? '--' : d.projectCount) + '(契约恒 null 显 --)</div>' +
'|项目 ' + (d.projectCount == null ? '--' : d.projectCount) + '</div>' +
'<div style="margin:8px 0 4px"><b>金额汇总</b>' + dbadge('P3-6') +
'<span class="hint">(4 卡装配未接 seam,契约恒 null 显 "--",不显 0)</span></div>' +
'<div class="sumcards">' +
'<div class="card">已中标项目总额<b>' + esc(d.wonProjectAmount == null ? '--' : d.wonProjectAmount) + '</b></div>' +
'<div class="card">跟进中方案预估<b>' + esc(d.planEstimateAmount == null ? '--' : d.planEstimateAmount) + '</b></div>' +
'<div class="card">进行中商机总额<b>' + esc(d.ongoingOpportunityAmount == null ? '--' : d.ongoingOpportunityAmount) + '</b></div>' +
'<div class="card">合同/回款<b>' + esc(d.contractAmount == null ? '--' : d.contractAmount) + ' / ' + esc(d.paidAmount == null ? '--' : d.paidAmount) + '</b></div>' +
'</div>' +
'<div class="toolbar">' +
'<button onclick="dlgEdit()">编辑</button><button onclick="dlgFollow()">写跟进</button>' +
'<button onclick="openQa()">+快速添加</button><button onclick="dlgMemberAdd()">+成员</button>' +
@ -1182,15 +1482,35 @@ async function switchSub(s) {
try {
if (s === 'info') {
const d = await api('/api/customer/detail?id=' + D.id);
box.innerHTML = '<div class="grid">' + grid(d) + '</div>';
box.innerHTML = '<div style="margin-bottom:6px"><b>客户信息</b><span class="hint">(系统信息区:创建/更新时间经 BaseDTO 实测有值;创建人/更新人不暴露,恒 "--")</span>' + dbadge('G4') + '</div>' +
'<div class="grid">' + grid(d) + '</div>';
} else if (s === 'follow') {
const d = await api('/api/customer/follow/page?id=' + D.id + '&current=1&size=50');
box.innerHTML = followHtml((d && d.content) || []);
box.innerHTML = '<div class="viewbar">' +
'<select id="fFollowWay" onchange="loadFollow()"><option value="">全部方式</option>' +
Object.entries(FOLLOW_WAYS).map(([k, v]) => '<option value="' + k + '">' + v + '</option>').join('') + '</select>' +
'<input id="fFollowStart" type="date" onchange="loadFollow()">' +
'<input id="fFollowEnd" type="date" onchange="loadFollow()">' +
'<button onclick="loadFollow()">查询</button>' +
'<button onclick="dlgFollow()">+ 写跟进</button>' + dbadge('G1') +
'<span class="hint">(原型有「操作人」筛选;FollowPageParam 无此参数,豁免不渲染)</span></div>' +
'<div id="followBox"><span class="loading">加载中...</span></div>';
await loadFollow();
} else if (s === 'opps') {
const d = await api('/api/customer/opportunity/page?id=' + D.id);
const rows = Array.isArray(d) ? d : ((d && d.content) || []);
box.innerHTML = rows.length ? tableHtml(['商机名称', '阶段', '金额', '负责人', '状态'], rows.map(o =>
[esc(o.opportunityName || o.name || o.id), esc(o.stageName || o.stage || '--'), esc(o.estimateAmount == null ? '--' : o.estimateAmount), esc(o.ownerUserName || o.ownerName || '--'), esc(o.statusName || o.status || '--')])) : '<div class="empty">无关联商机(port 反查,失败前端显 --)</div>';
box.innerHTML = '<div style="margin-bottom:6px"><b>关联商机</b><span class="hint">(契约 7 字段真名渲染;原型期望 13 列,缺列不补;port 失败/无数据显空态)</span>' + dbadge('G2') + '</div>' +
tableHtml(['商机ID', '商机名称', '阶段', '预计金额', '负责人ID', '负责人', '最近更新'], rows.map(o =>
[esc(o.id || '--'), esc(o.oppName || '--'), esc(o.stageName || '--'),
esc(o.estimateAmount == null ? '--' : o.estimateAmount),
esc(o.ownerUserId || '--'), esc(o.ownerNameSnapshot || '--'), esc(o.updateTime || '--')]));
} else if (s === 'projects') {
const d = await api('/api/customer/project/page?id=' + D.id + '&current=1&size=50');
const rows = (d && d.content) || [];
box.innerHTML = '<div style="margin-bottom:6px"><b>关联项目</b><span class="hint">(真端点 CustomerProjectQueryPort 反查;当前后端为 Noop 实现 → 数据空态显 "--",K2 口径非硬占位)</span></div>' +
tableHtml(['项目名称', '阶段', '状态', '报备状态', '项目金额', '负责人', '最近更新'], rows.map(p =>
[esc(p.projectName || '--'), esc(p.stageName || '--'), esc(p.statusName || '--'),
esc(p.filingStatusName || '--'), esc(p.projectAmount == null ? '--' : p.projectAmount),
esc(p.ownerNameSnapshot || p.ownerUserId || '--'), esc(p.updateTime || '--')]));
} else if (s === 'agreements') {
const d = await api('/api/customer/agreement/list?customerId=' + D.id);
const rows = Array.isArray(d) ? d : ((d && d.content) || []);
@ -1201,8 +1521,16 @@ async function switchSub(s) {
const rows = Array.isArray(d) ? d : ((d && d.content) || []);
box.innerHTML = membersHtml(rows);
} else if (s === 'oplog') {
const d = await api('/api/customer/oplog/page?id=' + D.id + '&current=1&size=50');
box.innerHTML = oplogHtml((d && d.content) || []);
box.innerHTML = '<div class="viewbar">' +
'<select id="fOpAction" onchange="loadOplog()"><option value="">全部类型</option>' +
Object.entries(OPLOG_ACTIONS).map(([k, v]) => '<option value="' + k + '">' + v + '</option>').join('') + '</select>' +
'<input id="fOpKw" placeholder="叙事关键字" style="width:150px" onchange="loadOplog()">' +
'<input id="fOpStart" type="date" onchange="loadOplog()">' +
'<input id="fOpEnd" type="date" onchange="loadOplog()">' +
'<button onclick="loadOplog()">查询</button>' +
'<span class="hint">(action 权威 18 种含 GRAPH_EDIT;AS §3「17 种」未同步——K4)</span></div>' +
'<div id="oplogBox"><span class="loading">加载中...</span></div>';
await loadOplog();
}
} catch (e) { box.innerHTML = errHtml(e); }
}
@ -1210,9 +1538,10 @@ function grid(d) {
const F = [['客户编号', 'customerNo'], ['客户名称', 'customerName'], ['客户类型', 'customerTypeName|customerType'],
['阶段', 'customerStageName|customerStage'], ['客户星级', 'customerStarLevel'], ['关系星级', 'relationStarLevel'],
['战略协议', 'strategicAgreementLevelName|strategicAgreementLevel'], ['VIP 等级', 'vipCustomerLevel'],
['信用代码', 'creditCode'], ['省/市/区', 'provinceCode|cityCode|districtCode'], ['行业', 'industryName|industryCode'],
['信用代码', 'unifiedCreditCode'], ['省/市/区', 'provinceCode|cityCode|districtCode'], ['行业', 'industryName|industryCode'],
['商务谈判', 'isBizNegotiated'], ['子客户', 'isChild'], ['负责人', 'ownerUserNameSnapshot|ownerUserId'],
['创建时间', 'createTime'], ['更新时间', 'updateTime']];
['创建时间', 'createTime'], ['更新时间', 'updateTime'],
['创建人', 'creatorId'], ['更新人', 'updaterId']]; // G4:creatorId/updaterId 不在 CustomerDetailDTO,恒 "--"(K1 两件口径)
return F.map(p => {
const v = p[1].split('|').map(k => d[k]).find(x => x !== undefined && x !== null && x !== '');
return '<div class="kv"><b>' + p[0] + '</b><span>' + esc(v === undefined ? '--' : v) + '</span></div>';
@ -1224,16 +1553,19 @@ function tableHtml(heads, rows) {
'<tr><td colspan="' + heads.length + '" class="empty">无数据</td></tr>') + '</tbody></table>';
}
/* ---- 联系人:收编为 contactsShell 常驻面板(p4/p6 注入),旧 contactsHtml 退役 ---- */
function dlgContact(cid) {
async function dlgContact(cid) {
if (!dictLoaded) await ensureDicts(); // K6:来源下拉走 contact_source 字典(group_meeting/group_activity/referral/other),旧硬编码 contact_source_01..04 为字典改版前遗留
const c = (window._contacts || []).find(x => String(x.id) === String(cid)) || {};
const srcSel = '<option value=""></option>' + SOURCE_DICT.map(s =>
'<option value="' + esc(s.code) + '"' + (c.source === s.code ? ' selected' : '') + '>' + esc(s.name) + '</option>').join('');
dlgOpen(cid ? '编辑联系人' : '新建联系人(67008=入参非法/不属于该客户)', `
<label><b>姓名*</b><input id="ctName" value="${esc(c.name || '')}"></label>
<label><b>职务名称*</b><input id="ctJob" value="${esc(c.jobTitleName || '')}"></label>
<label><b>电话</b><input id="ctPhone" value="${esc(c.phone || '')}" placeholder="手机号格式(非格式 67008)"></label>
<label><b>电话</b><input id="ctPhone" value="${esc(c.phone || '')}" placeholder="手机号格式(非格式 67008);脱敏形态提交=不改号"></label>
<label><b>关键联系人</b><select id="ctKey"><option value="0" ${c.isKeyContact ? '' : 'selected'}></option><option value="1" ${c.isKeyContact ? 'selected' : ''}></option></select></label>
<label><b>内线</b><select id="ctInternal"><option value="0" ${c.isInternal ? '' : 'selected'}></option><option value="1" ${c.isInternal ? 'selected' : ''}></option></select></label>
<label><b>礼品备注</b><input id="ctGift" value="${esc(c.giftRemark || '')}"></label>
<label><b>来源</b><select id="ctSource"><option value=""></option><option value="contact_source_01" ${c.source === 'contact_source_01' ? 'selected' : ''}>组会</option><option value="contact_source_02" ${c.source === 'contact_source_02' ? 'selected' : ''}>组局</option><option value="contact_source_03" ${c.source === 'contact_source_03' ? 'selected' : ''}>转介绍</option><option value="contact_source_04" ${c.source === 'contact_source_04' ? 'selected' : ''}>其他</option></select></label>
<label><b>来源</b><select id="ctSource">${srcSel}</select></label>
<div id="ctOut"></div>`,
[{ text: '保存', ok: true, fn: async () => {
const fields = { name: formVal('ctName'), jobTitleName: formVal('ctJob'), phone: formVal('ctPhone'), isKeyContact: formVal('ctKey'), isInternal: formVal('ctInternal'), giftRemark: formVal('ctGift'), source: formVal('ctSource') };
@ -1259,16 +1591,227 @@ function dlgCheckPhone(phone) {
} catch (e) { $('cpOut').innerHTML = errHtml(e); }
} }]);
}
/* ================= 联系人独立菜单(票 12:#50 顶级列表 / contact.list 列偏好+形态偏好) ================= */
const CT = { page: 1, size: 10, total: 0, rows: [] };
const CT_COLS = [
{ k: 'jobTitleName', t: '职务' }, { k: 'phone', t: '电话' }, { k: 'isKeyContact', t: '关键联系人' },
{ k: 'isInternal', t: '内线' }, { k: 'source', t: '来源' }, { k: 'giftRemark', t: '礼品备注' },
{ k: 'customerName', t: '所属客户' }
];
let ctVisibleKeys = null; // null=未取列偏好(全列显);姓名列固定不参与隐显
function ctCols() { return ctVisibleKeys ? CT_COLS.filter(c => ctVisibleKeys.includes(c.k)) : CT_COLS; }
function ynCell(v) { return v == null ? '--' : (Number(v) === 1 ? '是' : '否'); }
function ctRowHtml(r) {
const c = [];
c.push('<td class="row" onclick="dlgContactDetail(\'' + r.id + '\')">' + esc(r.name) + '</td>');
for (const col of ctCols()) {
let v;
if (col.k === 'phone') v = esc(r.phone || '--'); // K10:服务端已脱敏(138****1234),前端直显
else if (col.k === 'isKeyContact' || col.k === 'isInternal') v = ynCell(r[col.k]);
else if (col.k === 'source') v = esc(sourceName(r.source)); // K6:字典驱动翻译
else v = esc(r[col.k] == null ? '--' : r[col.k]);
c.push('<td>' + v + '</td>');
}
c.push('<td><button onclick="dlgContactDetail(\'' + r.id + '\')">详情</button></td>');
return '<tr>' + c.join('') + '</tr>';
}
async function loadContactPage(dir) {
if (!TOKEN()) { $('ctPageInfo').textContent = '请先登录'; return; }
if (dir === -1) CT.page = Math.max(1, CT.page - 1);
else if (dir === 1) CT.page += 1;
$('ctPageInfo').textContent = '加载中...';
try {
const d = await api('/api/customer/contact/page?current=' + CT.page + '&size=' + CT.size + '&keyword=' + encodeURIComponent($('cKw').value.trim()));
CT.rows = (d && d.content) || [];
CT.total = (d && d.total) || 0;
if (!CT.rows.length && CT.page > 1) { CT.page = 1; return loadContactPage(); }
$('ctPageHead').innerHTML = '<tr><th>姓名</th>' + ctCols().map(c => '<th>' + c.t + '</th>').join('') + '<th>操作</th></tr>';
$('ctPageBody').innerHTML = CT.rows.length ? CT.rows.map(ctRowHtml).join('') : '<tr><td colspan="' + (ctCols().length + 2) + '" class="empty">无数据</td></tr>';
$('ctPageInfo').textContent = '第 ' + CT.page + ' 页 / 共 ' + CT.total + ' 条';
} catch (e) { $('ctPageInfo').textContent = '加载失败: ' + e.message; }
}
/* ---- 联系人菜单偏好:形态 view-form / 列偏好 preference(scopeKey=contact.list) ---- */
async function loadContactForm() {
let v = 'list';
try { v = (await api('/api/preference/view-form/get?scopeKey=contact.list')) || 'list'; } catch (e) { /* 未登录/网络失败回落 */ }
$('cForm').innerHTML = '<option value="list">列表形态</option>';
$('cForm').value = v;
}
async function saveContactForm(v) {
try { await api('/api/preference/view-form/save', { method: 'POST', body: qs({ scopeKey: 'contact.list', viewForm: v }) }); toast('形态偏好已保存(scopeKey=contact.list)', 'ok'); }
catch (e) { toast('失败: ' + e.message, 'err'); }
}
async function dlgCpEdit() {
try {
const p = await api('/api/preference/get?scopeKey=contact.list');
const vis = (p && p.visibleKeys) || [];
dlgOpen('列偏好(scopeKey=contact.list;GET /api/preference/get + POST /api/preference/save,visibleKeys 重复键)', `
<div class="hint">姓名列固定不可隐藏(对齐客户侧「客户名称固定列」口径);全不勾=保存空集(后读回落全列显)。</div>
<label><b>显示列</b></label>
<div style="display:flex;flex-wrap:wrap;gap:6px">` + CT_COLS.map(c =>
'<label style="margin-right:8px"><input type="checkbox" class="cpChk" value="' + c.k + '"' + (vis.includes(c.k) ? ' checked' : '') + '> ' + c.t + '</label>').join('') + `</div>
<div id="cpOut"></div>`,
[{ text: '保存', ok: true, fn: async () => {
const keys = [...document.querySelectorAll('.cpChk:checked')].map(x => x.value);
try {
await api('/api/preference/save?scopeKey=contact.list' + (keys.length ? '&' + keys.map(k => 'visibleKeys=' + encodeURIComponent(k)).join('&') : ''), { method: 'POST' });
ctVisibleKeys = keys.length ? keys : null;
toast('列偏好已保存', 'ok'); dlgClose(); loadContactPage();
} catch (e) { $('cpOut').innerHTML = errHtml(e); }
} }]);
} catch (e) { toast('读取列偏好失败: ' + e.message, 'err'); }
}
/* ---- 联系人详情弹窗(#51 contact/detail 回显)+ 操作日志空态(#53/G6)---- */
async function dlgContactDetail(id) {
try {
const d = await api('/api/customer/contact/detail?id=' + id);
dlgOpen('联系人详情(contact/detail 回显;phone 脱敏形态提交=不改号)', `
<div class="subtabs">
<button class="on" id="ctTabBtn0" onclick="ctSubTab(0)">基本信息</button>
<button id="ctTabBtn1" onclick="ctSubTab(1)">操作日志</button>
</div>
<div id="ctPaneInfo">${ctInfoHtml(d)}</div>
<div id="ctPaneLog" style="display:none" data-defect="G6">
<div class="errbox" style="margin:0">G6(豁免记档):联系人维度操作日志无数据源——原型有页签,后端无联系人级 oplog 端点(07 票定稿口径),空态展示。</div>
</div>`, []);
} catch (e) { dlgOpen('联系人详情', errHtml(e), []); }
}
function ctInfoHtml(d) {
const kv = (t, v) => '<div class="kv"><b>' + t + '</b><span>' + (v == null || v === '' ? '--' : esc(v)) + '</span></div>';
return kv('姓名', d.name) + kv('职务名称', d.jobTitleName) + kv('职务类别', d.jobTitleCategory) + kv('职务层级', d.jobTitleLevel) +
kv('电话(服务端脱敏)', d.phone) + kv('关键联系人', ynCell(d.isKeyContact)) + kv('内线', ynCell(d.isInternal)) +
kv('来源', sourceName(d.source)) + kv('礼品备注', d.giftRemark) +
kv('所属客户', d.customerName) + kv('客户编号', d.customerNo) + kv('所属客户 id', d.customerId);
}
function ctSubTab(i) {
$('ctPaneInfo').style.display = i === 0 ? '' : 'none';
$('ctPaneLog').style.display = i === 1 ? '' : 'none';
$('ctTabBtn0').classList.toggle('on', i === 0);
$('ctTabBtn1').classList.toggle('on', i === 1);
}
/* ---- 联系人全局搜索(#52 contact/search;商机「关键联系人」数据源演示)---- */
function dlgContactSearch() {
dlgOpen('联系人全局搜索(GET /api/customer/contact/search?keyword=&limit=)', `
<label><b>关键词*</b><input id="csKw" placeholder="姓名 / 电话(恒信达)"></label>
<label><b>limit</b><input id="csLimit" value="20" title="默认 20;≤0 回落默认"></label>
<div class="hint">场景文案:商机表单「关键联系人」数据源——姓名/电话模糊 + 带出所属公司名;电话服务端脱敏(K10);客户可见性收口。</div>
<div id="csOut"></div>`,
[{ text: '搜索', ok: true, fn: async () => {
try {
const kw = formVal('csKw');
if (!kw) { $('csOut').innerHTML = '<div class="errbox">keyword 必填</div>'; return; }
const lim = formVal('csLimit');
const arr = await api('/api/customer/contact/search?keyword=' + encodeURIComponent(kw) + (lim ? '&limit=' + lim : ''));
const rows = (arr || []).map(r => [esc(r.name), esc(r.jobTitleName || '--'), esc(r.phone || '--'), ynCell(r.isKeyContact), esc(sourceName(r.source)), esc(r.customerName || '--'), esc(r.customerNo || '--')]);
$('csOut').innerHTML = tableHtml(['姓名', '职务', '电话', '关键联系人', '来源', '所属客户', '客户编号'], rows);
} catch (e) { $('csOut').innerHTML = errHtml(e); }
} }]);
}
/* ================= 商机侧演示入口(票 12:#47 quick-create / #48 customer/search / 建商机) ================= */
/* ---- #47 快建客户:K9 以 DTO 为准 7 必填 + confirmSimilar(.bru 漏 confirmSimilar/误标星级选填系漂移)---- */
function dlgQuickCreate() {
dlgOpen('商机表单快建客户(POST /api/customer/quick-create;K9:DTO 全 7 必填,服务端 owner=当前用户)', `
<label><b>客户名称*</b><input id="qName" placeholder="e2c-快创-demo"></label>
<label><b>客户类型*</b><input id="qType" placeholder="customer_type_01(总包)"></label>
<label><b>省/市*</b><input id="qProv" placeholder="440000" style="width:120px"><input id="qCity" placeholder="440100" style="width:120px"></label>
<label><b>行业一级*</b><input id="qInd" placeholder="gov"></label>
<label><b>客户星级*</b><select id="qStar"><option value="1">★1</option><option value="2">★2</option><option value="3" selected>★3</option><option value="4">★4</option><option value="5">★5</option></select></label>
<label><b>关系星级*</b><select id="qRStar"><option value="1">★1</option><option value="2">★2</option><option value="3" selected>★3</option><option value="4">★4</option><option value="5">★5</option></select></label>
<div class="hint">confirmSimilar 默认 false(命中相似先回 needConfirm 列表,确认后 true 重发);查重三层与编号生成复用主表 create;similarHits 行级 masked(无权命中不透出业务字段);错误 67001/67003。</div>
<div id="qOut"></div>`,
[{ text: '快建', ok: true, fn: () => doQuickCreate(false) }]);
}
async function doQuickCreate(confirmSimilar) {
try {
const d = await api('/api/customer/quick-create', { method: 'POST', body: qs({
customerName: formVal('qName'), customerType: formVal('qType'),
provinceCode: formVal('qProv'), cityCode: formVal('qCity'), industryCode: formVal('qInd'),
customerStarLevel: formVal('qStar'), relationStarLevel: formVal('qRStar'),
confirmSimilar: confirmSimilar
}) });
if (d && d.needConfirm) {
const hits = d.similarHits || [];
$('qOut').innerHTML = '<div class="simbox">命中相似客户 ' + hits.length + ' 条:<ul>' + hits.map(h =>
'<li>' + esc(h.customerName || '存在匹配记录') + '(负责人=' + esc(h.ownerName || '--') + ',' + esc(STAGE[h.customerStage] || h.customerStage || '') + ',' + (h.archiveStatus === 2 ? '已归档' : '有效') + ')</li>').join('') +
'</ul>仍要创建? <button onclick="doQuickCreate(true)">确认创建(confirmSimilar=true 重发)</button></div>';
return;
}
toast('快建成功:' + ((d && d.customerNo) ? d.customerNo + ' / ' : '') + ((d && d.customerName) || ''), 'ok'); dlgClose();
} catch (e) { $('qOut').innerHTML = errHtml(e); }
}
/* ---- #48 商机候选客户三维搜索:keyword=名称/联系人/电话 ---- */
function dlgCustSearch() {
dlgOpen('商机候选客户搜索(POST /api/customer/search;keyword 名称/联系人/电话三维模糊)', `
<label><b>关键词</b><input id="dsKw" placeholder="名称/联系人/电话 三维模糊"></label>
<label><b>客户类型</b><input id="dsType" placeholder="customer_type_01(可空=全部)"></label>
<label><b>归档状态</b><select id="dsArchive"><option value="">全部</option><option value="1">有效</option><option value="2">已归档</option></select></label>
<label><b>排除客户 id</b><input id="dsEx" placeholder="已关联客户,重复键 excludeCustomerIds(可空)"></label>
<div class="hint">轻量 9 字段出参;选中后走 GET /api/customer/detail?id= 带出全量;DataScope 注入可见范围。</div>
<div id="dsOut"></div>`,
[{ text: '搜索', ok: true, fn: async () => {
try {
const ex = formVal('dsEx').split(/[,\s,]+/).filter(Boolean);
const bodyStr = qs({ keyword: formVal('dsKw'), customerType: formVal('dsType'), archiveStatus: formVal('dsArchive') }) +
ex.map(x => '&excludeCustomerIds=' + encodeURIComponent(x)).join('');
const d = await api('/api/customer/search', { method: 'POST', body: bodyStr });
const rows = ((d && d.content) || []).map(r => [esc(r.id), esc(r.customerNo || '--'), esc(r.customerName), esc(typeName(r.customerType)),
esc(r.ownerUserId || '--'), esc(r.ownerName || '--'), esc(STAGE[r.customerStage] || r.customerStage || '--'),
esc(r.archiveStatus === 2 ? '已归档' : '有效')]);
$('dsOut').innerHTML = '<div class="hint">共 ' + ((d && d.total) || 0) + ' 条</div>' +
tableHtml(['id', '客户编号', '客户名称', '类型', '负责人id', '负责人', '阶段', '归档'], rows);
} catch (e) { $('dsOut').innerHTML = errHtml(e); }
} }]);
}
/* ---- 从客户发起建商机:POST /api/opportunity 预填 customerId(opp-deps §4;核心 6 必填 D-14)---- */
function dlgOppCreate(cid) {
const r = S.rows.find(x => String(x.id) === String(cid));
if (!r) return toast('行数据缺失,请刷新列表', 'err');
dlgOpen('从客户发起建商机(POST /api/opportunity,预填 customerId/customerName;关联落 opportunity_customer,客户详情「关联商机」页签立即可见)', `
<label><b>客户(只读)</b><input value="${esc(r.customerName)}(id=${esc(r.id)})" readonly></label>
<label><b>商机名称*</b><input id="oName" placeholder="演示-从客户建商机"></label>
<label><b>商机来源*</b><input id="oSource" placeholder="opp_source_02 等(opp_source_01=线索转入须关联线索,本入口勿用)"></label>
<label><b>招标形式*</b><input id="oBid" placeholder="bid_form_01"></label>
<label><b>项目属地*</b><input id="oLoc" placeholder="locality_type_01"></label>
<label><b>省/市*</b><input id="oProv" value="${esc(r.provinceCode || '')}" style="width:120px" placeholder="440000"><input id="oCity" value="${esc(r.cityCode || '')}" style="width:120px" placeholder="440100"></label>
<label><b>主要意向客户*</b><select id="oPrimary"><option value="1">1 主要意向客户</option><option value="0">0 普通关联客户</option></select>
<b>客户角色*</b><input id="oRole" value="customer_role_01" style="width:160px"><span class="hint">customerId 非空时两字段必填</span></label>
<label><b>备注</b><input id="oRemark"></label>
<div class="hint">核心 6 必填缺一报 66001;直接新建路径不做客户存在性/归档校验(区别于商机详情挂客户 requireLive)。</div>
<div id="oOut"></div>`,
[{ text: '创建商机', ok: true, fn: async () => {
try {
const id = await api('/api/opportunity', { method: 'POST', body: qs({
opportunityName: formVal('oName'), oppSource: formVal('oSource'), bidForm: formVal('oBid'), localityType: formVal('oLoc'),
provinceCode: formVal('oProv'), cityCode: formVal('oCity'),
customerId: r.id, customerName: r.customerName,
isPrimaryIntended: formVal('oPrimary'), customerRole: formVal('oRole'), remark: formVal('oRemark')
}) });
toast('商机已创建 id=' + id + ';客户详情「关联商机」页签可反查', 'ok'); dlgClose();
} catch (e) { $('oOut').innerHTML = errHtml(e); }
} }]);
}
/* ---- 跟进 ---- */
const FOLLOW_WAYS = { follow_way_01: '电话沟通', follow_way_02: '上门拜访', follow_way_03: '微信对接', follow_way_04: '线上会议', follow_way_05: '展会沟通' };
function followHtml(rows) {
if (!rows.length) return '<div class="toolbar"><button onclick="dlgFollow()">+ 写跟进</button></div><div class="empty">暂无跟进记录</div>';
if (!rows.length) return '<div class="empty">暂无跟进记录</div>';
const days = {};
rows.forEach(r => { const day = String(r.followTime || r.createTime || '').slice(0, 10) || '未知日期'; (days[day] = days[day] || []).push(r); });
return '<div class="toolbar"><button onclick="dlgFollow()">+ 写跟进</button></div>' +
Object.keys(days).sort().reverse().map(day =>
return Object.keys(days).sort().reverse().map(day =>
'<div class="logday">' + esc(day) + '</div>' + days[day].map(r =>
'<div class="panel" style="margin-bottom:6px"><b>' + esc(r.followWayName || r.followWay || '--') + '</b> ' + esc(r.createByName || r.createBy || r.operatorName || '') +
'<div>' + esc(r.followContent || '') + '</div><div class="hint">下次跟进:' + esc(r.nextFollowTime || '--') + '</div></div>').join('')).join('');
'<div class="panel" style="margin-bottom:6px"><b>' + esc(FOLLOW_WAYS[r.followWay] || r.followWay || '--') + '</b> ' + esc(r.followByName || r.followBy || '') +
'<div style="white-space:normal">' + esc(r.followContent || '') + '</div><div class="hint">下次跟进:' + esc(r.nextFollowTime || '--') + '</div></div>').join('')).join('');
}
async function loadFollow() {
const box = $('followBox');
if (!box) return;
try {
const d = await api('/api/customer/follow/page?' + qs({
id: D.id, followWay: formVal('fFollowWay'),
startTime: formVal('fFollowStart') ? formVal('fFollowStart') + ' 00:00:00' : '',
endTime: formVal('fFollowEnd') ? formVal('fFollowEnd') + ' 23:59:59' : '', current: 1, size: 50
}));
box.innerHTML = followHtml((d && d.content) || []);
} catch (e) { box.innerHTML = errHtml(e); }
}
function dlgFollow() {
dlgOpen('写跟进(append-only;副作用刷新 last_valid_follow_time 锚点)', `
@ -1290,7 +1833,8 @@ function membersHtml(rows) {
[esc(m.userName || m.memberUserName || m.userId || '--'),
isOwner(m) ? badge('负责人', 'b2') : badge('协同人', 'b1'),
isOwner(m) ? '--' : '<button onclick="delMember(\'' + (m.memberUserId || m.userId) + '\')">移除</button>']));
return '<div class="toolbar"><button onclick="dlgMemberAdd()">+ 添加协同人</button></div>' + t;
return '<div class="toolbar"><button onclick="dlgMemberAdd()">+ 添加协同人</button>' + dbadge('G3') +
'<span class="hint">(原型另有职务/加入时间两列;CustomerMemberDTO 未暴露,缺列不补)</span></div>' + t;
}
function dlgMemberAdd() {
dlgOpen('添加协同人(两阶段校验整批拒绝 67016)', '<label><b>userId 列表</b><textarea id="mIds" rows="2" placeholder="逗号分隔多个 userId"></textarea></label><div id="mOut"></div>',
@ -1305,13 +1849,31 @@ async function delMember(uid) {
catch (e) { toast('失败: ' + e.message + codeSuffix(e), 'err'); }
}
/* ---- 操作日志 ---- */
const OPLOG_ACTIONS = { // K4:权威 18 种(CustomerConstants 全部 ACTION_*,含 GRAPH_EDIT;AS §3「17 种」未同步)
CREATE: '新增客户', UPDATE: '编辑', FOLLOW: '跟进', ARCHIVE: '归档', RESTORE: '恢复', TRANSFER: '交接',
CLAIM: '领取', POOL: '抛公海', STAGE_CHANGE: '阶段流转', MEMBER_ADD: '添加成员', MEMBER_REMOVE: '移除成员',
CONTACT_ADD: '新增联系人', CONTACT_EDIT: '编辑联系人', CONTACT_DELETE: '删除联系人',
AGREEMENT_ADD: '新增协议', AGREEMENT_EDIT: '编辑协议', AGREEMENT_DELETE: '删除协议', GRAPH_EDIT: '图谱编辑'
};
function oplogHtml(rows) {
if (!rows.length) return '<div class="empty">暂无日志</div>';
const days = {};
rows.forEach(r => { const day = String(r.createTime || '').slice(0, 10) || '未知日期'; (days[day] = days[day] || []).push(r); });
return Object.keys(days).sort().reverse().map(day =>
'<div class="logday">' + esc(day) + '</div>' + days[day].map(r =>
'<div>' + esc(r.createTime || '') + ' ' + badge(r.action, 'b1') + ' ' + esc(r.operatorName || r.operator || '') + ':' + esc(r.detail || '') + '</div>').join('')).join('');
'<div>' + esc(r.createTime || '') + ' ' + badge(OPLOG_ACTIONS[r.action] || r.action, 'b1') + ' ' + esc(r.operatorName || r.operator || '') + ':' + esc(r.detail || '') + '</div>').join('')).join('');
}
async function loadOplog() {
const box = $('oplogBox');
if (!box) return;
try {
const d = await api('/api/customer/oplog/page?' + qs({
id: D.id, action: formVal('fOpAction'), keyword: formVal('fOpKw'),
startTime: formVal('fOpStart') ? formVal('fOpStart') + ' 00:00:00' : '',
endTime: formVal('fOpEnd') ? formVal('fOpEnd') + ' 23:59:59' : '', current: 1, size: 50
}));
box.innerHTML = oplogHtml((d && d.content) || []);
} catch (e) { box.innerHTML = errHtml(e); }
}
/* ---- 战略协议(详情页签 6,返工票 12) ---- */
@ -1375,7 +1937,9 @@ async function loadImportPage() {
const b = $('impBody');
if (!TOKEN()) { b.innerHTML = '<tr><td colspan="12" class="empty">请先登录</td></tr>'; return; }
try {
const d = await api('/api/customer/import/page?current=1&size=20');
const d = await api('/api/customer/import/page?current=1&size=20' +
($('impFStatus').value !== '' ? '&status=' + $('impFStatus').value : '') +
($('impFMode').value !== '' ? '&importMode=' + $('impFMode').value : ''));
const rows = (d && d.content) || [];
b.innerHTML = rows.length ? rows.map(t => '<tr>' +
'<td>' + esc(t.taskId) + '</td><td>' + esc(t.importMode || '--') + '</td>' + impBadge(t.status) +
@ -2341,7 +2905,9 @@ function bindContactsUI() {
$('apiBox').textContent = 'API: ' + (API_BASE || '同源部署') + (localStorage.getItem('e2c_api') ? '(已记忆,?api=reset 恢复本地)' : '');
paintWho();
bindContactsUI();
if (TOKEN()) { loadList(); loadSummary(); }
paintFormOptions();
paintHead();
if (TOKEN()) switchWs(S.ws);
})();
</script>

BIN
.scratch/customer-demo-ref/demo/shots/s01-list.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

BIN
.scratch/customer-demo-ref/demo/shots/s02-pool-i06.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
.scratch/customer-demo-ref/demo/shots/s07-board.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

BIN
.scratch/customer-demo-ref/demo/shots/s11-head.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

BIN
.scratch/customer-demo-ref/demo/shots/s19-oplog.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
.scratch/customer-demo-ref/demo/shots/s20-import.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

BIN
.scratch/customer-demo-ref/demo/shots/s24-transfer.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
.scratch/customer-demo-ref/demo/shots/s30-ledger.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

BIN
.scratch/customer-demo-ref/demo/shots/s40-final.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
.scratch/customer-demo-ref/demo/shots/t10-quick-pool.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

BIN
.scratch/customer-demo-ref/demo/shots/t11-quick-detail.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

BIN
.scratch/customer-demo-ref/demo/shots/t12-quick-contacts.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

BIN
.scratch/customer-demo-ref/demo/shots/t13-quick-settings.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

444
.scratch/customer-demo-ref/demo/smoke-report.json

@ -0,0 +1,444 @@
{
"summary": {
"total": 40,
"passed": 40,
"failed": 0,
"passRate": "100%",
"startedAt": "2026-09-12 00:19:41",
"durationS": 176.4,
"verdict": "PASS",
"verdictRule": "全部步骤 ok 即 PASS,任一 FAIL 即 FAIL"
},
"steps": [
{
"step": "01 打开Demo+uid登录+首屏列表渲染",
"ok": true,
"note": "rows=10"
},
{
"step": "02 三workspace切换;pool不发board+I-06注记[D]",
"ok": true,
"note": "overview请求OK=True;pool新请求3条;i06可见=True"
},
{
"step": "03 内置视图下拉五值+切FOLLOW_UP_DUE",
"ok": true,
"note": "opts=['ASSIGNED', 'COLLABORATING', 'FOLLOW_UP_DUE', 'FOCUSED', 'RECENT']"
},
{
"step": "04 列筛选阶段=已成交",
"ok": true,
"note": "请求参数 customerStage=3 已命中"
},
{
"step": "05 归档态开关+e2c-已归档可见",
"ok": true,
"note": "archiveStatus=2命中=True;样例可见=True"
},
{
"step": "06 saved-view 新建→选中查询→删除",
"ok": true,
"note": "save=True;选中带savedViewId=True;删后消失=True;body=POST /api/preference/view/save?scopeKey=customer.mine # {\"name\":\"e2c-dr-视图\",\"isDefault\":false,\"seqNo\":10,\"conditions\":[{\"field\":\"keyword\",\"operator\":\""
},
{
"step": "07 形态偏好overview切看板→渲染→还原list",
"ok": true,
"note": "看板列=3;cards请求=True;还原=True"
},
{
"step": "08 工商联想(companyName)+NO_HIT现象+STUB-LOOKUP注记[D]",
"ok": true,
"note": "未查询到匹配企业(一期企查查 port 为 stub 恒 NO_HIT,如实呈现;可手工录入,真实源接入后自动激活)。"
},
{
"step": "09 名称查重needConfirm+similarHits→confirmSimilar重发→创建成功",
"ok": true,
"note": "simbox=相似命中 1 条:e2c-dr-恒信达科技有限责任公司(负责人=罗伟健,潜在客户,有效)仍要创建? 确认创建(confirmSimilar=;toast=创建成功 id=753771843382411264;夹具A=753771843382411264"
},
{
"step": "10 编辑客户回显+CAS保存(含相似确认重发)",
"ok": true,
"note": "回显名=e2c-dr-恒信达科技有限责任公司;simbox=命中;toast=保存成功;db=e2c-dr-恒信达科技有限责任公司-改 v1"
},
{
"step": "11 详情头部渲染+4金额卡恒--[D]",
"ok": true,
"note": "cards=['--', '--', '--', '-- / --'];P3-6角标=1"
},
{
"step": "12 8页签全遍历+联系人顶级菜单contact/page",
"ok": true,
"note": "subs=['info', 'contacts', 'follow', 'opps', 'projects', 'agreements', 'members', 'oplog'];contact/page行=10;新增errs=[]"
},
{
"step": "13 信息栅格16字段+G4创建/更新人--[D]+creditCode明文",
"ok": true,
"note": "kv=18;创建人=--;信用代码=91440101E2CTEST001"
},
{
"step": "14 商机7列(G2-D)+项目页签Noop空态+contact/search命中",
"ok": true,
"note": "商机列=['商机ID', '商机名称', '阶段', '预计金额', '负责人ID', '负责人', '最近更新'];Noop空态=True;search=姓名职务电话关键联系人来源所属客户客户编号张关键董事长138****0001是contact_source_01e2c-"
},
{
"step": "15 成员加BUDDY→渲染→移除(G3-D无职务/加入时间列)",
"ok": true,
"note": "列头=['成员', '角色', '操作'];DB成员行=28;memberUserIds重复键=True"
},
{
"step": "16 联系人quickAdd2行+G5无负责人列[D]+电话脱敏+reveal明文+DB埋点",
"ok": true,
"note": "脱敏=True;DB联系人=5;reveal_log=8;明文=True;列头=['', '#', '姓名', '职务', '联系电话', '关键', '内线', '来源', '礼品备注', '操作'];qaRet={\"successCount\":2,\"failedRows\":[]}"
},
{
"step": "17 batchEdit空串=DB不改(D-G1)[D]+check-phone命中+删除双刷",
"ok": true,
"note": "DB phone=13812340001;D-G1角标=1;check-phone=客户编号--客户名称--客户类型--阶段--客户星级--关系星级--战略协议--;删后行=4(删前5,del_ok=True,errs+0);lid=753771972256595968;names=李冒烟,赵冒烟,李普通,王重复,张关键"
},
{
"step": "18 写跟进+日期分组+followWay筛选",
"ok": true,
"note": "way选项=6(空+5);日期分组=5;筛选=True"
},
{
"step": "19 关注/重点标记+oplog留痕+action筛选",
"ok": true,
"note": "action=CREATE筛选含新增客户=True;action=FOLLOW筛选=True;DB跟进=24"
},
{
"step": "20 客户导入三段式(缺省份预检FAIL→DONE计数=预检→明细吻合)",
"ok": true,
"note": "预检 t=2 ins=1 fail=1;taskId=753772071518994432;result={'taskId': '753772071518994432', 'importMode': 'APPEND_ONLY', 'templateVersion': 'V1', 'status': 2, 'totalCount': 2, 'insertCount': 1, 'updateCount': 0, 'unchangedCount': 0, 'failCount': 1, 'suspectCount': 0, 'failReason': None, 'creatorName': None, 'createTime': '2026-09-12 00:21:06', 'finishTime': '2026-09-12 00:21:08'};明细=1行"
},
{
"step": "21 导入任务列表+status/importMode过滤+P3-3注记[D]",
"ok": true,
"note": "status过滤=True;mode过滤=True;P3-3=1"
},
{
"step": "22 联系人导入三段式+P3-3无校验复选框[D]",
"ok": true,
"note": "预检=预检完成(任务 753772113634000896 待确认):共 2 行,预计新增 2、更新 0、失败 0、疑似重复 ;result={'taskId': '753772113634000896', 'customerId': '753771843382411264', 'importMode': 'APPEND_ONLY', 'templateVersion': 'V1', 'status': 2, 'totalCount': 2, 'insertCount': 2, 'updateCount': 0, 'unchangedCount': 0, 'failCount': 0, 'suspectCount': 0, 'failReason': None, 'creatorName': None, 'createTime': '2026-09-12 00:21:16', 'finis"
},
{
"step": "23 商机联动链(UI建商机→抛公海拦截→关商机→放行)",
"ok": true,
"note": "商机=753772150371909632;状态2→4;一次release拦截=True(toast=商机已创建 id=753772150371909632;客户详情「关联商机」页签可反查 || {\"total\":1,\"s);进公海=True"
},
{
"step": "24 交割(preview圈定→initiate→page→detail→assignable→assign明细态变化;F-13清leftover=0)",
"ok": true,
"note": "单号=JG202609120001;圈定含夹具=True;明细=1→assign后变化=True(assign对象=99021002);UI行=10"
},
{
"step": "25 归属动作族(release→claim→archive→restore→再archive+oplog+assign)",
"ok": true,
"note": "进公海=True(pool默认视图行=0,清视图后行=1);claim=True({\"total\":1,\"successCount\":1,\"failCount\":);archive=True({\"total\":1,\"successCount\":1,\"failCount\":);assign=True(success=1 fail=0);恢复1→再归档2;oplog=3"
},
{
"step": "26 查重设置读→改90→save生效→还原(含匹配方式不被污染)",
"ok": true,
"note": "原=80→90→还原=80;mode=2(原2)"
},
{
"step": "27 check-name命中e2c-恒信达+信用代码撞码67003(L3硬拦)+check-credit-code L1",
"ok": true,
"note": "check-name=check-name:[{\"customerId\":\"753771720497692672\",\"customerName;err=统一社会信用代码已被占用:系统已存在该企业 (code=67003);creditCode.exists=True"
},
{
"step": "28 超期提醒save非法0→64023toast→还原30/7",
"ok": true,
"note": "64023=True;还原=30/7(原30/7)"
},
{
"step": "29 公海池配置占位卡只读[D]",
"ok": true,
"note": "角标=1;禁用输入=2"
},
{
"step": "30 缺陷台账面板(A≥14/B4/C14/D1+固定声明)",
"ok": true,
"note": "A=14 B=4 C=14 D=1;渲染行=33"
},
{
"step": "31 data-defect角标≥12且关键类目齐全(多页签累计)",
"ok": true,
"note": "累计=87;类目=['D-G1', 'G1', 'G2', 'G3', 'G4', 'G5', 'I-06', 'P3-3', 'P3-6', 'POOL-CFG', 'STUB-LOOKUP']"
},
{
"step": "32 I-06双现象(pool无board选项+注记含67001/记忆成功)",
"ok": true,
"note": "fForm选项=['list', 'split']"
},
{
"step": "33 豁免入口不出现(换负责人/日志导出/完整金额开关/导入校验复选框)",
"ok": true,
"note": "命中=0"
},
{
"step": "34 G系列合并复核(G1无操作人筛选/G2七列/G3两列缺/G4--[D]/G5无负责人列/G6空态)",
"ok": true,
"note": "G1=True G2=True G3=True G4=True G5=True G6=True"
},
{
"step": "35 金额卡P3-6四卡--口径+关联项目Noop空态",
"ok": true,
"note": "cards=['--', '--', '--', '-- / --']"
},
{
"step": "36 company-lookup NO_HIT+F6 stub文案",
"ok": true,
"note": "NO_HIT=True;F6stub=True"
},
{
"step": "37 console/pageerror=0",
"ok": true,
"note": "无错误"
},
{
"step": "38 零PUT/DELETE(ADR-0017)",
"ok": true,
"note": "违例=[]"
},
{
"step": "39 端点覆盖广度(唯一路径≥50+矩阵清单50条)",
"ok": true,
"note": "唯一路径=91;缺失=[]"
},
{
"step": "40 夹具正门清扫+规则/形态偏好还原核对",
"ok": true,
"note": "清扫=4行;残留=0;dedup=80(原80);reminder=30(原30);overview形态=list"
}
],
"errors": [],
"http_fails": [],
"api_urls": [
"GET /api/auth/debug/token?userId=739564171091247104",
"GET /api/dict/item/enabled-list?groupCode=customer_type",
"GET /api/dict/item/enabled-list?groupCode=industry",
"GET /api/dict/item/tree?groupCode=job_title",
"POST /api/customer/create",
"GET /api/rule/customer/dedup",
"GET /api/customer/check-name?name=e2c-dr-%E6%81%92%E4%BF%A1%E8%BE%BE%E7%A7%91%E6%8A%80%E6%9C%89%E9%99%90%E8%B4%A3%E4%BB%BB%E5%85%AC%E5%8F%B8",
"GET /api/auth/debug/token?userId=739564171091247104",
"GET /api/auth/me",
"GET /api/preference/view/list?scopeKey=customer.mine",
"GET /api/preference/view-form/get?scopeKey=customer.mine",
"POST /api/customer/workspace/page # workspace=mine&current=1&size=10&viewType=ASSIGNED&archiveStatus=1",
"GET /api/preference/view/list?scopeKey=customer.overview",
"GET /api/preference/view-form/get?scopeKey=customer.overview",
"POST /api/customer/workspace/page # workspace=overview&current=1&size=10&viewType=ASSIGNED&archiveStatus=1",
"GET /api/preference/view/list?scopeKey=customer.pool",
"GET /api/preference/view-form/get?scopeKey=customer.pool",
"POST /api/customer/workspace/page # workspace=pool&current=1&size=10&viewType=ASSIGNED&archiveStatus=1",
"GET /api/preference/view/list?scopeKey=customer.mine",
"GET /api/preference/view-form/get?scopeKey=customer.mine",
"POST /api/customer/workspace/page # workspace=mine&current=1&size=10&viewType=ASSIGNED&archiveStatus=1",
"POST /api/customer/workspace/page # workspace=mine&current=1&size=10&viewType=FOLLOW_UP_DUE&archiveStatus=1",
"POST /api/customer/workspace/page # workspace=mine&current=1&size=10&viewType=ASSIGNED&archiveStatus=1",
"POST /api/customer/workspace/page # workspace=mine&current=1&size=10&viewType=ASSIGNED&archiveStatus=1&customerStage=3",
"POST /api/customer/workspace/page # workspace=mine&current=1&size=10&viewType=ASSIGNED&archiveStatus=1",
"POST /api/customer/workspace/page # workspace=mine&current=1&size=10&viewType=ASSIGNED&archiveStatus=2",
"POST /api/customer/workspace/page # workspace=mine&current=1&size=10&viewType=ASSIGNED&keyword=e2c-%E5%B7%B2%E5%BD%92%E6%A1%A3&archiveStatus=2",
"POST /api/customer/workspace/page # workspace=mine&current=1&size=10&viewType=ASSIGNED&keyword=e2c-%E5%B7%B2%E5%BD%92%E6%A1%A3&archiveStatus=1",
"POST /api/preference/view/save?scopeKey=customer.mine # {\"name\":\"e2c-dr-视图\",\"isDefault\":false,\"seqNo\":10,\"conditions\":[{\"field\":\"keyword\",\"operator\":\"like\",\"value\":\"e2c\"}]}",
"GET /api/preference/view/list?scopeKey=customer.mine",
"POST /api/customer/workspace/page # workspace=mine&current=1&size=10&viewType=ASSIGNED&keyword=e2c&archiveStatus=1&savedViewId=76ac031d780e41558ceaf438ffd0b163",
"POST /api/customer/workspace/page # workspace=mine&current=1&size=10&viewType=ASSIGNED&keyword=e2c&archiveStatus=1&savedViewId=76ac031d780e41558ceaf438ffd0b163",
"POST /api/preference/view/delete?scopeKey=customer.mine&viewId=76ac031d780e41558ceaf438ffd0b163",
"GET /api/preference/view/list?scopeKey=customer.mine",
"GET /api/preference/view/list?scopeKey=customer.overview",
"GET /api/preference/view-form/get?scopeKey=customer.overview",
"POST /api/customer/workspace/page # workspace=overview&current=1&size=10&viewType=ASSIGNED&keyword=e2c&archiveStatus=1",
"POST /api/preference/view-form/save # scopeKey=customer.overview&viewForm=board",
"POST /api/customer/workspace/board/summary # workspace=overview&viewType=ASSIGNED&groupColumn=stage",
"POST /api/customer/workspace/board/cards # workspace=overview&viewType=ASSIGNED&groupColumn=stage&groupValue=1&current=1&size=20",
"POST /api/customer/workspace/board/cards # workspace=overview&viewType=ASSIGNED&groupColumn=stage&groupValue=2&current=1&size=20",
"POST /api/customer/workspace/board/cards # workspace=overview&viewType=ASSIGNED&groupColumn=stage&groupValue=3&current=1&size=20",
"POST /api/preference/view-form/save # scopeKey=customer.overview&viewForm=list",
"GET /api/preference/view/list?scopeKey=customer.mine",
"GET /api/preference/view-form/get?scopeKey=customer.mine",
"POST /api/customer/workspace/page # workspace=mine&current=1&size=10&viewType=ASSIGNED&keyword=e2c&archiveStatus=1",
"GET /api/customer/company-lookup?companyName=%E5%8D%8E%E5%85%B4%E6%99%BA%E9%80%A0%E7%A7%91%E6%8A%80",
"POST /api/customer/create # customerName=e2c-dr-%E6%81%92%E4%BF%A1%E8%BE%BE%E7%A7%91%E6%8A%80%E6%9C%89%E9%99%90%E8%B4%A3%E4%BB%BB%E5%85%AC%E5%8F%B8&customerType=customer_type_01&provinceCode=440000&cityCode=440100&districtCode=440103&industryCode=gov_1&customerStarLevel=3&relationStarLevel=3&isBizNegotiated=0&isChild=0&ownerUserId=739564171091247104",
"POST /api/customer/create # customerName=e2c-dr-%E6%81%92%E4%BF%A1%E8%BE%BE%E7%A7%91%E6%8A%80%E6%9C%89%E9%99%90%E8%B4%A3%E4%BB%BB%E5%85%AC%E5%8F%B8&customerType=customer_type_01&provinceCode=440000&cityCode=440100&districtCode=440103&industryCode=gov_1&customerStarLevel=3&relationStarLevel=3&isBizNegotiated=0&isChild=0&ownerUserId=739564171091247104&confirmSimilar=true",
"POST /api/customer/workspace/page # workspace=mine&current=1&size=10&viewType=ASSIGNED&keyword=e2c&archiveStatus=1",
"GET /api/customer/detail-head?id=753771843382411264",
"GET /api/customer/detail?id=753771843382411264",
"GET /api/customer/detail?id=753771843382411264",
"POST /api/customer/edit?id=753771843382411264 # customerName=e2c-dr-%E6%81%92%E4%BF%A1%E8%BE%BE%E7%A7%91%E6%8A%80%E6%9C%89%E9%99%90%E8%B4%A3%E4%BB%BB%E5%85%AC%E5%8F%B8-%E6%94%B9&customerType=customer_type_01&provinceCode=440000&cityCode=440100&districtCode=440103&industryCode=gov_1&customerStarLevel=3&relationStarLevel=3&version=0&isBizNegotiated=0&isChild=0",
"POST /api/customer/edit?id=753771843382411264 # customerName=e2c-dr-%E6%81%92%E4%BF%A1%E8%BE%BE%E7%A7%91%E6%8A%80%E6%9C%89%E9%99%90%E8%B4%A3%E4%BB%BB%E5%85%AC%E5%8F%B8-%E6%94%B9&customerType=customer_type_01&provinceCode=440000&cityCode=440100&districtCode=440103&industryCode=gov_1&customerStarLevel=3&relationStarLevel=3&version=0&isBizNegotiated=0&isChild=0&confirmSimilar=true",
"POST /api/customer/workspace/page # workspace=mine&current=1&size=10&viewType=ASSIGNED&keyword=e2c&archiveStatus=1",
"GET /api/customer/detail-head?id=753771843382411264",
"GET /api/customer/detail?id=753771843382411264",
"GET /api/customer/detail?id=753771843382411264",
"GET /api/dict/item/tree?groupCode=job_title",
"GET /api/dict/item/enabled-list?groupCode=contact_source",
"GET /api/dict/item/tree?groupCode=industry",
"GET /api/customer/contacts?customerId=753771843382411264",
"GET /api/customer/contact/graph/detail?customerId=753771843382411264",
"GET /api/customer/follow/page?id=753771843382411264&current=1&size=50",
"GET /api/customer/opportunity/page?id=753771843382411264",
"GET /api/customer/project/page?id=753771843382411264&current=1&size=50",
"GET /api/customer/agreement/list?customerId=753771843382411264",
"GET /api/customer/member/list?id=753771843382411264",
"GET /api/customer/oplog/page?id=753771843382411264&current=1&size=50",
"GET /api/customer/contact/page?current=1&size=10&keyword=",
"GET /api/customer/detail?id=753771843382411264",
"GET /api/customer/detail-head?id=750844477165273088",
"GET /api/customer/detail?id=750844477165273088",
"GET /api/customer/detail?id=750844477165273088",
"GET /api/customer/opportunity/page?id=750844477165273088",
"GET /api/customer/project/page?id=750844477165273088&current=1&size=50",
"GET /api/customer/contact/search?keyword=%E5%BC%A0%E5%85%B3%E9%94%AE&limit=20",
"GET /api/customer/member/list?id=750844477165273088",
"POST /api/customer/member/add?id=750844477165273088 # memberUserIds=744842318024015872",
"GET /api/customer/member/list?id=750844477165273088",
"POST /api/customer/member/remove?id=750844477165273088&memberUserId=744842318024015872",
"GET /api/customer/member/list?id=750844477165273088",
"GET /api/customer/contacts?customerId=750844477165273088",
"GET /api/customer/contact/graph/detail?customerId=750844477165273088",
"POST /api/customer/contact/quickAdd # customerId=750844477165273088&rows[0].name=李冒烟&rows[0].jobTitleCode=chairman&rows[0].phone=13800002222&rows[0].source=other&rows[0].isKeyContact=0&rows[1].name=赵冒烟&rows[1].jobTitleCode=chairman&rows[1].source=other",
"GET /api/customer/contacts?customerId=750844477165273088",
"GET /api/customer/contact/reveal?id=753771972256595968",
"GET /api/customer/contact/reveal?id=753771972432756736",
"GET /api/customer/contact/reveal?id=750844500779204608",
"GET /api/customer/contact/reveal?id=750844501387378688",
"GET /api/customer/contact/reveal?id=750844500171030528",
"POST /api/customer/contact/batchEdit # rows%5B0%5D.id=750844500171030528&rows%5B0%5D.name=%E5%BC%A0%E5%85%B3%E9%94%AE&rows%5B0%5D.jobTitleCode=chairman&rows%5B0%5D.phone=&rows%5B0%5D.source=contact_source_01&rows%5B0%5D.isKeyContact=1",
"GET /api/customer/contacts?customerId=750844477165273088",
"GET /api/customer/contact/graph/detail?customerId=750844477165273088",
"GET /api/customer/contact/check-phone?phone=13812340001",
"POST /api/customer/contact/delete?id=753771972256595968",
"GET /api/customer/contacts?customerId=750844477165273088",
"GET /api/customer/contact/graph/detail?customerId=750844477165273088",
"GET /api/customer/follow/page?id=750844477165273088&current=1&size=50",
"POST /api/customer/follow/add?id=750844477165273088 # followWay=follow_way_01&followContent=%E5%86%92%E7%83%9F%E8%B7%9F%E8%BF%9B%E5%86%85%E5%AE%B9-%E7%94%B5%E8%AF%9D%E6%B2%9F%E9%80%9A",
"GET /api/customer/follow/page?id=750844477165273088&current=1&size=50",
"GET /api/customer/detail-head?id=750844477165273088",
"GET /api/customer/follow/page?id=750844477165273088&followWay=follow_way_01&current=1&size=50",
"POST /api/customer/focus?id=750844477165273088",
"POST /api/customer/workspace/page # workspace=mine&current=1&size=10&viewType=ASSIGNED&keyword=e2c&archiveStatus=1",
"GET /api/customer/detail-head?id=750844477165273088",
"POST /api/customer/star?id=750844477165273088",
"POST /api/customer/workspace/page # workspace=mine&current=1&size=10&viewType=ASSIGNED&keyword=e2c&archiveStatus=1",
"GET /api/customer/detail-head?id=750844477165273088",
"GET /api/customer/oplog/page?id=750844477165273088&current=1&size=50",
"GET /api/customer/oplog/page?id=750844477165273088&action=FOLLOW&current=1&size=50",
"GET /api/customer/oplog/page?id=750844477165273088&action=CREATE&current=1&size=50",
"POST /api/customer/import/upload",
"GET /api/customer/import/page?current=1&size=20",
"POST /api/customer/import/confirm?taskId=753772071518994432",
"GET /api/customer/import/result?taskId=753772071518994432",
"GET /api/customer/import/failures?taskId=753772071518994432",
"GET /api/customer/import/page?current=1&size=20",
"GET /api/customer/import/page?current=1&size=20&status=2",
"GET /api/customer/import/page?current=1&size=20&status=2&importMode=UPSERT",
"GET /api/customer/detail-head?id=753771843382411264",
"GET /api/customer/detail?id=753771843382411264",
"GET /api/customer/contacts?customerId=753771843382411264",
"GET /api/customer/contact/graph/detail?customerId=753771843382411264",
"POST /api/customer/contact/import/upload",
"POST /api/customer/contact/import/confirm?taskId=753772113634000896",
"GET /api/customer/contact/import/result?taskId=753772113634000896",
"GET /api/customer/contact/import/result?taskId=753772113634000896",
"GET /api/customer/contact/import/failures?taskId=753772113634000896",
"GET /api/customer/contact/import/result?taskId=753772113634000896",
"GET /api/customer/contacts?customerId=753771843382411264",
"GET /api/customer/contact/graph/detail?customerId=753771843382411264",
"GET /api/preference/view/list?scopeKey=customer.mine",
"GET /api/preference/view-form/get?scopeKey=customer.mine",
"POST /api/customer/workspace/page # workspace=mine&current=1&size=10&viewType=ASSIGNED&keyword=e2c&archiveStatus=1",
"POST /api/customer/workspace/page # workspace=mine&current=1&size=10&viewType=ASSIGNED&keyword=e2c-dr-%E6%81%92%E4%BF%A1%E8%BE%BE&archiveStatus=1",
"POST /api/opportunity # opportunityName=e2c-dr-%E6%BC%94%E7%A4%BA%E5%95%86%E6%9C%BA&oppSource=opp_source_02&bidForm=bid_form_01&localityType=locality_type_01&provinceCode=440000&cityCode=440100&customerId=753771843382411264&customerName=e2c-dr-%E6%81%92%E4%BF%A1%E8%BE%BE%E7%A7%91%E6%8A%80%E6%9C%89%E9%99%90%E8%B4%A3%E4%BB%BB%E5%85%AC%E5%8F%B8-%E6%94%B9&isPrimaryIntended=1&customerRole=customer_role_01",
"POST /api/customer/release-pool-batch?ids=753771843382411264",
"POST /api/customer/workspace/page # workspace=mine&current=1&size=10&viewType=ASSIGNED&keyword=e2c-dr-%E6%81%92%E4%BF%A1%E8%BE%BE&archiveStatus=1",
"GET /api/customer/detail-head?id=753771843382411264",
"POST /api/opportunity/assign?id=753772150371909632&userId=739564171091247104",
"POST /api/opportunity/close?id=753772150371909632&closeReason=%E5%86%92%E7%83%9F%E6%BC%94%E7%A4%BA%E5%85%B3%E9%97%AD",
"POST /api/customer/release-pool-batch?ids=753771843382411264",
"POST /api/customer/workspace/page # workspace=mine&current=1&size=10&viewType=ASSIGNED&keyword=e2c-dr-%E6%81%92%E4%BF%A1%E8%BE%BE&archiveStatus=1",
"GET /api/customer/detail-head?id=753771843382411264",
"POST /api/customer/claim-batch?ids=753771843382411264",
"POST /api/customer/assign-batch?ids=753771843382411264&userId=744842318024015872",
"GET /api/auth/debug/token?userId=744842318024015872",
"GET /api/customer/transfer/preview",
"POST /api/customer/transfer/initiate",
"GET /api/customer/transfer/page?current=1&size=10",
"GET /api/customer/transfer/detail?id=753772181279735808",
"GET /api/customer/transfer/assignable?id=753772181279735808",
"POST /api/customer/transfer/assign?id=753772181279735808&customerIds=753771843382411264&assignUserId=99021002",
"GET /api/customer/transfer/detail?id=753772181279735808",
"GET /api/customer/transfer/page?current=1&size=10",
"POST /api/customer/release-pool-batch?ids=753772080058597376",
"GET /api/preference/view/list?scopeKey=customer.pool",
"GET /api/preference/view-form/get?scopeKey=customer.pool",
"POST /api/customer/workspace/page # workspace=pool&current=1&size=10&viewType=ASSIGNED&keyword=e2c-dr-%E6%81%92%E4%BF%A1%E8%BE%BE&archiveStatus=1",
"POST /api/customer/workspace/page # workspace=pool&current=1&size=10&viewType=ASSIGNED&keyword=e2c-dr-imp&archiveStatus=1",
"POST /api/customer/workspace/page # workspace=pool&current=1&size=10&keyword=e2c-dr-imp&archiveStatus=1",
"POST /api/customer/claim-batch?ids=753772080058597376",
"POST /api/customer/workspace/page # workspace=pool&current=1&size=10&keyword=e2c-dr-imp&archiveStatus=1",
"GET /api/customer/detail-head?id=753771843382411264",
"POST /api/customer/archive-batch?ids=753772080058597376",
"POST /api/customer/workspace/page # workspace=pool&current=1&size=10&keyword=e2c-dr-imp&archiveStatus=1",
"GET /api/customer/detail-head?id=753771843382411264",
"GET /api/customer/detail-head?id=753772080058597376",
"GET /api/customer/detail?id=753772080058597376",
"POST /api/customer/restore?id=753772080058597376",
"POST /api/customer/workspace/page # workspace=pool&current=1&size=10&keyword=e2c-dr-imp&archiveStatus=1",
"GET /api/customer/detail-head?id=753772080058597376",
"POST /api/customer/archive?id=753772080058597376",
"POST /api/customer/workspace/page # workspace=pool&current=1&size=10&keyword=e2c-dr-imp&archiveStatus=1",
"GET /api/customer/detail-head?id=753772080058597376",
"POST /api/customer/assign-batch?ids=753772080058597376&userId=744842318024015872",
"GET /api/rule/customer/dedup",
"GET /api/rule/customer/dedup",
"POST /api/rule/customer/dedup/save # masterEnabled=1&nameEnabled=1&nameMatchMode=2&similarityThreshold=90&phoneEnabled=1",
"GET /api/rule/customer/dedup",
"POST /api/rule/customer/dedup/save # masterEnabled=1&nameEnabled=1&nameMatchMode=2&similarityThreshold=80&phoneEnabled=1",
"GET /api/rule/customer/dedup",
"GET /api/rule/customer/dedup",
"GET /api/customer/check-name?name=e2c-dr-%E6%81%92%E4%BF%A1%E8%BE%BE%E7%A7%91%E6%8A%80%E6%9C%89%E9%99%90%E8%B4%A3%E4%BB%BB%E5%85%AC%E5%8F%B8",
"GET /api/customer/check-credit-code?creditCode=91440101E2CTEST001",
"POST /api/customer/create # customerName=e2c-dr-%E6%92%9E%E7%A0%81%E5%AE%A2%E6%88%B7&customerType=customer_type_01&provinceCode=440000&cityCode=440100&districtCode=440103&industryCode=gov_1&unifiedCreditCode=91440101E2CTEST001&customerStarLevel=3&relationStarLevel=3&isBizNegotiated=0&isChild=0",
"GET /api/rule/customer/reminder",
"POST /api/rule/customer/reminder/save # masterEnabled=1&firstTriggerEnabled=1&firstTriggerDays=0&secondIntervalEnabled=1&secondIntervalDays=0",
"POST /api/rule/customer/reminder/save # masterEnabled=1&firstTriggerEnabled=1&firstTriggerDays=30&secondIntervalEnabled=1&secondIntervalDays=7",
"GET /api/rule/customer/reminder",
"GET /api/preference/view/list?scopeKey=customer.pool",
"GET /api/preference/view-form/get?scopeKey=customer.pool",
"POST /api/customer/workspace/page # workspace=pool&current=1&size=10&keyword=e2c-dr-imp&archiveStatus=1",
"GET /api/preference/view/list?scopeKey=customer.mine",
"GET /api/preference/view-form/get?scopeKey=customer.mine",
"POST /api/customer/workspace/page # workspace=mine&current=1&size=10&keyword=e2c-dr-imp&archiveStatus=1",
"GET /api/customer/detail-head?id=753771843382411264",
"GET /api/customer/detail?id=753771843382411264",
"GET /api/customer/detail?id=753771843382411264",
"GET /api/customer/follow/page?id=753771843382411264&current=1&size=50",
"GET /api/customer/opportunity/page?id=753771843382411264",
"GET /api/customer/member/list?id=753771843382411264",
"GET /api/customer/contacts?customerId=753771843382411264",
"GET /api/customer/contact/graph/detail?customerId=753771843382411264",
"GET /api/preference/view/list?scopeKey=customer.pool",
"GET /api/preference/view-form/get?scopeKey=customer.pool",
"POST /api/customer/workspace/page # workspace=pool&current=1&size=10&keyword=e2c-dr-imp&archiveStatus=1",
"GET /api/preference/view/list?scopeKey=customer.mine",
"GET /api/preference/view-form/get?scopeKey=customer.mine",
"POST /api/customer/workspace/page # workspace=mine&current=1&size=10&keyword=e2c-dr-imp&archiveStatus=1",
"GET /api/customer/detail-head?id=750844477165273088",
"GET /api/customer/detail?id=750844477165273088",
"GET /api/customer/follow/page?id=750844477165273088&current=1&size=50",
"GET /api/customer/opportunity/page?id=750844477165273088",
"GET /api/customer/member/list?id=750844477165273088",
"GET /api/customer/detail?id=750844477165273088",
"GET /api/customer/contacts?customerId=750844477165273088",
"GET /api/customer/contact/graph/detail?customerId=750844477165273088",
"GET /api/customer/contact/detail?id=753771972432756736",
"GET /api/customer/project/page?id=750844477165273088&current=1&size=50",
"GET /api/customer/company-lookup?companyName=%E6%B5%8B%E8%AF%95%E8%81%94%E6%83%B3%E8%AF%8D",
"GET /api/rule/customer/dedup",
"GET /api/rule/customer/reminder",
"GET /api/preference/view-form/get?scopeKey=customer.overview"
]
}

1099
.scratch/customer-demo-ref/demo/smoke.py

File diff suppressed because it is too large

BIN
.scratch/customer-demo-ref/demo/tmp_xlsx/cont.xlsx

Binary file not shown.

BIN
.scratch/customer-demo-ref/demo/tmp_xlsx/cust.xlsx

Binary file not shown.

BIN
.scratch/customer-demo-ref/demo/tmp_xlsx/tpl_cust.xlsx

Binary file not shown.

30
.scratch/customer-demo-ref/issues/07-field-alignment-checklist.md

@ -1,7 +1,7 @@
# 07 逐入口字段对齐验收清单
Type: research
Status: open
Status: resolved
Blocked by: 01
## Question
@ -20,3 +20,31 @@ Blocked by: 01
- Bruno 文档仓 `D:\code\crm-api-docs`(按菜单分组的 .bru);`.scratch/customer-module/API-SUMMARY.md`。
- DTO 源码:`crm-customer/src/main/java/com/crm/customer/` 下 dto/vo 包。
- 现有 demo 的渲染口径("--"、脱敏 reveal 按钮)可直接对照。
## Answer
(2026-09-11,AFK 决断。产出:`assets/field-alignment.md`(1091 行)+ 中间分片 `_fa-core.md`/`_fa-flow.md`(已并入正文,收口时删除)。两个抽取代理分工:核心域(矩阵 #1-#49)/ 联系人族+重流程+规则族(#31-#69),逐入口产出请求参数表、出参字段表(路径/类型/文案口径/脱敏)、空值"--"与枚举字典口径,每表附权威出处(.bru 相对路径 + DTO/Param 源码缩写,缩写表见文件头)。)
### 结构
- **头部「跨票校准注记 K1-K11」**:两代理交叉发现统一收敛,**实施/冒烟必须遵守、覆盖矩阵与 AS 旧口径**。
- **第一部分 · 核心域**(#1-#49):通用口径(BaseParam 分页/Long→str 序列化/时间格式/"--"口径/脱敏总述/seed id)+ 逐入口表 + 字典与枚举专节 + 来源清单。
- **第二部分 · 联系人族+重流程+规则族**(#31-#69):12 节(联系人菜单/列表/图谱/增强/导入六端点/CRUD/交割五端点/客户导入/规则族/字典/脱敏专节/来源清单)。
### K1-K11 校准要点(已回写落地)
- **K1** G4 收窄为「两件」:detail 实测 `createTime/updateTime` 经 BaseDTO 有值,仅 `creatorId/updaterId` 不暴露 → demo DEFECT_LEDGER G4 条目 + 冒烟步 13/34 已同步改(本票收口动作)。
- **K2** 关联项目页签有真端点(`CustomerProjectQueryPort`,当前 Noop → 数据空态显"--")→ 票 11 scope 已修(矩阵 #42「无端点」过时),冒烟步 14 同步。
- **K3** 看板 summary 字段 = `cnt`/`groupValue`(demo 旧读 count/name/key 必显"--");cards 必传 groupValue → 票 10。
- **K4** oplog action 权威值域 **18 种**(含 GRAPH_EDIT;AS §3「17 种」未同步)→ 票 11 筛选下拉。
- **K5** projectCount 三方漂移(AS 恒 null / SQL 恒 0 / E2E 序列化 "0"):渲染按实返回,"--"兜底。
- **K6** contact_source 真值 = group_meeting/group_activity/referral/other(crm-dict DictDataInitializer);demo 硬编码 contact_source_01..04 系字典改版前遗留 → 票 12/13 改字典驱动。
- **K7** reminder save 控制器实为**表单绑定**(.bru 自称 JSON 例外不实)→ demo form 提交正确,不改。
- **K8** focus-batch 返回 **Void**(其余批量端点 BatchResult)→ 票 10 toast 自算条数。
- **K9** quick-create `.bru``confirmSimilar`、误标星级选填(DTO 全 7 必填)→ 票 12 以 DTO 为准。
- **K10** 联系人电话全读路径脱敏 `138****1234`(短号全遮、导出同口径、明文唯一通道 reveal);similarHits/check-phone 行级 masked;核心域 V1 明文(creditCode 明文回显)→ 冒烟脱敏断言。
- **K11** transfer status 权威 0/1(.bru 示例 2 超值域);reminder 64023 精确条件 = 两天数均非空且 >0 → 冒烟断言。
### 验收用法
- 冒烟断言与人工验收双用;实施票 10-13 的字段名/枚举/端点路径一律以本清单为准,与矩阵/AS 旧口径冲突时**以 K 注记与本清单覆盖**。

20
.scratch/customer-demo-ref/issues/10-core-list.md

@ -1,7 +1,7 @@
# 10 核心域·列表层改造
Type: task
Status: open
Status: resolved
Blocked by: 07, 09
## Scope
@ -20,3 +20,21 @@ Blocked by: 07, 09
smoke-plan A 段步 2-7/归属动作可过;新增/改造 UI 不引入 console error。
## Answer
(2026-09-11,AFK 实施。demo/index.html fork 基线 2350 行 → 2492 行(+142)。字段口径全部按 field-alignment.md K 注记与 #1/#7/#8/#9/#13-19 表。)
1. **视图下拉五值齐全**(矩阵 #2):补 FOLLOW_UP_DUE/RECENT;pool 越界视图不做禁用特判(SQL 自然空集);RECENT 选中时 svSel 禁用(saved-view 不接 RECENT)。
2. **列筛选器六参数 + 归档态开关**(#5/#6):第二行 viewbar 增加 archiveStatus(有效/已归档)+ customerStage/customerType(六种字典静态值)/customerStarLevel/relationStarLevel(1-5)/industryCode/provinceCode(code 输入框),loadList 全量携带 + 重置筛选按钮。
3. **saved-view 四端点接线**(#7,P3-2 翻正):list 载入下拉、存为视图(**save 发 @RequestBody JSON**——唯一例外端点,conditions 本期只提交 1 组 keyword like)、选中→page 带 `savedViewId`、设默认、删除;scopeKey=`customer.mine/overview/pool`。
4. **形态偏好 + 看板视图**(#8/#9,P3-1 翻正 + K3 落地):view-form/get 载入持久形态、save 发出;list/split/board 三态;**看板=board/summary(显式 groupColumn,字段 `cnt`/`groupValue` 渲染,空列骨架前端补全)+ board/cards(groupValue 必传,卡片=列表行子集,不接 saved-view)**,分组列 stage/star/relation 可切;**汇总卡面板整体移除**(矩阵 #12 二选一取移除:A4 原型列表页无独立统计卡,board/summary 不再被挪用)。
5. **pool 边界**:形态下拉不给 board 选项;pool workspace 显示 I-06 静态注记(`data-defect="I-06"`,文本含「67001」+「记忆成功」双现象,不实调保 console 干净)。
6. **分屏占位**(#10):split=列表保留 + 行卡片预览面板(点击客户名称出该行子集卡片,不跳详情页)。
7. **列表补列**(#11):类型(字典文案)/行业(industry 树字典翻译,ensureDicts 增拉 `tree?groupCode=industry`)/协议等级/VIP 等级(null 显 "--");**pool 列组切换**:无负责人/部门列、加进入公海时间(原型差异口径);表头改 JS 动态渲染(listCols/paintHead)并补勾选列空表头(修复基线 11 th vs 12 td 错位);项目数按 K5 口径改按实返回 "--" 兜底(原硬编码 "--")。
8. **分配整批化**(#15/#16):dlgAssign 改取全部勾选 ids 走 `assign-batch`,BatchResult 三计数+失败明细表呈现;目标人员校验失败=整批拒绝(顶层非 0 落 errbox+提示预检语义)。
### 证据
- Playwright 快验(`_quickcheck.py`,无 token 冷载):**pageerror=0**;新表头 15 列渲染;mine 形态下拉 3 选项;切 pool 后 I-06 注记可见、形态下拉仅 list/split。截图 `demo/shots/t10-quick-pool.png`。console 2 条=file:// 直连默认 API 的网络失败(冒烟经 route 反代后为 0,非本次 UI 缺陷)。
- 完整 40 步冒烟归票 14(A 段步 2-7 断言本票行为)。

23
.scratch/customer-demo-ref/issues/11-core-detail.md

@ -1,7 +1,7 @@
# 11 核心域·详情层改造
Type: task
Status: open
Status: resolved
Blocked by: 07, 09
## Scope
@ -9,7 +9,7 @@ Blocked by: 07, 09
矩阵 #29/30/40/41/42/44/45
- 详情头部 4 张金额卡(恒"--"+P3-6 角标)。
- 页签补齐 8 个:页签5 关联项目占位(恒"--")
- 页签补齐 8 个:页签5 关联项目接线真端点(`CustomerProjectQueryPort`,当前 Noop 实现 → 数据空态显"--",不做硬占位);端点路径查 `assets/field-alignment.md` 第二部分
- 关联商机 7 列真名(G2 角标;列名以 field-alignment/opp-deps 为准)。
- 信息页签系统信息区(G4 角标);成员列表(G3 角标)。
- 跟进筛选器(followWay/时间范围);oplog 筛选器(action 17 值/keyword/时间)。
@ -19,3 +19,22 @@ Blocked by: 07, 09
smoke-plan 步 11-19 可过;8 页签全遍历。
## Answer
(2026-09-11,AFK 实施。demo/index.html → 2597 行(票 11 净增约 +105)。契约全部按 field-alignment #29/#30/#40/#41/#42/#44/#45 + K1/K2/K4/K5/K10。)
1. **详情头部 4 张金额卡**(#29/P3-6):金额汇总区 4 卡(已中标项目总额/跟进中方案预估/进行中商机总额/合同·回款)按契约恒 null 显 "--" 不显 0,挂 P3-6 dbadge;头部「项目计数」去掉旧「(契约恒 null 显 --)」文案改按实返回。
2. **页签补齐 8 个**:subtabs = 信息/联系人/跟进/关联商机/**关联项目(新)**/战略协议/团队成员/操作日志。
3. **页签5 关联项目接真端点**(K2):`GET /api/customer/project/page?id=`(CustomerProjectQueryPort 反查,当前 Noop 实现 → 数据空态显 "--",非硬占位);列=ProjectItem 真名 9 字段子集(项目名称/阶段/状态/报备状态/项目金额/负责人/最近更新)。
4. **关联商机 7 列真名**(#41/G2):修复旧 5 列三处字段名漂移(opportunityName/statusName/ownerUserName 系幻影字段)→ 契约 7 字段列头(商机ID/商机名称/阶段/预计金额/负责人ID/负责人/最近更新),G2 dbadge+缺列说明。
5. **信息页签**(#30/G4/K1/K10):grid 补「创建人/更新人」两行(creatorId/updaterId 不在 DTO → 恒 "--")+ G4 dbadge 与口径提示(创建/更新时间经 BaseDTO 有值,如实渲染);**修复信用代码字段名漂移 creditCode→unifiedCreditCode**(K10 明文回显,否则冒烟步 13 明文断言必 FAIL)。
6. **跟进筛选器**(#40/G1):followWay 五值下拉 + 起止日期(补 00:00:00/23:59:59 含端)+ 查询;G1 dbadge 明示操作人筛选豁免(FollowPageParam 无参数);写跟进按钮挪入筛选条;行渲染修正 followWay 字典名(后端不回名→前端 FOLLOW_WAYS 翻译)与 followByName 快照(旧读 createByName 系幻影字段)。
7. **oplog 筛选器**(#45/K4):action 下拉按**权威 18 种**渲染(含 GRAPH_EDIT,映射中文文案)+ 叙事 keyword + 起止日期;日志行 action 翻译文案。
8. **成员列表 G3**(#44):添加协同人旁挂 G3 dbadge + 「职务/加入时间两列 DTO 未暴露,缺列不补」提示。
9. **G5 角标**:联系人页签列表视图条挂静态 `data-defect="G5"`(ContactDTO 未暴露负责人快照)。G6 归票 12 联系人独立菜单。
### 证据
- Playwright 快验(`_quickcheck.py`):**pageerror=0**;subtabs=8(projects 就位);G5 静态角标存在;缺陷面板 33 行不受影响。截图 `demo/shots/t11-quick-detail.png`
- 金额卡/7 列/筛选器/项目页签的**带数据断言**归票 14 冒烟步 11-19(需起服)。

17
.scratch/customer-demo-ref/issues/12-contacts-opp-entry.md

@ -1,7 +1,7 @@
# 12 联系人独立菜单 + 商机侧演示入口
Type: task
Status: open
Status: resolved
Blocked by: 07, 09
## Scope
@ -15,3 +15,18 @@ Blocked by: 07, 09
smoke-plan 步 23(商机联动链)与联系人菜单相关步可过。
## Answer
**全部落地,Playwright 快验 pageerror=0(登录态走远程 debug token,真数据)。demo/index.html 2620→2852 行。**
1. **新页签「联系人」**(nav 第 3 位,`tab-contacts`):contact/page 真端点列表(GET,BaseParam+keyword 匹配姓名/电话/所属客户名)——真数据 10 行/共 22 条;表头=姓名(固定列)+7 可隐藏列(职务/电话/关键联系人/内线/来源/礼品备注/所属客户)+操作;分页。工具栏摆 **G5 dbadge**(ContactDTO 无 owner 字段,07 票定稿口径)。
2. **K10 脱敏直显**:列表/详情电话列服务端已脱敏(真值实测 `138****0001`),前端直显不做二次加工。
3. **K6 字典驱动**:来源列经 `sourceName()`(SOURCE_DICT=enabled-list contact_source)翻译;新数据正常翻名。
4. **contact.list 偏好接线**:形态偏好 view-form/get|save(联系人菜单仅 list 单项,读写闭环);列偏好 preference/get|save(visibleKeys 重复键,`dlgCpEdit()` 勾选保存后 ctVisibleKeys 即时生效,姓名列固定不参与隐显)。
5. **行点开 contact/detail 弹窗回显**(#51:明细行级最新值;phone 脱敏形态提交=不改号提示):12 个 kv(姓名/职务三件/电话/关键/内线/来源/礼品/所属客户三件)+ 子页签「操作日志」**空态+G6 dbadge**(#53:后端无联系人级 oplog 端点,07 票豁免记档)。
6. **全局搜索演示入口**(#52):`dlgContactSearch()` GET keyword=&limit=(默认 20),场景文案=商机表单「关键联系人」数据源;真名回查 n=1 ✅。
7. **商机侧三入口**(按 opp-deps §4):客户列表新增「商机侧演示入口」panel——①快建客户 `dlgQuickCreate()`(POST /api/customer/quick-create,**K9:DTO 全 7 必填**+confirmSimilar 默认 false、needConfirm→similarHits 列表→确认后 true 重发链);②候选客户三维搜索 `dlgCustSearch()`(POST /api/customer/search:keyword 三维模糊+customerType+archiveStatus+excludeCustomerIds 重复键,9 字段出参);③「从客户发起建商机」行操作 `dlgOppCreate(id)`(POST /api/opportunity 预填 customerId/customerName+isPrimaryIntended+customerRole;核心 6 必填 D-14、66001 提示;**写操作留冒烟步 23 真跑**)。
8. **K6 顺手修正**:详情页签 dlgContact 的 source 下拉由硬编码 contact_source_01..04(字典改版前遗留)改为 SOURCE_DICT 动态渲染(dlgContact 改 async+幂等 ensureDicts)。
9. **快验结果**(_quickcheck12*.py):tabs=6、section 切换可见、G5=1、cform 渲染、未登录 hint「请先登录」→登录后真数据;详情弹窗 12kv+G6 空态;quick-create 7 控件齐;三搜索弹窗开合正常。**customer/search 三维真验证**:客户名「佛山季华铝业」total=1、联系人名「梁诗敏」命中其所属客户(三维模糊实证)、excludeCustomerIds 重复键 before "1"→after "0" ✅。
10. **环境观察(票 14 冒烟注意)**:①共享库存量数据 source 仍是旧 code(contact_source_02/03)——字典改版后存量未迁移,demo sourceName() 回退显原 code(按实返回口径,非 demo 缺陷);②seed 张关键/e2c-* 不在远程共享库(现为佛山季华铝业等另一批 demo 数据),冒烟步 13/23 的 seed 断言须先重建 seed 夹具或改用库内真值。

14
.scratch/customer-demo-ref/issues/13-flow-rules-tail.md

@ -1,7 +1,7 @@
# 13 重流程 + 规则族收尾
Type: task
Status: open
Status: resolved
Blocked by: 07, 09
## Scope
@ -17,3 +17,15 @@ Blocked by: 07, 09
smoke-plan B/C/D 段相应步可过。
## Answer
**五项全部落地(I-06 票 10 已覆盖跳过),Playwright 快验 pageerror=0(登录态真请求)。demo/index.html 2852→2925 行。**
1. **工商联想接线(#24)**:dlgCreate 客户名称行下加「工商联想」控件(input cLk+联想按钮+`data-defect="STUB-LOOKUP"` 注记)——`doLookup()` GET `/api/customer/company-lookup?companyName=`(参数名 companyName 非 name);真请求实证**stub 恒空数组 `[]`** → UI 显「未查询到匹配企业(一期 stub 恒 NO_HIT,可手工录入)」+ 注记(①+②双呈现)。有结果时(真实源接入后自动激活)候选 9 字段渲染、`applyLookup(i)` **只回填当前为空的字段**(bizName→cName、unifiedCreditCode→cCredit,已填不覆盖)且回填后立即调 check-credit-code(#24 前端职责三项)。无结果/失败均不阻断手工提交。
2. **F6 按钮明示文案**:`批量工商更新` 按钮文字改「F6 stub」+ 按钮挂 `data-defect="STUB-LOOKUP"` 冒烟钩子 + title 补豁免口径;结果弹窗尾部 stub hint 原已有(票 05 遗产)。
3. **导入任务列表过滤(#63)**:任务 panel 加 status(0 待确认/1 处理中/2 已完成/3 已失败)与 importMode(APPEND_ONLY/UPDATE_ONLY/UPSERT)两下拉(id=impFStatus/impFMode,避开上传工具栏已有 impMode),`loadImportPage()` 拼接查询参数(空值不传)。真请求验证:全量 1 条 → status=2 过滤 allDone ✅、importMode=UPSERT 过滤 allUpsert ✅。
4. **POOL-CFG 只读禁用占位卡(#69)**:tab-settings 查重设置 panel 后新增 panel——准入/回收两 input 均 `readonly disabled`(灰底),dbadge 文案引决策票 02 结论(后端无端点、v29 原型无此页、客户公海=固定准入规则虚拟池、线索域 /api/rule/pool 不得混入)。快验 panel inputs 全部 disabled+readOnly ✅。
5. **注记类**:导入 panel 尾部 **P3-3** dbadge(「触发校验规则后确认执行」=原型幻影控件,实现语义=确认即执行,矩阵 #64 豁免);详情页签联系人工具栏 **D-G1** dbadge(batchEdit 空串绑定 null=不修改,清空语义豁免)。
6. **快验汇总**(_quickcheck13.py):lookup_raw=[]、lookup_no_hit=true、stub_note=1、P3-3=1、POOL-CFG=1+disabled、D-G1=1、F6 钩子≥1、pageerror=[]。

22
.scratch/customer-demo-ref/issues/14-smoke-script.md

@ -1,7 +1,7 @@
# 14 Playwright 冒烟脚本
Type: task
Status: open
Status: resolved
Blocked by: 10, 11, 12, 13
## Scope
@ -16,3 +16,23 @@ Blocked by: 10, 11, 12, 13
40 步 ALL_PASS=true,console error=0,零 PUT/DELETE。
## Answer
**达成**:第八跑 `40 steps, 0 failed (176.4s)`,ALL_PASS=true;GET 147 + POST 79(零 PUT/DELETE);console_errors=0、http_fails=0;唯一路径 91≥50。报告 `demo/smoke-report.json`,截图 `demo/shots/`
**八轮迭代轨迹**(崩溃轮 1-4 → 全量轮 5-8):
- 第 1-4 轮:delete_key 崩溃 / 输出缓冲卡死 / 步 23 中文 URL 未 quote 崩(两次同因)/ 步 30 对象字面量缺括号崩。
- 第 5 轮(首次全量 29/11):定位出李普通撞号(seed 预置联系人 13812340002 与步 16 夹具撞号→failedRows 未落库连锁)、`ensure_seeds 在 sweep 之前`(上轮 seed 被 find 命中不重建→随即被 sweep 清掉→整轮查重基线缺失)、transfer 明细列=assign_status 非 status、assignableUsers 排除发起人与总监本人(assign 目标改动态取 assignable[0])。
- 第 6 轮(31/9):新增 http_fails 捕获(page.on response status≥400)→ 定位 404 project/page = 运行 jar 旧于源码(票 07 后源码加了端点);步 26 save 从 dpMode 收集把共享库 nameMatchMode 写成 1(EXACT 污染,出厂 FUZZY=2)。
- 第 7 轮(39/1):jar 重建重启(project/page 200);seed 2f 修旧联系人职务(张关键 job_title_id=NULL+名字不在字典→loadContacts 的 _jobCode 反查空→saveListAll「职务必填」拦截 batchEdit,六跑步 17/39 缺失实锤);quickAdd 后显式 loadContacts() 重拉(fetch 直调不刷新 _contacts→lid=None 连锁);步 26 save 前显式 loadDedup() 填充;pool+ASSIGNED 组合互斥呈现(Mapper L83 owner=me 与公海 owner IS NULL 恒冲突→先呈现默认视图空现象再清 fViewType 走基础集)。
- 第 8 轮(40/0 全绿):最后一项步 10 实锤 **demo onclick 陷阱**——`dlgOpen` 把 fn 直接挂 `btn.onclick`(浏览器传 PointerEvent 作首参),doEdit 首次带 confirmSimilar 形参后序列化出 `confirmSimilar=[object PointerEvent]`→后端 400;且 doEdit 原不处理 edit 的 L2 needConfirm 响应(六跑「toast 保存成功但 DB 没变」假成功实锤)。修复:doEdit/doCreate 首参防御(非显式 'true' 一律 undefined,qs 跳过)+ doEdit 增加 needConfirm simbox 分支(同 doCreate 契约)。第八跑步 10 全链 PASS:simbox=命中→确认保存→db v0→v1 名字-改。
**demo 侧修复**(demo 为本 effort fork,属票 11 详情层范畴的缺陷修复):doEdit needConfirm 分支+confirmSimilar 参数防御、doCreate 同防御(onclick 陷阱消除)、loadContacts 由 quickAdd 冒烟后显式重拉(demo 本身行为未改)。
**seed/环境修复**(纪律允许范围):main 调序 sweep 先于 ensure_seeds+查重基线自检行;ensure_seeds 2d 查重模式幂等归位 FUZZY(2)/2e 冒烟联系人清扫/2f 旧 seed 联系人职务按 dict_item 归位;sweep 覆盖 e2c-dr 客户+联系人+导入任务+交割单(2 小时窗);重建 fat jar(corretto-17+IDEA bundled mvn,先杀 8080 旧进程解文件锁)并重启 verify+MinIO。
**后端行为甄别(只测不修,均如实呈现)**:edit L2 相似命中返回 needConfirm 不落库(demo 已补确认分支后通过);pool 工作台带 ASSIGNED 视图恒空(视图条件与公海口径互斥,冒烟 note 记录默认视图行=0 现象);步 26 断言「匹配方式不被污染」作共享库保护。
取证:`demo/_t14d7.py`(DB 联系人职务+pool 复现+edit CAS 复现)、`demo/_run7.log`/`_run8.log`、`demo/_jarbuild.log`。

10
.scratch/customer-demo-ref/issues/15-report-handover.md

@ -1,7 +1,7 @@
# 15 报告 + 双仓未提交变更清单 + 交付收尾
Type: task
Status: open
Status: resolved
Blocked by: 14
## Scope
@ -14,3 +14,11 @@ Blocked by: 14
四标准自检表 + 双仓清单落盘,map 可关。
## Answer
**全部达成,map 已关(Status: done)**。
1. **四标准自检表**:`assets/acceptance-selfcheck.md`——功能覆盖(69 入口对账零缺口+冒烟唯一路径 91)/ 字段对齐(field-alignment #1-#69+K1-K11 全落)/ 流程闭环(冒烟步 9-25 全链+商机联动 67007 阻断链+oplog 留痕+规则族还原)/ 齐备(demo+smoke.py+report+4 assets+shots 9 张)——四项全达标,证据均落盘。
2. **双仓清单**:`assets/uncommitted-changes.md`——backend 51 行全部属本 effort(M 10 + ?? 37:交付物/诊断脚本/日志,前序 java 变更已不在工作区=用户已提交);**文档仓实测在 `E:\code\crm-api-docs`(票面 D:\ 为换机前旧路径,清单已勘误)**,仅 2 个 untracked 目录(战略协议/、联系人导入/=前序 frontend-handover 迁移遗留,本 effort 未动文档仓)。agent 未提交 git。
3. **map 收口**:Status→done;Destination 增验收对照块(四标准 ✓);Not yet specified 三条全部收口划线。

32
.scratch/customer-demo-ref/map.md

@ -1,13 +1,22 @@
# 客户模块全功能单文件 Demo「正确传参照」— wayfinder map
Labels: wayfinder:map
Status: open
Status: done
Created: 2026-09-11
## Destination
交付一个零构建的单文件静态前端 Demo(HTML+JS,纯 fetch 直连后端),覆盖客户模块全功能(核心域 + 重流程 + 规则族),作为联调与用户验收的「正确传参照」;随附 Playwright UI 冒烟脚本(≥30 步)与 PASS/FAIL 报告。验收四标准:功能覆盖(原型可见入口全部可点且触发对应端点)、字段对齐(与 Bruno 出参一致,含脱敏与 "--" 口径)、流程闭环(新增→归档/抛公海全链路 + 操作日志留痕)、交付物齐备。已知 P1/P2 缺陷只展示现象不修代码;双仓未提交变更清单留待用户审查。
**验收对照(2026-09-12,票 15;详见 assets/acceptance-selfcheck.md)**:
- ✅ 功能覆盖:69 入口对账零缺口(33+9 新增+16 改造+14 豁免全落地);冒烟唯一路径 91。
- ✅ 字段对齐:field-alignment.md #1-#69 + K1-K11 校准全落 demo;40 步 note 字段级实证。
- ✅ 流程闭环:冒烟步 9-25 全链 PASS(含商机 67007 阻断链、交割 assignStatus、oplog 留痕、规则族还原)。
- ✅ 齐备:demo/index.html + smoke.py + smoke-report.json(40/0 ALL_PASS,176.4s)+ 5 assets + shots 9 张。
- 纪律达成:GET 147+POST 79 零 PUT/DELETE;console error=0;http_fails=0;缺陷只呈现不修(14 现象三档呈现+冒烟断言)。
- 遗留移交:双仓未提交变更清单 assets/uncommitted-changes.md(agent 不提交 git,待用户审查)。
## Notes
- **第一依据**:蓝湖 v29 原型 A4 页族(`.scratch/api-docs-reorg/lanhu-tree-v29.md`)决定功能逻辑与字段回显;不追求样式高保真。
@ -36,13 +45,28 @@ Created: 2026-09-11
- [06 缺陷展示设计](issues/06-defect-phenomenon-design.md) — 三档呈现全用:**①渲染即现象**(默认,缺列/恒 null/空态如实渲染)+ **②角标** `data-defect="<id>"` 原生 title 悬浮(只加在易误读处)+ **③顶栏「已知缺陷」面板**(DEFECT_LEDGER 常量纯静态渲染,零网络请求)。面板四分区:A 开放现象 14 条(G1-G6/P3-3/4/5/6/D-G1/I-06 双现象静态注记/公海池只读占位卡 POOL-CFG/工商 stub STUB-LOOKUP)、B 已修复与本次接线 4 条、C 豁免清单 14 项、D 环境观察(F-15)。文案中性纪律(漂移/未实现/环境观察,不写 bug);全部带 data-defect 断言钩子供冒烟。
- [07 逐入口字段对齐清单](issues/07-field-alignment-checklist.md) — 产出 `assets/field-alignment.md`(1091 行两部分:核心域 #1-#49 / 联系人族+重流程+规则族 #31-#69),逐入口请求参数/出参字段/空值"--"与枚举字典口径三表 + .bru/DTO 权威出处;**头部 K1-K11 校准注记覆盖矩阵与 AS 旧口径**(G4 收窄两件、关联项目真端点 CustomerProjectQueryPort·Noop、看板 summary=cnt/groupValue、oplog action 18 种、contact_source 字典真值、focus-batch Void、quick-create DTO 七必填、联系人电话全读路径脱敏 138****1234、transfer status 0/1 等)。收口回写:demo G4 条目文案、冒烟步 13/14/34、票 11 scope 均已按 K 口径校准。
- [08 冒烟步骤表](issues/08-smoke-step-plan.md) — **40 步**(assets/smoke-plan.md):A 核心域 19 + B 重流程 6(含商机联动链 67007 阻断→关闭→放行、交割两段式)+ C 规则族 4 + D 缺陷现象 7(data-defect 钩子断言,FAIL=Demo 缺陷非后端缺陷)+ E 全局 4(console=0/零 PUT-DELETE/端点广度≥50/清扫还原)。4xx 弹窗通道用真实业务拒绝三处验证(67007/67003/64023)。seed:写夹具 e2c-dr-* 用后正门硬删、e2c- 只读、偏好/规则写后 API 正门还原、交割只动夹具无 F-13 负担。报告=demo/smoke-report.json(t04 形态+summary.verdict,全 ok 即 PASS)。
- [12 联系人独立菜单+商机侧入口](issues/12-contacts-opp-entry.md) — nav 第 3 位顶级「联系人」页签:contact/page 真端点(姓名固定+7 可隐藏列,K10 服务端脱敏直显、K6 source 字典驱动)+ contact.list 形态/列偏好双接线(列偏好 preference/get|save visibleKeys 重复键)+ 行点开 contact/detail 弹窗回显(子页签日志空态 G6)+ contact/search 演示入口;商机侧三入口 panel:quick-create(K9 七必填+needConfirm→confirmSimilar=true 重发)、customer/search(三维模糊+excludeCustomerIds 重复键,真请求实证三维度+排除生效)、「从客户发起建商机」dlgOppCreate(POST /api/opportunity 预填 customerId,写操作留冒烟步 23);K6 顺手修正 dlgContact source 硬编码→字典驱动。**环境观察**:共享库存量 source=旧 code 未迁移(sourceName 回退显 code);张关键/e2c seed 不在共享库,冒烟须重建 seed(票 14)。
- [13 重流程+规则族收尾](issues/13-flow-rules-tail.md) — #24 工商联想接线(companyName 参数;stub 恒 `[]` 实证→NO_HIT 现象+STUB-LOOKUP 注记;applyLookup 只回填空字段+回填即 check-credit-code);F6 按钮明示「F6 stub」+钩子;#63 导入任务列表 status/importMode 过滤(真请求过滤命中);#69 POOL-CFG 只读禁用占位卡(readonly+disabled,文案引票 02);P3-3 导入注记+D-G1 批量编辑注记落 UI。至此矩阵改造/新增入口全部出票完成,I-06 由票 10 覆盖。
- [09 缺陷呈现基建](issues/09-defect-infra.md) — DEFECT_LEDGER 常量 + 顶栏「已知缺陷」面板四分区(A 开放现象/B 已修复/C 豁免/D 环境观察)+ `data-defect` 角标钩子落 demo;面板纯静态渲染零网络请求,文案中性(漂移/未实现/环境观察),全部钩子供冒烟断言(票 14 步 31 采样累计 11 类目 87 角标)。
- [10 核心域·列表层](issues/10-core-list.md) — fork 基线 2350→2492 行:workspace/page POST 全参数接线(五视图+savedViewId+pool 无负责人列+进入公海时间列)、列形态/字段口径全按 field-alignment K 注记(星级--口径、枚举字典翻译、脱敏直显)、I-06 双现象静态注记、claim/release/archive/restore/assign 扁平动作接线;快验 pageerror=0。
- [11 核心域·详情层](issues/11-core-detail.md) — demo/index.html → 2597 行:详情头部 4 金额卡恒 "--"〔P3-6〕+ 8 页签(补关联项目 project/page 真端点恒空 Noop)、联系人列表/图谱双视图(编辑态 batchEdit 原子批+quickAdd 查重确认)、跟进/商机/项目/协议/成员/oplog 子页签接线;K1/K2/K4/K5/K10 校准全落;快验 pageerror=0。
- [14 Playwright 冒烟](issues/14-smoke-script.md) — demo/smoke.py 40 步八轮迭代全绿:**第八跑 40/0(176.4s)ALL_PASS=true,GET 147+POST 79 零 PUT/DELETE,console=0,http_fails=0,唯一路径 91**。关键根因与修法:ensure_seeds 必须先 sweep 后自举(否则查重基线被 sweep 清掉);共享库查重模式被步 26 写成 EXACT→2d 幂等归位 FUZZY(2)+save 前 loadDedup 填充 dpMode;旧 seed 联系人职务不在字典→saveListAll「职务必填」拦截 batchEdit→2f 按 dict_item 归位;quickAdd 后显式 loadContacts 重拉;pool+ASSIGNED 视图与公海口径恒互斥(Mapper L83)→冒烟呈现默认视图空现象后清 fViewType;404 project/page=jar 旧于源码→重建 fat jar 重启;**demo onclick 陷阱**(dlgOpen 把 fn 挂 btn.onclick,事件对象作首参)→doEdit/doCreate 首参防御+doEdit 补 L2 needConfirm simbox 分支(六跑「toast 保存成功 DB 没变」假成功实锤)。后端行为只呈现不修。
## Not yet specified
- ~~构建实施拆票~~ **已落票(09-15)**:09 缺陷呈现基建(DEFECT_LEDGER/面板/角标)→ 10 核心域·列表层 → 11 核心域·详情层 → 12 联系人菜单+商机侧入口 → 13 重流程+规则族收尾 → 14 冒烟脚本(40 步)→ 15 报告+双仓清单+收尾。依赖链:10/11/12/13 blocked by 07+09;14 blocked by 10-13;15 blocked by 14。fork 基线已复制(demo/index.html,2268 行含 4 行头注,原行号 +4 平移)。
- ~~双仓未提交变更清单~~ 随 15 号票(格式已定:assets/uncommitted-changes.md,分「本 effort / 前序遗留」两区;摸底:backend 20 java 改+若干 untracked、api-docs 大量 .bru 改,均前序遗留)。
- ~~PASS/FAIL 报告~~ 已随 08 定(demo/smoke-report.json,summary.verdict)。
- ~~构建实施拆票~~ **已落票(09-15)并全部 resolved**:09 缺陷呈现基建 → 10 核心域·列表层 → 11 核心域·详情层 → 12 联系人菜单+商机侧入口 → 13 重流程+规则族收尾 → 14 冒烟脚本(40 步,八轮迭代全绿 40/0)→ 15 报告+双仓清单+收尾。
- ~~双仓未提交变更清单~~ 已随 15 号票落盘:assets/uncommitted-changes.md(backend 全属本 effort;文档仓 E:\code\crm-api-docs 仅 2 个前序迁移遗留目录)。
- ~~PASS/FAIL 报告~~ 已交付:demo/smoke-report.json(第八跑 40/0 ALL_PASS=true)。
- ~~四标准自检~~ 已交付:assets/acceptance-selfcheck.md(四项全达标,map 已关)。
## Out of scope

6136
logs/crm-app.log

File diff suppressed because it is too large

BIN
logs/crm-app.log.2026-09-11.0.gz

Binary file not shown.
Loading…
Cancel
Save