yiwaima / 调拨记录查询

// ==UserScript==
// @name         调拨记录查询
// @namespace    https://openuserjs.org/users/yiwaima
// @version      1.1.2
// @description  提供调拨记录查询功能,支持通过关键词搜索调拨记录
// @author       yiwaima
// @match        https://*.erp321.com/app/wms/allocate/allocateTab.aspx*
// @icon         https://files.erp321.com/img/platIcon/ziShen.png
// @grant        none
// @license      MIT
// @homepageURL  https://openuserjs.org/scripts/yiwaima/调拨记录查询
// @supportURL   https://github.com/yiwaima/erp-scripts/issues
// @updateURL    https://openuserjs.org/meta/yiwaima/调拨记录查询.meta.js
// @downloadURL  https://openuserjs.org/install/yiwaima/调拨记录查询.user.js
// ==/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) { /* 忽略 */ }
        }
    };

    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();
        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;
    };

    /***********************
     * 业务请求
     ***********************/
    const Api = {
        // 获取VIEWSTATE和VIEWSTATEGENERATOR
        async getViewStateData(baseUrl, path) {
            try {
                const url = `${baseUrl}/app/wms/allocate/${path}`;
                const res = await fetch(url, { credentials: 'include' });
                if (!res.ok) throw new Error(`页面获取失败 ${res.status}`);
                const html = await res.text();
                return parseViewState(html);
            } catch (error) {
                console.error(`获取${path}的VIEWSTATE失败:`, error);
                throw error;
            }
        },
        
        // 搜索寄样调拨记录
        async searchTransferRecords(keyword) {
            try {
                const uCoId = getCookie('u_co_id');
                if (!uCoId) throw new Error('cookie缺少u_co_id');
                
                // 获取当前页面的主机名,用于构建请求URL
                const host = window.location.host;
                const baseUrl = `https://${host}`;
                
                // 获取当前日期和31天前的日期
                const now = new Date();
                const endDate = now.toISOString().split('T')[0];
                const startDate = new Date(now.getTime() - 31 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
                const endDateTime = `${endDate} 23:59:59.998`;
                
                // 获取第一个请求页面的VIEWSTATE和VIEWSTATEGENERATOR
                const { viewState: firstRequestViewState, viewStateGenerator: firstRequestViewStateGenerator } = await this.getViewStateData(baseUrl, 'allocate.aspx');
                
                // 获取第二个请求页面的VIEWSTATE和VIEWSTATEGENERATOR
                const { viewState: secondRequestViewState, viewStateGenerator: secondRequestViewStateGenerator } = await this.getViewStateData(baseUrl, 'allocateIn.aspx');
                
                // 构建第一个请求的URL
                const timestamp = Date.now();
                const authorizeCoId = '15049315'; // 固定的authorize_co_id
                const firstRequestUrl = `${baseUrl}/app/wms/allocate/allocate.aspx?inoutType=5&owner_co_id=${uCoId}&authorize_co_id=${authorizeCoId}&ts___=${timestamp}&am___=LoadDataToJSON`;
                
                // 输出第一个请求的构建信息
                console.log('第一个请求URL:', firstRequestUrl);
                console.log('第一个请求参数:', {
                    viewState: firstRequestViewState.substring(0, 50) + '...', // 只显示前50个字符
                    viewStateGenerator: firstRequestViewStateGenerator,
                    uCoId,
                    authorizeCoId,
                    keyword,
                    startDate,
                    endDateTime
                });
                
                // 发送第一个请求
                const firstRequestData = await this.sendFirstTransferRequest(
                    baseUrl, 
                    firstRequestViewState, 
                    firstRequestViewStateGenerator, 
                    uCoId, 
                    keyword, 
                    startDate, 
                    endDateTime
                );
                
                // 输出第一个请求的响应数据
                console.log('第一个请求的响应数据:', firstRequestData);
                
                // 发送第二个请求
                const secondRequestData = await this.sendSecondTransferRequest(
                    baseUrl, 
                    secondRequestViewState, 
                    secondRequestViewStateGenerator, 
                    uCoId, 
                    keyword, 
                    startDate, 
                    endDateTime
                );
                
                // 合并两个请求的数据
                const allData = [...firstRequestData, ...secondRequestData];
                
                // 去重,避免重复的io_id
                const uniqueData = this.removeDuplicates(allData, 'io_id');
                
                // 提取需要的字段并返回
                return uniqueData;
            } catch (error) {
                console.error('搜索调拨记录失败:', error);
                throw error;
            }
        },
        
        // 搜索退样调拨记录
        async searchReturnTransferRecords(keyword, startDate, endDateTime) {
            try {
                const uCoId = getCookie('u_co_id');
                if (!uCoId) throw new Error('cookie缺少u_co_id');
                
                // 获取当前页面的主机名,用于构建请求URL
                const host = window.location.host;
                const baseUrl = `https://${host}`;
                
                // 获取退样第一个请求页面的VIEWSTATE和VIEWSTATEGENERATOR
                const { viewState: returnSampleFirstViewState, viewStateGenerator: returnSampleFirstViewStateGenerator } = await this.getViewStateData(baseUrl, 'allocate.aspx');
                
                // 获取退样第二个请求页面的VIEWSTATE和VIEWSTATEGENERATOR
                const { viewState: returnSampleSecondViewState, viewStateGenerator: returnSampleSecondViewStateGenerator } = await this.getViewStateData(baseUrl, 'allocateIn.aspx');
                
                // 发送退样第一个请求
                const returnSampleFirstData = await this.sendFirstReturnSampleRequest(
                    baseUrl, 
                    returnSampleFirstViewState, 
                    returnSampleFirstViewStateGenerator, 
                    uCoId, 
                    keyword, 
                    startDate, 
                    endDateTime
                );

                // 发送退样第二个请求
                const returnSampleSecondData = await this.sendSecondReturnSampleRequest(
                    baseUrl, 
                    returnSampleSecondViewState, 
                    returnSampleSecondViewStateGenerator, 
                    uCoId, 
                    keyword, 
                    startDate, 
                    endDateTime
                );

                // 合并两个退样请求的数据
                const allData = [...returnSampleFirstData, ...returnSampleSecondData];
                
                // 去重,避免重复的io_id
                const uniqueData = this.removeDuplicates(allData, 'io_id');
                
                // 提取需要的字段并返回
                return uniqueData;
            } catch (error) {
                console.error('搜索退样调拨记录失败:', error);
                throw error;
            }
        },
        
        // 处理仓库文本的函数
        processWarehouseText(text, isReturnSample = false) {
            if (!text) return '';
            
            // 对于退样记录,获取link_warehouse并处理文本
            if (isReturnSample) {
                // 获取"-"之后的文本
                const parts = text.split('-');
                let processedText = parts.length > 1 ? parts.slice(1).join('-') : text;
                
                // 排除"杭州达人"和"项目"字样
                processedText = processedText.replace(/杭州达人|项目/g, '').trim();
                
                return processedText;
            }
            
            // 对于寄样记录,直接返回warehouse
            return text;
        },
        
        // 通用调拨记录请求方法
        async sendTransferRequest(baseUrl, viewState, viewStateGenerator, uCoId, keyword, startDate, endDateTime, config) {
            try {
                // 构建请求URL
                const timestamp = Date.now();
                const url = `${baseUrl}/app/wms/allocate/${config.urlPath}?${config.urlParams}&t=${timestamp}&owner_co_id=${uCoId}&authorize_co_id=15049315&ts___=${timestamp}&am___=LoadDataToJSON`;
                
                // 构建表单数据
                const form = new FormData();
                form.append('__VIEWSTATE', viewState);
                form.append('__VIEWSTATEGENERATOR', viewStateGenerator);
                form.append('io_date', startDate);
                form.append('io_date', endDateTime);
                form.append('_jt_page_size', '25');
                form.append('sku_id', keyword);
                form.append('__CALLBACKID', 'JTable1');
                
                // 添加仓库相关参数
                if (config.warehouseParams) {
                    Object.entries(config.warehouseParams).forEach(([key, value]) => {
                        if (Array.isArray(value)) {
                            value.forEach(item => form.append(key, item));
                        } else {
                            form.append(key, value);
                        }
                    });
                }
                
                // 添加状态相关参数
                if (config.statusParams) {
                    Object.entries(config.statusParams).forEach(([key, value]) => {
                        if (Array.isArray(value)) {
                            value.forEach(item => form.append(key, item));
                        } else {
                            form.append(key, value);
                        }
                    });
                }
                
                // 构建CALLBACKPARAM
                const callbackParam = {
                    Method: 'LoadDataToJSON',
                    Args: [
                        '1',
                        JSON.stringify(config.callbackParams.map(param => ({
                            ...param,
                            v: param.k === 'sku_id' ? keyword : param.v,
                            v: param.k === 'io_date' && param.c === '>=' ? startDate : param.v,
                            v: param.k === 'io_date' && param.c === '<=' ? endDateTime : param.v
                        }))),
                        '{}'
                    ]
                };
                form.append('__CALLBACKPARAM', JSON.stringify(callbackParam));
                
                // 发送请求
                console.log(`发送${config.name}请求:`, url);
                console.log(`${config.name}请求表单数据:`, {
                    hasViewState: form.has('__VIEWSTATE'),
                    hasViewStateGenerator: form.has('__VIEWSTATEGENERATOR'),
                    hasSkuId: form.has('sku_id'),
                    hasCallbackId: form.has('__CALLBACKID'),
                    hasCallbackParam: form.has('__CALLBACKPARAM'),
                    warehouseParams: config.warehouseParams
                });
                
                const res = await fetchJsonSafe(url, {
                    method: 'POST',
                    credentials: 'include',
                    body: form
                });
                
                // 输出响应信息
                console.log(`${config.name}请求API响应:`, res);
                
                // 解析响应数据
                const rv = res.ReturnValue ? JSON.parse(res.ReturnValue) : null;
                console.log(`${config.name}请求解析后的ReturnValue:`, rv);
                const rows = rv?.datas || [];
                console.log(`${config.name}请求响应数据行数:`, rows.length);
                console.log(`${config.name}请求响应数据:`, rows);
                
                // 提取需要的字段并返回,排除labels为"调拨中取消"和"调拨红冲"的记录
                return rows.filter(item => item.labels !== '调拨中取消' && item.labels !== '调拨红冲').map(item => {
                    // 判断是否为退样记录(根据配置名称判断)
                    const isReturnSample = config.name.includes('退样');
                    // 判断是否为退样的第二个请求
                    const isSecondReturnSample = config.name.includes('退样') && config.name.includes('第二个');
                    
                    // 对于退样的第二个请求,获取link_warehouse;其他情况获取warehouse
                    const warehouse = isSecondReturnSample ? item.link_warehouse || '' : item.warehouse || '';
                    
                    // 处理仓库文本
                    const processedWarehouse = this.processWarehouseText(warehouse, isSecondReturnSample);
                    
                    return {
                        io_id: item.io_id || '',
                        remark: item.remark || '',
                        created: item.created || '',
                        warehouse: processedWarehouse
                    };
                });
            } catch (error) {
                console.error(`发送${config.name}调拨记录请求失败:`, error);
                return [];
            }
        },
        
        // 发送第一个调拨记录请求(寄样)
        async sendFirstTransferRequest(baseUrl, viewState, viewStateGenerator, uCoId, keyword, startDate, endDateTime) {
            const config = {
                name: '第一个',
                urlPath: 'allocate.aspx',
                urlParams: 'inoutType=5',
                warehouseParams: {
                    'link_wh_id_id': '15049315#6',
                    'link_wh_id': '本仓-达人一仓',
                    '_cbb_link_wh_id': '15049315#6'
                },
                statusParams: {
                    'status_v': 'Confirmed|Confirmed,Confirmed|WaitConfirm,Picking,Creating,OuterConfirming',
                    'status': '完成,调拨中,拣货中,待确认,外部确认中',
                    '_cbb_status': ['Confirmed|Confirmed', 'Confirmed|WaitConfirm', 'Picking', 'Creating', 'OuterConfirming']
                },
                callbackParams: [
                    { "k": "link_wh_id", "v": "15049315#6", "c": "@=" },
                    { "k": "io_date", "v": startDate, "c": ">=", "t": "date" },
                    { "k": "io_date", "v": endDateTime, "c": "<=", "t": "date" },
                    { "k": "status", "v": "Confirmed|Confirmed,Confirmed|WaitConfirm,Picking,Creating,OuterConfirming", "c": "=" },
                    { "k": "sku_id", "v": keyword, "c": "=" }
                ]
            };
            return this.sendTransferRequest(baseUrl, viewState, viewStateGenerator, uCoId, keyword, startDate, endDateTime, config);
        },
        
        // 发送第二个调拨记录请求(寄样)
        async sendSecondTransferRequest(baseUrl, viewState, viewStateGenerator, uCoId, keyword, startDate, endDateTime) {
            const config = {
                name: '第二个',
                urlPath: 'allocateIn.aspx',
                urlParams: '',
                warehouseParams: {
                    'wh_id_id': '15049315#6,15049315#7',
                    'wh_id': '本仓-达人一仓,本仓-达人二仓',
                    '_cbb_wh_id': ['15049315#6', '15049315#7']
                },
                statusParams: {
                    'status_v': 'OuterConfirming,Confirmed,WaitConfirm',
                    'status': '外部确认中,完成,待收货',
                    '_cbb_status': ['OuterConfirming', 'Confirmed', 'WaitConfirm']
                },
                callbackParams: [
                    { "k": "wh_id", "v": "15049315#6,15049315#7", "c": "@=" },
                    { "k": "io_date", "v": startDate, "c": ">=", "t": "date" },
                    { "k": "io_date", "v": endDateTime, "c": "<=", "t": "date" },
                    { "k": "status", "v": "OuterConfirming,Confirmed,WaitConfirm", "c": "=" },
                    { "k": "sku_id", "v": keyword, "c": "=" }
                ]
            };
            return this.sendTransferRequest(baseUrl, viewState, viewStateGenerator, uCoId, keyword, startDate, endDateTime, config);
        },
        
        // 发送退样详细的第一个请求
        async sendFirstReturnSampleRequest(baseUrl, viewState, viewStateGenerator, uCoId, keyword, startDate, endDateTime) {
            const config = {
                name: '退样第一个',
                urlPath: 'allocate.aspx',
                urlParams: 'inoutType=5',
                warehouseParams: {
                    'link_wh_id_id': '15049315#11',
                    'link_wh_id': '本仓-六六仓',
                    '_cbb_link_wh_id': '15049315#11'
                },
                statusParams: {
                    'status_v': 'Confirmed|Confirmed,Confirmed|WaitConfirm,Picking,Creating,OuterConfirming',
                    'status': '完成,调拨中,拣货中,待确认,外部确认中',
                    '_cbb_status': ['Confirmed|Confirmed', 'Confirmed|WaitConfirm', 'Picking', 'Creating', 'OuterConfirming']
                },
                callbackParams: [
                    { "k": "link_wh_id", "v": "15049315#11", "c": "@=" },
                    { "k": "io_date", "v": startDate, "c": ">=", "t": "date" },
                    { "k": "io_date", "v": endDateTime, "c": "<=", "t": "date" },
                    { "k": "status", "v": "Confirmed|Confirmed,Confirmed|WaitConfirm,Picking,Creating,OuterConfirming", "c": "=" },
                    { "k": "sku_id", "v": keyword, "c": "=" }
                ]
            };
            return this.sendTransferRequest(baseUrl, viewState, viewStateGenerator, uCoId, keyword, startDate, endDateTime, config);
        },
        
        // 发送退样详细的第二个请求
        async sendSecondReturnSampleRequest(baseUrl, viewState, viewStateGenerator, uCoId, keyword, startDate, endDateTime) {
            const config = {
                name: '退样第二个',
                urlPath: 'allocateIn.aspx',
                urlParams: '',
                warehouseParams: {
                    'wh_id_id': '15049315#11',
                    'wh_id': '本仓-六六仓',
                    '_cbb_wh_id': '15049315#11'
                },
                statusParams: {
                    'status_v': 'OuterConfirming,Confirmed,WaitConfirm',
                    'status': '外部确认中,完成,待收货',
                    '_cbb_status': ['OuterConfirming', 'Confirmed', 'WaitConfirm']
                },
                callbackParams: [
                    { "k": "wh_id", "v": "15049315#11", "c": "@=" },
                    { "k": "io_date", "v": startDate, "c": ">=", "t": "date" },
                    { "k": "io_date", "v": endDateTime, "c": "<=", "t": "date" },
                    { "k": "status", "v": "OuterConfirming,Confirmed,WaitConfirm", "c": "=" },
                    { "k": "sku_id", "v": keyword, "c": "=" }
                ]
            };
            return this.sendTransferRequest(baseUrl, viewState, viewStateGenerator, uCoId, keyword, startDate, endDateTime, config);
        },
        

        
        // 去重函数
        removeDuplicates(arr, key) {
            const seen = new Set();
            return arr.filter(item => {
                const value = item[key];
                if (seen.has(value)) {
                    return false;
                }
                seen.add(value);
                return true;
            });
        },
        
        // 获取调拨数量
        async getTransferQuantity(io_id, keyword) {
            try {
                const uCoId = getCookie('u_co_id');
                if (!uCoId) throw new Error('cookie缺少u_co_id');
                
                // 获取当前页面的主机名,用于构建请求URL
                const host = window.location.host;
                const baseUrl = `https://${host}`;
                
                // 构建请求URL
                const timestamp = Date.now();
                const url = `${baseUrl}/app/wms/allocate/allocate_item.aspx?io_id=${io_id}&type=%E8%B0%83%E6%8B%A8%E5%85%A5&link_co_id=${uCoId}&IsArchive=false&owner_co_id=${uCoId}&authorize_co_id=15049315&ts___=${timestamp}&am___=LoadDataToJSON`;
                
                // 获取页面的VIEWSTATE和VIEWSTATEGENERATOR
                const page = await fetch(url, { credentials: 'include' });
                if (!page.ok) throw new Error(`页面获取失败 ${page.status}`);
                const html = await page.text();
                const { viewState, viewStateGenerator } = parseViewState(html);
                
                // 构建表单数据
                const form = new FormData();
                form.append('__VIEWSTATE', viewState);
                form.append('__VIEWSTATEGENERATOR', viewStateGenerator);
                form.append('printsource', '0');
                form.append('sku_search', keyword);
                form.append('_jt_page_size', '25');
                form.append('__CALLBACKID', 'JTable1');
                
                // 构建CALLBACKPARAM
                const callbackParam = {
                    Method: 'LoadDataToJSON',
                    Args: [
                        '1',
                        JSON.stringify([{ "k": "sku_search", "v": keyword, "c": "like" }]),
                        '{}'
                    ]
                };
                form.append('__CALLBACKPARAM', JSON.stringify(callbackParam));
                
                // 发送请求
                const res = await fetchJsonSafe(url, {
                    method: 'POST',
                    credentials: 'include',
                    body: form
                });
                
                // 解析响应数据
                const rv = res.ReturnValue ? JSON.parse(res.ReturnValue) : null;
                const rows = rv?.datas || [];
                
                // 提取第一个数据的qty
                if (rows.length > 0) {
                    return rows[0].qty || 0;
                }
                return 0;
            } catch (error) {
                console.error('获取调拨数量失败:', error);
                return 0;
            }
        }
    };

    /***********************
     * 状态管理
     ***********************/
    const EDGE_THRESHOLD = 10;
    const COLLAPSE_WIDTH = 32;
    const EXPAND_WIDTH = 550; // 调整窗口宽度为550px
    const AUTO_COLLAPSE_DELAY = 150;
    const HOVER_EXPAND_DELAY = 80;

    const state = {
        ui: null,
        currentUserName: '',
        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: 10px;
            background: #fff;
            padding: 10px;
            border: 2px solid #4A90E2;
            border-radius: 8px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.15);
            font-family: Arial, sans-serif;
            width: ${EXPAND_WIDTH}px;
            min-height: 350px;
            max-height: 500px;
            overflow-y: auto;
            transition: width .3s ease, left .3s ease;
            user-select: text;
        `);

        const header = createEl('div', { textContent: '调拨记录查询' }, `
            background-color: #4A90E2;
            padding: 8px 15px;
            border-radius: 6px 6px 0 0;
            cursor: move;
            font-weight: bold;
            color: white;
            text-align: center;
            margin: -10px -10px 10px -10px;
            user-select: none;
        `);

        // 第一行:输入框和搜索按钮(平行排列)
        const searchRow = createEl('div', {}, `
            display: flex;
            gap: 8px;
            width: 100%;
            box-sizing: border-box;
        `);

        const searchInput = createEl('input', { 
            type: 'text', 
            placeholder: '输入关键词搜索调拨记录...' 
        }, `
            flex: 1;
            padding: 8px 12px;
            border: 1px solid #ccc;
            border-radius: 4px;
            font-size: 14px;
            box-sizing: border-box;
        `);

        const searchBtn = createEl('button', { textContent: '搜索' }, `
            padding: 8px 15px;
            background: #4CAF50;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-size: 14px;
            box-sizing: border-box;
            white-space: nowrap;
            height: 36px;
            width: 90px;
            text-align: center;
        `);

        searchRow.append(searchInput, searchBtn);

        // 第二行:结果显示区域,包含寄样和退样两个部分
        const resultsContainer = createEl('div', {}, `
            display: flex;
            gap: 10px;
            width: 100%;
            box-sizing: border-box;
        `);

        // 寄样详细显示
        const sendSampleDisplay = createEl('div', { 
            textContent: '寄样详细将显示在这里' 
        }, `
            flex: 1;
            padding: 10px;
            border: 1px solid #e0e0e0;
            border-radius: 4px;
            font-size: 13px;
            background: #f5f5f5;
            text-align: left;
            min-height: 200px;
            max-height: 300px;
            overflow-y: auto;
            box-sizing: border-box;
        `);

        // 退样详细显示
        const returnSampleDisplay = createEl('div', { 
            textContent: '退样详细将显示在这里' 
        }, `
            flex: 1;
            padding: 10px;
            border: 1px solid #e0e0e0;
            border-radius: 4px;
            font-size: 13px;
            background: #f5f5f5;
            text-align: left;
            min-height: 200px;
            max-height: 300px;
            overflow-y: auto;
            box-sizing: border-box;
        `);

        resultsContainer.append(sendSampleDisplay, returnSampleDisplay);

        container.append(header, searchRow, resultsContainer);
        document.body.appendChild(container);

        state.ui = { 
            container, 
            header, 
            searchRow, 
            searchInput, 
            searchBtn, 
            sendSampleDisplay, 
            returnSampleDisplay 
        };
        return state.ui;
    }

    /***********************
     * 拖动与折叠
     ***********************/
    function enableDragAndSnap() {
        const { container, header, searchRow, sendSampleDisplay, returnSampleDisplay } = 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;
            // 隐藏所有子元素,只保留header
            searchRow.style.display = 'none';
            sendSampleDisplay.style.display = 'none';
            returnSampleDisplay.style.display = 'none';
            // 改变容器样式,使其收缩为一个小按钮
            container.style.display = 'block';
            container.style.height = '40px';
            container.style.width = `${COLLAPSE_WIDTH}px`;
            container.style.padding = '0';
            container.style.margin = '0';
            container.style.gap = '0';
            // 保持适当的圆角和边框
            container.style.borderRadius = '4px';
            container.style.border = '2px solid #4A90E2';
            container.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)';
            // 调整位置
            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样式
            header.textContent = '🔍';
            header.style.display = 'block';
            header.style.width = '100%';
            header.style.height = '100%';
            header.style.padding = '0';
            header.style.margin = '0';
            header.style.borderRadius = '2px';
            header.style.textAlign = 'center';
            header.style.lineHeight = '36px'; // 减去边框的2px
            header.style.fontSize = '16px';
            header.style.backgroundColor = '#4A90E2';
            header.style.color = 'white';
            header.style.cursor = 'pointer';
            container.dataset.collapsedSide = edge;
            storage.set('erpSearchCollapseState', { isCollapsed: true, side: edge, position: state.positions.collapsed });
        }

        function expand() {
            state.collapsed = false;
            // 恢复所有子元素的显示
            searchRow.style.display = 'flex';
            sendSampleDisplay.style.display = 'block';
            returnSampleDisplay.style.display = 'block';
            // 恢复容器的原始样式
            container.style.display = 'flex';
            container.style.flexDirection = 'column';
            container.style.height = 'auto';
            container.style.width = `${EXPAND_WIDTH}px`;
            container.style.padding = '10px';
            container.style.margin = '0';
            container.style.gap = '10px';
            container.style.border = '2px solid #4A90E2';
            container.style.borderRadius = '8px';
            container.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)';
            // 若无记录,使用当前偏移作为展开位置的基准
            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的原始样式
            header.textContent = '调拨记录查询';
            header.style.display = 'block';
            header.style.width = 'auto';
            header.style.height = 'auto';
            header.style.padding = '8px 15px';
            header.style.margin = '-10px -10px 10px -10px';
            header.style.borderRadius = '6px 6px 0 0';
            header.style.textAlign = 'center';
            header.style.lineHeight = 'normal';
            header.style.fontSize = '14px';
            header.style.backgroundColor = '#4A90E2';
            header.style.color = 'white';
            header.style.cursor = 'move';
            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 };
            }
        });
    }

    /***********************
     * 表单辅助
     ***********************/
    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 */ });
        }
    };

    /***********************
     * 事件绑定
     ***********************/
    function bindEvents() {
        const { searchInput, searchBtn, sendSampleDisplay, returnSampleDisplay } = state.ui;

        // 获取寄样数组中最早的日期
        function getEarliestDate(records) {
            if (records.length === 0) {
                // 如果没有记录,返回30天前的日期
                const date = new Date();
                date.setDate(date.getDate() - 30);
                return date.toISOString().split('T')[0];
            }
            
            // 找出最早的日期
            const dates = records
                .map(record => record.created)
                .filter(date => date)
                .map(date => new Date(date));
            
            if (dates.length === 0) {
                const date = new Date();
                date.setDate(date.getDate() - 30);
                return date.toISOString().split('T')[0];
            }
            
            const earliestDate = new Date(Math.min(...dates));
            return earliestDate.toISOString().split('T')[0];
        }

        // 显示搜索结果
        function displayResults(records, container, type = '寄样', sendSampleRecordsWithReturnQty = []) {
            if (records.length === 0) {
                container.innerHTML = `<div style="text-align: center; color: #999;">未找到匹配的${type}记录</div>`;
            } else {
                let html = `<div style="font-weight: bold; margin-bottom: 8px;">${type}详细:</div>`;
                html += '<div style="display: flex; flex-direction: column; gap: 8px; user-select: text;">'

                // 对退样记录进行处理,保持原有的显示数量,通过高度限制实现只显示三个的效果
                records.forEach((record, index) => {
                    // 处理创建日期,保留年月日
                    let createdDate = '';
                    if (record.created) {
                        const dateMatch = record.created.match(/^(\d{4}-\d{2}-\d{2})/);
                        if (dateMatch) {
                            createdDate = dateMatch[1];
                        }
                    }
                    
                    // 构建标题部分,包含退样数量
                    let titleHtml = `${index + 1}. ${record.io_id || '无'} ${record.warehouse || ''}`;
                    
                    // 处理退样数量显示
                    if (record.returnQty !== undefined && record.returnQty > 0) {
                        // 根据类型和数量是否相符设置不同的颜色
                        let returnQtyColor;
                        if (type === '寄样') {
                            // 寄样:如果退样数量与寄样数量相符则显示绿色,否则显示红色
                            returnQtyColor = record.returnQty === record.qty ? '#4CAF50' : '#f44336';
                        } else {
                            // 退样:始终显示红色
                            returnQtyColor = '#f44336';
                        }
                        titleHtml += `-数量:${record.qty || 0} <span style="color: ${returnQtyColor}; font-weight: bold;">-${record.returnQty}</span>`;
                    } else {
                        titleHtml += `-数量:${record.qty || 0}`;
                    }
                    
                    // 处理退样记录的数量显示(右侧退样详细)
                    if (type === '退样') {
                        // 查找当前退样记录匹配的寄样调拨单号
                        const matchingSendRecord = sendSampleRecordsWithReturnQty.find(sendRecord => {
                            return record.remark && record.remark.includes(sendRecord.io_id);
                        });
                        if (matchingSendRecord) {
                            titleHtml += ` <span style="color: #f44336; font-weight: bold;">-${record.qty || 0}</span>`;
                        }
                    }
                    
                    // 不需要单独显示数量,标题中已经显示了
                    
                    html += `
                        <div style="padding: 8px; border: 1px solid #e0e0e0; border-radius: 4px; background: #fff; word-wrap: break-word; user-select: text;">
                            <div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 4px; user-select: text;">
                                <div style="font-weight: bold; flex: 1; user-select: text;">${titleHtml}</div>
                                ${createdDate ? `<div style="font-size: 12px; color: #999; margin-left: 10px; flex-shrink: 0; user-select: text;">${createdDate}</div>` : ''}
                            </div>
                            <div style="display: flex; justify-content: space-between; align-items: flex-start; font-size: 12px; color: #666; user-select: text;">
                                <div style="flex: 1; user-select: text;">备注:${record.remark || '无'}</div>
                                <button 
                                    class="copy-remark-btn" 
                                    data-remark="${encodeURIComponent((record.remark || '') + (record.io_id || ''))}"
                                    title="复制备注和调拨单号"
                                    style="
                                        padding: 4px 8px;
                                        background: #4CAF50;
                                        color: white;
                                        border: none;
                                        border-radius: 3px;
                                        cursor: pointer;
                                        font-size: 12px;
                                        transition: background 0.2s;
                                        display: flex;
                                        align-items: center;
                                        justify-content: center;
                                        margin-left: 10px;
                                        flex-shrink: 0;
                                        user-select: none;
                                    "
                                    onmouseover="this.style.background='#45a049'"
                                    onmouseout="this.style.background='#4CAF50'"
                                >
                                    <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
                                        <path fill="currentColor" d="M11.4 0a.85.85 0 0 1 .622.273l2.743 3a.88.88 0 0 1 .235.602v9.25a.867.867 0 0 1-.857.875H3.857A.867.867 0 0 1 3 13.125V.875C3 .392 3.384 0 3.857 0zM14 4h-2.6a.4.4 0 0 1-.4-.4V1H4v12h10z"></path>
                                        <path fill="currentColor" d="M3 1H2a1 1 0 0 0-1 1v13a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-1h-1v1H2V2h1z"></path>
                                    </svg>
                                </button>
                            </div>
                        </div>
                    `;
                });

                html += '</div>';
                container.innerHTML = html;
                
                // 绑定复制按钮事件
                document.querySelectorAll('.copy-remark-btn').forEach(btn => {
                    btn.addEventListener('click', async function() {
                        const remark = decodeURIComponent(this.dataset.remark);
                        try {
                            await navigator.clipboard.writeText(remark);
                            // 显示复制成功提示
                            this.title = '复制成功';
                            this.style.background = '#2196F3';
                            setTimeout(() => {
                                this.title = '复制备注';
                                this.style.background = '#4CAF50';
                            }, 1000);
                        } catch (error) {
                            console.error('复制失败:', error);
                            this.title = '复制失败';
                            this.style.background = '#f44336';
                            setTimeout(() => {
                                this.title = '复制备注';
                                this.style.background = '#4CAF50';
                            }, 1000);
                        }
                    });
                });
            }
        }

        // 搜索按钮点击事件
        async function handleSearch() {
            const keyword = searchInput.value.trim();
            if (!keyword) {
                notify('请输入搜索关键词', 'warning');
                return;
            }

            try {
                // 显示加载状态
                sendSampleDisplay.innerHTML = '<div style="text-align: center; color: #4CAF50;">搜索中</div>';
                returnSampleDisplay.innerHTML = '<div style="text-align: center; color: #4CAF50;">搜索中</div>';
                searchBtn.disabled = true;
                searchBtn.textContent = '搜索中';

                // 获取当前页面的主机名,用于构建请求URL
                const host = window.location.host;
                const baseUrl = `https://${host}`;
                const uCoId = getCookie('u_co_id');
                if (!uCoId) throw new Error('cookie缺少u_co_id');

                // 调用API搜索寄样调拨记录
                const sendSampleRecords = await Api.searchTransferRecords(keyword);

                // 为每个寄样记录获取调拨数量
                const sendSampleRecordsWithQuantity = await Promise.all(sendSampleRecords.map(async (record) => {
                    const qty = await Api.getTransferQuantity(record.io_id, keyword);
                    return {
                        ...record,
                        qty
                    };
                }));

                // 按照日期排序(最新的在前)
                sendSampleRecordsWithQuantity.sort((a, b) => {
                    const dateA = new Date(a.created || 0);
                    const dateB = new Date(b.created || 0);
                    return dateB - dateA;
                });

                // 获取寄样数组中最早的日期
                const earliestDate = getEarliestDate(sendSampleRecords);
                const endDate = new Date().toISOString().split('T')[0];
                const endDateTime = `${endDate} 23:59:59.998`;

                // 调用API搜索退样调拨记录
                const returnSampleRecords = await Api.searchReturnTransferRecords(keyword, earliestDate, endDateTime);

                // 按照日期排序(最新的在前)
                returnSampleRecords.sort((a, b) => {
                    const dateA = new Date(a.created || 0);
                    const dateB = new Date(b.created || 0);
                    return dateB - dateA;
                });

                // 根据左侧寄样记录的数量+2来限制退样记录的显示数量
                const limit = sendSampleRecordsWithQuantity.length + 2;
                const limitedReturnSampleRecords = returnSampleRecords.slice(0, limit);

                // 为每个退样记录获取调拨数量
                const returnSampleRecordsWithQuantity = await Promise.all(limitedReturnSampleRecords.map(async (record) => {
                    const qty = await Api.getTransferQuantity(record.io_id, keyword);
                    return {
                        ...record,
                        qty
                    };
                }));

                // 为左侧寄样记录匹配退样数量(统计汇总)
                const sendSampleRecordsWithReturnQty = sendSampleRecordsWithQuantity.map(sendRecord => {
                    // 查找所有备注中包含当前寄样调拨单号的退样记录
                    const matchingReturnRecords = returnSampleRecordsWithQuantity.filter(returnRecord => {
                        return returnRecord.remark && returnRecord.remark.includes(sendRecord.io_id);
                    });
                    
                    // 统计汇总退样数量
                    const returnQty = matchingReturnRecords.reduce((total, record) => total + (record.qty || 0), 0);
                    
                    return {
                        ...sendRecord,
                        returnQty,
                        matchingReturnRecords // 保存匹配的退样记录,用于右侧显示
                    };
                });

                // 显示寄样搜索结果
                displayResults(sendSampleRecordsWithReturnQty, sendSampleDisplay, '寄样');

                // 显示退样搜索结果,传递寄样记录参数
                displayResults(returnSampleRecordsWithQuantity, returnSampleDisplay, '退样', sendSampleRecordsWithReturnQty);
            } catch (error) {
                console.error('搜索失败:', error);
                sendSampleDisplay.innerHTML = `<div style="text-align: center; color: #f44336;">搜索失败:${error.message}</div>`;
                returnSampleDisplay.innerHTML = `<div style="text-align: center; color: #f44336;">搜索失败:${error.message}</div>`;
            } finally {
                // 恢复按钮状态
                searchBtn.disabled = false;
                searchBtn.textContent = '搜索';
            }
        }

        searchBtn.addEventListener('click', handleSearch);
        searchInput.addEventListener('keypress', (e) => {
            if (e.key === 'Enter') handleSearch();
        });
    }

    /***********************
     * 初始化
     ***********************/
    function init() {
        buildUI();
        enableDragAndSnap();
        FormHelper.fetchUser();
        bindEvents();
    }

    window.addEventListener('load', init);
})();