3DSTU WMS
倉儲掃碼・庫存・出貨作業台
正式環境

建立出貨單

SKU商品需求
先加入商品,再建立出貨單。

草稿 / 最近出貨單

單號狀態進度

掃描出貨

尚未載入出貨單
設定 Windows 印表機
模式:一般出貨|掃 TEMPOUT 可切換臨時追加;掃 NOPRINT 可讓下一次出貨不印單。
尚未連線本機列印代理
流程:先掃商品 → 聽到「要XXX N個」→ 再掃 SHIPONE / SHIPALL 確認出貨。
條碼指令頁:/commands
等待掃描…

本張出貨單明細

尚未載入
可新增商品,也可直接修改需求與已掃數量。
SKU商品需求已掃剩餘庫存
`); printWindow.document.close(); printWindow.focus(); setTimeout(() => { printWindow.print(); }, 300); setPrintStatus(`已開啟瀏覽器列印:${payload.name} × ${copies}。在列印視窗選 XP-D10,紙張保持 70×50mm、縮放 100%。`, true); } catch (error) { printWindow.close(); setPrintStatus(`建立瀏覽器列印失敗:${error}`, false); } } async function printLabelPayload(payload) { if (!payload || !payload.gtin) { setPrintStatus('略過列印:新出貨標籤需要 GTIN/商品條碼', false); return false; } const active = document.getElementById('outboundPrinterProfile').value; if (active === 'browser') { pendingBrowserLabelPayload = { ...payload }; showBrowserPrintButton(true); setPrintStatus(`標籤已備妥:${payload.name} × ${Math.max(1, Number(payload.copies || 1))}。按「開啟瀏覽器列印」後選 XP-D10。`, true); return true; } if (!printerReady) { setPrintStatus('略過列印:尚未連線本機列印代理', false); return false; } const copies = Math.max(1, Number(payload.copies || 1)); for (let i = 0; i < copies; i += 1) { const dataUrl = await buildLabelDataUrl(payload.name || '', payload.gtin || ''); await sendQ901CImage(dataUrl, 1, payload.name || '', payload.gtin || ''); } showBrowserPrintButton(false); setPrintStatus(`已列印新出貨標籤:${payload.name} × ${copies}`, true); return true; } function renderCommandLabel(canvasId, title, value) { const canvas = document.getElementById(canvasId); if (!canvas) return; canvas.width = 384; canvas.height = 720; // Q901C: use portrait canvas to prevent driver/SDK auto-rotating short landscape command labels. const ctx = canvas.getContext('2d'); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, canvas.width, canvas.height); const labelH = 330; ctx.strokeStyle = '#000'; ctx.lineWidth = 2; ctx.strokeRect(6, 6, canvas.width - 12, labelH - 12); ctx.fillStyle = '#000'; ctx.font = 'bold 26px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; ctx.fillText(title, canvas.width / 2, 42); if (typeof JsBarcode !== 'undefined') { const barcanvas = document.createElement('canvas'); JsBarcode(barcanvas, value, { format: 'CODE128', width: 2.3, height: 150, displayValue: true, font: 'Arial', fontSize: 24, margin: 24, background: '#ffffff', lineColor: '#000000', textMargin: 6, }); const maxWidth = canvas.width - 10; const maxHeight = 245; const ratio = Math.min(1, maxWidth / barcanvas.width, maxHeight / barcanvas.height); const drawWidth = Math.floor(barcanvas.width * ratio); const drawHeight = Math.floor(barcanvas.height * ratio); const x = Math.floor((canvas.width - drawWidth) / 2); const y = 58; ctx.imageSmoothingEnabled = false; ctx.drawImage(barcanvas, x, y, drawWidth, drawHeight); } else { ctx.font = 'bold 42px Arial'; ctx.fillText(value, canvas.width / 2, 130); } ctx.font = 'bold 20px Arial'; ctx.fillText('請掃描此指令,不是商品標籤', canvas.width / 2, 315); } async function printCommandCanvas(canvasId, jobName, okText) { if (!printerReady) { setPrintStatus('請先初始化 Q901C 本地代理', false); return; } const dataUrl = hardenLabelCanvas(document.getElementById(canvasId)); await sendQ901CImage(dataUrl, 1, jobName, jobName); setPrintStatus(okText.replace('已列印', 'Q901C 已列印'), true); } function renderProductOptions() { const select = document.getElementById('productSelect'); if (!productResults.length) { select.innerHTML = ''; return; } select.innerHTML = productResults.map(p => ``).join(''); } function renderDraftItems() { const tbody = document.getElementById('draftItems'); if (!draftItems.length) { tbody.innerHTML = '尚未加入商品'; return; } tbody.innerHTML = draftItems.map((item, idx) => ` ${esc(item.sku)} ${esc(item.name)} ${item.required_qty} `).join(''); } function renderShipment(shipment) { currentShipment = shipment; document.getElementById('activeShipmentId').value = shipment.id; document.getElementById('activeShipmentInfo').innerHTML = `出貨單 ${esc(shipment.shipment_no)} 狀態:${esc(shipment.status)} 下載調撥單`; document.getElementById('shipmentSummary').textContent = `總需求 ${shipment.required_total} / 已掃 ${shipment.scanned_total}`; const tbody = document.getElementById('shipmentItemsBody'); if (!shipment.items.length) { tbody.innerHTML = '沒有商品'; return; } tbody.innerHTML = shipment.items.map(item => ` ${esc(item.sku)} ${esc(item.name)} ${item.remaining_qty} ${item.inventory_qty} `).join(''); } async function searchProducts() { const q = document.getElementById('productSearch').value.trim(); const r = await fetch(q ? `/api/inventory?search=${encodeURIComponent(q)}` : '/api/inventory'); const data = await r.json(); if (!r.ok) return; productResults = data; renderProductOptions(); const editSelect = document.getElementById('editProductSelect'); if (editSelect) editSelect.innerHTML = productResults.length ? productResults.map(p => ``).join('') : ''; } async function refreshShipments() { const r = await fetch('/api/outbound-shipments'); const data = await r.json(); const tbody = document.getElementById('shipmentsBody'); if (!r.ok) { tbody.innerHTML = '載入失敗'; return; } if (!data.length) { tbody.innerHTML = '目前沒有出貨單'; return; } tbody.innerHTML = data.map(row => { const canArchive = row.status !== 'archived'; return ` ${esc(row.shipment_no)} ${esc(row.status)} ${row.scanned_total} / ${row.required_total} 調撥單 ${canArchive ? `` : ''} `; }).join(''); } function removeDraftItem(idx) { draftItems.splice(idx, 1); renderDraftItems(); } window.removeDraftItem = removeDraftItem; window.loadShipmentById = async function(id) { await loadShipment(id); }; document.getElementById('addItemBtn').addEventListener('click', () => { const select = document.getElementById('productSelect'); const productId = Number(select.value || 0); const qty = Number(document.getElementById('requiredQty').value || 0); const found = productResults.find(p => p.id === productId); if (!found || qty <= 0) return; if (draftItems.some(item => item.product_id === productId)) return; draftItems.push({ product_id: productId, required_qty: qty, sku: found.sku, name: found.name }); renderDraftItems(); }); document.getElementById('createShipmentBtn').addEventListener('click', async () => { const shipment_no = document.getElementById('shipmentNo').value.trim(); const note = document.getElementById('shipmentNote').value.trim(); const result = document.getElementById('createShipmentResult'); const r = await fetch('/api/outbound-shipments', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ shipment_no, note, items: draftItems.map(({product_id, required_qty}) => ({product_id, required_qty})) }) }); const data = await r.json(); if (!r.ok) { result.className = 'result err'; result.textContent = data.error || '建立失敗'; return; } draftItems = []; renderDraftItems(); result.className = 'result ok'; result.textContent = `已建立出貨單 ${data.shipment_no}(ID ${data.id})`; renderShipment(data); refreshShipments(); speak(`已建立出貨單 ${data.shipment_no}`); }); async function loadShipment(id) { const r = await fetch(`/api/outbound-shipments/${id}`); const data = await r.json(); if (!r.ok) { document.getElementById('scanResult').className = 'result err'; document.getElementById('scanResult').textContent = data.error || '載入失敗'; return; } renderShipment(data); document.getElementById('scanResult').className = 'result muted'; document.getElementById('scanResult').textContent = '已載入出貨單,開始掃描。'; document.getElementById('scanOutboundBarcode').focus(); } document.getElementById('loadShipmentBtn').addEventListener('click', async () => { const id = document.getElementById('activeShipmentId').value.trim(); if (!id) return; await loadShipment(id); }); document.getElementById('scanOutboundBtn').addEventListener('click', async () => { const shipmentId = document.getElementById('activeShipmentId').value.trim(); const barcode = document.getElementById('scanOutboundBarcode').value.trim(); if (!barcode) return; const box = document.getElementById('scanResult'); const input = document.getElementById('scanOutboundBarcode'); const upperBarcode = barcode.toUpperCase(); if (upperBarcode === TEMP_OUT_COMMAND) { temporaryAddMode = !temporaryAddMode; box.className = 'result ' + (temporaryAddMode ? 'ok' : 'muted'); box.textContent = temporaryAddMode ? '臨時追加模式已開啟:接下來掃到的商品會需求+1、已掃+1,並列印一張。再掃 TEMPOUT 可關閉。' : '臨時追加模式已關閉'; speak(temporaryAddMode ? '臨時追加模式開啟' : '臨時追加模式關閉'); updateScanModeStatus(); input.value = ''; input.focus(); return; } if (upperBarcode === NO_PRINT_COMMAND) { suppressNextPrint = !suppressNextPrint; box.className = 'result ' + (suppressNextPrint ? 'ok' : 'muted'); box.textContent = suppressNextPrint ? '下一次出貨不印單:請接著掃 SHIPONE 或 SHIPALL。再掃 NOPRINT 可取消。' : '已取消「下一次出貨不印單」'; speak(suppressNextPrint ? '下一次出貨不印單' : '取消不印單'); updateScanModeStatus(); input.value = ''; input.focus(); return; } if (!shipmentId) return; const r = await fetch(`/api/outbound-shipments/${shipmentId}/scan`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ barcode, temporary_add: temporaryAddMode }) }); const data = await r.json(); if (!r.ok) { box.className = 'result err'; box.textContent = data.error || '掃描失敗'; return; } renderShipment(data.shipment); box.className = 'result ' + (data.decision === 'ship' ? 'ok' : 'err'); box.innerHTML = `${esc(data.speech_text)}${data.item ? `
進度:${data.item.scanned_qty} / ${data.item.required_qty}` : ''}`; speak(data.speech_text); const shouldSuppressPrint = suppressNextPrint && data.decision === 'ship'; if (shouldSuppressPrint) { suppressNextPrint = false; box.innerHTML += '
已依 NOPRINT 指令略過本次列印'; setPrintStatus('本次出貨依 NOPRINT 指令略過列印', true); updateScanModeStatus(); } else if (data.decision === 'ship' && document.getElementById('autoPrintEnabled').checked && data.label_print) { try { await printLabelPayload(data.label_print); box.innerHTML += '
已自動列印標籤'; } catch (error) { setPrintStatus(`自動列印失敗:${error}`, false); box.innerHTML += '
自動列印失敗'; } } input.value = ''; input.focus(); }); document.getElementById('scanOutboundBarcode').addEventListener('keydown', (e) => { if (e.key === 'Enter') document.getElementById('scanOutboundBtn').click(); }); document.getElementById('confirmShipmentBtn').addEventListener('click', async () => { const shipmentId = document.getElementById('activeShipmentId').value.trim(); if (!shipmentId) return; const r = await fetch(`/api/outbound-shipments/${shipmentId}/confirm`, { method: 'POST' }); const data = await r.json(); const box = document.getElementById('scanResult'); if (!r.ok) { box.className = 'result err'; box.textContent = data.error || '確認失敗'; return; } renderShipment(data); box.className = 'result ok'; box.textContent = '已確認出貨並扣除庫存'; refreshShipments(); speak('已確認出貨'); }); window.saveShipmentItem = async function(itemId) { const shipmentId = document.getElementById('activeShipmentId').value.trim(); if (!shipmentId) return; const required_qty = Number(document.getElementById(`req-${itemId}`).value || 0); const scanned_qty = Number(document.getElementById(`scn-${itemId}`).value || 0); const r = await fetch(`/api/outbound-shipments/${shipmentId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ updates: [{ item_id: itemId, required_qty, scanned_qty }] }) }); const data = await r.json(); const box = document.getElementById('editShipmentResult'); if (!r.ok) { box.className = 'result err'; box.textContent = data.error || '更新失敗'; return; } box.className = 'result ok'; box.textContent = '已更新出貨單明細'; renderShipment(data); }; window.deleteShipmentItem = async function(itemId, sku) { const shipmentId = document.getElementById('activeShipmentId').value.trim(); if (!shipmentId) return; if (!confirm(`確定從這張出貨單刪除 ${sku || '這個 SKU'}?`)) return; const r = await fetch(`/api/outbound-shipments/${shipmentId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ delete_items: [itemId] }) }); const data = await r.json(); const box = document.getElementById('editShipmentResult'); if (!r.ok) { box.className = 'result err'; box.textContent = data.error || '刪除失敗'; return; } box.className = 'result ok'; box.textContent = '已刪除 SKU'; renderShipment(data); refreshShipments(); }; window.archiveShipment = async function(shipmentId, shipmentNo) { if (!confirm(`確定封存出貨單 ${shipmentNo || shipmentId}?封存後會從最近清單隱藏。`)) return; const r = await fetch(`/api/outbound-shipments/${shipmentId}/archive`, { method: 'POST' }); const data = await r.json(); const box = document.getElementById('scanResult'); if (!r.ok) { box.className = 'result err'; box.textContent = data.error || '封存失敗'; return; } if (currentShipment && Number(currentShipment.id) === Number(shipmentId)) renderShipment(data); refreshShipments(); box.className = 'result ok'; box.textContent = `已封存出貨單 ${data.shipment_no}`; }; document.getElementById('editSearchProductBtn').addEventListener('click', async () => { document.getElementById('productSearch').value = document.getElementById('editProductSearch').value; await searchProducts(); }); document.getElementById('addShipmentItemBtn').addEventListener('click', async () => { const shipmentId = document.getElementById('activeShipmentId').value.trim(); const productId = Number(document.getElementById('editProductSelect').value || 0); const required_qty = Number(document.getElementById('editRequiredQty').value || 0); if (!shipmentId || !productId || required_qty <= 0) return; const r = await fetch(`/api/outbound-shipments/${shipmentId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ add_items: [{ product_id: productId, required_qty }] }) }); const data = await r.json(); const box = document.getElementById('editShipmentResult'); if (!r.ok) { box.className = 'result err'; box.textContent = data.error || '新增失敗'; return; } box.className = 'result ok'; box.textContent = '已新增商品到出貨單'; renderShipment(data); }); document.getElementById('searchProductBtn').addEventListener('click', searchProducts); document.getElementById('refreshShipmentsBtn').addEventListener('click', refreshShipments); document.getElementById('initOutboundPrinterBtn').addEventListener('click', initOutboundPrinter); document.getElementById('browserPrintLabelBtn').addEventListener('click', browserPrintPendingLabel); document.getElementById('outboundPrinterProfile').addEventListener('change', () => { printerReady = false; initOutboundPrinter(); }); document.getElementById('printShipOneBtn').addEventListener('click', async () => { try { await printCommandCanvas('shipOneCanvas', 'dtpweb-shipone', '已列印 SHIPONE'); } catch (error) { setPrintStatus(`列印 SHIPONE 失敗:${error}`, false); } }); document.getElementById('printShipAllBtn').addEventListener('click', async () => { try { await printCommandCanvas('shipAllCanvas', 'dtpweb-shipall', '已列印 SHIPALL'); } catch (error) { setPrintStatus(`列印 SHIPALL 失敗:${error}`, false); } }); document.getElementById('printTempOutBtn').addEventListener('click', async () => { try { await printCommandCanvas('tempOutCanvas', 'dtpweb-tempout', '已列印 TEMPOUT'); } catch (error) { setPrintStatus(`列印 TEMPOUT 失敗:${error}`, false); } }); document.getElementById('printNoPrintBtn').addEventListener('click', async () => { try { await printCommandCanvas('noPrintCanvas', 'dtpweb-noprint', '已列印 NOPRINT'); } catch (error) { setPrintStatus(`列印 NOPRINT 失敗:${error}`, false); } }); initOutboundPrinter().catch(() => {}); renderCommandLabel('shipOneCanvas', '單個出貨', 'SHIPONE'); renderCommandLabel('shipAllCanvas', '全部出貨', 'SHIPALL'); renderCommandLabel('tempOutCanvas', '臨時追加', 'TEMPOUT'); renderCommandLabel('noPrintCanvas', '下次不印', 'NOPRINT'); updateScanModeStatus(); searchProducts(); renderDraftItems(); refreshShipments();