// ==UserScript== // @name receiptauditbatch // @namespace https://scriptcat.org/zh-CN/script-show-page/8391 // @version 1.1 // @description 销售退货单列表:批量搜索 saleorderid 并自动审核匹配数据 // @author fengxia // @match http://47.107.106.156:8888/sales/receiptListPage.do // @match http://122.13.25.247:8888/sales/receiptListPage.do // @grant none // @run-at document-idle // ==/UserScript== (function () { 'use strict'; // ===== 配置常量 ===== const CONFIG = { // 无首字母时,在下列列做"包含"匹配(不区分大小写) generalFields: ['saleorderid'], // 首字母 -> { field: 搜索列, prefixes: 该列值需以其中之一开头 } prefixRules: { x: { field: 'saleorderid', prefixes: ['XD', 'HQXD'] }, t: { field: 'id', prefixes: ['TZD', 'HQTZD'] }, h: { field: 'sourceid', prefixes: ['HH', 'HQHH'] }, c: { field: 'id', prefixes: ['CC'] }, }, // 批量输入分隔符:空白(含换行/制表/全角空格)、半角逗号/分号、全角逗号/顿号/分号 tokenSeparator: /[\s,;,、;]+/, placeholder: '每行/逗号分隔多个单号;回车执行,Shift+回车换行;首字母 x/t/h/c 选类,或直接输入在 id/sourceid/saleorderid 等搜索', toastDurationMs: 3000, resultAreaMaxHeight: 200, // 结果显示区域最大高度(px),超出可滚动 }; // ===== 权限校验配置 ===== const CHECK_URL = `${location.origin}/basefiles/showWorkJobPage.do`; const CHECK_TIMEOUT_MS = 5000; const NO_PERMISSION_KEYWORD = '无权限访问该页面'; const CHECK_CACHE_KEY = `receipt_audit_perm_${location.host}`; // ===== 通用工具 ===== function notify(msg) { console.log('[receipt-audit-batch]', msg); const toast = document.createElement('div'); toast.textContent = msg; toast.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:99999;background:#333;color:#fff;padding:8px 16px;border-radius:4px;max-width:60%;font-size:14px;box-shadow:0 2px 8px rgba(0,0,0,.3);transition:opacity .5s;'; document.body.appendChild(toast); setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 500); }, CONFIG.toastDurationMs); } function splitTokens(raw) { if (!raw) return []; const parts = String(raw).split(CONFIG.tokenSeparator).map(s => s.trim()).filter(Boolean); return Array.from(new Set(parts)); } // ===== 匹配规则 ===== function buildMatcher(token) { const prefix = token.charAt(0).toLowerCase(); const rule = CONFIG.prefixRules[prefix]; const full = token.toLowerCase(); const keyword = token.slice(1).trim().toLowerCase(); return function (row) { if (!rule) { return CONFIG.generalFields.some(f => String(row[f] == null ? '' : row[f]).toLowerCase().includes(full)); } const value = String(row[rule.field] == null ? '' : row[rule.field]); if (!value) return false; const upper = value.toUpperCase(); const lower = value.toLowerCase(); const prefixOk = rule.prefixes.some(p => upper.indexOf(p) === 0); return prefixOk && ((keyword && lower.includes(keyword)) || lower.includes(full)); }; } // ===== 阶段一:数据采集 ===== // POST 请求获取列表数据 function fetchReceiptList() { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', `${location.origin}/sales/getReceiptList.do`); xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); xhr.timeout = 15000; xhr.onload = () => { if (xhr.status === 200) { try { resolve(JSON.parse(xhr.responseText)); } catch (e) { reject(new Error('返回数据解析失败')); } } else { reject(new Error('请求失败,状态码: ' + xhr.status)); } }; xhr.onerror = () => reject(new Error('网络错误')); xhr.ontimeout = () => reject(new Error('请求超时')); xhr.send( 'businessdate=&businessdate1=&id=&customersort=&detailOrder=0&printsign=&queryprinttimes=0' + '&writeoffdate1=&writeoffdate2=&indooruserid=&salesuser=&isinvoice=-1&accounttype=&customerid=' + '&salesarea=&ispassdue=&brandid=&checkstatusflag=&status=2&isledger=&deliveryid=&sourceid=&goodsid=' + '&oldFromData=' + encodeURIComponent(JSON.stringify({ businessdate: '', businessdate1: '', id: '', customersort: '', detailOrder: '0', printsign: '', queryprinttimes: '0', writeoffdate1: '', writeoffdate2: '', indooruserid: '', salesuser: '', isinvoice: '-1', accounttype: '', customerid: '', salesarea: '', ispassdue: '', brandid: '', checkstatusflag: '', status: '2', isledger: '', deliveryid: '', sourceid: '', goodsid: '' })) + '&page=1&rows=1000&sort=id&order=desc' ); }); } // ===== 阶段二:搜索匹配 ===== // 在 rows 中搜索匹配 token 的行,返回匹配行的 { token, id, saleorderid } 数组 function searchMatches(rows, tokens) { const results = []; const matchedIds = new Set(); tokens.forEach(token => { const matcher = buildMatcher(token); rows.forEach(row => { if (matchedIds.has(row.id)) return; if (matcher(row)) { matchedIds.add(row.id); results.push({ token: token, id: row.id, saleorderid: row.saleorderid || '' }); } }); }); return results; } // ===== 阶段三:自动提交审核 ===== function submitAudit(ids) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', `${location.origin}/sales/auditMultiReceipt.do`); xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); xhr.timeout = 15000; xhr.onload = () => { if (xhr.status === 200) { try { const resp = JSON.parse(xhr.responseText); resolve(resp); } catch (e) { reject(new Error('审核返回数据解析失败')); } } else { reject(new Error('审核请求失败,状态码: ' + xhr.status)); } }; xhr.onerror = () => reject(new Error('审核网络错误')); xhr.ontimeout = () => reject(new Error('审核请求超时')); xhr.send('ids=' + encodeURIComponent(ids)); }); } // ===== 数据缓存 ===== let cachedRows = null; // 获取数据(有缓存则直接返回) async function ensureData(resultArea) { if (cachedRows && cachedRows.length) return cachedRows; appendResult(resultArea, '正在获取数据...'); const data = await fetchReceiptList(); const rows = (data && data.rows) || []; if (!rows.length) throw new Error('未获取到列表数据'); cachedRows = rows; // appendResult(resultArea, '已获取 ' + rows.length + ' 条数据(已缓存,后续搜索不再重复请求)'); return rows; } // 手动刷新数据缓存 async function refreshData(resultArea) { cachedRows = null; appendResult(resultArea, '正在刷新数据...'); try { await ensureData(resultArea); notify('数据刷新成功,共 ' + cachedRows.length + ' 条'); } catch (err) { notify('刷新数据失败: ' + err.message); appendResult(resultArea, '刷新数据失败: ' + err.message); } } // ===== 统计计数 ===== let totalDocCount = 0; // 单据数(累计提交字符串总条数,点击清空重置) let auditSuccessCount = 0; // 审核成功数 let statsDocEl = null; // 单据数 DOM let statsAuditEl = null; // 审核数 DOM function updateStats() { if (statsDocEl) statsDocEl.textContent = totalDocCount; if (statsAuditEl) statsAuditEl.textContent = auditSuccessCount; } // ===== 批量执行入口 ===== async function runBatch(raw, resultArea) { const tokens = splitTokens(raw); if (!tokens.length) { notify('请输入要搜索的单号'); appendResult(resultArea, '请输入要搜索的单号'); return; } // 更新单据数统计 totalDocCount += tokens.length; updateStats(); let rows; try { rows = await ensureData(resultArea); } catch (err) { notify('获取数据失败: ' + err.message); appendResult(resultArea, '获取数据失败: ' + err.message); return; } // 阶段二:搜索匹配 const matches = searchMatches(rows, tokens); // 找出未匹配到的 token const matchedTokens = new Set(matches.map(m => m.token)); const unmatchedTokens = tokens.filter(t => !matchedTokens.has(t)); // 显示未匹配的 token(每条用横线隔开) unmatchedTokens.forEach(t => { appendResult(resultArea, "──────────"); appendResult(resultArea, "'" + t + "' → 无此保存状态的单号", '#d32f2f'); }); if (!matches.length) { notify('未匹配到任何数据'); return; } // 阶段三:并行提交审核,所有请求同时发出 const promises = matches.map(m => submitAudit(m.id) .then(resp => ({ match: m, resp: resp, error: null })) .catch(err => ({ match: m, resp: null, error: err })) ); const results = await Promise.all(promises); // 按原始顺序输出结果(每条用横线隔开) let batchSuccess = 0; results.forEach(r => { appendResult(resultArea, '──────────'); if (r.error) { appendResult(resultArea, "'" + r.match.token + "' - " + r.match.saleorderid + ' → 审核失败(' + r.error.message + ')', '#d32f2f'); } else { const ok = r.resp && (r.resp.success === 1 || r.resp.success === '1' || r.resp.success === true || r.resp.success === 'true'); const statusText = ok ? '审核成功' : '审核失败'; if (ok) { batchSuccess++; auditSuccessCount++; } appendResult(resultArea, "'" + r.match.token + "' - " + r.match.saleorderid + ' → ' + statusText, ok ? undefined : '#d32f2f'); } }); updateStats(); notify('本批 ' + matches.length + ' 条,成功 ' + batchSuccess + ' 条,失败 ' + (matches.length - batchSuccess) + ' 条'); } // ===== 面板 UI ===== function appendResult(area, text, color) { const line = document.createElement('div'); line.textContent = text; if (color) line.style.color = color; area.appendChild(line); area.scrollTop = area.scrollHeight; } function injectPanel() { if (document.getElementById('receiptAuditBatchPanel')) return; const panel = document.createElement('div'); panel.id = 'receiptAuditBatchPanel'; panel.style.cssText = 'position:fixed;top:80px;right:16px;z-index:99998;width:300px;background:#fff;border:1px solid #95b8e7;border-radius:4px;box-shadow:0 2px 10px rgba(0,0,0,.25);font-size:13px;color:#333;'; panel.innerHTML = '