源文件:
C:\Users\PC\Desktop\搜索供应商_v3.2.5.user.js这是个**"补丁堆叠"型**脚本——v3.1.0 → v3.2.5 共迭代 8 个小版本,每个版本都修一行或一段;它的事件链横跨"面板 + 宿主 ERP 页面 + iframe + 后端 5 套接口",排障重点是 changelog 不是代码。
| 项目 | 内容 |
|---|---|
| 脚本名 | ERP 供应商搜索工具(v3.2.5) |
| 版本 | 3.2.5(@version 3.2.5,启动日志无版本号) |
| 作者 | HuaSao |
| 生效域名 | https://*.erp321.com/app/scm/purchaseout/purchaseout.aspx* |
| 主要生效页 | 采购退货出库单列表 |
| 权限 | @grant none(不依赖油猴特权 API) |
| 依赖 | localStorage、document.cookie、宿主页面的 #wms_co_id 与 #owner_co_id 隐藏域 |
一句话概括:在采购退货单页面左上角挂一张折叠式面板,输入商品编码就能查出对应供应商 + 供应商备注,并支持创建退货单(可指定非本仓)/ 一键复制今日汇总 / 自动筛选往日草拟 / 自动给所有退货单行加复制按钮。
它解决什么问题:配货岗要在"搜货 → 找供应商 → 看备注 → 创建退货单"几个动作间切换,且操作多依赖宿主页面的下拉和按钮(不一定稳定);脚本把这些动作集中到一处,并允许绕过本仓指定任意仓储方创建退货单。
| 版本 | 主题 | 实际改动一行说明 |
|---|---|---|
| v3.1.0 | 公司上下文兜底 | 原 cookie u_co_id 直接取 → 切货主后查不到数据;改为按当前 frame URL → 顶层 → iframe → cookie 四层兜底取 owner_co_id/authorize_co_id |
| v3.1.1 | URL 必须带 ctx | 只放 FormData 时 purchaseout.aspx 返回 HTTP 200 + 0 字节 body;现在 URL 也拼 owner/authorize;空响应显式报错而不是 JSON.parse 异常 |
| v3.2.0 | 仓储方下拉 | "确认退单"可指定非本仓的 wms_co_id;同步下发 Args[3]、FormData、URL 三处(兜底策略);加 console.group 输出完整 payload;新增 dry-run 模式(erpSearchReturnDryRun='1') |
| v3.2.1 | Args 签名 + 子仓库 | NewTask Args 真实签名实测是 [wms_co_id, supplier_id, wh_id](之前 [owner, supplierId, "10"] 里的 "10" 其实是采购出库类型码,被误认作 wh_id);新增 GetSubWarehouse 接口拉子仓库 |
| v3.2.2 | GetSubWarehouse 响应字典适配 | 真实响应是纯字典 {wh_id: wh_name},不是对象数组;之前 Object.values().filter(typeof==='object') 会过滤掉所有字符串 |
| v3.2.3 | ReferenceError 修复 | returnBtn click handler 里引用了 whSelect,但 whSelect 是 buildUI() 内部 const,没被加入 bindEvents() 顶部的 state.ui 解构 → 修复:解构里加上 whSelect |
| v3.2.4 | 默认本仓 + 主动触发 change | 同步宿主页 #wms_co_id 后默认 value='',用户不主动改选时 change 永远不触发 → 子仓库永远是占位。修复:从宿主页 #wms_co_id 当前 value 取默认 + dispatchEvent('change') 主动触发 |
| v3.2.5 | 0/'' → 公司 ID 解析 |
宿主页 #wms_co_id 用 0=本仓 但 GetSubWarehouse 不认 0,返回空字典。修复:新增 resolveWmsCoId() 把空/0 解析成 ctx.auth(公司自身 wms_co_id),refreshWhSelect / returnBtn / newReturnTask 三处共用 |
阅读建议:先读 changelog 再读代码,效率比直接看代码高 3 倍。
┌─ ERP 供应商搜索工具 ─────────────────┐ ← 标题栏(蓝色,可拖动)
│ [搜索供应商...] [搜索] │ ← 搜索行
│ [获取到的供应商备注...(只读)] │ ← 备注显示
│ 退单仓储方(可换仓) │
│ [(同步宿主页面的仓储方下拉...)] │ ← v3.2.0 仓储方下拉
│ 退单仓库(wh_id) │
│ [(选好仓储方后自动加载子仓库)] │ ← v3.2.1 子仓库下拉
│ [ 确认退单 ] │ ← v3.2.0 红色按钮(实发退单/建退货单据)
│ [今日汇总][往日草拟][重置] │ ← 三个二级按钮
└─────────────────────────────────────┘
拖到屏幕边缘 10px 内会自动折叠成 32px 宽的窄条(仅显示 🔍),鼠标 hover 80ms 后自动展开;拖完松开自动 snap。
触发:输入商品编码 → 点搜索 或回车
链路:
searchSupplier(sku) → 取到 supplier_id / supplier_name
↓
supplierRemark(name) → 拿供应商备注(LoadDataToJSON 三段优先级:精确=(") "1" → 模糊 like → 查全量)
↓
把"无备注信息"过滤掉;真备注红字加粗显示,否则灰色"无特殊要求"
↓
如果搜索词长度 > 3,把前 N-3 个字符复制到剪贴板(用途:去掉末尾 3 位后给手输的场景用)
触发:点"今日汇总"
链路:
fetchUser() // ① cookie 取 u_name;② 拿不到则请求 /UserApi/Passport/GetUserInfo
↓
loadTodaySummary(uid) // 拉今天 created >= 今日0点 AND <= 今日23:59:59.998 AND creator_name = 当前用户
↓
拼成 9 列表格行:md\tmd\tsupplier\tskuCount\ttotalQty\ttotalAmount\"正常退货\"\tcreator\ttotalQty\ttotalAmount\"0\"
↓
clipboardCopy → notify("今日汇总已复制")
输出格式是给"ERP 数据上报模板"用的固定 9 列 TSV 文本,可以直接粘贴到 Excel。
触发:点"往日草拟"
链路:
resetForm() // 点宿主页 .btn_search_reset 清空所有筛选
↓
fetchUser() + selectCreator() // 把"制单人"下拉勾上当前用户名
↓
selectStatus() // 把"状态"下拉勾上"草拟"
↓
setDate('date2', 两天前日期) // 把"创建日期"设为 today-2
↓
clickSearch() // 触发宿主 .btn_search 重新查询
隐含逻辑:先清空表单再回填,是为了让宿主 ERP 的复合筛选条件能正确刷新(直接设值有时不触发 change)。
触发:点"确认退单"
完整链路:
1. 读 state.supplierId(必须先搜过)
2. 读 wmsCoSelect.value 和 whSelect.value
3. resolveWmsCoId() 把 ''/'0' 解析成 ctx.auth
4. __manual__ 占位值不是真实 wh_id(拉取失败时用)→ 拒绝
5. newReturnTask(ctx, supplierId, wms, whActual)
├─ 拉一次 purchaseout.aspx 拿 VIEWSTATE / __VIEWSTATEGENERATOR
├─ Args = [wms_co_id, supplier_id, wh_id] ← v3.2.1 实测签名
├─ FormData: owner/authorize + wms_co_id + wh_id
├─ URL: withCtx() 拼 owner/authorize + wms_co_id + wh_id
├─ console.groupCollapsed 打印完整 payload
├─ dryRun? → return { _dryRun: true }
└─ 真发: fetchJsonSafe(url, POST, FormData)
v3.2.0 关键保护:params.append 必须有空字符串字段,即便为空也要 append,服务端两处都读。
触发:宿主页面的退货单表格自动出现"复制"按钮(无需点)
实现:
setupMutationObserver() 监听 document.body 任何子树变化addCopyButtonsOnce()(避免短时间内反复重建)processedTables 用 WeakSet 防重复处理复制格式:<supplier去除"(新供应商)"等标注后><退货单号>.
实际作用:配货岗在退货单表格里选中某行,点末尾的"复制"按钮直接得到"供应商名+退货单号."的可粘贴字符串。
脚本头是 @grant none,意味着不依赖任何油猴特权 API,所有请求走标准 fetch。这带来几个权衡:
| 取舍 | 影响 |
|---|---|
| ✅ 无 CORS 问题(fetch 走浏览器 CORS,宿主页面本身就是 erp321.com 域) | — |
✅ 不需要 @connect * 跨域声明 |
— |
❌ 无法用 GM_xmlhttpRequest 强制带自定义 cookie(但 credentials: 'include' 就够了) |
— |
❌ 无法用 GM_setValue / GM_notification 等 |
— |
❌ 与 物流盒子提示.js / 仓位信息获取.js 风格不同——那两个用了 GM_xmlhttpRequest |
见上面"6 个静默问题"的对比 |
好处:脚本可以直接当普通浏览器脚本用(即便在不支持油猴的环境下也能跑,缺点是 localStorage 写完会被浏览器跨域策略拦截)。坏处:审查时 grep "GM_" 会一无所获,与其他脚本的体感差异大。
fetchJsonSafe 的循环 trim 前导非 JSON 字符——可能误切掉响应体while (text && !text.trim().startsWith('{')) text = text.slice(1);
ERP 一些接口的响应是 0|{...} 形态(这个脚本专门有 parseResponseData 兼容)。但这一行 trim 到 { 而不是 0|——如果响应体里第一个 { 之前有合法 JSON 数组(如 [{...}] 形态的接口),第一个 { 永远是第 1 位——OK;但如果响应体是 { "0|xxx": "yyy" } 这种带 0| 的字符串里也有 {——脚本会把 { 之前的合法前缀切掉,可能误删注释/BOM/版本前缀。
修法:明确 trim 到 0| 而不是第一个 {:
while (text && !text.startsWith('0|') && !text.startsWith('{')) text = text.slice(1);
MutationObserver 监听 document.body 全子树会反复触发observer.observe(document.body, { childList: true, subtree: true });
虽然加了 400ms 防抖,但 ERP 的退货单表格每次分页/排序/筛选都会触发 DOM 大改,单次操作可能产生 50+ 次 MutationRecord,防抖会合并到 1 次 addCopyButtonsOnce,但单次处理要走完所有表格扫描——表格有 13+ 个,扫一次 5~10ms,频繁触发会造成整页卡顿(脚本本身也提到"页面卡顿"是 v3.1.0 修复内容之一,但只修了"复制按钮被反复重建",没修"扫表格本身的开销")。
修法:
_jt_body / _jt_body_list 两个容器(不是整个 body)requestIdleCallback 把 addCopyButtonsOnce 推到空闲期parseViewState(html) 只查 __VIEWSTATE,不查 __EVENTVALIDATIONconst parseViewState = (html) => {
const vs = html.match(/id="__VIEWSTATE"\s+value="([^"]+)"/i);
const vg = html.match(/id="__VIEWSTATEGENERATOR"\s+value="([^"]+)"/i);
return { viewState: vs ? vs[1] : '', viewStateGenerator: vg ? vg[1] : '' };
};
.NET WebForm 完整三件套是 __VIEWSTATE + __VIEWSTATEGENERATOR + __EVENTVALIDATION,缺第三个会导致触发服务端 ViewState MAC 校验失败,返回 500 或被识别为 CSRF 攻击。
实测影响:v3.1.0 之前没用 cookies 兜底时,这个错误会被掩盖(因为压根没发请求);现在 v3.2.x 在某些特殊页面(如带复杂筛选状态的)可能踩到这个坑。修法:加上 __EVENTVALIDATION 的解析与提交。
selectFirstRowAndCheckbox 用了魔法数字 13for (const t of tables) {
idx += 1;
if (idx === 13) continue; // ← 跳过第 13 张表
const txt = t.textContent.toLowerCase();
if (txt.includes('退货单号') && txt.includes('供应商') && txt.includes('总金额')) {
targetTable = t; break;
}
}
第 13 张表刚好是某次观察到的不需要处理的表(猜测是 ERP 顶部导航里的内嵌 iframe 副本)。一旦 ERP 改版把第 13 张表挪走,这个 idx === 13 就会误跳真正的目标表,整段逻辑失效。
修法:用文本匹配而不是 index 数字:
if (t.textContent.includes('退货单号') && t.textContent.includes('供应商') &&
t.textContent.includes('总金额') && !t.textContent.includes('快捷创建')) {
targetTable = t; break;
}
Args[3] 这个数字是历史遗留但实际 Args 现在是 3 个元素// v3.2.0 注释里说 "Args[3]" 是直觉首位 —— 那是 Args 第 4 位(index 3)
// v3.2.1 修正后 Args = [wms_co_id, supplier_id, wh_id] -- 只有 3 个元素
const args = [wms, supplierId, wh]; // 实际只有 3 个
代码本身已经更新为 3 元素 Args,但 v3.2.0 加的兜底注释("同时下发 Args[3]、FormData、URL 三处")还在原位——这条注释现在会让新读者困惑(以为 Args 是 4 元素)。grep "Args\[3\]" 会命中这条误导性注释。
修法:要么删掉那段注释,要么改成「v3.2.1 修正后 Args=3 元素,下发三处」。
@grant none 但脚本启动时改写宿主页面全局样式可能与 CSP 冲突ERP 不少页面有 Content-Security-Policy 头 script-src 'self' 'unsafe-inline' —— 油猴脚本注入的 <style> 块会被 CSP 阻止(取决于页面如何加载用户脚本)。Tampermonkey 在 @grant none 模式下注入的样式走的是油猴特权 API,通常不受 CSP 限制——但如果是 ScriptCat 或其他同类实现,可能踩坑。
实测:addCopyButtonsOnce 通过 targetCell.innerHTML = ''; targetCell.appendChild(copyBtn); 替换原单元格内容——如果原单元格里有 JTable 框架绑的事件,会被整体清掉(虽然这是有意的,但任何依赖该单元格内嵌事件的二次点击都会失效)。
需要改阈值就在这里找:
| 行为 | 阈值 / 字段 | 位置 | 默认值 |
|---|---|---|---|
| 自动折叠延时 | AUTO_COLLAPSE_DELAY |
state.timers 旁 |
150 ms |
| hover 展开延时 | HOVER_EXPAND_DELAY |
同上 | 80 ms |
| 折叠/展开宽度 | COLLAPSE_WIDTH / EXPAND_WIDTH |
同上 | 32 / 250 px |
| 边缘折叠阈值 | EDGE_THRESHOLD |
同上 | 10 px |
| 供应商搜索 pageSize | payload.page.pageSize |
searchSupplier | 50 |
| 子仓库 pageSize | _jt_page_size = '25' |
getSubWarehouse | 25 |
| 今日汇总 pageSize | _jt_page_size = '500' |
newReturnTask | 500 |
| dry-run 开关键 | erpSearchReturnDryRun |
storage.setString | '0' |
| 仓储方记忆键 | erpSearchReturnWmsCoId |
wmsCoSelect.change | — |
| 子仓库记忆键 | erpSearchReturnWhId |
whSelect.change | — |
| 位置记忆键 | erpSearchPosition |
drag end | — |
| 折叠状态记忆键 | erpSearchCollapseState |
snap | — |
.user.js 复制粘贴 → 保存purchaseout.aspx 会自动在左上角弹出面板1. 在面板顶部搜索框输入商品编码(ERP 里搜货时会得到一串 SKU/编码)
2. 点 🔍搜索(或回车)
3. 供应商备注红字显示(或灰色"无特殊要求")
4. 如果搜索词 > 3 位 → 自动复制前 N-3 位到剪贴板(用于去掉末尾的 SKU 码)
5. 状态栏弹 notify "搜索到供应商:XXX"
1. 先搜索供应商(拿到 supplier_id)
2. 选仓储方:默认从宿主页 #wms_co_id 同步 → 改成其他公司就下拉里改
3. 子仓库自动刷新(v3.2.4 起默认本仓也会主动触发 change)
4. 点红色「确认退单」
5. 控制台 [vote 段] 看完整 payload(含 URL/FormData/Args JSON)
6. 成功 → notify "成功为 XXX 创建退货单据",500ms 后自动触发宿主页搜索
dry-run 调试:
localStorage.setItem('erpSearchReturnDryRun', '1');
// 之后所有"确认退单"只打印 payload,不真发请求
// 调试完:
localStorage.setItem('erpSearchReturnDryRun', '0');
1. 点蓝色「今日汇总」
2. 后台拉当前用户今日 created 的所有退货单
3. 拼成 9 列 TSV 格式复制到剪贴板
4. 直接粘到 Excel 或上报模板
1. 点橙色「往日草拟」
2. 自动清空当前筛选 → 自动勾上"制单人=当前用户" + "状态=草拟" + "日期=2 天前"
3. 自动点宿主页的搜索按钮
4. 自动选中第一行符合条件的数据行
这是个一键回到工作流上一步的快捷操作——昨天你查过什么,今天重复一次就能找到。
无需任何操作——脚本一加载就会在所有退货单行的"退货进程"列自动追加一个"复制"按钮:
<供应商名>(去除"新供应商"等标注)<退货单号>.navigator.clipboard.writeText(降级到 document.execCommand('copy'))// 切换 dry-run
localStorage.setItem('erpSearchReturnDryRun', '1');
// 清位置/折叠记忆
localStorage.removeItem('erpSearchPosition');
localStorage.removeItem('erpSearchCollapseState');
// 让所有已添加的"复制"按钮重新扫描
window.dispatchEvent(new Event('resize')); // 触发 MutationObserver 失效
// 或者直接:
// 重新载入页面(F5)
// 手动调用某次确认退单(dry-run 模式)
const event = new Event('click');
document.querySelector('#erp_search_wms_co_select').value = '0';
document.querySelector('#erp_search_wms_co_select').dispatchEvent(new Event('change'));
// 然后点确认退单按钮
| 症状 | 第一时间看 | 典型原因 |
|---|---|---|
| 搜索无反应 | 控制台 fetch 错误 |
getCompanyCtx() 4 层兜底都拿不到 owner;cookie u_co_id 过期 |
| "确认退单"显示"未指定仓储方" | 宿主页 #wms_co_id 是否已渲染 |
v3.2.0 同步 8 次×500ms 超时;宿主下拉被覆盖在 iframe 里 |
| "确认退单"显示"未指定仓库" | 子仓库下拉是否成功加载 | GetSubWarehouse 返回空字典;本仓占位 0 没被解析成 ctx.auth |
| "确认退单"卡住不返回 | 控制台 console.group 段 |
URL 没拼上 ctx;FormData 缺 __EVENTVALIDATION;VIEWSTATE 解析异常 |
| "确认退单"后页面没刷新 | notify 提示文本 | 服务端返回了 ReturnValue=false 但脚本误判为成功 |
| "往日草拟"按了无变化 | 控制台 resetForm 警告 |
宿主 .btn_search_reset 不存在(v3.1.0 之前是用 XPath,改版后失效) |
| 表格行没有"复制"按钮 | _jt_body 元素是否存在 |
表格被渲染在 iframe 里,MutationObserver 跨 iframe 失效 |
| 复制按钮点了没复制 | navigator.clipboard.writeText 是否报错 |
当前页面不是 HTTPS 或没用户手势;fallback 到 textarea 也可能因 CSP 失败 |
| 主题 | v3.1.0 | v3.2.0 | v3.2.5 |
|---|---|---|---|
| 公司上下文 | cookie 写死 → URL/隐藏域兜底 | 同 v3.1.0 | 同 v3.1.0 |
| 退单仓储方 | ❌ 不可选(永远本仓) | ✅ 可选 + 三处兜底 | ✅ + 0/空解析成公司自身 |
| 退单 wh_id | ❌ 不传 | ❌ 固定 10(其实是类型码) | ✅ 真实 Args = [wms, sup, wh] |
| 子仓库下拉 | ❌ 无 | ❌ 无 | ✅ 联动刷新 |
| dry-run 模式 | ❌ 无 | ✅ erpSearchReturnDryRun='1' |
同 v3.2.0 |
| GET 子仓库 | ❌ 无 | ❌ 无 | ✅ GetSubWarehouse 接口 |
| Args 签名正确 | — | ❌ 错 | ✅ 实测正确 |
完整链路、参数速查、6 个静默问题都在文档里。最重要的提醒是:这是个"补丁堆叠"型脚本,每个小版本都修一行或一段;改之前先把 changelog 读完。