diff --git a/.scratch/customer-demo-ref/HANDOFF-2026-09-12.md b/.scratch/customer-demo-ref/HANDOFF-2026-09-12.md
new file mode 100644
index 0000000..afb354a
--- /dev/null
+++ b/.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`
diff --git a/.scratch/customer-demo-ref/_quickcheck.py b/.scratch/customer-demo-ref/_quickcheck.py
new file mode 100644
index 0000000..ceb8f0d
--- /dev/null
+++ b/.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))
diff --git a/.scratch/customer-demo-ref/_quickcheck12.py b/.scratch/customer-demo-ref/_quickcheck12.py
new file mode 100644
index 0000000..f781ccd
--- /dev/null
+++ b/.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))
diff --git a/.scratch/customer-demo-ref/_quickcheck12b.py b/.scratch/customer-demo-ref/_quickcheck12b.py
new file mode 100644
index 0000000..1bad020
--- /dev/null
+++ b/.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))
diff --git a/.scratch/customer-demo-ref/_quickcheck12c.py b/.scratch/customer-demo-ref/_quickcheck12c.py
new file mode 100644
index 0000000..3e6f3d6
--- /dev/null
+++ b/.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¤t=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))
diff --git a/.scratch/customer-demo-ref/_quickcheck12d.py b/.scratch/customer-demo-ref/_quickcheck12d.py
new file mode 100644
index 0000000..6978e29
--- /dev/null
+++ b/.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))
diff --git a/.scratch/customer-demo-ref/_quickcheck13.py b/.scratch/customer-demo-ref/_quickcheck13.py
new file mode 100644
index 0000000..33d5ea6
--- /dev/null
+++ b/.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))
diff --git a/.scratch/customer-demo-ref/_scan.py b/.scratch/customer-demo-ref/_scan.py
new file mode 100644
index 0000000..2162388
--- /dev/null
+++ b/.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())
diff --git a/.scratch/customer-demo-ref/_t12scan.py b/.scratch/customer-demo-ref/_t12scan.py
new file mode 100644
index 0000000..a7768c9
--- /dev/null
+++ b/.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+)| 用途:`.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 = {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`,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¤t=1&size=1&keyword=恒信达`
-- 出参(`List`,数组元素两字段):
-
-| 字段路径 | 类型 | 口径/文案 | 示例 |
-|---|---|---|---|
-| [].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¤t=1&size=20&keyword=恒信达`
-- 出参:`PageResult`——**与列表 #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`,按 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) | 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`,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`,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`,行 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`,行 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` 跟进 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`,行 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`,**契约 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`(页签卡片,时间倒序**非分页**;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`,**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}`(**重复键** `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 的漂移证据)
diff --git a/.scratch/customer-demo-ref/assets/acceptance-selfcheck.md b/.scratch/customer-demo-ref/assets/acceptance-selfcheck.md
new file mode 100644
index 0000000..59e366e
--- /dev/null
+++ b/.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 幂等自举解决)。
diff --git a/.scratch/customer-demo-ref/assets/smoke-plan.md b/.scratch/customer-demo-ref/assets/smoke-plan.md
index aa1b652..02ec0da 100644
--- a/.scratch/customer-demo-ref/assets/smoke-plan.md
+++ b/.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 |
diff --git a/.scratch/customer-demo-ref/assets/uncommitted-changes.md b/.scratch/customer-demo-ref/assets/uncommitted-changes.md
new file mode 100644
index 0000000..45746d4
--- /dev/null
+++ b/.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 目录,其余变更用户已在收官后提交。
diff --git a/.scratch/customer-demo-ref/demo/__pycache__/_t14d7.cpython-313.pyc b/.scratch/customer-demo-ref/demo/__pycache__/_t14d7.cpython-313.pyc
new file mode 100644
index 0000000..6cff004
Binary files /dev/null and b/.scratch/customer-demo-ref/demo/__pycache__/_t14d7.cpython-313.pyc differ
diff --git a/.scratch/customer-demo-ref/demo/__pycache__/smoke.cpython-313.pyc b/.scratch/customer-demo-ref/demo/__pycache__/smoke.cpython-313.pyc
new file mode 100644
index 0000000..def6125
Binary files /dev/null and b/.scratch/customer-demo-ref/demo/__pycache__/smoke.cpython-313.pyc differ
diff --git a/.scratch/customer-demo-ref/demo/_jarbuild.log b/.scratch/customer-demo-ref/demo/_jarbuild.log
new file mode 100644
index 0000000..00bd539
--- /dev/null
+++ b/.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] ------------------------------------------------------------------------
diff --git a/.scratch/customer-demo-ref/demo/_run7.log b/.scratch/customer-demo-ref/demo/_run7.log
new file mode 100644
index 0000000..28baf67
--- /dev/null
+++ b/.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
diff --git a/.scratch/customer-demo-ref/demo/_run8.log b/.scratch/customer-demo-ref/demo/_run8.log
new file mode 100644
index 0000000..ac45f48
--- /dev/null
+++ b/.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
diff --git a/.scratch/customer-demo-ref/demo/_server.log b/.scratch/customer-demo-ref/demo/_server.log
new file mode 100644
index 0000000..e69de29
diff --git a/.scratch/customer-demo-ref/demo/_t14api.py b/.scratch/customer-demo-ref/demo/_t14api.py
new file mode 100644
index 0000000..a7e6bf3
--- /dev/null
+++ b/.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))
diff --git a/.scratch/customer-demo-ref/demo/_t14cols.py b/.scratch/customer-demo-ref/demo/_t14cols.py
new file mode 100644
index 0000000..9f9ee3b
--- /dev/null
+++ b/.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()
diff --git a/.scratch/customer-demo-ref/demo/_t14d7.py b/.scratch/customer-demo-ref/demo/_t14d7.py
new file mode 100644
index 0000000..58b6521
--- /dev/null
+++ b/.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(" (已还原名字)")
diff --git a/.scratch/customer-demo-ref/demo/_t14db.py b/.scratch/customer-demo-ref/demo/_t14db.py
new file mode 100644
index 0000000..1a668a7
--- /dev/null
+++ b/.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()
diff --git a/.scratch/customer-demo-ref/demo/_t14diag.py b/.scratch/customer-demo-ref/demo/_t14diag.py
new file mode 100644
index 0000000..4fd03de
--- /dev/null
+++ b/.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)
diff --git a/.scratch/customer-demo-ref/demo/_t14org.py b/.scratch/customer-demo-ref/demo/_t14org.py
new file mode 100644
index 0000000..84f82fb
--- /dev/null
+++ b/.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()
diff --git a/.scratch/customer-demo-ref/demo/_t14org2.py b/.scratch/customer-demo-ref/demo/_t14org2.py
new file mode 100644
index 0000000..c2c4266
--- /dev/null
+++ b/.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()
diff --git a/.scratch/customer-demo-ref/demo/_t14org3.py b/.scratch/customer-demo-ref/demo/_t14org3.py
new file mode 100644
index 0000000..82f42b9
--- /dev/null
+++ b/.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()
diff --git a/.scratch/customer-demo-ref/demo/_t14scan.py b/.scratch/customer-demo-ref/demo/_t14scan.py
new file mode 100644
index 0000000..2c33876
--- /dev/null
+++ b/.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}")
diff --git a/.scratch/customer-demo-ref/demo/index.html b/.scratch/customer-demo-ref/demo/index.html
index feec12d..cabeb1e 100644
--- a/.scratch/customer-demo-ref/demo/index.html
+++ b/.scratch/customer-demo-ref/demo/index.html
@@ -177,6 +177,7 @@ tr.err-row td { background:#fef2f2 !important; }
@@ -232,6 +279,7 @@ tr.err-row td { background:#fef2f2 !important; }
+
@@ -248,6 +296,7 @@ tr.err-row td { background:#fef2f2 !important; }
+ D-G1:空值提交=不修改(清空语义豁免)
@@ -257,6 +306,7 @@ tr.err-row td { background:#fef2f2 !important; }
+ ⚠G5
@@ -296,6 +346,26 @@ tr.err-row td { background:#fef2f2 !important; }
+
+
+
两段式导入D-05 / D-04
@@ -314,8 +384,17 @@ tr.err-row td { background:#fef2f2 !important; }
上传后生成 DRAFT 任务并返回行级预检计数(响应回显 duplicateStrategy);确认后异步执行,任务列表可轮询结果/失败明细。67013=预检失败(mode/策略非法/表头/空文件/行数),67014=状态机不允许(重复 confirm)。F8:duplicateStrategy 仅作用文件内重复组,不触名称相似 SUSPECT 判定。
+ P3-3:原型「触发校验规则后确认执行」复选框为幻影控件——实现语义=确认即执行,上传预检已含规则校验
- 导入任务(仅本人可见,creator 过滤)
+
导入任务(仅本人可见,creator 过滤;#63 状态/模式过滤已接 ImportPageParam)
+
+
+
+
| 任务ID | 模式 | 状态 | 总数 | 新增 | 更新 | 未变化 | 失败 | 疑似 | 失败原因 | 提交时间 | 操作 |
@@ -377,6 +456,14 @@ tr.err-row td { background:#fef2f2 !important; }
+
+
客户公海池配置 POOL-CFG(未实现 · 只读占位)
+
+
查重试玩(消费侧试玩 · 配置走上方查重设置单例;信用代码恒开无配置)
@@ -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('') : '
| 无数据 |
';
+ paintHead();
+ $('listBody').innerHTML = S.rows.length ? S.rows.map(rowHtml).join('') : '
| 无数据 |
';
$('pageInfo').textContent = '第 ' + S.page + ' 页 / 共 ' + S.total + ' 条';
$('listHint').textContent = '';
paintPick();
@@ -573,34 +688,158 @@ async function loadList(dir) {
}
function badge(txt, cls) { return '
' + esc(txt) + ''; }
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 = '
| ' + listCols().map(h => '' + h + ' | ').join('') + '
|---|
';
+}
function rowHtml(r) {
const ck = S.picked.has(String(r.id)) ? ' checked' : '';
- return '
' +
- ' | ' +
- '' + (Number(r.focusFlag) ? '★' : '☆') + ' | ' +
- '' + esc(r.customerName) + ' | ' +
- '' + esc(r.customerNo) + ' | ' +
- '' + badge(STAGE[r.customerStage] || r.customerStage, r.customerStage === 3 ? 'b2' : 'b1') + ' | ' +
- '' + star(r.customerStarLevel) + ' | ' +
- '' + esc(r.ownerNameSnapshot || r.ownerUserNameSnapshot || r.ownerUserId) + ' | ' +
- '' + esc(r.ownerDeptNameSnapshot || '--') + ' | ' +
- '' + (r.opportunityCount == null ? '--' : r.opportunityCount) + ' | ' +
- '-- | ' +
- '' + esc(r.lastValidFollowTime || '--') + ' | ' +
- ' |
';
+ const nameClick = (S.form === 'split' ? 'splitPick' : 'openDetail') + '(\'' + r.id + '\')';
+ const c = [];
+ c.push('
| ');
+ c.push('
' + (Number(r.focusFlag) ? '★' : '☆') + ' | ');
+ c.push('
' + esc(r.customerName) + ' | ');
+ c.push('
' + esc(r.customerNo) + ' | ');
+ c.push('
' + badge(STAGE[r.customerStage] || r.customerStage, r.customerStage === 3 ? 'b2' : 'b1') + ' | ');
+ c.push('
' + star(r.customerStarLevel) + ' | ');
+ c.push('
' + esc(typeName(r.customerType)) + ' | ');
+ c.push('
' + esc(industryName(r.industryCode)) + ' | ');
+ c.push('
' + (r.strategicAgreementLevel == null ? '--' : esc(r.strategicAgreementLevel)) + ' | ');
+ c.push('
' + (r.vipCustomerLevel == null ? '--' : esc(r.vipCustomerLevel)) + ' | ');
+ if (S.ws === 'pool') c.push('
' + esc(r.enterPoolTime || '--') + ' | ');
+ else {
+ c.push('
' + esc(r.ownerNameSnapshot || r.ownerUserNameSnapshot || '--') + ' | ');
+ c.push('
' + esc(r.ownerDeptNameSnapshot || '--') + ' | ');
+ }
+ c.push('
' + (r.opportunityCount == null ? '--' : esc(r.opportunityCount)) + ' | ');
+ c.push('
' + (r.projectCount == null ? '--' : esc(r.projectCount)) + ' | '); // K5:三方漂移收敛=按实返回,null 兜底 "--"
+ c.push('
' + esc(r.lastValidFollowTime || '--') + ' | ');
+ c.push('
| ');
+ return '
' + c.join('') + '
';
}
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 = '
' +
+ S.savedViews.map(v => '
').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 = '
' + (S.ws === 'pool' ? '' : '
');
+}
+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 '
' +
+ '' + esc(r.customerName || '--') + ' ' + esc(r.customerNo || '--') + '
' +
+ '' + esc(STAGE[r.customerStage] || '--') + ' · ' + star(r.customerStarLevel) + ' · ' + esc(r.ownerNameSnapshot || r.ownerUserNameSnapshot || '无主') + '
';
+}
+async function loadBoard() {
+ const box = $('boardRow');
+ if (!TOKEN()) { box.innerHTML = '
请先登录'; return; }
+ box.innerHTML = '
看板加载中...';
+ 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 => '
' + esc(it.count != null ? it.count : '--') + '' + esc(it.name || it.key || '') + '
').join('') : '
空';
- } 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 ? '
加载失败: ' + esc(d.err.message) + '
'
+ : (rows.length ? rows.map(cardHtml).join('') : '
空列
');
+ return '
' +
+ '
' + BOARD_GROUP[col][gv] + ' ' + esc(map[String(gv)] != null ? map[String(gv)] : '0') + '' +
+ '
' + cards + '
';
+ }).join('');
+ } catch (e) { box.innerHTML = '
看板加载失败: ' + esc(e.message) + codeSuffix(e) + ''; }
+}
+
+/* ---- 分屏形态占位(票 10;卡片=列表行子集) ---- */
+function splitPick(id) {
+ const r = S.rows.find(x => String(x.id) === String(id));
+ if (!r) return;
+ $('splitCard').innerHTML = '
' + esc(r.customerName || '--') + ' ' + esc(r.customerNo || '--') + '' +
+ '
' +
+ '
阶段' + esc(STAGE[r.customerStage] || '--') + '
' +
+ '
星级' + star(r.customerStarLevel) + '
' +
+ '
类型' + esc(typeName(r.customerType)) + '
' +
+ '
行业' + esc(industryName(r.industryCode)) + '
' +
+ '
负责人' + esc(r.ownerNameSnapshot || r.ownerUserNameSnapshot || '--') + '
' +
+ '
最近跟进' + esc(r.lastValidFollowTime || '--') + '
' +
+ '
「详情」按钮进入完整详情页。
';
}
/* ================= 联系人图谱/页签增强(票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 确认后重发)', `
+
@@ -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 = '
companyName 必填
'; return; }
try {
+ const arr = await api('/api/customer/company-lookup?companyName=' + encodeURIComponent(nm));
+ const list = Array.isArray(arr) ? arr : [];
+ if (!list.length) {
+ $('cOut').innerHTML = '
未查询到匹配企业(一期企查查 port 为 stub 恒 NO_HIT,如实呈现;可手工录入,真实源接入后自动激活)。
';
+ return;
+ }
+ window._lookupHits = list;
+ $('cOut').innerHTML = '
命中 ' + list.length + ' 条(点「采用」只回填当前为空的字段,已填不覆盖):
' +
+ list.map((c, i) => '
' + esc(c.bizName || ('候选' + (i + 1))) + '' +
+ esc(c.unifiedCreditCode || '--') + ' / 法人=' + esc(c.legalRepresentative || '--') + ' / ' + esc(c.establishedDate || '--') +
+ '
').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 = '
已回填空字段;check-credit-code(回填即查重):' + esc(JSON.stringify(hit)) + '
';
+ 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 = '
相似命中 ' + hits.length + ' 条:
' + hits.map(h =>
+ '- ' + esc(h.customerName) + '(负责人=' + esc(h.ownerName) + ')
').join('') +
+ '
仍要保存?
';
+ 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('分配给销售', '
' +
+ const ids = pickedIds();
+ if (!ids.length) return toast('请先勾选客户行(支持整批分配)', 'err');
+ dlgOpen('分配给销售(整批 ' + ids.length + ' 条 · assign-batch)', '
' +
'
',
[{ 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 = '
总 ' + esc(r && r.total != null ? r.total : ids.length) + ' · 成功 ' + esc(r && r.successCount != null ? r.successCount : '-') + ' · 失败 ' + esc(r && r.failCount != null ? r.failCount : '-') + '
' +
+ (fails.length ? tableHtml(['客户 id', '原因', '说明'], fails.map(f => [esc(f.id || '--'), esc(f.reason || '--'), '
' + esc(f.message || '--') + ''])) : '');
+ loadList();
+ } catch (e) { $('asOut').innerHTML = errHtml(e) + '
目标人员校验失败=整批拒绝(assign-batch 预检语义)。
'; 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 显 --)
' +
+ '|项目 ' + (d.projectCount == null ? '--' : d.projectCount) + '
' +
+ '金额汇总' + dbadge('P3-6') +
+ '(4 卡装配未接 seam,契约恒 null 显 "--",不显 0)
' +
+ '' +
+ '
已中标项目总额' + esc(d.wonProjectAmount == null ? '--' : d.wonProjectAmount) + '
' +
+ '
跟进中方案预估' + esc(d.planEstimateAmount == null ? '--' : d.planEstimateAmount) + '
' +
+ '
进行中商机总额' + esc(d.ongoingOpportunityAmount == null ? '--' : d.ongoingOpportunityAmount) + '
' +
+ '
合同/回款' + esc(d.contractAmount == null ? '--' : d.contractAmount) + ' / ' + esc(d.paidAmount == null ? '--' : d.paidAmount) + '
' +
+ '
' +
'' +
'
' +
'
' +
@@ -1182,15 +1482,35 @@ async function switchSub(s) {
try {
if (s === 'info') {
const d = await api('/api/customer/detail?id=' + D.id);
- box.innerHTML = '
' + grid(d) + '
';
+ box.innerHTML = '
客户信息(系统信息区:创建/更新时间经 BaseDTO 实测有值;创建人/更新人不暴露,恒 "--")' + dbadge('G4') + '
' +
+ '
' + grid(d) + '
';
} else if (s === 'follow') {
- const d = await api('/api/customer/follow/page?id=' + D.id + '¤t=1&size=50');
- box.innerHTML = followHtml((d && d.content) || []);
+ box.innerHTML = '
' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' + dbadge('G1') +
+ '(原型有「操作人」筛选;FollowPageParam 无此参数,豁免不渲染)
' +
+ '
加载中...
';
+ 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 || '--')])) : '
无关联商机(port 反查,失败前端显 --)
';
+ box.innerHTML = '
关联商机(契约 7 字段真名渲染;原型期望 13 列,缺列不补;port 失败/无数据显空态)' + dbadge('G2') + '
' +
+ 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 + '¤t=1&size=50');
+ const rows = (d && d.content) || [];
+ box.innerHTML = '
关联项目(真端点 CustomerProjectQueryPort 反查;当前后端为 Noop 实现 → 数据空态显 "--",K2 口径非硬占位)
' +
+ 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 + '¤t=1&size=50');
- box.innerHTML = oplogHtml((d && d.content) || []);
+ box.innerHTML = '
' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '(action 权威 18 种含 GRAPH_EDIT;AS §3「17 种」未同步——K4)
' +
+ '
加载中...
';
+ 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 '
' + p[0] + '' + esc(v === undefined ? '--' : v) + '
';
@@ -1224,16 +1553,19 @@ function tableHtml(heads, rows) {
'
| 无数据 |
') + '';
}
/* ---- 联系人:收编为 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 = '
' + SOURCE_DICT.map(s =>
+ '
').join('');
dlgOpen(cid ? '编辑联系人' : '新建联系人(67008=入参非法/不属于该客户)', `
-
+
-
+
`,
[{ 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('
' + esc(r.name) + ' | ');
+ 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('
' + v + ' | ');
+ }
+ c.push('
| ');
+ return '
' + c.join('') + '
';
+}
+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 = '
| 姓名 | ' + ctCols().map(c => '' + c.t + ' | ').join('') + '操作 |
|---|
';
+ $('ctPageBody').innerHTML = CT.rows.length ? CT.rows.map(ctRowHtml).join('') : '
| 无数据 |
';
+ $('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 = '
';
+ $('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 重复键)', `
+
姓名列固定不可隐藏(对齐客户侧「客户名称固定列」口径);全不勾=保存空集(后读回落全列显)。
+
+
` + CT_COLS.map(c =>
+ '').join('') + `
+
`,
+ [{ 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 脱敏形态提交=不改号)', `
+
+
+
+
+
${ctInfoHtml(d)}
+
+
G6(豁免记档):联系人维度操作日志无数据源——原型有页签,后端无联系人级 oplog 端点(07 票定稿口径),空态展示。
+
`, []);
+ } catch (e) { dlgOpen('联系人详情', errHtml(e), []); }
+}
+function ctInfoHtml(d) {
+ const kv = (t, v) => '
' + t + '' + (v == null || v === '' ? '--' : esc(v)) + '
';
+ 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=)', `
+
+
+
场景文案:商机表单「关键联系人」数据源——姓名/电话模糊 + 带出所属公司名;电话服务端脱敏(K10);客户可见性收口。
+
`,
+ [{ text: '搜索', ok: true, fn: async () => {
+ try {
+ const kw = formVal('csKw');
+ if (!kw) { $('csOut').innerHTML = '
keyword 必填
'; 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=当前用户)', `
+
+
+
+
+
+
+
confirmSimilar 默认 false(命中相似先回 needConfirm 列表,确认后 true 重发);查重三层与编号生成复用主表 create;similarHits 行级 masked(无权命中不透出业务字段);错误 67001/67003。
+
`,
+ [{ 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 = '
命中相似客户 ' + hits.length + ' 条:
' + hits.map(h =>
+ '- ' + esc(h.customerName || '存在匹配记录') + '(负责人=' + esc(h.ownerName || '--') + ',' + esc(STAGE[h.customerStage] || h.customerStage || '') + ',' + (h.archiveStatus === 2 ? '已归档' : '有效') + ')
').join('') +
+ '
仍要创建?
';
+ 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 名称/联系人/电话三维模糊)', `
+
+
+
+
+
轻量 9 字段出参;选中后走 GET /api/customer/detail?id= 带出全量;DataScope 注入可见范围。
+
`,
+ [{ 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 = '
共 ' + ((d && d.total) || 0) + ' 条
' +
+ 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,客户详情「关联商机」页签立即可见)', `
+
+
+
+
+
+
+
+
+
核心 6 必填缺一报 66001;直接新建路径不做客户存在性/归档校验(区别于商机详情挂客户 requireLive)。
+
`,
+ [{ 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 '
暂无跟进记录
';
+ if (!rows.length) return '
暂无跟进记录
';
const days = {};
rows.forEach(r => { const day = String(r.followTime || r.createTime || '').slice(0, 10) || '未知日期'; (days[day] = days[day] || []).push(r); });
- return '
' +
- Object.keys(days).sort().reverse().map(day =>
- '
' + esc(day) + '
' + days[day].map(r =>
- '
' + esc(r.followWayName || r.followWay || '--') + ' ' + esc(r.createByName || r.createBy || r.operatorName || '') +
- '
' + esc(r.followContent || '') + '
下次跟进:' + esc(r.nextFollowTime || '--') + '
').join('')).join('');
+ return Object.keys(days).sort().reverse().map(day =>
+ '
' + esc(day) + '
' + days[day].map(r =>
+ '
' + esc(FOLLOW_WAYS[r.followWay] || r.followWay || '--') + ' ' + esc(r.followByName || r.followBy || '') +
+ '
' + esc(r.followContent || '') + '
下次跟进:' + esc(r.nextFollowTime || '--') + '
').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) ? '--' : '
']));
- return '
' + t;
+ return '
' + dbadge('G3') +
+ '(原型另有职务/加入时间两列;CustomerMemberDTO 未暴露,缺列不补)
' + t;
}
function dlgMemberAdd() {
dlgOpen('添加协同人(两阶段校验整批拒绝 67016)', '
',
@@ -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 '
暂无日志
';
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 =>
'
' + esc(day) + '
' + days[day].map(r =>
- '
' + esc(r.createTime || '') + ' ' + badge(r.action, 'b1') + ' ' + esc(r.operatorName || r.operator || '') + ':' + esc(r.detail || '') + '
').join('')).join('');
+ '
' + esc(r.createTime || '') + ' ' + badge(OPLOG_ACTIONS[r.action] || r.action, 'b1') + ' ' + esc(r.operatorName || r.operator || '') + ':' + esc(r.detail || '') + '
').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 = '
| 请先登录 |
'; 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 => '
' +
'| ' + esc(t.taskId) + ' | ' + esc(t.importMode || '--') + ' | ' + 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);
})();
diff --git a/.scratch/customer-demo-ref/demo/shots/s01-list.png b/.scratch/customer-demo-ref/demo/shots/s01-list.png
new file mode 100644
index 0000000..a307c1f
Binary files /dev/null and b/.scratch/customer-demo-ref/demo/shots/s01-list.png differ
diff --git a/.scratch/customer-demo-ref/demo/shots/s02-pool-i06.png b/.scratch/customer-demo-ref/demo/shots/s02-pool-i06.png
new file mode 100644
index 0000000..74f9e00
Binary files /dev/null and b/.scratch/customer-demo-ref/demo/shots/s02-pool-i06.png differ
diff --git a/.scratch/customer-demo-ref/demo/shots/s07-board.png b/.scratch/customer-demo-ref/demo/shots/s07-board.png
new file mode 100644
index 0000000..03eceb5
Binary files /dev/null and b/.scratch/customer-demo-ref/demo/shots/s07-board.png differ
diff --git a/.scratch/customer-demo-ref/demo/shots/s11-head.png b/.scratch/customer-demo-ref/demo/shots/s11-head.png
new file mode 100644
index 0000000..065d03f
Binary files /dev/null and b/.scratch/customer-demo-ref/demo/shots/s11-head.png differ
diff --git a/.scratch/customer-demo-ref/demo/shots/s19-oplog.png b/.scratch/customer-demo-ref/demo/shots/s19-oplog.png
new file mode 100644
index 0000000..4bf2698
Binary files /dev/null and b/.scratch/customer-demo-ref/demo/shots/s19-oplog.png differ
diff --git a/.scratch/customer-demo-ref/demo/shots/s20-import.png b/.scratch/customer-demo-ref/demo/shots/s20-import.png
new file mode 100644
index 0000000..701d0ac
Binary files /dev/null and b/.scratch/customer-demo-ref/demo/shots/s20-import.png differ
diff --git a/.scratch/customer-demo-ref/demo/shots/s24-transfer.png b/.scratch/customer-demo-ref/demo/shots/s24-transfer.png
new file mode 100644
index 0000000..d2816b3
Binary files /dev/null and b/.scratch/customer-demo-ref/demo/shots/s24-transfer.png differ
diff --git a/.scratch/customer-demo-ref/demo/shots/s30-ledger.png b/.scratch/customer-demo-ref/demo/shots/s30-ledger.png
new file mode 100644
index 0000000..d76f696
Binary files /dev/null and b/.scratch/customer-demo-ref/demo/shots/s30-ledger.png differ
diff --git a/.scratch/customer-demo-ref/demo/shots/s40-final.png b/.scratch/customer-demo-ref/demo/shots/s40-final.png
new file mode 100644
index 0000000..918078c
Binary files /dev/null and b/.scratch/customer-demo-ref/demo/shots/s40-final.png differ
diff --git a/.scratch/customer-demo-ref/demo/shots/t10-quick-pool.png b/.scratch/customer-demo-ref/demo/shots/t10-quick-pool.png
new file mode 100644
index 0000000..ff2c40e
Binary files /dev/null and b/.scratch/customer-demo-ref/demo/shots/t10-quick-pool.png differ
diff --git a/.scratch/customer-demo-ref/demo/shots/t11-quick-detail.png b/.scratch/customer-demo-ref/demo/shots/t11-quick-detail.png
new file mode 100644
index 0000000..980370e
Binary files /dev/null and b/.scratch/customer-demo-ref/demo/shots/t11-quick-detail.png differ
diff --git a/.scratch/customer-demo-ref/demo/shots/t12-quick-contacts.png b/.scratch/customer-demo-ref/demo/shots/t12-quick-contacts.png
new file mode 100644
index 0000000..c97b27b
Binary files /dev/null and b/.scratch/customer-demo-ref/demo/shots/t12-quick-contacts.png differ
diff --git a/.scratch/customer-demo-ref/demo/shots/t13-quick-settings.png b/.scratch/customer-demo-ref/demo/shots/t13-quick-settings.png
new file mode 100644
index 0000000..f008527
Binary files /dev/null and b/.scratch/customer-demo-ref/demo/shots/t13-quick-settings.png differ
diff --git a/.scratch/customer-demo-ref/demo/smoke-report.json b/.scratch/customer-demo-ref/demo/smoke-report.json
new file mode 100644
index 0000000..c1c7349
--- /dev/null
+++ b/.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¤t=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¤t=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¤t=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¤t=1&size=10&viewType=ASSIGNED&archiveStatus=1",
+ "POST /api/customer/workspace/page # workspace=mine¤t=1&size=10&viewType=FOLLOW_UP_DUE&archiveStatus=1",
+ "POST /api/customer/workspace/page # workspace=mine¤t=1&size=10&viewType=ASSIGNED&archiveStatus=1",
+ "POST /api/customer/workspace/page # workspace=mine¤t=1&size=10&viewType=ASSIGNED&archiveStatus=1&customerStage=3",
+ "POST /api/customer/workspace/page # workspace=mine¤t=1&size=10&viewType=ASSIGNED&archiveStatus=1",
+ "POST /api/customer/workspace/page # workspace=mine¤t=1&size=10&viewType=ASSIGNED&archiveStatus=2",
+ "POST /api/customer/workspace/page # workspace=mine¤t=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¤t=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¤t=1&size=10&viewType=ASSIGNED&keyword=e2c&archiveStatus=1&savedViewId=76ac031d780e41558ceaf438ffd0b163",
+ "POST /api/customer/workspace/page # workspace=mine¤t=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¤t=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¤t=1&size=20",
+ "POST /api/customer/workspace/board/cards # workspace=overview&viewType=ASSIGNED&groupColumn=stage&groupValue=2¤t=1&size=20",
+ "POST /api/customer/workspace/board/cards # workspace=overview&viewType=ASSIGNED&groupColumn=stage&groupValue=3¤t=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¤t=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¤t=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¤t=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¤t=1&size=50",
+ "GET /api/customer/opportunity/page?id=753771843382411264",
+ "GET /api/customer/project/page?id=753771843382411264¤t=1&size=50",
+ "GET /api/customer/agreement/list?customerId=753771843382411264",
+ "GET /api/customer/member/list?id=753771843382411264",
+ "GET /api/customer/oplog/page?id=753771843382411264¤t=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¤t=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¤t=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¤t=1&size=50",
+ "GET /api/customer/detail-head?id=750844477165273088",
+ "GET /api/customer/follow/page?id=750844477165273088&followWay=follow_way_01¤t=1&size=50",
+ "POST /api/customer/focus?id=750844477165273088",
+ "POST /api/customer/workspace/page # workspace=mine¤t=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¤t=1&size=10&viewType=ASSIGNED&keyword=e2c&archiveStatus=1",
+ "GET /api/customer/detail-head?id=750844477165273088",
+ "GET /api/customer/oplog/page?id=750844477165273088¤t=1&size=50",
+ "GET /api/customer/oplog/page?id=750844477165273088&action=FOLLOW¤t=1&size=50",
+ "GET /api/customer/oplog/page?id=750844477165273088&action=CREATE¤t=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¤t=1&size=10&viewType=ASSIGNED&keyword=e2c&archiveStatus=1",
+ "POST /api/customer/workspace/page # workspace=mine¤t=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¤t=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¤t=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¤t=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¤t=1&size=10&viewType=ASSIGNED&keyword=e2c-dr-imp&archiveStatus=1",
+ "POST /api/customer/workspace/page # workspace=pool¤t=1&size=10&keyword=e2c-dr-imp&archiveStatus=1",
+ "POST /api/customer/claim-batch?ids=753772080058597376",
+ "POST /api/customer/workspace/page # workspace=pool¤t=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¤t=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¤t=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¤t=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¤t=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¤t=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¤t=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¤t=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¤t=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¤t=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¤t=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"
+ ]
+}
\ No newline at end of file
diff --git a/.scratch/customer-demo-ref/demo/smoke.py b/.scratch/customer-demo-ref/demo/smoke.py
new file mode 100644
index 0000000..14da713
--- /dev/null
+++ b/.scratch/customer-demo-ref/demo/smoke.py
@@ -0,0 +1,1099 @@
+# -*- coding: utf-8 -*-
+"""customer-demo-ref 40 步冒烟(票 14;骨架照搬 t04 + contact-graph 冒烟)
+
+被测:demo/index.html?api=http://localhost:8080(本 effort fork)
+前置:SPRING_PROFILES_ACTIVE=verify CRM_MINIO_AK=admin CRM_MINIO_SK=Itc@123456 java -jar crm-app/target/crm-app-1.0.0-SNAPSHOT.jar
+运行:py -X utf8 .scratch/customer-demo-ref/demo/smoke.py
+认证:page.route 反代 http://localhost:8080/**(剥 origin/host/authorization + 注 Bearer debug token + 补 CORS)
+纪律:只测不修;写夹具 e2c-dr-* 用后正门硬删;偏好/规则写后 API 还原;零 PUT/DELETE。
+"""
+import io, os, re, sys, json, time, urllib.request, urllib.parse, urllib.error
+sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', line_buffering=True)
+import pymysql
+from playwright.sync_api import sync_playwright
+
+ROOT = r"e:\code\crm-backend-matt\.scratch\customer-demo-ref"
+DEMO = os.path.join(ROOT, "demo")
+SHOTS = os.path.join(DEMO, "shots"); os.makedirs(SHOTS, exist_ok=True)
+URL = 'file:///' + os.path.join(DEMO, 'index.html').replace('\\', '/') + '?api=http://localhost:8080'
+BASE = "http://localhost:8080"
+ADMIN = "739564171091247104" # 罗伟健 admin
+BUDDY = "744842318024015872" # 曾偲青(协同样例)
+PFX = "e2c-dr"
+TS = time.strftime("%H%M%S")
+
+steps, api_urls, console_errors, http_fails = [], [], [], []
+
+def step(name, ok, note=""):
+ steps.append({"step": name, "ok": bool(ok), "note": str(note)[:400]})
+ print(("PASS " if ok else "FAIL ") + name + (" | " + str(note)[:300] if note else ""))
+
+def shot(pg, name):
+ try: pg.screenshot(path=os.path.join(SHOTS, name), full_page=False)
+ except Exception: pass
+
+# ---------------- HTTP 直调(夹具/取证不走浏览器;请求同样记入 api_urls 供步 39 端点矩阵覆盖) ----------------
+def post(url, data, token=None, raw=False):
+ api_urls.append("POST " + url.split(BASE)[-1])
+ 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:
+ b = r.read().decode()
+ return b if raw else json.loads(b)
+
+def get_json(url, token=None, raw=False):
+ if url.startswith(BASE):
+ api_urls.append("GET " + url.split(BASE)[-1])
+ req = urllib.request.Request(url)
+ if token: req.add_header("Authorization", "Bearer " + token)
+ with urllib.request.urlopen(req, timeout=30) as r:
+ b = r.read().decode()
+ return b if raw else json.loads(b)
+
+def get_token(uid, tries=8):
+ for _ in range(tries):
+ try:
+ d = get_json(f"{BASE}/api/auth/debug/token?userId={uid}").get("data")
+ if d: return d if isinstance(d, str) else d.get("token")
+ except Exception: pass
+ time.sleep(2)
+ raise RuntimeError("debug/token 重试后仍失败(服务未起?)")
+
+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 dbq(sql, args=None):
+ with db() as conn, conn.cursor() as cur:
+ cur.execute(sql, args); return cur.fetchall()
+
+def dbx(sql, args=None):
+ with db() as conn, conn.cursor() as cur:
+ cur.execute(sql, args); return cur.rowcount
+
+def dict_one(group, token):
+ try:
+ d = get_json(f"{BASE}/api/dict/item/enabled-list?groupCode={group}", token).get("data") or []
+ return (d[0] or {}).get("code") if d else None
+ except Exception: return None
+
+def mk_customer(name, token, ctype, gov, owner=ADMIN, credit=None):
+ form = {"customerName": name, "customerType": ctype,
+ "provinceCode": "440000", "cityCode": "440100", "districtCode": "440103",
+ "industryCode": gov, "customerStarLevel": 3, "relationStarLevel": 3,
+ "isBizNegotiated": 0, "isChild": 0, "ownerUserId": owner}
+ if credit: form["unifiedCreditCode"] = credit
+ r = post(f"{BASE}/api/customer/create", form, token)
+ d = r.get("data") or {}
+ if isinstance(d, dict) and d.get("needConfirm"):
+ r = post(f"{BASE}/api/customer/create", dict(form, confirmSimilar="true"), token)
+ d = r.get("data") or {}
+ if not (isinstance(d, dict) and d.get("id")):
+ raise RuntimeError(f"夹具客户创建失败 {name}: {str(r)[:160]}")
+ return str(d["id"])
+
+def quick_add(cid, rows, token):
+ form = {"customerId": str(cid)}
+ for i, row in enumerate(rows):
+ for k, v in row.items():
+ form[f"rows[{i}].{k}"] = str(v)
+ r = post(f"{BASE}/api/customer/contact/quickAdd", form, token)
+ d = r.get("data") or {}
+ if d.get("failedRows"):
+ print(" [warn] quickAdd 失败行:", json.dumps(d["failedRows"], ensure_ascii=False)[:200])
+ return r
+
+def job_code_first(token):
+ """job_title 两级树(/api/dict/item/tree)取首个二级 code(quickAdd/导入模板行都吃字典编码)。"""
+ try:
+ tree = get_json(f"{BASE}/api/dict/item/tree?groupCode=job_title", token).get("data") or []
+ for g in tree:
+ for c in (g.get("children") or []):
+ if c.get("code"): return c["code"]
+ except Exception: pass
+ return None
+
+# ---------------- seed 自举(共享库 e2c- 种子缺失时幂等补建;票 12 实测远程库无 e2c- seed) ----------------
+SEEDS = {}
+
+def ensure_seeds(tok, ctype, gov, jobc):
+ def find(name):
+ rows = dbq("SELECT id, archive_status FROM customer WHERE customer_name=%s AND deleted=0", (name,))
+ return (str(rows[0]["id"]), rows[0]["archive_status"]) if rows else (None, None)
+ # 1) e2c-全字段-科技(详情/图谱/日志样例,owner=ADMIN,带信用代码)
+ sid, _ = find("e2c-全字段-科技")
+ if not sid:
+ sid = mk_customer("e2c-全字段-科技", tok, ctype, gov, credit="91440101E2CTEST001")
+ SEEDS["full"] = sid
+ # 2) e2c-dr-恒信达科技有限责任公司(查重相似源,步 9/27;带 e2c-dr 前缀便于 sweep 清扫)
+ hid, _ = find("e2c-dr-恒信达科技有限责任公司")
+ if not hid:
+ hid = mk_customer("e2c-dr-恒信达科技有限责任公司", tok, ctype, gov)
+ SEEDS["hxd"] = hid
+ # 2b) 旧版 seed(无 dr 前缀)改名进 sweep 射程,防跨轮残留
+ old_hid = dbq("SELECT id FROM customer WHERE customer_name=%s AND deleted=0 AND id<>%s",
+ ("e2c-恒信达科技有限责任公司", int(hid)))
+ for r in old_hid:
+ dbx("UPDATE customer SET customer_name=%s WHERE id=%s", ("e2c-dr-旧seed-%s" % r["id"], r["id"]))
+ # 2c) 组织关系 seed:BUDDY 所在部门 leader 缺失 → initiate 67011(D22 总监推导走 sys_dept.leader_user_id)
+ try:
+ bd = dbq("SELECT dept_id FROM crm_auth_user WHERE id=%s", (int(BUDDY),))
+ if bd and bd[0]["dept_id"]:
+ dbx("UPDATE sys_dept SET leader_user_id=%s WHERE id=%s AND leader_user_id IS NULL",
+ (int(ADMIN), bd[0]["dept_id"]))
+ except Exception as e:
+ print(" [warn] leader seed 跳过: %s" % e)
+ # 2d) 查重设置归位:出厂 FUZZY(2)(CustomerNameMatchMode javadoc「模糊=出厂默认」);
+ # 五跑步 26 save 硬编码 nameMatchMode=1 曾把共享库写成 EXACT,这里幂等归位
+ try:
+ dd = get_json(f"{BASE}/api/rule/customer/dedup", tok).get("data") or {}
+ if dd.get("nameMatchMode") != 2:
+ post(f"{BASE}/api/rule/customer/dedup/save",
+ {"masterEnabled": dd.get("masterEnabled") or 1, "nameEnabled": dd.get("nameEnabled") or 1,
+ "nameMatchMode": 2, "similarityThreshold": dd.get("similarityThreshold") or 80,
+ "phoneEnabled": dd.get("phoneEnabled") or 1}, tok)
+ print(" [seed] 查重匹配方式归位 FUZZY(2)", flush=True)
+ except Exception as e:
+ print(" [warn] dedup 归位跳过: %s" % e)
+ # 2e) seed 客户冒烟联系人清扫(上轮步 16 的李冒烟/赵冒烟,保证步 16 幂等;「李普通」为共享 seed 预置不动)
+ dbx("DELETE FROM customer_contact WHERE customer_id=%s AND name IN (%s,%s) AND deleted=0",
+ (int(sid), "李冒烟", "赵冒烟"))
+ # 2f) 旧 seed 联系人职务修复:张关键/王重复(9/10 旧行)job_title_id 为 NULL 且 job_title_name
+ # 不在字典树(「采购总监(已更新)」)→ loadContacts 的 _jobCode 反查失败 → saveListAll
+ # 「职务必填」拦截 batchEdit(六跑步 17/39 实锤)。按 jobc 的 dict_item 归位。
+ try:
+ drow = dbq("SELECT id, name FROM dict_item WHERE code=%s LIMIT 1", (jobc,))
+ if drow:
+ for nm in ("张关键", "王重复"):
+ dbx("UPDATE customer_contact SET job_title_id=%s, job_title_name=%s "
+ "WHERE customer_id=%s AND name=%s AND job_title_id IS NULL AND deleted=0",
+ (drow[0]["id"], drow[0]["name"], int(sid), nm))
+ except Exception as e:
+ print(" [warn] 联系人职务 seed 修复跳过: %s" % e)
+ # 3) e2c-已归档(步 5 只读消费)
+ aid, ast = find("e2c-已归档")
+ if not aid:
+ aid = mk_customer("e2c-已归档", tok, ctype, gov)
+ post(f"{BASE}/api/customer/archive?id={aid}", {}, tok)
+ elif ast != 2:
+ post(f"{BASE}/api/customer/archive?id={aid}", {}, tok)
+ SEEDS["archived"] = aid
+ # 4) 联系人样例:张关键 / 王重复(同号 13812340001,步 16/17);行字段=jobTitleCode(RowInput 契约,jobTitleName 不被绑定)
+ rows = dbq("SELECT name FROM customer_contact WHERE customer_id=%s AND deleted=0", (sid,))
+ have = {r["name"] for r in rows}
+ todo = []
+ if "张关键" not in have: todo.append({"name": "张关键", "jobTitleCode": jobc, "phone": "13812340001", "isKeyContact": 1})
+ if "王重复" not in have: todo.append({"name": "王重复", "jobTitleCode": jobc, "phone": "13812340001"})
+ if todo: quick_add(sid, todo, tok)
+ return SEEDS
+
+# ---------------- sweep(e2c-dr* 正门 DB 硬删,幂等重跑) ----------------
+def sweep():
+ n = 0
+ rows = dbq("SELECT id FROM customer WHERE customer_name LIKE %s", (PFX + "%",))
+ for row in rows:
+ cid = str(row["id"]); n += 1
+ # 商机关联行(步 23 建的商机):先删 opportunity_customer 再删商机主体+尽力清子表
+ opps = [r["opportunity_id"] for r in dbq(
+ "SELECT opportunity_id FROM opportunity_customer WHERE customer_id=%s", (cid,))]
+ dbx("DELETE FROM opportunity_customer WHERE customer_id=%s", (cid,))
+ for oid in opps:
+ for t in ("opportunity_dynamic", "opportunity_follow", "opportunity_oplog", "opportunity_member"):
+ try: dbx(f"DELETE FROM {t} WHERE opportunity_id=%s", (oid,))
+ except Exception: pass
+ dbx("DELETE FROM opportunity WHERE id=%s", (oid,))
+ for t in ("customer_contact_edge", "customer_contact_graph", "customer_contact_reveal_log",
+ "contact_import_fail", "contact_import_task", "customer_import_fail", "customer_import_task",
+ "customer_contact", "customer_oplog", "customer_focus", "customer_follow",
+ "customer_team_member", "customer_view_log", "customer_transfer_detail"):
+ try: dbx(f"DELETE FROM {t} WHERE customer_id=%s", (cid,))
+ except Exception: pass
+ dbx("DELETE FROM customer WHERE id=%s", (cid,))
+ # 交接单(发起人/接收人维度,e2c-dr 期间的 JG 单)
+ try:
+ bills = dbq("SELECT id FROM customer_transfer WHERE create_time > DATE_SUB(NOW(), INTERVAL 2 HOUR)")
+ for b in bills:
+ dbx("DELETE FROM customer_transfer_detail WHERE transfer_id=%s", (b["id"],))
+ dbx("DELETE FROM customer_transfer WHERE id=%s", (b["id"],))
+ n += 1
+ except Exception: pass
+ return n
+
+# ---------------- multipart 分流(Chromium 不回传文件内容 → Python 重发完整 multipart) ----------------
+_CUP_RE = re.compile(r"/api/customer/import/upload") # 客户导入
+_CUP_C_RE = re.compile(r"/api/customer/contact/import/upload") # 联系人导入
+_FLD = {"importMode": re.compile(rb'name="importMode"\r?\n\r?\n([A-Z_]+)'),
+ "duplicateStrategy": re.compile(rb'name="duplicateStrategy"\r?\n\r?\n([A-Z_]+)'),
+ "customerId": re.compile(rb'name="customerId"\r?\n\r?\n(\d+)')}
+
+def _py_upload(path, xlsx, token, mode, strategy, cid=None):
+ boundary = "----smokeBoundaryDr009876543210"
+ parts = []
+ kv = [("importMode", mode), ("duplicateStrategy", strategy)]
+ if cid: kv.insert(0, ("customerId", cid))
+ for k, v in kv:
+ parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode())
+ with open(xlsx, "rb") as f: fb = f.read()
+ parts.append((f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{os.path.basename(xlsx)}"\r\n'
+ f'Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\r\n\r\n').encode() + fb + b"\r\n")
+ parts.append(f"--{boundary}--\r\n".encode())
+ req = urllib.request.Request(BASE + path, data=b"".join(parts), method="POST")
+ req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
+ req.add_header("Authorization", "Bearer " + token)
+ with urllib.request.urlopen(req, timeout=60) as r:
+ return json.loads(r.read().decode())
+
+def make_handle(route_token):
+ def handle_api(route):
+ req = route.request
+ cors = {"Access-Control-Allow-Origin": req.headers.get("origin") or "null",
+ "Access-Control-Allow-Credentials": "true"}
+ u = req.url
+ p = u.split("?")[0]
+ ct = (req.headers.get("content-type") or "")
+ # NET 断言载体:URL 带查询串;POST 文本体(urlencoded/JSON,multipart 已分流)附记在 # 之后
+ tail = ("?" + u.split("?", 1)[1]) if "?" in u else ""
+ if req.method == "POST" and "multipart" not in ct:
+ bt = (req.post_data or "")[:400]
+ if bt: tail += " # " + bt.replace("\n", " ")
+ api_urls.append(req.method + " " + p.split(BASE)[-1] + tail)
+ if req.method == "POST" and (_CUP_RE.search(p) or _CUP_C_RE.search(p)):
+ buf = req.post_data_buffer or b""
+ mode = (_FLD["importMode"].search(buf) or [b"", b"UPSERT"])[1].decode()
+ strat = (_FLD["duplicateStrategy"].search(buf) or [b"", b"SKIP"])[1].decode()
+ cid = (_FLD["customerId"].search(buf) or [b"", b""])[1].decode() or None
+ path = "/api/customer/import/upload" if _CUP_RE.search(p) else "/api/customer/contact/import/upload"
+ xlsx = XLSX_CUST if _CUP_RE.search(p) else XLSX_CONT
+ try:
+ env = _py_upload(path, xlsx, route_token, mode, strat, cid)
+ route.fulfill(status=200, body=json.dumps(env, ensure_ascii=False).encode(),
+ headers={"Content-Type": "application/json", **cors})
+ except urllib.error.HTTPError as e:
+ route.fulfill(status=e.code, body=e.read(), headers={"Content-Type": "application/json", **cors})
+ except Exception as e:
+ route.fulfill(status=502, headers={"Content-Type": "application/json", **cors},
+ body=json.dumps({"code": -1, "success": False, "message": f"py-upload: {e}"}))
+ return
+ h = {k: v for k, v in req.headers.items()
+ if k.lower() not in ("origin", "authorization", "referer", "host", "content-length")}
+ h["Authorization"] = "Bearer " + route_token
+ try:
+ data = req.post_data_buffer if req.method not in ("GET", "HEAD") else None
+ r2 = urllib.request.Request(req.url, data=data, method=req.method)
+ for k, v in h.items(): r2.add_header(k, v)
+ with urllib.request.urlopen(r2, timeout=60) as resp:
+ body = resp.read(); ct = resp.headers.get("Content-Type") or "application/json"; status = resp.status
+ route.fulfill(status=status, body=body, headers={"Content-Type": ct, **cors})
+ except urllib.error.HTTPError as e:
+ route.fulfill(status=e.code, body=e.read(), headers={
+ "Content-Type": e.headers.get("Content-Type") or "application/json", **cors})
+ except Exception as e:
+ route.fulfill(status=502, headers={"Content-Type": "application/json", **cors},
+ body=json.dumps({"code": -1, "success": False, "message": f"bridge: {e}"}))
+ return handle_api
+
+def wait_api(pg, substr, timeout_ms=8000):
+ """轮询 api_urls 直到出现含 substr 的请求(或超时)。"""
+ t0 = time.time()
+ while time.time() - t0 < timeout_ms / 1000:
+ if any(substr in u for u in api_urls): return True
+ pg.wait_for_timeout(200)
+ return False
+
+
+# ================= Part 2 · UI 主流程(步 1-19 A 核心域) =================
+def ev(pg, expr):
+ return pg.evaluate("() => " + expr)
+
+def read_toast(pg):
+ try:
+ return ev(pg, "[...document.querySelectorAll('#toast .toast-item')].map(x => x.textContent).join(' || ')") or ""
+ except Exception:
+ return ""
+
+def dlg_btn(pg, text):
+ """点击主