// ==UserScript== // @name ERP 供应商搜索工具 // @namespace https://scriptcat.org/zh-CN/users/214207 // @version 3.2.6 // @description 提供供应商搜索、退货单快捷创建、数据汇总和复制等增强功能。v3.1.0 修复「切换货主后查不到数据/数据不对」——公司上下文(owner_co_id/authorize_co_id)改为按宿主页面实时取值,不再写死 cookie;修复「清空/搜索按钮点不到」(页面改版后旧 XPath 失效,改用稳定 class);修复复制按钮被 MutationObserver 反复重建导致的点击丢失与页面卡顿。v3.1.1 关键修正:上下文必须拼在 request URL 上,只放 FormData 会让 purchaseout.aspx 返回空响应(实测 HTTP 200 + body 0 字节)——「新建退货单」「今日汇总」之前就是这么静默失灵的;空响应现在报明确错误而不是 JSON 解析异常。v3.2.0 新增「仓储方下拉选择器」——「确认退单」可指定非本仓的 wms_co_id;同时下发 Args[3]、FormData、URL 三处(兜底策略),并 console.group 输出完整 payload 方便核对;可在 localStorage 设 erpSearchReturnDryRun='1' 走 dry-run 模式仅打印不发请求。v3.2.1 彻底修正 NewTask Args 签名(实测 [wms_co_id, supplier_id, wh_id])+ 子仓库下拉(GetSubWarehouse)+ 联动刷新 + wms/wh 必填校验。v3.2.2 修正 GetSubWarehouse 响应解析:真实返回是纯字典 {wh_id 字符串: wh_name 字符串}(键值都是字符串),不是对象数组;之前的 Object.values().filter(typeof==='object') 会过滤掉所有字符串导致下拉永远空。v3.2.3 修复 v3.2.2 引入的 ReferenceError:returnBtn click handler 在 bindEvents() 内引用 whSelect,但 whSelect 是 buildUI() 内部的 const 变量,没被加入 bindEvents 顶部的 state.ui 解构 —— 修复:在 bindEvents() 解构里加上 whSelect。v3.2.4 修复「默认本仓时不显示子仓库」:v3.2.3 syncWmsCoOptionsFromPage 完成后默认 wmsCoSelect.value='',且不主动触发 change,导致用户不主动改选仓储方时 change 永远不触发,refreshWhSelect 永远拿不到本仓子仓库。v3.2.4 默认值改为从宿主 #wms_co_id 当前 value 取(公司本仓 wms_co_id)+ 同步完成后主动 dispatchEvent('change') 触发 refreshWhSelect。v3.2.5 修复「选本仓看不到子仓库/退单 Args 错传 0」:宿主页 #wms_co_id 过滤器用 0=本仓,mirror 后选本仓时 value=0,GetSubWarehouse Args 传 ["0"] 服务端不认识返回空。实测 supplierpicker 选本仓时 Args=[公司自身 wms_co_id](=u_co_id,如 12816726)。v3.2.5 新增 resolveWmsCoId:把空/0(本仓占位)统一解析成 ctx.auth(公司自身 wms_co_id),refreshWhSelect / returnBtn / newReturnTask 三处都走该解析。 // @author HuaSao // @match https://*.erp321.com/app/scm/purchaseout/purchaseout.aspx* // @match https://*.erp321.com/app/scm/purchaseout/purchaseout.aspx?_c=jst-epaas&epaas=true // @icon https://files.erp321.com/img/platIcon/ziShen.png // @grant none // @license MIT // @homepageURL https://openuserjs.org/scripts/HuaSao/ERP_供应商搜索工具 // @supportURL https://github.com/HuaSao/erp-supplier-search-tool/issues // ==/UserScript== (function () { 'use strict'; /*********************** * 工具函数 ***********************/ const qs = (s, root = document) => root.querySelector(s); const qsa = (s, root = document) => Array.from(root.querySelectorAll(s)); const createEl = (tag, attrs = {}, css = '') => { const el = document.createElement(tag); Object.assign(el, attrs); if (css) el.style.cssText = css; return el; }; const storage = { get(key, fallback = null) { try { const raw = localStorage.getItem(key); return raw ? JSON.parse(raw) : fallback; } catch (e) { return fallback; } }, set(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { /* 忽略 */ } }, remove(key) { try { localStorage.removeItem(key); } catch (e) { /* ignore */ } }, // v3.2.0 新增:纯字符串读写(用于 dry-run 开关) getString(key, fallback = '') { try { const v = localStorage.getItem(key); return v === null ? fallback : v; } catch (e) { return fallback; } }, setString(key, value) { try { localStorage.setItem(key, String(value)); } catch (e) { /* ignore */ } } }; const notify = (msg, type = 'success') => { const el = createEl('div', { textContent: msg }, ` position: fixed; top: 20px; left: 50%; transform: translateX(-50%) scale(0.9); padding: 14px 20px; border-radius: 8px; font-size: 15px; font-weight: 600; color: #fff; background: ${type === 'success' ? '#4CAF50' : type === 'warning' ? '#FF9800' : '#f44336'}; box-shadow: 0 4px 12px rgba(0,0,0,0.25); z-index: 10001; opacity: 0; transition: all .25s ease; text-align: center; max-width: 80%; `); document.body.appendChild(el); requestAnimationFrame(() => { el.style.opacity = '1'; el.style.transform = 'translateX(-50%) scale(1)'; }); setTimeout(() => { el.style.opacity = '0'; el.style.transform = 'translateX(-50%) scale(0.9)'; setTimeout(() => el.remove(), 250); }, 2200); }; const clipboardCopy = async (text) => { try { await navigator.clipboard.writeText(text); } catch (err) { const temp = createEl('textarea', { value: text }, 'position:fixed;left:-9999px;top:-9999px;'); document.body.appendChild(temp); temp.select(); document.execCommand('copy'); temp.remove(); } }; const fetchJsonSafe = async (url, options = {}) => { const res = await fetch(url, options); if (!res.ok) throw new Error(`HTTP ${res.status}`); let text = await res.text(); // 🆕 v3.1.1 空响应必须报成"明确的失败",不能让它以 JSON.parse 的 // "Unexpected end of JSON input" 面目出现 —— 那会让人误以为是解析写错。 // 实测:purchaseout.aspx 在 URL 缺 owner_co_id/authorize_co_id 而表单带了时, // 服务端返回 HTTP 200 + 空 body,正是这个形态。 if (!text.trim()) { throw new Error('接口返回空响应(通常=公司/货主上下文缺失或会话已过期)'); } while (text && !text.trim().startsWith('{')) text = text.slice(1); return JSON.parse(text); }; const 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] : '' }; }; const getCookie = (name) => { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) return parts.pop().split(';').shift(); return null; }; /*********************** * 公司/货主上下文(v3.1.0 新增) ***********************/ const getCompanyCtx = () => { const readUrl = (w) => { try { const p = new URLSearchParams(w.location.search); return { owner: p.get('owner_co_id') || '', auth: p.get('authorize_co_id') || '' }; } catch (e) { return { owner: '', auth: '' }; } }; let ctx = readUrl(window); try { const o = document.getElementById('owner_co_id'); const a = document.getElementById('authorize_co_id'); if (!ctx.owner && o && o.value) ctx.owner = o.value; if (!ctx.auth && a && a.value) ctx.auth = a.value; } catch (e) { /* ignore */ } if (!ctx.owner) { const t = readUrl(window.top); if (t.owner) ctx = { owner: t.owner, auth: t.auth || ctx.auth }; } if (!ctx.owner) { const c = getCookie('u_co_id'); if (c) ctx.owner = c; } if (!ctx.auth) ctx.auth = ctx.owner; return ctx; }; /*********************** * v3.1.1 关键修正:公司上下文必须写在 request URL 上 ***********************/ const withCtx = (url, ctx) => { const o = (ctx && ctx.owner) || ''; const a = (ctx && ctx.auth) || o; if (!o) return url; const sep = url.includes('?') ? '&' : '?'; return url + sep + 'owner_co_id=' + encodeURIComponent(o) + '&authorize_co_id=' + encodeURIComponent(a); }; /*********************** * v3.2.0 新增:仓储方下拉选项同步 *********************** * 宿主页面的 `#wms_co_id` 下拉是查询过滤器,但同样的 option 列表(空 / 0=本仓 / 其他 ID) * 也是「创建退货单」时该用的目标仓储方集合。直接照搬过来最稳 —— 选项 ID 与文案跟 ERP 后端 * 的白名单对齐,不会出现「选了但服务端不认识」的情况。 */ const mirrorPageWmsCoOptions = (selectEl) => { const pageSel = qs('#wms_co_id'); if (!pageSel) return false; const opts = qsa('option', pageSel); if (!opts.length) return false; // 清掉「上次同步后用户新增过」之外的全部选项 selectEl.innerHTML = ''; opts.forEach(o => { const opt = document.createElement('option'); opt.value = o.value; opt.textContent = o.textContent; selectEl.appendChild(opt); }); return true; }; /*********************** * 🆕 v3.2.5:「本仓」占位值 → 公司自身 wms_co_id 解析 *********************** * 宿主页 #wms_co_id 是查询过滤器,用 value='0' 表示「本仓」(option 文案「(本仓)」), * 也有空字符串 option。但**接口层**没有 0 这个概念 —— 实测 supplierpicker 浮层选「本仓」时: * GetSubWarehouse 的 Args = ["12816726"](= cookie u_co_id = ctx.auth,公司自身 ID) * 传 ["0"] 服务端不认识 → 返回空字典 → 子仓库下拉显示「无子仓库」。 * 所以凡是取到 '' / '0' 都要解析成公司自身 wms_co_id 再下发。 */ const resolveWmsCoId = (ctx, raw) => { const w = String(raw == null ? '' : raw).trim(); if (w && w !== '0') return w; const self = (ctx && (ctx.auth || ctx.owner)) || ''; return String(self).trim(); }; /*********************** * 业务请求 ***********************/ const Api = { async supplierRemark(name) { try { const page = await fetch('/app/user/Supplier/Supplier.aspx', { credentials: 'include' }); if (!page.ok) throw new Error(`页面获取失败 ${page.status}`); const html = await page.text(); const { viewState, viewStateGenerator } = parseViewState(html); if (!viewState || !viewStateGenerator) throw new Error('未取得VIEWSTATE'); const safeName = JSON.stringify(name); const query = async (op, onlyEnabled) => { const filters = `[{"k":"name","v":${safeName},"c":"${op}"}` + (onlyEnabled ? `,{"k":"enabled","v":"1","c":"="}` : '') + `,{"k":"isLoadNotCommonlyUsed","v":false,"c":"="},{"k":"supplierRateId","v":"","c":"="},{"k":"loadQualificationExpiration","v":false,"c":"="}]`; const body = new FormData(); body.append('__VIEWSTATE', viewState); body.append('__VIEWSTATEGENERATOR', viewStateGenerator); body.append('name', name); if (onlyEnabled) body.append('enabled', '1'); body.append('_jt_page_size', '25'); body.append('__CALLBACKID', 'JTable1'); body.append('__CALLBACKPARAM', JSON.stringify({ Method: 'LoadDataToJSON', Args: ['1', filters, '{}'] })); const res = await fetchJsonSafe(`/app/user/Supplier/Supplier.aspx?_c=jst-epaas&ts___=${Date.now()}&am___=LoadDataToJSON`, { method: 'POST', credentials: 'include', body }); const rv = res.ReturnValue ? JSON.parse(res.ReturnValue) : null; return (rv && rv.datas) || []; }; const pick = (items) => items.find(it => it.name === name) || items[0] || null; let hit = pick(await query('=', true)); if (!hit) hit = pick(await query('like', true)); if (!hit) hit = pick(await query('=', false)); return hit ? (hit.remark || null) : null; } catch (e) { console.error('获取供应商备注失败:', e); return null; } }, // 🆕 v3.2.1:根据 wms_co_id 拿子仓库列表 // 实测 supplierpicker 浮层里选仓储方后会触发这个接口,参数:[] // 🆕 v3.2.2:经用户抓包验证,真实响应(解开 0| 二次 JSON.parse 后)是 **纯字典**: // { "": "", "": "", ... } // key 是 wh_id 字符串,value 是 wh_name 字符串 —— 例如: // { "1":"长兴/高兴供应链主仓", "4":"长兴/高兴供应链次品仓", "12":"调拨单接收7仓" } // 不是对象数组。之前 v3.2.1 误以为 [{wh_id,wh_name}, ...] 用 Object.values().filter(对象) 解析, // 会把所有字符串过滤掉返回 [],子仓库下拉永远是空。 // 这里统一转成 [{wh_id, wh_name}, ...],让 refreshWhSelect 里 getId/getName 无需再猜字段名。 async getSubWarehouse(ctx, wmsCoId) { const owner = (ctx && ctx.owner) || ''; const auth = (ctx && ctx.auth) || owner; if (!owner) throw new Error('未能识别当前公司/货主上下文'); if (!wmsCoId) throw new Error('未指定仓储方 wms_co_id'); // 先拉一次 supplierpicker.aspx 拿 VIEWSTATE(与 NewTask 同源但路径不同) const page = await fetch(withCtx('/app/scm/purchaseout/supplierpicker.aspx?enabled=1&_float=true&_h=600px', ctx), { credentials: 'include' }); if (!page.ok) throw new Error(`supplierpicker 页面获取失败 ${page.status}`); const html = await page.text(); const { viewState, viewStateGenerator } = parseViewState(html); if (!viewState || !viewStateGenerator) throw new Error('未取得 VIEWSTATE'); const cbParam = { Method: 'GetSubWarehouse', Args: [String(wmsCoId)], CallControl: '{page}' }; const form = new FormData(); form.append('__VIEWSTATE', viewState); form.append('__VIEWSTATEGENERATOR', viewStateGenerator); form.append('wms_co_id', String(wmsCoId)); form.append('wms_co_name', ''); form.append('wh_id', ''); form.append('@supplier_id', ''); form.append('@name', ''); form.append('_jt_page_size', '25'); form.append('__CALLBACKID', 'JTable1'); form.append('__CALLBACKPARAM', JSON.stringify(cbParam)); const url = withCtx( `/app/scm/purchaseout/supplierpicker.aspx?enabled=1&_float=true&_h=600px&ts___=${Date.now()}&am___=GetSubWarehouse`, ctx ); const res = await fetchJsonSafe(url, { method: 'POST', credentials: 'include', body: form }); // 服务端返回 0|{"ReturnValue":""} —— 二次解析 const rv = res.ReturnValue ? JSON.parse(res.ReturnValue) : null; // 🆕 v3.2.2 字典适配:GetSubWarehouse 真实返回 {wh_id: wh_name, ...} // - 主路径:rv 本身就是字典 { "1":"长兴...", "4":"...", "12":"..." } // → 转成 [{wh_id, wh_name}, ...] // - 兼容 1:rv 是数组 → 直接当作 items // - 兼容 2:rv 是 {datas|items|data: [...]} 形态 → 取数组字段 // - 兼容 3:rv 是 {dwh: {wh_id,wh_name, ...}} 单条 → 包成数组 let items = []; if (Array.isArray(rv)) { items = rv; } else if (rv && typeof rv === 'object') { if (Array.isArray(rv.datas)) items = rv.datas; else if (Array.isArray(rv.data)) items = rv.data; else if (Array.isArray(rv.items)) items = rv.items; else if (rv.dwh || rv.wh) { const one = rv.dwh || rv.wh; items = [one]; } else { // 🔑 主路径:把字典的 key 当 wh_id,value 当 wh_name // 跳过 value 不是字符串的(如状态字段、嵌套对象) items = Object.keys(rv) .filter(k => typeof rv[k] === 'string' || typeof rv[k] === 'number') .map(k => ({ wh_id: String(k), wh_name: String(rv[k]) })); } } // 数组里的对象若没有 wh_id/wh_name 字段,再兼容一组备用名 items = items.map(it => { if (it == null) return null; if (typeof it === 'string') return { wh_id: it, wh_name: it }; const id = it.wh_id ?? it.id ?? it.w_id ?? it.whId ?? ''; const name = it.wh_name ?? it.name ?? it.title ?? it.label ?? ''; return { wh_id: String(id), wh_name: String(name) }; }).filter(it => it && it.wh_id); return items; }, async searchSupplier(searchTerm, uId, ctx) { const owner = (ctx && ctx.owner) || ''; const auth = (ctx && ctx.auth) || owner; const url = `${window.location.origin}/webapi/ItemApi/ItemSku/GetPageListV2?__from=web_component&owner_co_id=${owner}&authorize_co_id=${auth}`; const payload = { ip: '', uid: uId, coid: owner, page: { currentPage: 1, pageSize: 50, hasPageInfo: true, pageAction: 1 }, data: { sku_type: 1, has_daily_purchase_in_qty: false, queryFlds: ["pic","i_id","sku_id","name","properties_value","labels","qty","order_lock","orderable","salesTrends30","daysInventory","purchaseQty","purchase_qty","purchase_plan_arrive","lock_qty","lwh_result_lock_qty","min_qty","max_qty","min_day","max_day","pick_lock","return_qty","in_qty","customize_qty_1","customize_qty_2","customize_qty_3","customize_qty_4","customize_qty_5","customize_qty_6","customize_qty_7","customize_qty_8","customize_qty_9","customize_qty_10","allocate_qty","virtual_qty","unlock_qty","stock_opensync","bin","sales_qty_yesterday","sales_qty_7","sales_qty_15","afs_qty_yesterday","afs_qty_7","afs_qty_15","qty_modified","multiTimeStocks_3","multiTimeStocks_5","multiTimeStocks_7","multiTimeStocks_10","multiTimeStocks_15","multiTimeStocks_20","multiTimeStocks_25","multiTimeStocks_30","multiTimeStocks_45","is_series_number","sale_price","c_id","supplier_id","supplier_name","pic_big","min_qty","max_qty"], orderBy: '', sku_id: searchTerm, c_id: '', stock_type: 6 } }; const res = await fetchJsonSafe(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'include', body: JSON.stringify(payload) }); return res && Array.isArray(res.data) ? res.data[0] : null; }, /*************************************************************** * v3.2.0 关键扩展:newReturnTask(ctx, supplierId, wmsCoId) * * 实测问题:「确认退单」创建的退货单 wms_co_id 永远是 0(本仓), * 跟宿主页面 `#wms_co_id` 下拉的选择无关 —— NewTask 调用根本没传这个参数。 * * 现在用户可指定仓储方。**兜底策略**:服务端可能从三种入口读这个参数, * 因此三个位置都写上(互不冲突): * 1. `Args[3]` —— JTable NewTask 方法的常见位置参数(直觉首位) * 2. FormData 字段 `wms_co_id` —— 部分页面走表单 * 3. URL query `&wms_co_id=...` —— 像 owner/authorize 一样走 URL 最稳 * * 注意:当 wmsCoId 为空字符串 / '0' 时三个位置都不带 —— 等价于「不指定」, * 服务端会走默认(本仓),与原 v3.1.1 行为一致。 * * 用户实测后可在控制台 `console.groupCollapsed` 块里看到完整的 * POST URL / FormData / Args JSON / 响应原文 便于核对。 ***************************************************************/ async newReturnTask(ctx, supplierId, wmsCoId, whId) { const owner = (ctx && ctx.owner) || ''; const auth = (ctx && ctx.auth) || owner; if (!owner) throw new Error('未能识别当前公司/货主上下文,请刷新页面后重试'); // v3.2.1 实测 Args 签名(从真实 supplierpicker 创建时刻 curl 抓包反推): // [wms_co_id, supplier_id, wh_id] // 而不是 v3.2.0 之前猜的 [owner, supplierId, "10"] —— 那其实是采购**出库**类型码。 // 必须三个参数都齐全,缺一服务端就会落到默认(本仓 / 类型出错)。 // 🆕 v3.2.5:调用方可能传来 '0'(宿主过滤器语义=本仓),接口层要解析成公司自身 ID const wmsRaw = String(wmsCoId || '').trim(); const wms = (wmsRaw === '' || wmsRaw === '0') ? String(auth || owner).trim() : wmsRaw; const wh = String(whId || '').trim(); if (!wms) throw new Error('未指定仓储方(wms_co_id),无法创建退单。请先在下拉里选好仓储方'); if (!wh) throw new Error('未指定仓库(wh_id),无法创建退单。请先在子仓库下拉里选好仓库(或手动填入)'); // 必须带 _c=jst-epaas&epaas=true —— 这是 EaaS 框架的路由分发标记,缺了可能不走到 NewTask const page = await fetch(withCtx('/app/scm/purchaseout/purchaseout.aspx?_c=jst-epaas&epaas=true', ctx), { credentials: 'include' }); if (!page.ok) throw new Error(`页面获取失败 ${page.status}`); const html = await page.text(); const { viewState, viewStateGenerator } = parseViewState(html); if (!viewState || !viewStateGenerator) throw new Error('未取得VIEWSTATE'); // ✅ v3.2.1 关键修正:Args 真实签名 = [wms_co_id, supplier_id, wh_id] const args = [wms, supplierId, wh]; const cbParam = { Method: 'NewTask', Args: args, CallControl: '{page}' }; const form = new FormData(); form.append('__VIEWSTATE', viewState); form.append('__VIEWSTATEGENERATOR', viewStateGenerator); form.append('owner_co_id', owner); form.append('authorize_co_id', auth); // v3.2.1:FormData 必须带 wms_co_id 和 wh_id 两个字段(即便为空也要 append,服务端两处都读) form.append('wms_co_id', wms); form.append('wh_id', wh); form.append('_jt_page_size', '500'); form.append('__CALLBACKID', 'JTable1'); form.append('__CALLBACKPARAM', JSON.stringify(cbParam)); // URL 必须带 _c=jst-epaas&epaas=true + owner/authorize + wms_co_id + wh_id let url = withCtx( `/app/scm/purchaseout/purchaseout.aspx?_c=jst-epaas&epaas=true&ts___=${Date.now()}&am___=NewTask`, ctx ); url += '&wms_co_id=' + encodeURIComponent(wms); url += '&wh_id=' + encodeURIComponent(wh); // 🆕 v3.2.0 dry-run 开关:localStorage.erpSearchReturnDryRun === '1' 时只打印不发请求 // 避免首次装新版误创一堆真实退单,调试完毕删 key 或置 '0' 即可恢复。 const dryRun = storage.getString('erpSearchReturnDryRun', '0') === '1'; // 🆕 v3.2.0 完整 payload 诊断(不开 dry-run 也会打,便于事后排查) console.groupCollapsed( `%c[供应商工具] 确认退单 ${dryRun ? '(DRY-RUN)' : ''}`, `color:${dryRun ? '#FF9800' : '#4A90E2'};font-weight:bold` ); console.log('▶ 上下文', { owner, auth, supplierId, wmsCoId: wms, whId: wh }); console.log('▶ POST URL', url); console.log('▶ FormData 字段', Array.from(form.entries()).map(([k, v]) => [k, typeof v === 'string' ? (v.length > 80 ? v.slice(0, 80) + '…' : v) : v])); console.log('▶ __CALLBACKPARAM', JSON.stringify(cbParam)); console.log('▶ Args', `[wms_co_id=${wms}, supplier_id=${supplierId}, wh_id=${wh}]`); console.groupEnd(); if (dryRun) { return { ReturnValue: true, _dryRun: true, _payload: { url, args } }; } const res = await fetchJsonSafe(url, { method: 'POST', credentials: 'include', body: form }); return res; }, async loadTodaySummary(currentUserName) { const now = new Date(); const pad = (n) => String(n).padStart(2, '0'); const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; const end = `${date} 23:59:59.998`; const ctx = getCompanyCtx(); if (!ctx.owner) throw new Error('未能识别当前公司/货主上下文,请刷新页面后重试'); const page = await fetch(withCtx('/app/scm/purchaseout/purchaseout.aspx', ctx), { credentials: 'include' }); if (!page.ok) throw new Error(`页面获取失败 ${page.status}`); const html = await page.text(); const { viewState, viewStateGenerator } = parseViewState(html); const cbParam = { Method: 'LoadDataToJSON', Args: [ '1', JSON.stringify([ { k: 'created', v: date, c: '>=', t: 'date' }, { k: 'created', v: end, c: '<=', t: 'date' }, { k: 'creator_name', v: currentUserName, c: '=' } ]), '{}' ] }; const form = new FormData(); form.append('__VIEWSTATE', viewState); form.append('__VIEWSTATEGENERATOR', viewStateGenerator); form.append('owner_co_id', ctx.owner); form.append('authorize_co_id', ctx.auth || ctx.owner); form.append('date_type', 'created'); form.append('created', date); form.append('created', end); form.append('creator_name', currentUserName); form.append('__CALLBACKID', 'JTable1'); form.append('__CALLBACKPARAM', JSON.stringify(cbParam)); const res = await fetchJsonSafe(withCtx(`/app/scm/purchaseout/purchaseout.aspx?_c=jst-epaas&ts___=${Date.now()}&am___=LoadDataToJSON`, ctx), { method: 'POST', credentials: 'include', body: form }); const rv = res.ReturnValue ? JSON.parse(res.ReturnValue) : null; const rows = rv?.datas || []; return rows.map(item => ({ date, supplier: item.receiver_name || '', skuCount: item.total_sku_ids || 0, totalQty: item.custom_num || item.total_qty || 0, totalAmount: item.total_amount || 0, creator: currentUserName })); } }; /*********************** * 状态管理 ***********************/ const EDGE_THRESHOLD = 10; const COLLAPSE_WIDTH = 32; const EXPAND_WIDTH = 250; const AUTO_COLLAPSE_DELAY = 150; const HOVER_EXPAND_DELAY = 80; const state = { ui: null, supplierId: '', supplierName: '', currentUserName: '', // v3.2.0 新增:仓储方下拉的当前选择(空字符串 = 不指定/本仓) selectedWmsCoId: '', positions: { open: { left: '10px', top: '10px' }, collapsed: { left: '0px', top: '10px' } }, collapsed: false, timers: { collapseTimer: null, expandTimer: null } }; /*********************** * UI 组件 ***********************/ function buildUI() { const container = createEl('div', {}, ` position: fixed; top: 10px; left: 10px; z-index: 9999; display: flex; flex-direction: column; gap: 1px; background: #fff; padding: 0; border: 2px solid #4A90E2; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); user-select: none; font-family: Arial, sans-serif; width: ${EXPAND_WIDTH}px; transition: width .3s ease, left .3s ease; `); const header = createEl('div', { textContent: 'ERP 供应商搜索工具' }, ` background-color: #4A90E2; padding: 10px 15px; border-radius: 6px 6px 0 0; cursor: move; font-weight: bold; color: white; text-align: center; `); const searchInput = createEl('input', { type: 'text', placeholder: '搜索供应商...' }, ` padding: 5px 10px; border: 1px solid #ccc; border-radius: 3px; font-size: 14px; width: 100%; box-sizing: border-box; `); const searchBtn = createEl('button', { textContent: '搜索' }, ` padding: 5px 10px; background: #4CAF50; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 14px; width: 60px; box-sizing: border-box; `); const searchRow = createEl('div', {}, ` display: flex; gap: 5px; width: 100%; padding: 5px 15px; box-sizing: border-box; `); searchRow.append(searchInput, searchBtn); const supplierDisplay = createEl('input', { type: 'text', placeholder: '获取到的供应商备注...', readOnly: true }, ` padding: 5px 10px; border: 1px solid #ccc; border-radius: 3px; font-size: 14px; width: 100%; box-sizing: border-box; `); const returnBtn = createEl('button', { textContent: '确认退单' }, ` padding: 5px 10px; background: #f44336; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 14px; width: 100%; box-sizing: border-box; `); // ===== v3.2.0 新增:仓储方下拉 ===== // 同步宿主页面的 #wms_co_id 选项;持久化上次选择。 const wmsCoSelect = createEl('select', {}, ` padding: 4px 6px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px; width: 100%; box-sizing: border-box; background: #fff; `); wmsCoSelect.id = 'erp_search_wms_co_select'; wmsCoSelect.title = '选择创建退货单的目标仓储方(留空=本仓,即 ERP 默认)'; // 兜底:先把空选项放进去,避免「#wms_co_id 还没渲染」时按钮是空的 const placeholderOpt = document.createElement('option'); placeholderOpt.value = ''; placeholderOpt.textContent = '(同步宿主页面的仓储方下拉…)'; wmsCoSelect.appendChild(placeholderOpt); const wmsCoLabel = createEl('div', { textContent: '退单仓储方(可换仓)' }, ` font-size: 11px; color: #666; line-height: 1.4; `); const wmsCoRow = createEl('div', {}, ` display: flex; flex-direction: column; gap: 2px; width: 100%; box-sizing: border-box; `); wmsCoRow.append(wmsCoLabel, wmsCoSelect); wmsCoSelect.addEventListener('change', () => { state.selectedWmsCoId = wmsCoSelect.value; storage.setString('erpSearchReturnWmsCoId', wmsCoSelect.value); // 给个轻量提示,避免用户点了但没注意到「真的换了」 const sel = wmsCoSelect.options[wmsCoSelect.selectedIndex]; const label = sel ? sel.textContent : ''; const tip = label && wmsCoSelect.value ? `本次退单目标:${label}` : '本次退单目标:本仓(默认)'; console.log('%c[供应商工具] ' + tip, 'color:#4A90E2'); // 🆕 v3.2.1:仓储方变化时自动刷新子仓库下拉 refreshWhSelect(); }); // ============================= // ===== v3.2.1 新增:子仓库(wh_id)下拉 ===== // 默认自动从 GetSubWarehouse 接口拉取;失败时回退到手动输入模式 const whSelect = createEl('select', {}, ` padding: 4px 6px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px; width: 100%; box-sizing: border-box; background: #fff; `); whSelect.id = 'erp_search_wh_select'; whSelect.title = '选择具体仓库(wh_id),从已选仓储方自动拉取'; // 兜底:占位 option const whPlaceholder = document.createElement('option'); whPlaceholder.value = ''; whPlaceholder.textContent = '(选好仓储方后自动加载子仓库)'; whSelect.appendChild(whPlaceholder); const whLabel = createEl('div', { textContent: '退单仓库(wh_id)' }, ` font-size: 11px; color: #666; line-height: 1.4; `); const whRow = createEl('div', {}, ` display: flex; flex-direction: column; gap: 2px; width: 100%; box-sizing: border-box; `); whRow.append(whLabel, whSelect); // 子仓库下拉变化时持久化 whSelect.addEventListener('change', () => { state.selectedWhId = whSelect.value; storage.setString('erpSearchReturnWhId', whSelect.value); }); // ============================= // 子仓库列表拉取函数 async function refreshWhSelect() { const wmsRaw = wmsCoSelect.value; // 🆕 v3.2.5:本仓占位('' / '0')也要拉子仓库 —— 先取 ctx,再解析成公司自身 wms_co_id // 实测 supplierpicker 选本仓时 GetSubWarehouse Args=["12816726"](=ctx.auth), // 传 "0" 服务端不认识 → 返回空 → 下拉显示「无子仓库」(v3.2.4 之前的 bug) const ctx = (typeof getCompanyCtx === 'function') ? getCompanyCtx() : null; const wms = resolveWmsCoId(ctx, wmsRaw); // 清空现有 option + 重置占位 whSelect.innerHTML = ''; const newPh = document.createElement('option'); newPh.value = ''; newPh.textContent = wms ? '(拉取子仓库中…)' : '(请先选仓储方)'; whSelect.appendChild(newPh); state.selectedWhId = ''; if (!wms) return; try { if (!ctx || !ctx.owner) { newPh.textContent = '(未识别公司上下文)'; return; } // 🆕 v3.2.5:传解析后的 wms(本仓 → 公司自身 ID),不是原始 '0' / '' const list = await Api.getSubWarehouse(ctx, wms); whSelect.innerHTML = ''; if (!list || !list.length) { newPh.textContent = '(该仓储方下无子仓库,可手动填 wh_id)'; newPh.value = '__manual__'; whSelect.appendChild(newPh); return; } // 子仓库字段名多种可能(wh_id / id / w_id),按需兼容 const getId = (it) => it.wh_id || it.id || it.w_id || it.whId || ''; const getName = (it) => it.wh_name || it.name || it.title || it.label || ('wh_id=' + getId(it)); list.forEach(it => { const opt = document.createElement('option'); opt.value = String(getId(it)); opt.textContent = getName(it); whSelect.appendChild(opt); }); // 恢复上次选择(同一个仓储方下的) const saved = storage.getString('erpSearchReturnWhId', ''); if (saved && Array.from(whSelect.options).some(o => o.value === saved)) { whSelect.value = saved; } state.selectedWhId = whSelect.value; console.log( `%c[供应商工具] 已加载 ${list.length} 个子仓库`, 'color:#4CAF50' ); } catch (e) { console.error('[供应商工具] 拉取子仓库失败:', e); whSelect.innerHTML = ''; const errOpt = document.createElement('option'); errOpt.value = '__manual__'; errOpt.textContent = `(拉取失败:${e.message},可手动填 wh_id)`; whSelect.appendChild(errOpt); state.selectedWhId = ''; } } // ============================= const todayBtn = createEl('button', { textContent: '今日汇总' }, ` padding: 5px 8px; background: #2196F3; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 12px; flex: 1; box-sizing: border-box; margin: 0; `); const pastBtn = createEl('button', { textContent: '往日草拟' }, ` padding: 5px 8px; background: #FF9800; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 12px; flex: 1; box-sizing: border-box; margin: 0; `); const resetBtn = createEl('button', { textContent: '重置' }, ` padding: 5px 8px; background: #9E9E9E; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 12px; flex: 1; box-sizing: border-box; margin: 0; `); const draftRow = createEl('div', {}, ` display: flex; flex-direction: row; gap: 5px; width: 100%; box-sizing: border-box; `); draftRow.append(todayBtn, pastBtn, resetBtn); const resultRow = createEl('div', {}, ` display: flex; flex-direction: column; gap: 5px; width: 100%; padding: 0 15px 15px; box-sizing: border-box; `); // v3.2.0:下拉放在备注显示框和退单按钮之间 resultRow.append(supplierDisplay, wmsCoRow, whRow, returnBtn, draftRow); container.append(header, searchRow, resultRow); document.body.appendChild(container); state.ui = { container, header, searchInput, searchBtn, supplierDisplay, returnBtn, wmsCoSelect, wmsCoLabel, wmsCoRow, // v3.2.0 whSelect, whLabel, whRow, // v3.2.1 子仓库 todayBtn, pastBtn, resetBtn, searchRow, resultRow }; return state.ui; } /*********************** * v3.2.0 同步宿主页面的仓储方下拉选项 * - 页面 #wms_co_id 是 JTable 框架在 onload 后动态渲染的, * 所以本工具面板打开时它可能还没出来 —— 用重试兜底(最多 8 次 × 500ms)。 * - 拿到后立刻恢复上次选择 + 触发一次 change 把 state 同步上。 ***********************/ function syncWmsCoOptionsFromPage() { const { wmsCoSelect } = state.ui; if (!wmsCoSelect) return; let tries = 0; const trySync = () => { tries += 1; const ok = mirrorPageWmsCoOptions(wmsCoSelect); if (ok) { // 🆕 v3.2.4:默认从宿主 #wms_co_id 当前 value 取(公司本仓 wms_co_id) // 之前默认 value='' → 用户不主动改选时 change 永远不触发 // → refreshWhSelect 永远拿不到本仓子仓库(本仓就是主仓) const pageSel = qs('#wms_co_id'); const pageDefault = pageSel ? String(pageSel.value || '').trim() : ''; // 恢复上次选择 const saved = storage.getString('erpSearchReturnWmsCoId', ''); let target = ''; if (saved && qsa('option', wmsCoSelect).some(o => o.value === saved)) { target = saved; } else if (pageDefault && qsa('option', wmsCoSelect).some(o => o.value === pageDefault)) { // 宿主页面当前 value 是有效 option(其他公司/本仓非空 ID),用它 target = pageDefault; } // 兜底:target 仍为空(宿主页面 value 是空)→ 维持 ''(本仓占位) wmsCoSelect.value = target; state.selectedWmsCoId = wmsCoSelect.value; console.log( `%c[供应商工具] 已同步 #wms_co_id 选项(${wmsCoSelect.options.length} 个),默认 wms_co_id=${wmsCoSelect.value || '<本仓占位>'}`, 'color:#4CAF50' ); // 🆕 v3.2.4:显式触发 change 让绑在 wmsCoSelect 上的 change 监听器 // (含 refreshWhSelect)跑一次。 // 之前用户不主动改选的话,change 永远不触发,子仓库永远是占位。 // - value 有值:change 触发 → refreshWhSelect → GetSubWarehouse → 拉子仓库 // - value 为空:change 触发 → refreshWhSelect 走 if(!wms) return → 子仓库保持占位 // (合理:宿主页面也是空,没法拉本仓子仓库) wmsCoSelect.dispatchEvent(new Event('change')); return; } if (tries < 8) setTimeout(trySync, 500); else console.warn('[供应商工具] 同步宿主页 #wms_co_id 选项超时(8 次 × 500ms)'); }; trySync(); } /*********************** * 拖动与折叠 ***********************/ function enableDragAndSnap() { const { container, header, searchRow, resultRow } = state.ui; let dragging = false; let startX = 0, startY = 0; let rafId = null; const savedPos = storage.get('erpSearchPosition'); const savedCollapse = storage.get('erpSearchCollapseState'); if (savedPos) { container.style.left = savedPos.left; container.style.top = savedPos.top; state.positions.open = savedPos; } if (savedCollapse?.isCollapsed) { state.positions.collapsed = savedCollapse.position || state.positions.collapsed; collapse(savedCollapse.side || 'left'); } header.addEventListener('mousedown', (e) => { dragging = true; startX = e.clientX - container.offsetLeft; startY = e.clientY - container.offsetTop; container.style.transition = 'none'; document.body.style.pointerEvents = 'none'; header.style.pointerEvents = 'auto'; if (state.collapsed) expand(); }); document.addEventListener('mousemove', (e) => { if (!dragging) return; if (rafId) cancelAnimationFrame(rafId); rafId = requestAnimationFrame(() => { const x = Math.max(0, Math.min(window.innerWidth - container.offsetWidth, e.clientX - startX)); const y = Math.max(0, Math.min(window.innerHeight - container.offsetHeight, e.clientY - startY)); container.style.left = `${x}px`; container.style.top = `${y}px`; }); }); document.addEventListener('mouseup', () => { if (!dragging) return; dragging = false; if (rafId) cancelAnimationFrame(rafId); container.style.transition = 'width .3s ease, left .3s ease'; document.body.style.pointerEvents = 'auto'; container.style.top = `${Math.max(0, parseInt(container.style.top || '0', 10))}px`; snapToEdge(); storage.set('erpSearchPosition', { left: container.style.left, top: container.style.top }); }); container.addEventListener('mouseleave', () => { if (dragging || state.collapsed) return; state.timers.collapseTimer = setTimeout(() => snapToEdge(), AUTO_COLLAPSE_DELAY); }); container.addEventListener('mouseenter', () => { if (state.timers.collapseTimer) clearTimeout(state.timers.collapseTimer); if (state.collapsed) { if (state.timers.expandTimer) clearTimeout(state.timers.expandTimer); state.timers.expandTimer = setTimeout(() => expand(), HOVER_EXPAND_DELAY); } }); function snapToEdge() { const left = parseInt(container.style.left || '0', 10); state.positions.open = { left: container.style.left, top: container.style.top }; if (left <= EDGE_THRESHOLD) { collapse('left'); } else if (left >= window.innerWidth - container.offsetWidth - EDGE_THRESHOLD) { collapse('right'); } } function collapse(edge) { state.collapsed = true; searchRow.style.display = 'none'; resultRow.style.display = 'none'; container.style.width = `${COLLAPSE_WIDTH}px`; state.positions.open = { left: container.style.left, top: container.style.top }; container.style.left = edge === 'left' ? '0px' : `${Math.max(0, window.innerWidth - COLLAPSE_WIDTH)}px`; state.positions.collapsed = { left: container.style.left, top: container.style.top }; header.textContent = '🔍'; header.style.padding = '10px 5px'; container.dataset.collapsedSide = edge; storage.set('erpSearchCollapseState', { isCollapsed: true, side: edge, position: state.positions.collapsed }); } function expand() { state.collapsed = false; searchRow.style.display = 'flex'; resultRow.style.display = 'flex'; container.style.width = `${EXPAND_WIDTH}px`; const targetLeft = state.positions.open.left || state.positions.collapsed.left || '10px'; const targetTop = state.positions.open.top || state.positions.collapsed.top || '10px'; container.style.left = targetLeft; container.style.top = targetTop; header.textContent = 'ERP 供应商搜索工具'; header.style.padding = '10px 15px'; storage.set('erpSearchPosition', { left: container.style.left, top: container.style.top }); storage.remove('erpSearchCollapseState'); } state.ui.expand = expand; state.ui.snapToEdge = snapToEdge; window.addEventListener('resize', () => { if (state.collapsed) { const side = container.dataset.collapsedSide || 'left'; if (side === 'right') { container.style.left = `${Math.max(0, window.innerWidth - COLLAPSE_WIDTH)}px`; } else { container.style.left = '0px'; } state.positions.collapsed = { left: container.style.left, top: container.style.top }; } else { const left = Math.min(Math.max(0, parseInt(container.style.left || '0', 10)), Math.max(0, window.innerWidth - EXPAND_WIDTH)); const top = Math.min(Math.max(0, parseInt(container.style.top || '0', 10)), Math.max(0, window.innerHeight - container.offsetHeight)); container.style.left = `${left}px`; container.style.top = `${top}px`; state.positions.open = { left: container.style.left, top: container.style.top }; } }); } /*********************** * 表格复制按钮(与 v3.1.1 一致,未变) ***********************/ function addCopyButtonsOnce() { const processedTables = new WeakSet(); const createCopyButton = (currentDoc, cells, returnIdIdx, supplierIdx, targetIndex) => { const targetCell = cells[targetIndex]; if (!targetCell) return; const rId = cells[returnIdIdx]?.textContent?.trim() || ''; const sName = cells[supplierIdx]?.textContent?.trim() || ''; const sig = rId + '\u0001' + sName; const existingCopyBtn = targetCell.querySelector('.copy-btn'); if (existingCopyBtn && existingCopyBtn.dataset.sig === sig) return; if (existingCopyBtn) existingCopyBtn.remove(); const copyBtn = currentDoc.createElement('div'); copyBtn.className = 'btn copy-btn'; copyBtn.dataset.sig = sig; copyBtn.textContent = '复制'; copyBtn.addEventListener('click', (e) => { e.stopPropagation(); const processedSupplier = sName.replace(/[()()][^()()]*新[^()()]*[()()]/gi, ''); const combinedText = `${processedSupplier}${rId}.`; clipboardCopy(combinedText); notify('已复制:' + combinedText); }); targetCell.innerHTML = ''; targetCell.appendChild(copyBtn); }; const getTableFieldPositions = (table) => { const headerCells = qsa('tr:first-child th, tr:first-child td', table); const fieldPositions = {}; headerCells.forEach((cell, idx) => { const name = cell.textContent.trim(); if (name && name !== ' ' && name !== ' ') { fieldPositions[name] = idx; } }); return fieldPositions; }; const getDivTableFieldPositions = (container, currentDoc) => { let fieldOrder = []; let jtRowHeadListElement = qs('#_jt_row_head_list', container.ownerDocument || currentDoc); if (jtRowHeadListElement) { const fieldItems = qsa('._jt_cell_head', jtRowHeadListElement); fieldItems.forEach(item => { const fieldName = item.textContent.trim(); if (fieldName && fieldName !== ' ' && fieldName !== ' ') { fieldOrder.push(fieldName); } }); } else { const rows = qsa('div[class*="row"], div[class*="jt_row"], div._jt_rh', container); const headerRow = rows.find(row => { const cells = qsa('div[class*="cell"], div[class*="jt_ch"]', row); return cells.length > 0 && cells.some(cell => cell.textContent.trim().length > 0 && cell.textContent.trim().length < 20); }) || rows[0]; if (headerRow) { const headerCells = qsa('div[class*="cell"], div[class*="jt_ch"]', headerRow); headerCells.forEach(cell => { const name = cell.textContent.trim(); if (name && name !== ' ' && name !== ' ') { fieldOrder.push(name); } }); } } const fieldPositions = {}; fieldOrder.forEach((fieldName, index) => { if (fieldName) fieldPositions[fieldName] = index; }); return fieldPositions; }; const processTable = (table, currentDoc) => { if (!table || processedTables.has(table)) return; processedTables.add(table); const fieldPositions = getTableFieldPositions(table); const returnIdIdx = fieldPositions['退货单号'] ?? fieldPositions['退货单'] ?? fieldPositions['单号'] ?? 1; const supplierIdx = fieldPositions['供应商'] ?? 17; const returnProcessIdx = fieldPositions['退货进程'] ?? fieldPositions['进程'] ?? fieldPositions['退货进程'] ?? fieldPositions['进程']; if (returnProcessIdx === undefined) return; const rows = qsa('tr:not(:first-child)', table); rows.forEach(row => { const cells = qsa('td', row); if (!cells.length) return; const actualReturnIdIdx = returnIdIdx + 2; const actualSupplierIdx = supplierIdx + 2; const actualReturnProcessIdx = returnProcessIdx + 2; if (actualReturnIdIdx >= 0 && actualReturnIdIdx < cells.length && actualSupplierIdx >= 0 && actualSupplierIdx < cells.length && actualReturnProcessIdx >= 0 && actualReturnProcessIdx < cells.length) { createCopyButton(currentDoc, cells, actualReturnIdIdx, actualSupplierIdx, actualReturnProcessIdx); } }); }; const processDivTable = (container, currentDoc) => { const rows = qsa('div[class*="row"], div[class*="jt_row"], div._jt_rh', container); const fieldPositions = getDivTableFieldPositions(container, currentDoc); const returnIdIdx = fieldPositions['退货单号'] ?? fieldPositions['退货单'] ?? fieldPositions['单号'] ?? 1; const supplierIdx = fieldPositions['供应商'] ?? 17; const returnProcessIdx = fieldPositions['退货进程'] ?? fieldPositions['进程'] ?? fieldPositions['退货进程'] ?? fieldPositions['进程']; if (returnProcessIdx === undefined) return; rows.forEach(row => { if (row.querySelector('._jt_cell_head')) return; const cells = qsa('div[class*="cell"], div[class*="jt_ch"]', row); if (!cells.length) return; const actualReturnIdIdx = returnIdIdx + 2; const actualSupplierIdx = supplierIdx + 2; const actualReturnProcessIdx = returnProcessIdx + 2; if (actualReturnIdIdx >= 0 && actualReturnIdIdx < cells.length && actualSupplierIdx >= 0 && actualSupplierIdx < cells.length && actualReturnProcessIdx >= 0 && actualReturnProcessIdx < cells.length) { createCopyButton(currentDoc, cells, actualReturnIdIdx, actualSupplierIdx, actualReturnProcessIdx); } }); }; const tryProcess = (doc) => { const containers = [ doc.getElementById('_jt_body'), doc.getElementById('_jt_body_list') ].filter(Boolean); containers.forEach(container => { const tables = container.querySelectorAll('table'); if (tables.length) { tables.forEach(t => processTable(t, doc)); } else { processDivTable(container, doc); } }); if (containers.length === 0) { const tables = doc.querySelectorAll('table'); tables.forEach(t => processTable(t, doc)); } }; tryProcess(document); qsa('iframe').forEach(iframe => { try { if (iframe.contentDocument) tryProcess(iframe.contentDocument); } catch (e) { /* ignore cross domain */ } }); } function setupMutationObserver() { let timer = null; const observer = new MutationObserver(() => { if (timer) clearTimeout(timer); timer = setTimeout(() => { addCopyButtonsOnce(); }, 400); }); observer.observe(document.body, { childList: true, subtree: true }); } /*********************** * 表单辅助 ***********************/ const FormHelper = { fetchUser() { const cookieName = document.cookie.match(/u_name=([^;]+)/); if (cookieName?.[1]) { state.currentUserName = decodeURIComponent(cookieName[1]); return; } fetch('https://api.erp321.com/erp/webapi/UserApi/Passport/GetUserInfo', { method: 'POST', headers: { 'Content-Type': 'application/json;charset=UTF-8', 'Referer': 'https://ww.erp321.com/', 'Origin': 'https://ww.erp321.com' }, credentials: 'include', body: JSON.stringify({}) }).then(r => r.json()).then(d => { state.currentUserName = d?.data?.userName || ''; }).catch(() => { /* ignore */ }); }, selectCreator(doc = document) { if (!state.currentUserName) return; const creator = doc.getElementById('creator_name') || document.evaluate('//*[@id="creator_name"]', doc, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; if (!creator) { console.warn('[供应商工具] 未找到 #creator_name(页面筛选组件结构可能已变),"往日草拟"的制单人条件不会生效'); return; } let cbb = creator.nextElementSibling?.classList?.contains('_cbb_items') ? creator.nextElementSibling : null; if (!cbb) { cbb = Array.from(doc.querySelectorAll('._cbb_items')).find(el => el.previousElementSibling?.id === 'creator_name') || null; } if (!cbb) { cbb = document.evaluate('//*[@id="creator_name"]/following-sibling::div[contains(@class, "_cbb_items")]', doc, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; } if (!cbb) return; let target = cbb.querySelector(`._cbb_label[value="${state.currentUserName}"]`)?.closest('._cbb_item') || null; if (!target) { target = Array.from(cbb.querySelectorAll('._cbb_item')).find(item => item.querySelector('._cbb_label')?.getAttribute('value') === state.currentUserName) || null; } if (target) { target.click(); creator.value = state.currentUserName; creator.dispatchEvent(new MouseEvent('click', { bubbles: true })); creator.dispatchEvent(new Event('change', { bubbles: true })); } }, selectStatus(doc = document) { const status = doc.getElementById('status') || document.evaluate('//*[@id="status"]', doc, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; if (!status) { console.warn('[供应商工具] 未找到 #status(页面筛选组件结构可能已变),"往日草拟"的状态条件不会生效'); return; } let cbb = status.nextElementSibling?.classList?.contains('_cbb_items') ? status.nextElementSibling : null; if (!cbb) { cbb = Array.from(doc.querySelectorAll('._cbb_items')).find(el => el.previousElementSibling?.id === 'status') || null; } if (!cbb) { cbb = document.evaluate('//*[@id="status"]/following-sibling::div[contains(@class, "_cbb_items")]', doc, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; } if (!cbb) return; let target = cbb.querySelector('._cbb_label[value="草拟"]')?.closest('._cbb_item') || null; if (!target) { target = Array.from(cbb.querySelectorAll('._cbb_item')).find(item => item.querySelector('._cbb_label')?.getAttribute('value') === '草拟') || null; } if (target) { const checkbox = target.querySelector('input[type="checkbox"][value="草拟"]'); const checked = checkbox ? checkbox.checked : target.querySelector('._cbb_cbx_p')?.classList.contains('checked'); if (!checked) (checkbox || target.querySelector('._cbb_cbx_p') || target).click(); } }, setDate(id, val, doc = document) { const el = doc.getElementById(id); if (el) { el.value = val; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } }, clickSearch(doc = document) { const searchBtn = qs('.btn_search', doc) || qs('a[onclick*="Search()"]', doc) || qs('a[onclick*="Search("]', doc); if (searchBtn) { try { searchBtn.click(); return; } catch (e) { /* 落到文本兜底 */ } } const textBtn = qsa('a, button, input[type="button"], input[type="submit"]', doc) .find(b => ((b.textContent || '').trim() === '搜索') || ((b.value || '').trim() === '搜索')); if (textBtn) { try { textBtn.click(); return; } catch (e) { /* ignore */ } } const input = qs('input[type="text"], input[type="search"]', doc); if (input) input.dispatchEvent(new KeyboardEvent('keypress', { key: 'Enter', bubbles: true })); console.warn('[供应商工具] 未找到搜索按钮(.btn_search),已用回车兜底'); }, resetForm(doc = document) { const clearBtn = qs('.btn_search_reset', doc) || qs('a[onclick*="ClearSearch()"]', doc) || qsa('a, button, input[type="button"], input[type="submit"]', doc) .find(b => ['清空', '重置', '清除'].includes((b.textContent || '').trim())); if (clearBtn) { try { clearBtn.click(); } catch (e) { /* ignore */ } } else { console.warn('[供应商工具] 未找到清空/重置按钮(.btn_search_reset)'); } setTimeout(() => { addCopyButtonsOnce(); }, 500); setTimeout(() => { selectFirstRowAndCheckbox(doc); }, 1000); } }; function selectFirstRowAndCheckbox(doc) { const tables = qsa('table', doc); let targetTable = null; let idx = 0; for (const t of tables) { idx += 1; if (idx === 13) continue; const txt = t.textContent.toLowerCase(); if (txt.includes('退货单号') && txt.includes('供应商') && txt.includes('总金额')) { targetTable = t; break; } } if (targetTable) { const rows = qsa('tr', targetTable); const header = rows[0]; const isHeader = header && (header.textContent.includes('退货单号') || header.querySelector('th')); const dataRows = isHeader ? rows.slice(1) : rows; for (const row of dataRows) { const checkbox = row.querySelector('input[type="checkbox"]'); const text = row.textContent; const hasOps = /创建新单据|创建退货单据|创建质检退货|创建返修退货|导入退货单据/.test(text); const hasData = /采购退货|草拟|待推单|退供|普通退货/.test(text); if (checkbox && !hasOps && hasData) { row.click(); if (!checkbox.checked) checkbox.checked = true; return true; } } } const jtBody = doc.getElementById('_jt_body_list'); if (jtBody) { const rows = qsa('div._jt_row._jt_rh', jtBody); for (const row of rows) { const checkbox = row.querySelector('input[type="checkbox"]'); const hasOps = /创建新单据|创建退货单据/.test(row.textContent); if (checkbox && !hasOps) { row.click(); row.classList.add('_jt_row_current'); row.setAttribute('row-select', 'true'); if (!checkbox.checked) checkbox.checked = true; return true; } } } const iframes = qsa('iframe', doc); for (const f of iframes) { try { if (f.contentDocument && selectFirstRowAndCheckbox(f.contentDocument)) return true; } catch (e) { /* ignore */ } } return false; } /*********************** * 事件绑定 ***********************/ function bindEvents() { // 🆕 v3.2.3:把 whSelect 加入解构,click handler 里才能引用 // (whSelect / wmsCoSelect 都在 buildUI() 内部 const, // bindEvents() 是另一个函数作用域,必须从 state.ui 解构出来才能用) const { searchInput, searchBtn, supplierDisplay, returnBtn, todayBtn, pastBtn, resetBtn, wmsCoSelect, whSelect } = state.ui; const doSearch = async () => { const term = searchInput.value.trim(); if (!term) return; const uId = getCookie('u_id'); const ctx = getCompanyCtx(); if (!uId || !ctx.owner) { notify('无法获取登录/公司上下文参数', 'error'); return; } try { const supplier = await Api.searchSupplier(term, uId, ctx); if (!supplier) { notify('未找到供应商', 'error'); return; } state.supplierId = supplier.supplier_id; state.supplierName = supplier.supplier_name; const remark = await Api.supplierRemark(supplier.supplier_name); if (remark && remark !== '无备注信息') { supplierDisplay.value = remark; supplierDisplay.style.color = '#ff0000'; supplierDisplay.style.fontWeight = 'bold'; } else { supplierDisplay.value = '无特殊要求'; supplierDisplay.style.color = '#999'; supplierDisplay.style.fontWeight = 'normal'; } if (term.length > 3) { await clipboardCopy(term.slice(0, -3)); } notify(`搜索到供应商:${supplier.supplier_name}`); } catch (e) { console.error(e); notify(`搜索失败:${e.message}`, 'error'); } }; searchBtn.addEventListener('click', doSearch); searchInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') doSearch(); }); returnBtn.addEventListener('click', async () => { if (!state.supplierId) { notify('请先搜索供应商', 'error'); return; } const ctx = getCompanyCtx(); if (!ctx.owner) { notify('无法获取当前公司/货主上下文,请刷新页面后重试', 'error'); return; } // v3.2.1:从下拉读 wms_co_id 和 wh_id const wmsRaw = wmsCoSelect ? wmsCoSelect.value : ''; const wh = whSelect ? whSelect.value : ''; // __manual__ 是拉取失败时的占位,不应作为真实 wh_id 提交 const whActual = wh && wh !== '__manual__' ? wh : ''; // 🆕 v3.2.5:本仓占位('' / '0')解析成公司自身 wms_co_id 再下发 const wms = resolveWmsCoId(ctx, wmsRaw); if (wmsRaw || wms) { const opt = wmsCoSelect ? wmsCoSelect.options[wmsCoSelect.selectedIndex] : null; const whLabel = whActual ? ` / 仓库=${whSelect.options[whSelect.selectedIndex]?.textContent || whActual}` : ''; const fromBen = (wmsRaw === '' || wmsRaw === '0') ? `(本仓→wms_co_id=${wms})` : ''; console.log( `%c[供应商工具] 「确认退单」目标:${opt ? opt.textContent : wms}${fromBen}${whLabel}`, 'color:#FF9800;font-weight:bold' ); } // v3.2.1:两个值都必需,缺一就拒绝(不让错误必填项跑出去乱发请求) if (!wms) { notify('请先选择仓储方(wms_co_id)', 'error'); return; } if (!whActual) { notify('请先选择子仓库(wh_id),或手动填入数字', 'error'); return; } try { const res = await Api.newReturnTask(ctx, state.supplierId, wms, whActual); if (res?._dryRun) { notify(`(DRY-RUN) 退单 payload 已打印到控制台,未真发请求`, 'warning'); } else if (res?.ReturnValue === true || res?.Success === true) { notify(`成功为 "${state.supplierName}" 创建退货单据`); setTimeout(() => { FormHelper.clickSearch(); }, 500); } else if (res?.ReturnValue === false || res?.Success === false) { notify('创建退货单据失败', 'error'); } else { notify('操作已提交,结果未知', 'warning'); } } catch (e) { console.error(e); notify(`退单失败:${e.message}`, 'error'); } }); todayBtn.addEventListener('click', async () => { try { if (!state.currentUserName) FormHelper.fetchUser(); await new Promise(r => setTimeout(r, 400)); if (!state.currentUserName) throw new Error('无法获取当前用户'); const rows = await Api.loadTodaySummary(state.currentUserName); if (!rows.length) { notify('今日没有汇总数据', 'warning'); return; } const date = rows[0].date; const [y, m, d] = date.split('-'); const md = `${parseInt(m, 10)}月${parseInt(d, 10)}日`; const text = rows.map(r => `${md}\t${md}\t${r.supplier}\t${r.skuCount}\t${r.totalQty}\t${r.totalAmount}\t"正常退货"\t${r.creator}\t${r.totalQty}\t${r.totalAmount}\t"0"`).join('\n'); await clipboardCopy(text); notify('今日汇总已复制'); } catch (e) { console.error(e); notify(`今日汇总失败:${e.message}`, 'error'); } }); pastBtn.addEventListener('click', () => { FormHelper.resetForm(); const now = new Date(); const twoDaysAgo = new Date(now); twoDaysAgo.setDate(now.getDate() - 2); const format = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; const date = format(twoDaysAgo); setTimeout(() => { FormHelper.fetchUser(); FormHelper.selectCreator(); FormHelper.selectStatus(); FormHelper.setDate('date2', date); setTimeout(() => FormHelper.clickSearch(), 100); }, 300); }); resetBtn.addEventListener('click', () => FormHelper.resetForm()); } /*********************** * 初始化 ***********************/ function init() { buildUI(); enableDragAndSnap(); FormHelper.fetchUser(); setupMutationObserver(); bindEvents(); addCopyButtonsOnce(); setTimeout(() => { addCopyButtonsOnce(); }, 1000); // v3.2.0:等宿主页面 #wms_co_id 渲染出来后同步选项 syncWmsCoOptionsFromPage(); } window.addEventListener('DOMContentLoaded', () => { setTimeout(() => { addCopyButtonsOnce(); }, 500); }); window.addEventListener('load', init); })();