Initial commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""Parts & Invoice Lookup application package."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Allows `python -m app` to run the app.
|
||||
|
||||
The real logic lives in app.main so the PyInstaller entry script can reuse it
|
||||
without depending on relative imports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from app.main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,965 @@
|
||||
/**
|
||||
* Picker Client JavaScript logic.
|
||||
* Handles UI state, Audio Context beeps, camera scanning, verification, and API calls.
|
||||
*/
|
||||
|
||||
// --- Global App State --------------------------------------------------------
|
||||
let activeInvoice = null;
|
||||
let selectedPart = null;
|
||||
let html5QrScanner = null;
|
||||
let activeCameraId = null;
|
||||
let isScanning = false;
|
||||
|
||||
// --- Sound Synthesizer (Web Audio API) ---------------------------------------
|
||||
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
|
||||
function playBeep(type) {
|
||||
// Resume context if suspended (browser security block)
|
||||
if (audioCtx.state === 'suspended') {
|
||||
audioCtx.resume();
|
||||
}
|
||||
|
||||
const osc = audioCtx.createOscillator();
|
||||
const gain = audioCtx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(audioCtx.destination);
|
||||
|
||||
const now = audioCtx.currentTime;
|
||||
|
||||
if (type === 'success') {
|
||||
// High pitch pleasant dual-chime (ding)
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(880, now); // A5
|
||||
osc.frequency.setValueAtTime(1320, now + 0.08); // E6
|
||||
gain.gain.setValueAtTime(0.15, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.3);
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.3);
|
||||
} else if (type === 'error') {
|
||||
// Low pitch buzzing alarm sound
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.setValueAtTime(130, now); // Low C
|
||||
gain.gain.setValueAtTime(0.2, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.4);
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.4);
|
||||
} else if (type === 'warning') {
|
||||
// Warning chime
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.setValueAtTime(440, now);
|
||||
gain.gain.setValueAtTime(0.1, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Notification Banner -----------------------------------------------------
|
||||
function showBanner(text, type = 'success') {
|
||||
const banner = document.getElementById('notification-banner');
|
||||
const bText = document.getElementById('banner-text');
|
||||
|
||||
banner.className = `show ${type}`;
|
||||
bText.textContent = text;
|
||||
|
||||
setTimeout(() => {
|
||||
banner.classList.remove('show');
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// --- API Helpers -------------------------------------------------------------
|
||||
async function fetchInvoice(invoiceNum) {
|
||||
try {
|
||||
const resp = await fetch(`/api/invoice/${invoiceNum}`);
|
||||
if (resp.status === 202) {
|
||||
const data = await resp.json();
|
||||
if (data.status === 'sync_pending') {
|
||||
return { sync_pending: true, message: data.message };
|
||||
}
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const errData = await resp.json();
|
||||
throw new Error(errData.error || `Server error: ${resp.status}`);
|
||||
}
|
||||
return await resp.json();
|
||||
} catch (e) {
|
||||
showBanner(e.message, 'error');
|
||||
playBeep('error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerReprint(sku, description) {
|
||||
if (!sku) return;
|
||||
try {
|
||||
const payload = {
|
||||
"label_data": {
|
||||
"order_id": activeInvoice ? activeInvoice.invoice_number : "REPRINT",
|
||||
"source": "picker_web",
|
||||
"qb_invoice_number": activeInvoice ? activeInvoice.invoice_number : "",
|
||||
"skus": [{
|
||||
"sku": sku,
|
||||
"description": description,
|
||||
"line_qty": 1,
|
||||
"unit_index": 1
|
||||
}]
|
||||
}
|
||||
};
|
||||
const resp = await fetch('/api/print-label', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
showBanner(`Label job enqueued for ${sku}!`, 'success');
|
||||
playBeep('success');
|
||||
} else {
|
||||
const txt = await resp.text();
|
||||
throw new Error(txt || `Server error ${resp.status}`);
|
||||
}
|
||||
} catch (e) {
|
||||
showBanner(`Reprint failed: ${e.message}`, 'error');
|
||||
playBeep('error');
|
||||
}
|
||||
}
|
||||
|
||||
// --- UI Operations -----------------------------------------------------------
|
||||
function displayInvoice(invoice) {
|
||||
activeInvoice = invoice;
|
||||
selectedPart = null;
|
||||
|
||||
// Clear lists
|
||||
const container = document.getElementById('parts-list');
|
||||
container.innerHTML = '';
|
||||
|
||||
// Unhide info
|
||||
document.getElementById('invoice-info-card').classList.remove('hidden');
|
||||
document.getElementById('lbl-invoice-num').textContent = invoice.invoice_number;
|
||||
document.getElementById('lbl-customer-name').textContent = invoice.customer;
|
||||
|
||||
// Populate items
|
||||
invoice.lines.forEach((line, index) => {
|
||||
const card = document.createElement('div');
|
||||
const isUnavailable = (line.available === 'N' || line.quantity === '0');
|
||||
|
||||
card.className = 'part-card' + (isUnavailable ? ' unavailable' : '');
|
||||
card.id = `part-card-${index}`;
|
||||
card.dataset.index = index;
|
||||
card.dataset.sku = line.sku;
|
||||
card.dataset.picked = isUnavailable ? 'unavailable' : 'false';
|
||||
|
||||
const statusIcon = isUnavailable
|
||||
? `<span style="font-weight:700; font-size:0.9rem;">✕</span>`
|
||||
: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="part-details-left">
|
||||
<div class="part-sku">${line.sku}</div>
|
||||
<div class="part-desc">${line.description}</div>
|
||||
<div class="tag-row">
|
||||
<span class="tag bin">Bin: ${line.bin_location || 'CSG'}</span>
|
||||
<span class="tag qty">Qty: ${line.qty || 1}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="part-status">
|
||||
${statusIcon}
|
||||
</div>
|
||||
`;
|
||||
|
||||
card.addEventListener('click', () => selectPartCard(index));
|
||||
container.appendChild(card);
|
||||
});
|
||||
|
||||
updateHeaderStats();
|
||||
|
||||
// Automatically select the first card
|
||||
if (invoice.lines.length > 0) {
|
||||
selectPartCard(0);
|
||||
}
|
||||
}
|
||||
|
||||
function selectPartCard(index) {
|
||||
if (!activeInvoice) return;
|
||||
const lines = activeInvoice.lines;
|
||||
const part = lines[index];
|
||||
selectedPart = { ...part, index: index };
|
||||
|
||||
// Update card selection border
|
||||
document.querySelectorAll('.part-card').forEach(c => c.classList.remove('active'));
|
||||
const activeCard = document.getElementById(`part-card-${index}`);
|
||||
if (activeCard) {
|
||||
activeCard.classList.add('active');
|
||||
}
|
||||
|
||||
// Show Details Panel content
|
||||
document.getElementById('details-empty-state').classList.add('hidden');
|
||||
document.getElementById('part-details-content').classList.remove('hidden');
|
||||
|
||||
document.getElementById('det-sku').textContent = part.sku;
|
||||
document.getElementById('det-desc').textContent = part.description;
|
||||
document.getElementById('det-bin').textContent = part.bin_location || 'Unknown';
|
||||
document.getElementById('det-yard').textContent = part.yard_location || 'CSG';
|
||||
document.getElementById('det-qty').textContent = part.qty || 1;
|
||||
document.getElementById('det-price').textContent = part.price ? `$${part.price}` : '—';
|
||||
document.getElementById('det-stock').textContent = part.quantity || '0';
|
||||
}
|
||||
|
||||
function markActivePartAsPicked() {
|
||||
if (!selectedPart) return;
|
||||
|
||||
const card = document.getElementById(`part-card-${selectedPart.index}`);
|
||||
if (card) {
|
||||
card.classList.add('picked');
|
||||
card.dataset.picked = 'true';
|
||||
}
|
||||
|
||||
playBeep('success');
|
||||
showBanner(`Verified SKU: ${selectedPart.sku}`, 'success');
|
||||
|
||||
updateHeaderStats();
|
||||
selectNextUnpickedPart();
|
||||
}
|
||||
|
||||
function selectNextUnpickedPart() {
|
||||
if (!activeInvoice) return;
|
||||
const cards = document.querySelectorAll('.part-card');
|
||||
for (let c of cards) {
|
||||
if (c.dataset.picked === 'false') {
|
||||
selectPartCard(parseInt(c.dataset.index));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateHeaderStats() {
|
||||
if (!activeInvoice) return;
|
||||
const total = activeInvoice.lines.length;
|
||||
const picked = document.querySelectorAll('.part-card.picked').length;
|
||||
const unavailable = document.querySelectorAll('.part-card.unavailable').length;
|
||||
document.getElementById('items-picked-count').textContent = `${picked} Picked / ${unavailable} Out of Stock (${total} Total)`;
|
||||
}
|
||||
|
||||
// --- QR Decoding Comparison --------------------------------------------------
|
||||
function handleScannedCode(decodedText) {
|
||||
const text = decodedText.trim();
|
||||
if (!text) return;
|
||||
|
||||
logDebug(`Scanned: "${text}"`);
|
||||
|
||||
// Check if we are scanning an invoice (idle or search input is focused)
|
||||
const isSearchFocused = document.activeElement === document.getElementById('invoice-search-input');
|
||||
|
||||
// If we don't have an invoice loaded yet, or if the search input is focused,
|
||||
// load it as an invoice lookup. Otherwise, always treat as a part SKU scan.
|
||||
if (!activeInvoice || isSearchFocused) {
|
||||
loadInvoiceNum(text);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, compare as a part SKU scan
|
||||
if (!selectedPart) {
|
||||
showBanner("Select a part in the list first!", "warning");
|
||||
playBeep("warning");
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.toUpperCase() === selectedPart.sku.toUpperCase()) {
|
||||
markActivePartAsPicked();
|
||||
} else {
|
||||
// Discrepancy warning modal
|
||||
playBeep('error');
|
||||
document.getElementById('scanned-sku-display').textContent = text;
|
||||
document.getElementById('target-sku-display').textContent = selectedPart.sku;
|
||||
document.getElementById('discrepancy-modal').style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInvoiceNum(num) {
|
||||
document.getElementById('invoice-search-input').value = num;
|
||||
showBanner(`Loading invoice ${num}...`, 'warning');
|
||||
|
||||
// Clear any existing polling
|
||||
if (window.invoicePollInterval) {
|
||||
clearInterval(window.invoicePollInterval);
|
||||
window.invoicePollInterval = null;
|
||||
}
|
||||
|
||||
const invoice = await fetchInvoice(num);
|
||||
if (invoice) {
|
||||
if (invoice.sync_pending) {
|
||||
showBanner(invoice.message, 'warning');
|
||||
playBeep('warning');
|
||||
|
||||
// Show polling status in the items view
|
||||
const partsList = document.getElementById('parts-list');
|
||||
partsList.innerHTML = `
|
||||
<div style="padding: 3rem 1.5rem; text-align: center; color: var(--text-secondary); background: var(--panel-bg); border-radius: 12px; border: 1px solid var(--border-color);">
|
||||
<div class="spin" style="font-size: 2.5rem; margin-bottom: 1rem; color: var(--warning-color);">⏳</div>
|
||||
<div style="font-size: 1.1rem; font-weight: 500; color: var(--text-primary); margin-bottom: 0.5rem;">Querying QuickBooks...</div>
|
||||
<div style="font-size: 0.9rem; line-height: 1.4;">${invoice.message}</div>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; color: var(--text-secondary); font-style: italic;">
|
||||
Please check/trigger the QuickBooks Web Connector client to complete the sync.
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Start polling for invoice creation
|
||||
window.invoicePollInterval = setInterval(async () => {
|
||||
try {
|
||||
const checkResp = await fetch(`/api/invoice/${num}`);
|
||||
if (checkResp.ok && checkResp.status !== 202) {
|
||||
clearInterval(window.invoicePollInterval);
|
||||
window.invoicePollInterval = null;
|
||||
const realInvoice = await checkResp.json();
|
||||
displayInvoice(realInvoice);
|
||||
showBanner(`Invoice ${num} synced and loaded!`, 'success');
|
||||
playBeep('success');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Polling check failed", e);
|
||||
}
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
displayInvoice(invoice);
|
||||
showBanner(`Invoice ${num} loaded successfully!`, 'success');
|
||||
playBeep('success');
|
||||
}
|
||||
}
|
||||
|
||||
function logDebug(msg) {
|
||||
console.log(msg);
|
||||
document.getElementById('scanner-status').textContent = msg.substring(0, 15).toUpperCase();
|
||||
}
|
||||
|
||||
// --- Camera Scanner Management -----------------------------------------------
|
||||
function startScanner() {
|
||||
if (!activeCameraId) return;
|
||||
|
||||
isScanning = true;
|
||||
document.getElementById('toggle-scan-btn').textContent = "Stop Scan";
|
||||
document.getElementById('toggle-scan-btn').classList.remove('btn-secondary');
|
||||
document.getElementById('toggle-scan-btn').style.backgroundColor = 'var(--error-color)';
|
||||
document.querySelector('.scanner-laser').classList.remove('hidden');
|
||||
|
||||
html5QrScanner.start(
|
||||
activeCameraId,
|
||||
{
|
||||
fps: 10,
|
||||
qrbox: (width, height) => {
|
||||
const min = Math.min(width, height);
|
||||
return { width: Math.floor(min * 0.7), height: Math.floor(min * 0.7) };
|
||||
}
|
||||
},
|
||||
(decodedText, decodedResult) => {
|
||||
// Avoid duplicate scan events triggers within 1.5s
|
||||
if (html5QrScanner.isPaused) return;
|
||||
html5QrScanner.pause(true);
|
||||
setTimeout(() => {
|
||||
if (html5QrScanner && isScanning) {
|
||||
html5QrScanner.resume();
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
handleScannedCode(decodedText);
|
||||
},
|
||||
(errorMessage) => {
|
||||
// Fail silently on frame scan misses (normal behavior)
|
||||
}
|
||||
).catch(err => {
|
||||
console.error("Camera start failed: ", err);
|
||||
showBanner("Failed to start camera feed.", "error");
|
||||
isScanning = false;
|
||||
document.getElementById('toggle-scan-btn').textContent = "Start Scan";
|
||||
});
|
||||
}
|
||||
|
||||
function stopScanner() {
|
||||
if (!html5QrScanner || !isScanning) return;
|
||||
|
||||
isScanning = false;
|
||||
document.getElementById('toggle-scan-btn').textContent = "Start Scan";
|
||||
document.getElementById('toggle-scan-btn').className = "btn-secondary";
|
||||
document.getElementById('toggle-scan-btn').removeAttribute('style');
|
||||
document.querySelector('.scanner-laser').classList.add('hidden');
|
||||
document.getElementById('scanner-status').textContent = "PAUSED";
|
||||
|
||||
html5QrScanner.stop().catch(err => console.error("Camera stop error: ", err));
|
||||
}
|
||||
|
||||
// --- Event Listeners ---------------------------------------------------------
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 1. Search buttons
|
||||
document.getElementById('invoice-search-btn').addEventListener('click', () => {
|
||||
const val = document.getElementById('invoice-search-input').value.trim();
|
||||
if (val) loadInvoiceNum(val);
|
||||
});
|
||||
|
||||
document.getElementById('invoice-search-input').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const val = e.target.value.trim();
|
||||
if (val) loadInvoiceNum(val);
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Modals (Map)
|
||||
document.getElementById('btn-show-map').addEventListener('click', () => {
|
||||
document.getElementById('map-modal').style.display = 'flex';
|
||||
playBeep('warning');
|
||||
});
|
||||
|
||||
const closeMap = () => {
|
||||
document.getElementById('map-modal').style.display = 'none';
|
||||
};
|
||||
document.getElementById('btn-close-map').addEventListener('click', closeMap);
|
||||
document.getElementById('btn-close-map-ok').addEventListener('click', closeMap);
|
||||
|
||||
// 3. Modals (Discrepancy warnings)
|
||||
document.getElementById('btn-override-yes').addEventListener('click', () => {
|
||||
document.getElementById('discrepancy-modal').style.display = 'none';
|
||||
markActivePartAsPicked();
|
||||
});
|
||||
|
||||
document.getElementById('btn-override-no').addEventListener('click', () => {
|
||||
document.getElementById('discrepancy-modal').style.display = 'none';
|
||||
playBeep('warning');
|
||||
});
|
||||
|
||||
// 4. Reprint Button
|
||||
document.getElementById('btn-reprint-label').addEventListener('click', () => {
|
||||
if (selectedPart) {
|
||||
triggerReprint(selectedPart.sku, selectedPart.description);
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Initialize camera selection
|
||||
html5QrScanner = new Html5Qrcode("qr-reader");
|
||||
|
||||
Html5Qrcode.getCameras().then(devices => {
|
||||
const select = document.getElementById('camera-select');
|
||||
select.innerHTML = '';
|
||||
|
||||
if (devices && devices.length > 0) {
|
||||
devices.forEach((device, index) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = device.id;
|
||||
// Prefer back camera for scan picker
|
||||
const label = device.label || `Camera ${index + 1}`;
|
||||
opt.textContent = label;
|
||||
|
||||
if (label.toLowerCase().includes('back') || index === devices.length - 1) {
|
||||
activeCameraId = device.id;
|
||||
opt.selected = true;
|
||||
}
|
||||
select.appendChild(opt);
|
||||
});
|
||||
|
||||
if (!activeCameraId) activeCameraId = devices[0].id;
|
||||
|
||||
// Start scanning immediately on camera load
|
||||
startScanner();
|
||||
} else {
|
||||
select.innerHTML = '<option value="">No cameras detected</option>';
|
||||
showBanner("No camera devices detected.", "error");
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error("Get cameras failed: ", err);
|
||||
document.getElementById('camera-select').innerHTML = '<option value="">Permission denied</option>';
|
||||
});
|
||||
|
||||
// Camera select change handler
|
||||
document.getElementById('camera-select').addEventListener('change', (e) => {
|
||||
activeCameraId = e.target.value;
|
||||
if (isScanning) {
|
||||
stopScanner();
|
||||
setTimeout(startScanner, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle scanning button click
|
||||
document.getElementById('toggle-scan-btn').addEventListener('click', () => {
|
||||
if (isScanning) {
|
||||
stopScanner();
|
||||
} else {
|
||||
startScanner();
|
||||
}
|
||||
});
|
||||
|
||||
// Mark part as unavailable click
|
||||
document.getElementById('btn-mark-unavailable').addEventListener('click', async () => {
|
||||
if (!selectedPart || !activeInvoice) return;
|
||||
|
||||
const sku = selectedPart.sku;
|
||||
if (!confirm(`Are you sure you want to mark SKU ${sku} as Out of Stock?\nThis will update total stock to 0 in the database.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/part/${encodeURIComponent(sku)}/unavailable`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (resp.ok) {
|
||||
// Update local model
|
||||
selectedPart.quantity = '0';
|
||||
selectedPart.available = 'N';
|
||||
activeInvoice.lines[selectedPart.index].quantity = '0';
|
||||
activeInvoice.lines[selectedPart.index].available = 'N';
|
||||
|
||||
// Update UI details
|
||||
document.getElementById('det-stock').textContent = '0';
|
||||
|
||||
// Style list card
|
||||
const card = document.getElementById(`part-card-${selectedPart.index}`);
|
||||
if (card) {
|
||||
card.classList.add('unavailable');
|
||||
card.classList.remove('picked');
|
||||
card.dataset.picked = 'unavailable';
|
||||
|
||||
// Replace status icon with cross
|
||||
const statusDiv = card.querySelector('.part-status');
|
||||
if (statusDiv) {
|
||||
statusDiv.innerHTML = `<span style="font-weight:700; font-size:0.9rem;">✕</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
showBanner(`SKU ${sku} marked Out of Stock.`, 'warning');
|
||||
playBeep('success');
|
||||
|
||||
updateHeaderStats();
|
||||
selectNextUnpickedPart();
|
||||
} else {
|
||||
const txt = await resp.text();
|
||||
throw new Error(txt || `Server error ${resp.status}`);
|
||||
}
|
||||
} catch (e) {
|
||||
showBanner(`Failed to update stock: ${e.message}`, 'error');
|
||||
playBeep('error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// QR Generator Tab
|
||||
// ============================================================================
|
||||
|
||||
/** In-memory batch queue of QR entries */
|
||||
let qrBatchQueue = [];
|
||||
|
||||
/** Current part context for the QR form */
|
||||
let qrCurrentPart = null;
|
||||
|
||||
// --- Tab switching -----------------------------------------------------------
|
||||
|
||||
function switchTab(tab) {
|
||||
const pickMain = document.querySelector('main');
|
||||
const qrTab = document.getElementById('tab-qr');
|
||||
const masterTab = document.getElementById('tab-master');
|
||||
const pickBtn = document.getElementById('tab-pick-btn');
|
||||
const qrBtn = document.getElementById('tab-qr-btn');
|
||||
const masterBtn = document.getElementById('tab-master-btn');
|
||||
|
||||
// Reset visibility of all tabs
|
||||
pickMain.style.display = 'none';
|
||||
qrTab.style.display = 'none';
|
||||
masterTab.style.display = 'none';
|
||||
|
||||
pickBtn.classList.remove('active');
|
||||
qrBtn.classList.remove('active');
|
||||
masterBtn.classList.remove('active');
|
||||
|
||||
if (tab === 'pick') {
|
||||
pickMain.style.display = '';
|
||||
pickBtn.classList.add('active');
|
||||
} else if (tab === 'qr') {
|
||||
qrTab.style.display = 'flex';
|
||||
qrTab.style.flexDirection = 'column';
|
||||
qrBtn.classList.add('active');
|
||||
document.getElementById('qr-sku-input').focus();
|
||||
} else if (tab === 'master') {
|
||||
masterTab.style.display = 'flex';
|
||||
masterTab.style.flexDirection = 'column';
|
||||
masterBtn.classList.add('active');
|
||||
document.getElementById('master-search-input').focus();
|
||||
|
||||
// Lazy load master list data if not already loaded
|
||||
if (masterParts.length === 0) {
|
||||
loadMasterList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Part lookup -------------------------------------------------------------
|
||||
|
||||
async function lookupQrSku() {
|
||||
const sku = document.getElementById('qr-sku-input').value.trim();
|
||||
if (!sku) return;
|
||||
|
||||
const btn = document.querySelector('#tab-qr .qr-gen-card button');
|
||||
btn.textContent = '⏳ Looking up…';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/part/${encodeURIComponent(sku)}`);
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
showBanner(err.error || `SKU "${sku}" not found.`, 'error');
|
||||
qrCurrentPart = null;
|
||||
clearQrFields();
|
||||
} else {
|
||||
const data = await resp.json();
|
||||
qrCurrentPart = data;
|
||||
document.getElementById('qr-bin-input').value = data.bin_location || '—';
|
||||
document.getElementById('qr-desc-input').value = data.description || '—';
|
||||
showBanner(`Found: ${sku} → Bin ${data.bin_location || '?'}`, 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
showBanner('Could not reach picker server.', 'error');
|
||||
} finally {
|
||||
btn.textContent = 'Look Up';
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearQrFields() {
|
||||
document.getElementById('qr-bin-input').value = '';
|
||||
document.getElementById('qr-desc-input').value = '';
|
||||
}
|
||||
|
||||
// --- Generate / Batch --------------------------------------------------------
|
||||
|
||||
function buildQrContent(sku, part) {
|
||||
const mode = document.getElementById('qr-content-select').value;
|
||||
if (mode === 'sku_bin' && part && part.bin_location) {
|
||||
return `${sku}\n${part.bin_location}`;
|
||||
}
|
||||
return sku;
|
||||
}
|
||||
|
||||
function generateQrCode() {
|
||||
const sku = document.getElementById('qr-sku-input').value.trim();
|
||||
if (!sku) { showBanner('Enter a SKU first.', 'error'); return; }
|
||||
|
||||
const content = buildQrContent(sku, qrCurrentPart);
|
||||
const entry = {
|
||||
sku,
|
||||
bin: qrCurrentPart ? (qrCurrentPart.bin_location || '') : '',
|
||||
description: qrCurrentPart ? (qrCurrentPart.description || '') : '',
|
||||
content,
|
||||
id: `qr-${Date.now()}`,
|
||||
};
|
||||
|
||||
// Replace existing card for this SKU if already in grid, else prepend
|
||||
const existing = qrBatchQueue.findIndex(e => e.sku === sku);
|
||||
if (existing >= 0) qrBatchQueue.splice(existing, 1);
|
||||
qrBatchQueue.unshift(entry);
|
||||
|
||||
renderQrGrid();
|
||||
}
|
||||
|
||||
function addToQueue() {
|
||||
const sku = document.getElementById('qr-sku-input').value.trim();
|
||||
if (!sku) { showBanner('Enter a SKU first.', 'error'); return; }
|
||||
|
||||
const content = buildQrContent(sku, qrCurrentPart);
|
||||
const entry = {
|
||||
sku,
|
||||
bin: qrCurrentPart ? (qrCurrentPart.bin_location || '') : '',
|
||||
description: qrCurrentPart ? (qrCurrentPart.description || '') : '',
|
||||
content,
|
||||
id: `qr-${Date.now()}`,
|
||||
};
|
||||
|
||||
if (!qrBatchQueue.find(e => e.sku === sku)) {
|
||||
qrBatchQueue.push(entry);
|
||||
}
|
||||
renderQrGrid();
|
||||
showBanner(`Added ${sku} to batch (${qrBatchQueue.length} total)`, 'success');
|
||||
|
||||
// Clear SKU field for next entry
|
||||
document.getElementById('qr-sku-input').value = '';
|
||||
clearQrFields();
|
||||
qrCurrentPart = null;
|
||||
document.getElementById('qr-sku-input').focus();
|
||||
}
|
||||
|
||||
function clearQrQueue() {
|
||||
qrBatchQueue = [];
|
||||
renderQrGrid();
|
||||
}
|
||||
|
||||
// --- Render ------------------------------------------------------------------
|
||||
|
||||
function renderQrGrid() {
|
||||
const grid = document.getElementById('qr-output-grid');
|
||||
const badge = document.getElementById('qr-count-badge');
|
||||
grid.innerHTML = '';
|
||||
|
||||
if (!qrBatchQueue.length) {
|
||||
badge.textContent = '— none yet';
|
||||
grid.innerHTML = '<div style="color:var(--text-secondary); font-size:0.9rem; padding:1rem 0;">Look up a SKU above and click <strong>Generate QR</strong> or <strong>Add to Batch</strong>.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
badge.textContent = `${qrBatchQueue.length} code${qrBatchQueue.length !== 1 ? 's' : ''}`;
|
||||
|
||||
qrBatchQueue.forEach(entry => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'qr-output-card';
|
||||
card.id = `card-${entry.id}`;
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'qr-canvas-wrap';
|
||||
wrap.style.width = '140px';
|
||||
wrap.style.height = '140px';
|
||||
|
||||
const skuLabel = document.createElement('div');
|
||||
skuLabel.className = 'qr-sku-label';
|
||||
skuLabel.textContent = entry.sku;
|
||||
|
||||
const descLabel = document.createElement('div');
|
||||
descLabel.className = 'qr-bin-label';
|
||||
descLabel.textContent = entry.description ? `🚗 ${entry.description}` : '';
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'qr-card-actions';
|
||||
|
||||
const dlBtn = document.createElement('button');
|
||||
dlBtn.textContent = '⬇️ Save';
|
||||
dlBtn.onclick = () => downloadSingleQr(entry.id, entry.sku);
|
||||
|
||||
const rmBtn = document.createElement('button');
|
||||
rmBtn.className = 'btn-secondary';
|
||||
rmBtn.textContent = '✕';
|
||||
rmBtn.style.color = 'var(--error-color)';
|
||||
rmBtn.style.flex = '0 0 36px';
|
||||
rmBtn.onclick = () => {
|
||||
qrBatchQueue = qrBatchQueue.filter(e => e.id !== entry.id);
|
||||
renderQrGrid();
|
||||
};
|
||||
|
||||
actions.appendChild(dlBtn);
|
||||
actions.appendChild(rmBtn);
|
||||
card.appendChild(wrap);
|
||||
card.appendChild(skuLabel);
|
||||
if (entry.description) card.appendChild(descLabel);
|
||||
card.appendChild(actions);
|
||||
grid.appendChild(card);
|
||||
|
||||
// Render QR into wrap div using qrcodejs
|
||||
new QRCode(wrap, {
|
||||
text: entry.content,
|
||||
width: 124,
|
||||
height: 124,
|
||||
colorDark: '#000000',
|
||||
colorLight: '#ffffff',
|
||||
correctLevel: QRCode.CorrectLevel.M,
|
||||
});
|
||||
|
||||
// Store reference to canvas for download
|
||||
setTimeout(() => {
|
||||
const canvas = wrap.querySelector('canvas');
|
||||
if (canvas) canvas.dataset.qrId = entry.id;
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Download / Print --------------------------------------------------------
|
||||
|
||||
function getCanvas(cardId) {
|
||||
const card = document.getElementById(`card-${cardId}`);
|
||||
return card ? card.querySelector('canvas') : null;
|
||||
}
|
||||
|
||||
function downloadSingleQr(cardId, sku) {
|
||||
// Give qrcodejs 150ms to finish rendering
|
||||
setTimeout(() => {
|
||||
const canvas = getCanvas(cardId);
|
||||
if (!canvas) { showBanner('Canvas not ready — try again.', 'error'); return; }
|
||||
|
||||
// Save the raw QR canvas — no text, no borders, nothing extra
|
||||
const link = document.createElement('a');
|
||||
link.download = `QR_${sku.replace(/[^a-zA-Z0-9_-]/g, '_')}.png`;
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.click();
|
||||
}, 150);
|
||||
}
|
||||
|
||||
async function downloadAllQr() {
|
||||
if (!qrBatchQueue.length) { showBanner('No QR codes to download.', 'error'); return; }
|
||||
// Download each individually (ZIP library not bundled)
|
||||
for (const entry of qrBatchQueue) {
|
||||
downloadSingleQr(entry.id, entry.sku);
|
||||
await new Promise(r => setTimeout(r, 300)); // stagger to avoid browser blocking
|
||||
}
|
||||
showBanner(`Downloaded ${qrBatchQueue.length} QR code(s).`, 'success');
|
||||
}
|
||||
|
||||
function printAllQr() {
|
||||
if (!qrBatchQueue.length) { showBanner('No QR codes to print.', 'error'); return; }
|
||||
|
||||
// Build a printable page — QR image only, no text
|
||||
const win = window.open('', '_blank');
|
||||
win.document.write(`<!DOCTYPE html><html><head><title>CSG QR Labels</title><style>
|
||||
body { margin:0; padding:4px; }
|
||||
.grid { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.label { page-break-inside: avoid; line-height:0; }
|
||||
.label img { display:block; width:130px; height:130px; }
|
||||
@media print { @page { margin: 4mm; } }
|
||||
</style></head><body><div class="grid">`);
|
||||
|
||||
let pending = qrBatchQueue.length;
|
||||
qrBatchQueue.forEach(entry => {
|
||||
setTimeout(() => {
|
||||
const canvas = getCanvas(entry.id);
|
||||
const src = canvas ? canvas.toDataURL('image/png') : '';
|
||||
win.document.write(`<div class="label"><img src="${src}"></div>`);
|
||||
pending--;
|
||||
if (pending === 0) {
|
||||
win.document.write('</div></body></html>');
|
||||
win.document.close();
|
||||
win.focus();
|
||||
setTimeout(() => win.print(), 400);
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Master Parts List Tab
|
||||
// ============================================================================
|
||||
let masterParts = [];
|
||||
let masterFiltered = [];
|
||||
let masterIndex = 0;
|
||||
const MASTER_BATCH_SIZE = 40;
|
||||
|
||||
async function loadMasterList() {
|
||||
const grid = document.getElementById('master-grid');
|
||||
grid.innerHTML = '<div style="color:var(--text-secondary); grid-column: 1 / -1; text-align: center; padding: 2rem;">Loading master parts inventory...</div>';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/parts');
|
||||
if (!resp.ok) throw new Error('API request failed');
|
||||
const data = await resp.json();
|
||||
|
||||
masterParts = data.parts || [];
|
||||
masterFiltered = [...masterParts];
|
||||
masterIndex = 0;
|
||||
|
||||
renderMasterGrid();
|
||||
} catch (e) {
|
||||
grid.innerHTML = `<div style="color:var(--error-color); grid-column: 1 / -1; text-align: center; padding: 2rem;">Failed to load inventory: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function filterMasterList() {
|
||||
const query = document.getElementById('master-search-input').value.trim().toUpperCase();
|
||||
|
||||
if (!query) {
|
||||
masterFiltered = [...masterParts];
|
||||
} else {
|
||||
masterFiltered = masterParts.filter(part =>
|
||||
part.sku.includes(query) ||
|
||||
(part.description && part.description.toUpperCase().includes(query)) ||
|
||||
(part.bin_location && part.bin_location.toUpperCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
masterIndex = 0;
|
||||
renderMasterGrid();
|
||||
}
|
||||
|
||||
function renderMasterGrid() {
|
||||
const grid = document.getElementById('master-grid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
if (masterFiltered.length === 0) {
|
||||
grid.innerHTML = '<div style="color:var(--text-secondary); grid-column: 1 / -1; text-align: center; padding: 2rem;">No matching parts found.</div>';
|
||||
document.getElementById('master-load-more').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
loadMoreMasterParts();
|
||||
}
|
||||
|
||||
function loadMoreMasterParts() {
|
||||
const grid = document.getElementById('master-grid');
|
||||
const start = masterIndex;
|
||||
const end = Math.min(start + MASTER_BATCH_SIZE, masterFiltered.length);
|
||||
|
||||
for (let i = start; i < end; i++) {
|
||||
const part = masterFiltered[i];
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'qr-output-card';
|
||||
card.style.background = 'rgba(255, 255, 255, 0.03)';
|
||||
card.style.border = '1px solid var(--border-color)';
|
||||
card.style.borderRadius = '12px';
|
||||
card.style.padding = '1rem';
|
||||
card.style.display = 'flex';
|
||||
card.style.flexDirection = 'column';
|
||||
card.style.alignItems = 'center';
|
||||
card.style.gap = '0.5rem';
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'qr-canvas-wrap';
|
||||
wrap.style.width = '100px';
|
||||
wrap.style.height = '100px';
|
||||
|
||||
const skuDiv = document.createElement('div');
|
||||
skuDiv.className = 'qr-sku-label';
|
||||
skuDiv.style.fontWeight = '700';
|
||||
skuDiv.style.fontSize = '0.80rem';
|
||||
skuDiv.style.color = 'var(--text-primary)';
|
||||
skuDiv.textContent = part.sku;
|
||||
|
||||
const descDiv = document.createElement('div');
|
||||
descDiv.style.fontSize = '0.7rem';
|
||||
descDiv.style.color = 'var(--text-secondary)';
|
||||
descDiv.style.textAlign = 'center';
|
||||
descDiv.style.lineHeight = '1.3';
|
||||
descDiv.style.maxHeight = '2.6em';
|
||||
descDiv.style.overflow = 'hidden';
|
||||
descDiv.style.textOverflow = 'ellipsis';
|
||||
descDiv.style.display = '-webkit-box';
|
||||
descDiv.style.webkitLineClamp = '2';
|
||||
descDiv.style.webkitBoxOrient = 'vertical';
|
||||
descDiv.textContent = part.description || 'No description';
|
||||
|
||||
const metaDiv = document.createElement('div');
|
||||
metaDiv.style.fontSize = '0.7rem';
|
||||
metaDiv.style.display = 'flex';
|
||||
metaDiv.style.gap = '0.5rem';
|
||||
metaDiv.innerHTML = `<span style="color:var(--warning-color);">Bin: ${part.bin_location || '—'}</span> <span style="color:var(--success-color);">Qty: ${part.quantity || '0'}</span>`;
|
||||
|
||||
card.appendChild(wrap);
|
||||
card.appendChild(skuDiv);
|
||||
card.appendChild(descDiv);
|
||||
card.appendChild(metaDiv);
|
||||
grid.appendChild(card);
|
||||
|
||||
// Generate QR code
|
||||
new QRCode(wrap, {
|
||||
text: part.sku,
|
||||
width: 84,
|
||||
height: 84,
|
||||
colorDark: '#000000',
|
||||
colorLight: '#ffffff',
|
||||
correctLevel: QRCode.CorrectLevel.M
|
||||
});
|
||||
}
|
||||
|
||||
masterIndex = end;
|
||||
|
||||
// Show/hide load more button
|
||||
const loadMoreBtn = document.getElementById('master-load-more');
|
||||
if (masterIndex < masterFiltered.length) {
|
||||
loadMoreBtn.style.display = 'block';
|
||||
} else {
|
||||
loadMoreBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,677 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CSG First-Time Setup: Batch Label Generator</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #0d0f12;
|
||||
--panel-bg: rgba(22, 26, 33, 0.85);
|
||||
--border-color: rgba(255, 255, 255, 0.08);
|
||||
--accent-color: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--text-primary: #f3f4f6;
|
||||
--text-secondary: #9ca3af;
|
||||
--success-color: #10b981;
|
||||
--error-color: #ef4444;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
header {
|
||||
background: rgba(13, 15, 18, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.logo-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: var(--accent-color);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 12px var(--accent-color);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
header p {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
font-size: 0.8rem;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 320px;
|
||||
background: var(--panel-bg);
|
||||
border-right: 1px solid var(--border-color);
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar h2 {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-group input, .form-group select {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.form-group input:focus, .form-group select:focus {
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
button.primary-btn {
|
||||
background: var(--accent-color);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
button.primary-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
button.secondary-btn {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
button.secondary-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.status-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Labels View grid default screen preview */
|
||||
.preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Label style in preview */
|
||||
.label-card {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid #ddd;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.label-qr-wrap {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.label-text-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0; /* allows text truncation/wrap */
|
||||
}
|
||||
|
||||
.label-sku {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
word-break: break-all;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.label-meta {
|
||||
font-size: 11px;
|
||||
color: #555;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.progress-bar-wrap {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: var(--accent-color);
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
/* Printing Specific Styles */
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body, html {
|
||||
background: #fff !important;
|
||||
color: #000 !important;
|
||||
height: auto !important;
|
||||
min-height: auto !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
main {
|
||||
display: block !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
display: block !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* Printing layout based on selection */
|
||||
.preview-grid.print-cols-1 {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
.preview-grid.print-cols-2 {
|
||||
display: grid !important;
|
||||
grid-template-columns: 1fr 1fr !important;
|
||||
gap: 12px !important;
|
||||
}
|
||||
|
||||
.preview-grid.print-cols-3 {
|
||||
display: grid !important;
|
||||
grid-template-columns: 1fr 1fr 1fr !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
.label-card {
|
||||
box-shadow: none !important;
|
||||
border: 1px solid #aaa !important;
|
||||
border-radius: 4px !important;
|
||||
background: #fff !important;
|
||||
color: #000 !important;
|
||||
margin: 0 !important;
|
||||
page-break-inside: avoid !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Header (Hidden during print) -->
|
||||
<header class="no-print">
|
||||
<div class="header-brand">
|
||||
<div class="logo-dot"></div>
|
||||
<div>
|
||||
<h1>CSG Warehouse Setup</h1>
|
||||
<p>Batch Label Generation & Printing Utility</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/picker" class="back-btn">⬅ Back to Picker Home</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Sidebar Controls (Hidden during print) -->
|
||||
<div class="sidebar no-print">
|
||||
<h2>🔍 Filter Parts</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="filter-sku">SKU Search / Prefix</label>
|
||||
<input type="text" id="filter-sku" placeholder="e.g. 84126" oninput="applyFilters()">
|
||||
</div>
|
||||
|
||||
<h2>📏 Label Layout</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="print-cols">Layout Columns (for print)</label>
|
||||
<select id="print-cols" onchange="updatePrintCols()">
|
||||
<option value="print-cols-1">Roll Printer (1 Column)</option>
|
||||
<option value="print-cols-2" selected>Sheet Paper (2 Columns)</option>
|
||||
<option value="print-cols-3">Sheet Paper (3 Columns)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="qr-mode">QR Code Mode</label>
|
||||
<select id="qr-mode" onchange="applyFilters()">
|
||||
<option value="sku">SKU only (highly recommended)</option>
|
||||
<option value="sku_bin">SKU + Bin Location</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="batch-limit">Batch Render Limit</label>
|
||||
<select id="batch-limit" onchange="applyFilters()">
|
||||
<option value="50">First 50 matches</option>
|
||||
<option value="100" selected>First 100 matches</option>
|
||||
<option value="250">First 250 matches</option>
|
||||
<option value="500">First 500 matches</option>
|
||||
<option value="all">⚠️ Render All (2,000+ parts)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<h2>💾 Import / Export</h2>
|
||||
<div style="display: flex; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||
<button class="secondary-btn" style="flex: 1; padding: 0.5rem;" onclick="triggerUpload()">📤 Upload</button>
|
||||
<button class="secondary-btn" style="flex: 1; padding: 0.5rem;" onclick="exportLabels()">⬇️ Export</button>
|
||||
</div>
|
||||
<input type="file" id="upload-file-input" accept=".json,.csv" style="display:none;" onchange="handleUpload(event)">
|
||||
|
||||
<button class="primary-btn" onclick="triggerPrint()">🖨️ Print Labels</button>
|
||||
<button class="secondary-btn" onclick="resetFilters()">🔄 Reset Filters</button>
|
||||
</div>
|
||||
|
||||
<!-- Printable content / preview area -->
|
||||
<div class="content-area">
|
||||
|
||||
<!-- Setup Progress / Status Banner (Hidden during print) -->
|
||||
<div class="status-card no-print">
|
||||
<div class="status-info">
|
||||
<span class="status-title" id="status-title">Loading parts inventory...</span>
|
||||
<span class="status-desc" id="status-desc">Fetching latest warehouse spreadsheet database.</span>
|
||||
<div class="progress-bar-wrap" id="progress-wrap">
|
||||
<div class="progress-bar-fill" id="progress-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Label Grid -->
|
||||
<div class="preview-grid print-cols-2" id="labels-grid">
|
||||
<!-- Dynamically loaded label cards -->
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Dependencies -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||
|
||||
<script>
|
||||
let allParts = [];
|
||||
let filteredParts = [];
|
||||
let renderQueue = [];
|
||||
let isRendering = false;
|
||||
|
||||
// Fetch all parts on load
|
||||
async function fetchParts() {
|
||||
try {
|
||||
const response = await fetch('/api/parts');
|
||||
if (!response.ok) throw new Error('Failed to load parts api');
|
||||
const data = await response.json();
|
||||
allParts = data.parts || [];
|
||||
|
||||
document.getElementById('status-title').textContent = `Loaded ${allParts.length} parts database.`;
|
||||
document.getElementById('status-desc').textContent = 'Filter below to print barcodes in batches.';
|
||||
|
||||
applyFilters();
|
||||
} catch (err) {
|
||||
document.getElementById('status-title').textContent = '⚠️ Inventory Database Error';
|
||||
document.getElementById('status-desc').textContent = 'Failed to fetch parts. Ensure picker server is running.';
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
function triggerUpload() {
|
||||
document.getElementById('upload-file-input').click();
|
||||
}
|
||||
|
||||
function handleUpload(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
const text = e.target.result;
|
||||
let parsedList = [];
|
||||
|
||||
try {
|
||||
if (file.name.endsWith('.json')) {
|
||||
// Parse JSON
|
||||
const data = JSON.parse(text);
|
||||
const items = Array.isArray(data) ? data : (data.parts || data.items || []);
|
||||
parsedList = items.map(item => ({
|
||||
sku: (item.sku || item.SKU || item.stock_number || item['STOCK NUMBER'] || '').trim().toUpperCase(),
|
||||
description: (item.description || item.DESCRIPTION || item.desc || '')
|
||||
})).filter(x => x.sku);
|
||||
} else {
|
||||
// Parse CSV
|
||||
const lines = text.split(/\r?\n/);
|
||||
if (lines.length > 0) {
|
||||
const headers = lines[0].split(',').map(h => h.trim().toUpperCase());
|
||||
|
||||
// Find column indices
|
||||
let skuIdx = headers.findIndex(h => h === 'SKU' || h === 'STOCK NUMBER' || h === 'STOCK_NUMBER');
|
||||
let descIdx = headers.findIndex(h => h === 'DESCRIPTION' || h === 'DESC');
|
||||
|
||||
// Fallback if no header match
|
||||
if (skuIdx === -1) skuIdx = 0;
|
||||
if (descIdx === -1) descIdx = 1;
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
|
||||
// Split comma-separated, respecting quoted fields
|
||||
const cols = line.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/).map(c => c.replace(/^"|"$/g, '').trim());
|
||||
const sku = (cols[skuIdx] || '').toUpperCase();
|
||||
const description = cols[descIdx] || '';
|
||||
|
||||
if (sku) {
|
||||
parsedList.push({ sku, description });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedList.length === 0) {
|
||||
alert("No valid part labels found in the uploaded file. Ensure columns contain SKU/STOCK NUMBER and DESCRIPTION.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Update parts list
|
||||
allParts = parsedList;
|
||||
document.getElementById('status-title').textContent = `Uploaded Custom List: ${allParts.length} parts loaded.`;
|
||||
document.getElementById('status-desc').textContent = 'Successfully imported custom label batch.';
|
||||
|
||||
// Reset search input
|
||||
document.getElementById('filter-sku').value = '';
|
||||
applyFilters();
|
||||
|
||||
alert(`Successfully imported ${allParts.length} custom labels!`);
|
||||
} catch (err) {
|
||||
alert("Error parsing file: " + err.message);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
event.target.value = '';
|
||||
}
|
||||
|
||||
function exportLabels() {
|
||||
if (filteredParts.length === 0) {
|
||||
alert("No labels to export.");
|
||||
return;
|
||||
}
|
||||
|
||||
const exportData = filteredParts.map(part => ({
|
||||
sku: part.sku,
|
||||
description: part.description
|
||||
}));
|
||||
|
||||
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(exportData, null, 2));
|
||||
const dlAnchor = document.createElement('a');
|
||||
dlAnchor.setAttribute("href", dataStr);
|
||||
dlAnchor.setAttribute("download", `CSG_Labels_Export_${Date.now()}.json`);
|
||||
document.body.appendChild(dlAnchor);
|
||||
dlAnchor.click();
|
||||
dlAnchor.remove();
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const skuVal = document.getElementById('filter-sku').value.toUpperCase().trim();
|
||||
const limitVal = document.getElementById('batch-limit').value;
|
||||
|
||||
// Apply filters
|
||||
filteredParts = allParts.filter(part => {
|
||||
if (skuVal && !part.sku.includes(skuVal)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Handle limit
|
||||
const limit = limitVal === 'all' ? filteredParts.length : parseInt(limitVal, 10);
|
||||
renderQueue = filteredParts.slice(0, limit);
|
||||
|
||||
// Re-render
|
||||
startBatchRender();
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
document.getElementById('filter-sku').value = '';
|
||||
document.getElementById('batch-limit').value = '100';
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function updatePrintCols() {
|
||||
const grid = document.getElementById('labels-grid');
|
||||
grid.className = 'preview-grid ' + document.getElementById('print-cols').value;
|
||||
}
|
||||
|
||||
// Start progressive asynchronous rendering of QRs to keep page responsive
|
||||
function startBatchRender() {
|
||||
isRendering = false; // stops active loops
|
||||
const grid = document.getElementById('labels-grid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
if (renderQueue.length === 0) {
|
||||
grid.innerHTML = '<div style="color:var(--text-secondary); grid-column: 1 / -1; text-align: center; padding: 2rem;">No matching parts found.</div>';
|
||||
document.getElementById('status-title').textContent = 'Loaded 0 labels';
|
||||
document.getElementById('progress-wrap').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('status-title').textContent = `Generating ${renderQueue.length} labels...`;
|
||||
document.getElementById('progress-wrap').style.display = 'block';
|
||||
document.getElementById('progress-fill').style.width = '0%';
|
||||
|
||||
isRendering = true;
|
||||
let index = 0;
|
||||
|
||||
const qrMode = document.getElementById('qr-mode').value;
|
||||
|
||||
function renderNextChunk() {
|
||||
if (!isRendering) return;
|
||||
|
||||
const chunk = 10; // Render 10 at a time
|
||||
const end = Math.min(index + chunk, renderQueue.length);
|
||||
|
||||
for (let i = index; i < end; i++) {
|
||||
const part = renderQueue[i];
|
||||
createLabelCard(grid, part, i, qrMode);
|
||||
}
|
||||
|
||||
index = end;
|
||||
const percentage = (index / renderQueue.length) * 100;
|
||||
document.getElementById('progress-fill').style.width = percentage + '%';
|
||||
|
||||
if (index < renderQueue.length) {
|
||||
requestAnimationFrame(renderNextChunk);
|
||||
} else {
|
||||
document.getElementById('status-title').textContent = `Ready: ${renderQueue.length} labels generated.`;
|
||||
document.getElementById('status-desc').textContent = 'Press "Print Labels" to open printer queue dialog.';
|
||||
document.getElementById('progress-wrap').style.display = 'none';
|
||||
isRendering = false;
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(renderNextChunk);
|
||||
}
|
||||
|
||||
function createLabelCard(container, part, index, qrMode) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'label-card';
|
||||
|
||||
const qrWrap = document.createElement('div');
|
||||
qrWrap.className = 'label-qr-wrap';
|
||||
qrWrap.id = `setup-qr-wrap-${index}`;
|
||||
|
||||
const textWrap = document.createElement('div');
|
||||
textWrap.className = 'label-text-wrap';
|
||||
|
||||
const skuDiv = document.createElement('div');
|
||||
skuDiv.className = 'label-sku';
|
||||
skuDiv.textContent = part.sku;
|
||||
|
||||
const metaDiv = document.createElement('div');
|
||||
metaDiv.className = 'label-meta';
|
||||
metaDiv.innerHTML = part.description || 'No description available';
|
||||
|
||||
textWrap.appendChild(skuDiv);
|
||||
textWrap.appendChild(metaDiv);
|
||||
card.appendChild(qrWrap);
|
||||
card.appendChild(textWrap);
|
||||
container.appendChild(card);
|
||||
|
||||
// Determine QR code text
|
||||
let qrText = part.sku;
|
||||
if (qrMode === 'sku_bin' && part.bin_location) {
|
||||
qrText = `${part.sku}\n${part.bin_location}`;
|
||||
}
|
||||
|
||||
// Create QR code
|
||||
new QRCode(qrWrap, {
|
||||
text: qrText,
|
||||
width: 80,
|
||||
height: 80,
|
||||
colorDark: "#000000",
|
||||
colorLight: "#ffffff",
|
||||
correctLevel: QRCode.CorrectLevel.M
|
||||
});
|
||||
}
|
||||
|
||||
function triggerPrint() {
|
||||
if (isRendering) {
|
||||
alert("Please wait for all labels to finish rendering before printing!");
|
||||
return;
|
||||
}
|
||||
window.print();
|
||||
}
|
||||
|
||||
// Initialize on load
|
||||
window.addEventListener('DOMContentLoaded', fetchParts);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
"""Loads and validates config.json. Expands Windows env vars in paths.
|
||||
|
||||
Any user-data path left empty in config.json is filled in from
|
||||
`app.paths` so all caches end up in the same `%LOCALAPPDATA%` folder.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from . import paths as app_paths
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _expand(path: str) -> str:
|
||||
"""Expand %ENV% and ~ in a path string."""
|
||||
if not path:
|
||||
return path
|
||||
return os.path.expandvars(os.path.expanduser(path))
|
||||
|
||||
|
||||
# Matches dated parts filenames: MM-DD-YY or MM-DD-YYYY, whitespace, "OnPart",
|
||||
# then any non-whitespace suffix (e.g. "_inv", "_inv(in)", "(in)"), then ".csv".
|
||||
_DATED_CSV_RE = re.compile(
|
||||
r"^(\d{2})-(\d{2})-(\d{2}|\d{4})\s+OnPart\S*\.csv$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _parse_dated_filename(name: str) -> date | None:
|
||||
"""Return the date encoded in a dated parts filename, or None.
|
||||
|
||||
Accepts any filename matching MM-DD-YY(YY) OnPart*.csv, e.g.
|
||||
'OnPart.csv', 'OnPart_inv.csv', 'OnPart_inv(in).csv'.
|
||||
"""
|
||||
m = _DATED_CSV_RE.match(name)
|
||||
if not m:
|
||||
return None
|
||||
mm_s, dd_s, yy_s = m.groups()
|
||||
mm, dd = int(mm_s), int(dd_s)
|
||||
# 2-digit years are interpreted as 20YY. 4-digit years used as-is.
|
||||
year = int(yy_s) if len(yy_s) == 4 else 2000 + int(yy_s)
|
||||
try:
|
||||
return date(year, mm, dd)
|
||||
except ValueError:
|
||||
return None # e.g., "13-40-26"
|
||||
|
||||
|
||||
def _find_dated_csv(directory: str) -> str:
|
||||
"""Find the newest dated parts CSV file in `directory` whose date is
|
||||
today or earlier. Returns the full path, or "" if none found."""
|
||||
if not directory or not os.path.isdir(directory):
|
||||
return ""
|
||||
today = date.today()
|
||||
best: tuple[date, str] | None = None
|
||||
try:
|
||||
entries = os.listdir(directory)
|
||||
except OSError as e:
|
||||
log.warning("Could not list %s: %s", directory, e)
|
||||
return ""
|
||||
for name in entries:
|
||||
d = _parse_dated_filename(name)
|
||||
if d is None or d > today:
|
||||
continue
|
||||
full = os.path.join(directory, name)
|
||||
if not os.path.isfile(full):
|
||||
continue
|
||||
if best is None or d > best[0]:
|
||||
best = (d, full)
|
||||
return best[1] if best else ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
csv_path: str = ""
|
||||
csv_path_glob_fallback: str = ""
|
||||
sqlite_path: str = ""
|
||||
image_cache_dir: str = ""
|
||||
smb_image_dir: str = ""
|
||||
default_tab: str = "parts"
|
||||
max_results: int = 15
|
||||
ui_scale: float = 1.2
|
||||
appearance_mode: str = "light"
|
||||
color_theme: str = "blue"
|
||||
|
||||
# ShipEngine API credentials
|
||||
shipengine_sandbox: bool = True
|
||||
shipengine_api_key: str = ""
|
||||
shipengine_return_key: str = ""
|
||||
|
||||
# USPS shipper (sender) address
|
||||
usps_shipper_name: str = ""
|
||||
usps_shipper_company: str = ""
|
||||
usps_shipper_phone: str = ""
|
||||
usps_shipper_address1: str = ""
|
||||
usps_shipper_city: str = ""
|
||||
usps_shipper_state: str = ""
|
||||
usps_shipper_zip: str = ""
|
||||
|
||||
# FedEx Ship API credentials
|
||||
fedex_sandbox: bool = False
|
||||
fedex_account: str = ""
|
||||
fedex_api_key: str = ""
|
||||
fedex_secret: str = ""
|
||||
|
||||
# FedEx shipper (sender) address — fill in config.json for your location
|
||||
fedex_shipper_name: str = ""
|
||||
fedex_shipper_company: str = ""
|
||||
fedex_shipper_phone: str = ""
|
||||
fedex_shipper_address1: str = ""
|
||||
fedex_shipper_city: str = ""
|
||||
fedex_shipper_state: str = ""
|
||||
fedex_shipper_zip: str = ""
|
||||
|
||||
# Resolved at runtime - not part of the JSON file itself.
|
||||
resolved_csv_path: str = field(default="", init=False)
|
||||
_loaded_path: Path | None = field(default=None, init=False)
|
||||
# Per-machine override for the scan folder. Set by the UI from ui_state.json
|
||||
# so each workstation can point at its own parts folder without editing
|
||||
# the deployable config.json.
|
||||
parts_folder_override: str = field(default="", init=False)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path | None = None) -> "Config":
|
||||
if path is None:
|
||||
# Look next to the running app first; fall back to repo-root config.json.
|
||||
base = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parents[1]))
|
||||
path = base / "config.json"
|
||||
if not path.exists():
|
||||
path = Path(__file__).resolve().parents[1] / "config.json"
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
|
||||
cfg = cls(**{k: raw[k] for k in raw if k in cls.__dataclass_fields__})
|
||||
cfg._loaded_path = path
|
||||
|
||||
cfg.csv_path = _expand(cfg.csv_path)
|
||||
cfg.csv_path_glob_fallback = _expand(cfg.csv_path_glob_fallback)
|
||||
cfg.sqlite_path = _expand(cfg.sqlite_path)
|
||||
cfg.image_cache_dir = _expand(cfg.image_cache_dir)
|
||||
cfg.smb_image_dir = _expand(cfg.smb_image_dir)
|
||||
|
||||
# Fall back to the centralized appdata location whenever a cache path
|
||||
# isn't explicitly set in config.json. This keeps every persisted
|
||||
# artifact under one folder per user so it's easy to find / clear and
|
||||
# never accidentally lands next to the .exe.
|
||||
if not cfg.sqlite_path:
|
||||
cfg.sqlite_path = app_paths.default_sqlite_path()
|
||||
if not cfg.image_cache_dir:
|
||||
cfg.image_cache_dir = app_paths.default_image_cache_dir()
|
||||
|
||||
# Make sure parent dirs for cache exist.
|
||||
Path(cfg.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(cfg.image_cache_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# The parts_folder_override is set by the UI after load (from
|
||||
# ui_state.json), so don't resolve the CSV here - the UI will call
|
||||
# resolve_csv() after setting the override.
|
||||
cfg.resolved_csv_path = cfg._resolve_csv()
|
||||
return cfg
|
||||
|
||||
def _scan_directory(self) -> str:
|
||||
"""Return the directory to scan for dated parts CSV files.
|
||||
|
||||
Resolution order:
|
||||
1. parts_folder_override - the per-machine folder the user picked via
|
||||
the Change folder button (stored in ui_state.json).
|
||||
2. csv_path_glob_fallback's dirname - the config.json default.
|
||||
3. csv_path's dirname - last-resort fallback.
|
||||
"""
|
||||
if self.parts_folder_override and os.path.isdir(self.parts_folder_override):
|
||||
return self.parts_folder_override
|
||||
if self.csv_path_glob_fallback:
|
||||
d = os.path.dirname(self.csv_path_glob_fallback)
|
||||
if d:
|
||||
return d
|
||||
if self.csv_path:
|
||||
d = os.path.dirname(self.csv_path)
|
||||
if d:
|
||||
return d
|
||||
return ""
|
||||
|
||||
def _resolve_csv(self) -> str:
|
||||
"""Pick the CSV the app should load.
|
||||
|
||||
Resolution order:
|
||||
1. Newest dated parts CSV file in the scan directory whose date is
|
||||
today or earlier. This is the canonical source and always wins on
|
||||
launch, even if the user previously picked a different file via
|
||||
Change CSV path.
|
||||
2. The last user-configured csv_path, if it exists on disk.
|
||||
3. Newest match of csv_path_glob_fallback (legacy fallback).
|
||||
"""
|
||||
scan_dir = self._scan_directory()
|
||||
dated = _find_dated_csv(scan_dir)
|
||||
if dated:
|
||||
log.info("CSV resolved via dated scan: %s (scan_dir=%s)", dated, scan_dir)
|
||||
return dated
|
||||
if self.csv_path and Path(self.csv_path).exists():
|
||||
log.info(
|
||||
"CSV resolved via csv_path fallback: %s (no dated files in %s)",
|
||||
self.csv_path, scan_dir,
|
||||
)
|
||||
return self.csv_path
|
||||
if self.csv_path_glob_fallback:
|
||||
matches = glob.glob(self.csv_path_glob_fallback)
|
||||
if matches:
|
||||
matches.sort(key=lambda p: Path(p).stat().st_mtime, reverse=True)
|
||||
log.info("CSV resolved via glob fallback: %s", matches[0])
|
||||
return matches[0]
|
||||
log.warning(
|
||||
"CSV resolution found nothing - returning csv_path (may not exist): %s",
|
||||
self.csv_path,
|
||||
)
|
||||
return self.csv_path # may not exist; caller decides what to do
|
||||
|
||||
def resolve_csv(self) -> str:
|
||||
"""Re-run CSV resolution and update self.resolved_csv_path.
|
||||
|
||||
Call this before each load/reload so a newly-dropped dated file picks
|
||||
up without restarting the app.
|
||||
"""
|
||||
self.resolved_csv_path = self._resolve_csv()
|
||||
return self.resolved_csv_path
|
||||
|
||||
def save(self) -> None:
|
||||
"""Write the current config back to config.json, preserving other fields."""
|
||||
if not self._loaded_path:
|
||||
return
|
||||
try:
|
||||
with open(self._loaded_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception:
|
||||
data = {}
|
||||
|
||||
for k in self.__dataclass_fields__:
|
||||
if k not in ("resolved_csv_path", "_loaded_path", "parts_folder_override"):
|
||||
data[k] = getattr(self, k)
|
||||
|
||||
with open(self._loaded_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
@@ -0,0 +1,428 @@
|
||||
"""Roland cut file and LightBurn file discovery and opening.
|
||||
|
||||
Searches a configured SMB folder (recursively) for files whose name matches
|
||||
a SKU, opens the chosen file with the system default application, and
|
||||
persists a list of saved folder paths so the user can swap between locations.
|
||||
|
||||
The search is on a worker thread because SMB roots can be slow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CUT_FILE_EXTENSIONS: tuple[str, ...] = (
|
||||
".eps", ".ai", ".svg", ".pdf", ".plt", ".gsd", ".dxf", ".cdr", ".ccx",
|
||||
)
|
||||
|
||||
LIGHTBURN_EXTENSIONS: tuple[str, ...] = (
|
||||
".lbrn", ".lbrn2",
|
||||
)
|
||||
|
||||
|
||||
def _walk_files(base: Path) -> list[Path]:
|
||||
"""Yield every file under `base` (recursive), tolerant of per-subfolder errors.
|
||||
|
||||
`Path.rglob("*")` raises on the first permission error; `os.walk` with an
|
||||
error handler lets us skip individual subdirectories.
|
||||
"""
|
||||
results: list[Path] = []
|
||||
|
||||
def _on_error(err: OSError) -> None:
|
||||
log.debug("Walk skipped (likely permission/IO): %s", err)
|
||||
|
||||
for root, _dirs, files in os.walk(base, onerror=_on_error):
|
||||
for name in files:
|
||||
results.append(Path(root) / name)
|
||||
return results
|
||||
|
||||
|
||||
def extract_sku_suffix(sku: str) -> str | None:
|
||||
"""Pull the part-code suffix off a SKU.
|
||||
|
||||
Example: ``'8620843-0423L'`` -> ``'0423L'``. Returns ``None`` if the SKU
|
||||
has no dash or has nothing after the first dash.
|
||||
"""
|
||||
if "-" not in sku:
|
||||
return None
|
||||
suffix = sku.split("-", 1)[1]
|
||||
return suffix if suffix else None
|
||||
|
||||
|
||||
def make_lightburn_sku(sku: str) -> str | None:
|
||||
"""Derive the LightBurn filename SKU from a part SKU.
|
||||
|
||||
LightBurn files all share the internal part number ``'E000'`` as their
|
||||
prefix followed by the portion of the part SKU after the first dash.
|
||||
Returns ``None`` if no suffix can be extracted.
|
||||
"""
|
||||
suffix = extract_sku_suffix(sku)
|
||||
if suffix is None:
|
||||
return None
|
||||
return "E000-" + suffix
|
||||
|
||||
|
||||
def _whole_token_pattern(token: str) -> re.Pattern:
|
||||
"""Return a compiled regex that matches `token` as a whole token in a filename."""
|
||||
escaped = re.escape(token)
|
||||
return re.compile(
|
||||
r"(?:^|[^a-z0-9])" + escaped + r"(?:[^a-z0-9]|$)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def find_cut_files(base_dir: str, sku: str, *, limit: int = 40) -> list[Path]:
|
||||
"""Find cut files anywhere under base_dir that match the SKU.
|
||||
|
||||
Match priority (highest first):
|
||||
1. Exact stem match against the full SKU
|
||||
2. Exact stem match against the suffix (portion after the first dash)
|
||||
|
||||
Returns a list of matching Paths, most specific match first.
|
||||
"""
|
||||
base = Path(base_dir)
|
||||
if not base.exists():
|
||||
log.warning("Cut file base dir does not exist: %s", base_dir)
|
||||
return []
|
||||
|
||||
try:
|
||||
all_files = _walk_files(base)
|
||||
except Exception as exc:
|
||||
log.error("Cut file base dir unreachable: %s (%s)", base_dir, exc)
|
||||
return []
|
||||
|
||||
suffix = extract_sku_suffix(sku)
|
||||
search_tokens = [sku]
|
||||
if suffix and suffix.lower() != sku.lower():
|
||||
search_tokens.append(suffix)
|
||||
|
||||
patterns = [_whole_token_pattern(t) for t in search_tokens]
|
||||
|
||||
matches: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for path in all_files:
|
||||
stem = path.stem
|
||||
for pat in patterns:
|
||||
if pat.search(stem):
|
||||
key = str(path)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
matches.append(path)
|
||||
break
|
||||
if len(matches) >= limit:
|
||||
break
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
def open_file(path: str) -> bool:
|
||||
"""Open a file with the system default application. Cross-platform."""
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
os.startfile(path)
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.Popen(["open", path])
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", path])
|
||||
return True
|
||||
except Exception as exc:
|
||||
log.error("Failed to open %s: %s", path, exc)
|
||||
return False
|
||||
|
||||
|
||||
def reveal_in_explorer(path: str) -> bool:
|
||||
"""Open the file's containing folder with the file selected (Windows)."""
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
subprocess.Popen(["explorer", f"/select,{path}"])
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.Popen(["open", "-R", path])
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", str(Path(path).parent)])
|
||||
return True
|
||||
except Exception as exc:
|
||||
log.error("Failed to reveal %s: %s", path, exc)
|
||||
return False
|
||||
|
||||
|
||||
class CutFileIndex:
|
||||
"""In-memory snapshot of all cut files under one folder.
|
||||
|
||||
The first search of a session walks the SMB share once (potentially slow)
|
||||
and caches the result. Subsequent lookups are O(1) dict access.
|
||||
"""
|
||||
|
||||
def __init__(self, extensions: tuple[str, ...]) -> None:
|
||||
self._extensions = extensions
|
||||
self._indexed_path: str | None = None
|
||||
self._built_at: float | None = None
|
||||
self._index: dict[str, list[Path]] = {}
|
||||
self._cut_file_count = 0
|
||||
self._total_files_seen = 0
|
||||
self._dirs_walked = 0
|
||||
|
||||
@property
|
||||
def file_count(self) -> int:
|
||||
return self._cut_file_count
|
||||
|
||||
@property
|
||||
def indexed_path(self) -> str | None:
|
||||
return self._indexed_path
|
||||
|
||||
@property
|
||||
def built_at(self) -> float | None:
|
||||
return self._built_at
|
||||
|
||||
@property
|
||||
def stats(self) -> tuple[int, int, int]:
|
||||
"""(cut_files_indexed, total_files_seen, dirs_walked)."""
|
||||
return (self._cut_file_count, self._total_files_seen, self._dirs_walked)
|
||||
|
||||
def is_valid_for(self, path: str | None) -> bool:
|
||||
"""True if the index reflects `path` (even if `path` is None)."""
|
||||
return self._indexed_path == path and self._built_at is not None
|
||||
|
||||
def invalidate(self) -> None:
|
||||
self._indexed_path = None
|
||||
self._built_at = None
|
||||
self._index.clear()
|
||||
self._cut_file_count = 0
|
||||
self._total_files_seen = 0
|
||||
self._dirs_walked = 0
|
||||
|
||||
def build(self, path: str | None) -> int:
|
||||
"""Walk `path` and replace the index. Returns the cut-file count.
|
||||
|
||||
Also records dirs_walked and files_seen so the UI can show a summary.
|
||||
"""
|
||||
if not path:
|
||||
self.invalidate()
|
||||
return 0
|
||||
|
||||
base = Path(path)
|
||||
if not base.exists():
|
||||
log.warning("Cut file base dir does not exist: %s", path)
|
||||
self.invalidate()
|
||||
return 0
|
||||
|
||||
new_index: dict[str, list[Path]] = {}
|
||||
cut_count = 0
|
||||
total_count = 0
|
||||
dir_count = 0
|
||||
|
||||
def _on_error(err: OSError) -> None:
|
||||
log.debug("Walk skipped (likely permission/IO): %s", err)
|
||||
|
||||
try:
|
||||
for root, dirs, files in os.walk(base, onerror=_on_error):
|
||||
dir_count += 1
|
||||
for name in files:
|
||||
total_count += 1
|
||||
p = Path(root) / name
|
||||
if p.suffix.lower() not in self._extensions:
|
||||
continue
|
||||
cut_count += 1
|
||||
token = p.stem.lower()
|
||||
new_index.setdefault(token, []).append(p)
|
||||
except Exception as exc:
|
||||
log.error("Cut file index build failed in %s: %s", path, exc)
|
||||
self.invalidate()
|
||||
return 0
|
||||
|
||||
self._index = new_index
|
||||
self._indexed_path = path
|
||||
self._built_at = time.monotonic()
|
||||
self._cut_file_count = cut_count
|
||||
self._total_files_seen = total_count
|
||||
self._dirs_walked = dir_count
|
||||
|
||||
log.info(
|
||||
"Cut file index built: %d cut files (of %d total) across %d folders under %s",
|
||||
cut_count, total_count, dir_count, path,
|
||||
)
|
||||
return cut_count
|
||||
|
||||
def lookup(self, sku: str) -> list[Path]:
|
||||
"""Match a SKU against the cached index using the same ranking as
|
||||
`find_cut_files` (exact, suffix-as-token, full-SKU substring).
|
||||
"""
|
||||
suffix = extract_sku_suffix(sku)
|
||||
search_tokens = [sku.lower()]
|
||||
if suffix and suffix.lower() != sku.lower():
|
||||
search_tokens.append(suffix.lower())
|
||||
|
||||
seen: set[str] = set()
|
||||
results: list[Path] = []
|
||||
for token_query in search_tokens:
|
||||
pat = _whole_token_pattern(token_query)
|
||||
for stem_token, paths in self._index.items():
|
||||
if pat.search(stem_token):
|
||||
for p in paths:
|
||||
key = str(p)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
results.append(p)
|
||||
return results
|
||||
|
||||
|
||||
class CutFileManager:
|
||||
"""Holds saved cut-file folder paths, the active selection, and the
|
||||
in-memory file index.
|
||||
|
||||
Persists path config to the shared ui_state.json dict via `save_fn` so
|
||||
each workstation can have its own set of folders. Pass different
|
||||
`state_key_paths` / `state_key_active` values to reuse this class for
|
||||
both Roland cut files and LightBurn files.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
state: dict,
|
||||
save_fn: Callable[[dict], None],
|
||||
extensions: tuple[str, ...],
|
||||
state_key_paths: str | None = None,
|
||||
state_key_active: str | None = None,
|
||||
) -> None:
|
||||
self._state = state
|
||||
self._save_fn = save_fn
|
||||
self._extensions = extensions
|
||||
self._key_paths = state_key_paths or "cut_file_paths"
|
||||
self._key_active = state_key_active or "active_cut_file_path"
|
||||
|
||||
raw = state.get(self._key_paths, [])
|
||||
self._paths: list[dict[str, str]] = [
|
||||
p for p in (raw if isinstance(raw, list) else [])
|
||||
if isinstance(p, dict) and "name" in p and "path" in p
|
||||
]
|
||||
self._active_name: str = state.get(self._key_active, "") or ""
|
||||
if self._active_name and not any(p["name"] == self._active_name for p in self._paths):
|
||||
self._active_name = self._paths[0]["name"] if self._paths else ""
|
||||
|
||||
self._index = CutFileIndex(extensions)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# ── Accessors ──────────────────────────────────────────────────────────────
|
||||
|
||||
def get_paths(self) -> list[dict[str, str]]:
|
||||
return list(self._paths)
|
||||
|
||||
def get_active_name(self) -> str | None:
|
||||
return self._active_name or None
|
||||
|
||||
def get_active_path(self) -> str | None:
|
||||
for p in self._paths:
|
||||
if p["name"] == self._active_name:
|
||||
return p["path"]
|
||||
return None
|
||||
|
||||
def index_file_count(self) -> int:
|
||||
return self._index.file_count
|
||||
|
||||
def index_built_at(self) -> float | None:
|
||||
return self._index.built_at
|
||||
|
||||
def index_stats(self) -> tuple[int, int, int]:
|
||||
"""(cut_files_indexed, total_files_seen, dirs_walked)."""
|
||||
return self._index.stats
|
||||
|
||||
def index_is_fresh_for_active(self) -> bool:
|
||||
return self._index.is_valid_for(self.get_active_path())
|
||||
|
||||
# ── Mutations (each persists immediately) ──────────────────────────────────
|
||||
|
||||
def add_or_update(self, name: str, path: str) -> None:
|
||||
for p in self._paths:
|
||||
if p["name"] == name:
|
||||
p["path"] = path
|
||||
self._index.invalidate()
|
||||
self._persist()
|
||||
return
|
||||
self._paths.append({"name": name, "path": path})
|
||||
if not self._active_name:
|
||||
self._active_name = name
|
||||
self._index.invalidate()
|
||||
self._persist()
|
||||
|
||||
def remove(self, name: str) -> None:
|
||||
self._paths = [p for p in self._paths if p["name"] != name]
|
||||
if self._active_name == name:
|
||||
self._active_name = self._paths[0]["name"] if self._paths else ""
|
||||
self._index.invalidate()
|
||||
self._persist()
|
||||
|
||||
def set_active(self, name: str) -> None:
|
||||
if any(p["name"] == name for p in self._paths):
|
||||
if self._active_name != name:
|
||||
self._active_name = name
|
||||
self._index.invalidate()
|
||||
self._persist()
|
||||
|
||||
def _persist(self) -> None:
|
||||
self._state[self._key_paths] = list(self._paths)
|
||||
self._state[self._key_active] = self._active_name
|
||||
try:
|
||||
self._save_fn(self._state)
|
||||
except Exception as exc:
|
||||
log.warning("Failed to persist cut file state: %s", exc)
|
||||
|
||||
# ── Search ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def search_async(
|
||||
self,
|
||||
sku: str,
|
||||
callback: Callable[[list[Path]], None],
|
||||
) -> None:
|
||||
"""Look up a SKU. Builds the index on first use or after a path switch."""
|
||||
active_path = self.get_active_path()
|
||||
|
||||
def worker() -> None:
|
||||
try:
|
||||
with self._lock:
|
||||
if not self._index.is_valid_for(active_path):
|
||||
self._index.build(active_path)
|
||||
results = self._index.lookup(sku)
|
||||
callback(results)
|
||||
except Exception:
|
||||
log.exception("Cut file search blew up")
|
||||
callback([])
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def refresh_index_async(
|
||||
self,
|
||||
callback: Callable[[int], None] | None = None,
|
||||
) -> None:
|
||||
"""Force-rebuild the index against the current active path."""
|
||||
active_path = self.get_active_path()
|
||||
|
||||
def worker() -> None:
|
||||
try:
|
||||
with self._lock:
|
||||
count = self._index.build(active_path)
|
||||
if callback:
|
||||
callback(count)
|
||||
except Exception:
|
||||
log.exception("Cut file index refresh failed")
|
||||
if callback:
|
||||
callback(0)
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
# ── State serialisation (for destroy()) ────────────────────────────────────
|
||||
|
||||
def to_state(self) -> dict:
|
||||
"""Return the manager's portion of ui_state.json."""
|
||||
return {
|
||||
self._key_paths: list(self._paths),
|
||||
self._key_active: self._active_name,
|
||||
}
|
||||
+512
@@ -0,0 +1,512 @@
|
||||
"""Parts data layer.
|
||||
|
||||
Loads the parts CSV into a local SQLite cache (one table, one meta row),
|
||||
and provides a fuzzy search API powered by rapidfuzz.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
- We rebuild the table from scratch whenever the CSV's mtime changes. The
|
||||
CSV is ~2k rows, so a full reload takes well under a second and avoids any
|
||||
diff/upsert bugs.
|
||||
- Fuzzy search runs entirely in memory over a tiny tuple of (sku, description,
|
||||
interchange) for each row. With ~2k rows this is fast enough to run on every
|
||||
keystroke without debouncing.
|
||||
- We expose Part as a plain dict so the UI layer doesn't need to import this
|
||||
module's internals.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
from typing import Iterable
|
||||
|
||||
try:
|
||||
import rapidfuzz.fuzz as fuzz
|
||||
import rapidfuzz.process as process
|
||||
_HAS_RAPIDFUZZ = True
|
||||
except ImportError:
|
||||
_HAS_RAPIDFUZZ = False
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# ── Make detection ────────────────────────────────────────────────────────────
|
||||
|
||||
_MAKE_PATTERNS: dict[str, list[str]] = {
|
||||
"Acura": ["Acura"],
|
||||
"Audi": ["Audi"],
|
||||
"BMW": ["BMW", "B.M.W"],
|
||||
"Buick": ["Buick"],
|
||||
"Cadillac": ["Cadillac", "Cad"],
|
||||
"Chevrolet": ["Chevrolet", "Chevy", "Chev"],
|
||||
"Chrysler": ["Chrysler"],
|
||||
"Dodge": ["Dodge"],
|
||||
"Ford": ["Ford"],
|
||||
"GMC": ["GMC"],
|
||||
"Honda": ["Honda"],
|
||||
"Hyundai": ["Hyundai"],
|
||||
"Infiniti": ["Infiniti", "Infinity"],
|
||||
"Isuzu": ["Isuzu"],
|
||||
"Jeep": ["Jeep"],
|
||||
"Kia": ["Kia"],
|
||||
"Lexus": ["Lexus"],
|
||||
"Lincoln": ["Lincoln"],
|
||||
"Mazda": ["Mazda"],
|
||||
"Mercedes-Benz": ["Mercedes", "Mercedes-Benz", "Benz"],
|
||||
"Mercury": ["Mercury"],
|
||||
"Mitsubishi": ["Mitsubishi", "Mitsu"],
|
||||
"Nissan": ["Nissan"],
|
||||
"Oldsmobile": ["Oldsmobile", "Olds"],
|
||||
"Plymouth": ["Plymouth"],
|
||||
"Pontiac": ["Pontiac"],
|
||||
"Ram": ["Ram"],
|
||||
"Saturn": ["Saturn"],
|
||||
"Subaru": ["Subaru"],
|
||||
"Suzuki": ["Suzuki"],
|
||||
"Toyota": ["Toyota"],
|
||||
"Volkswagen": ["Volkswagen", "VW"],
|
||||
"Volvo": ["Volvo"],
|
||||
}
|
||||
|
||||
_COMPILED_MAKE_PATTERNS: dict[str, re.Pattern] = {
|
||||
make: re.compile(
|
||||
r"\b(" + "|".join(re.escape(p) for p in patterns) + r")\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
for make, patterns in _MAKE_PATTERNS.items()
|
||||
}
|
||||
|
||||
|
||||
def _detect_makes(description: str) -> list[str]:
|
||||
"""Return the canonical car makes that appear in `description`."""
|
||||
found: list[str] = []
|
||||
for make, pat in _COMPILED_MAKE_PATTERNS.items():
|
||||
if pat.search(description):
|
||||
found.append(make)
|
||||
return found
|
||||
|
||||
|
||||
# ── Model detection ───────────────────────────────────────────────────────────
|
||||
|
||||
_MODEL_PATTERNS: dict[str, list[str]] = {
|
||||
# Honda
|
||||
"Accord": ["Accord"],
|
||||
"Civic": ["Civic"],
|
||||
"CR-V": ["CR-V", "CRV"],
|
||||
"Fit": ["Fit"],
|
||||
"HR-V": ["HR-V", "HRV"],
|
||||
"Odyssey": ["Odyssey"],
|
||||
"Pilot": ["Pilot"],
|
||||
"Ridgeline": ["Ridgeline"],
|
||||
# Toyota
|
||||
"4Runner": ["4Runner", "4-Runner"],
|
||||
"Camry": ["Camry"],
|
||||
"Corolla": ["Corolla"],
|
||||
"Highlander": ["Highlander"],
|
||||
"Prius": ["Prius"],
|
||||
"RAV4": ["RAV4", "RAV-4"],
|
||||
"Sienna": ["Sienna"],
|
||||
"Tacoma": ["Tacoma"],
|
||||
"Tundra": ["Tundra"],
|
||||
# Ford
|
||||
"Bronco": ["Bronco"],
|
||||
"Escape": ["Escape"],
|
||||
"Explorer": ["Explorer"],
|
||||
"F-150": ["F-150", "F150", "F 150"],
|
||||
"F-250": ["F-250", "F250"],
|
||||
"F-350": ["F-350", "F350"],
|
||||
"Focus": ["Focus"],
|
||||
"Fusion": ["Fusion"],
|
||||
"Mustang": ["Mustang"],
|
||||
"Ranger": ["Ranger"],
|
||||
# Chevy / GMC
|
||||
"Blazer": ["Blazer"],
|
||||
"Colorado": ["Colorado"],
|
||||
"Equinox": ["Equinox"],
|
||||
"Malibu": ["Malibu"],
|
||||
"Silverado": ["Silverado"],
|
||||
"Suburban": ["Suburban"],
|
||||
"Tahoe": ["Tahoe"],
|
||||
"Traverse": ["Traverse"],
|
||||
"Trax": ["Trax"],
|
||||
"Sierra": ["Sierra"],
|
||||
"Yukon": ["Yukon"],
|
||||
# Dodge / Ram / Jeep / Chrysler
|
||||
"Challenger": ["Challenger"],
|
||||
"Charger": ["Charger"],
|
||||
"Durango": ["Durango"],
|
||||
"Grand Cherokee": ["Grand Cherokee"],
|
||||
"Wrangler": ["Wrangler"],
|
||||
"Ram 1500": ["Ram 1500", "1500"],
|
||||
"Ram 2500": ["Ram 2500", "2500"],
|
||||
# Nissan
|
||||
"Altima": ["Altima"],
|
||||
"Frontier": ["Frontier"],
|
||||
"Maxima": ["Maxima"],
|
||||
"Murano": ["Murano"],
|
||||
"Pathfinder": ["Pathfinder"],
|
||||
"Rogue": ["Rogue"],
|
||||
"Sentra": ["Sentra"],
|
||||
"Titan": ["Titan"],
|
||||
"Versa": ["Versa"],
|
||||
# Hyundai / Kia
|
||||
"Elantra": ["Elantra"],
|
||||
"Santa Fe": ["Santa Fe"],
|
||||
"Sonata": ["Sonata"],
|
||||
"Tucson": ["Tucson"],
|
||||
"Soul": ["Soul"],
|
||||
"Sportage": ["Sportage"],
|
||||
"Sorento": ["Sorento"],
|
||||
"Telluride": ["Telluride"],
|
||||
# Subaru
|
||||
"Forester": ["Forester"],
|
||||
"Impreza": ["Impreza"],
|
||||
"Legacy": ["Legacy"],
|
||||
"Outback": ["Outback"],
|
||||
"WRX": ["WRX"],
|
||||
}
|
||||
|
||||
_COMPILED_MODEL_PATTERNS: dict[str, re.Pattern] = {
|
||||
model: re.compile(
|
||||
r"\b(" + "|".join(re.escape(p) for p in patterns) + r")\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
for model, patterns in _MODEL_PATTERNS.items()
|
||||
}
|
||||
|
||||
|
||||
def _detect_models(description: str) -> list[str]:
|
||||
"""Return the canonical models found in `description`."""
|
||||
found: list[str] = []
|
||||
for model, pat in _COMPILED_MODEL_PATTERNS.items():
|
||||
if pat.search(description):
|
||||
found.append(model)
|
||||
return found
|
||||
|
||||
|
||||
# ── Side detection ────────────────────────────────────────────────────────────
|
||||
|
||||
_SIDE_SUFFIX_RE = re.compile(
|
||||
r"[\s_-]?(LH?|RH?|LEFT|RIGHT|BOTH|DRIVER|PASSENGER|DR|PASS)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _detect_side(sku: str) -> str:
|
||||
"""Return 'left', 'right', 'both', or 'unknown' based on the SKU suffix."""
|
||||
m = _SIDE_SUFFIX_RE.search(sku)
|
||||
if not m:
|
||||
return "unknown"
|
||||
token = m.group(1).upper()
|
||||
if token in ("L", "LH", "LEFT", "DRIVER", "DR"):
|
||||
return "left"
|
||||
if token in ("R", "RH", "RIGHT", "PASSENGER", "PASS"):
|
||||
return "right"
|
||||
if token in ("BOTH",):
|
||||
return "both"
|
||||
return "unknown"
|
||||
|
||||
|
||||
# ── SQLite schema ─────────────────────────────────────────────────────────────
|
||||
|
||||
COLUMNS = (
|
||||
"stock_number", "description", "customer_price", "list_price",
|
||||
"part_type", "grade", "yard_location", "available", "quantity",
|
||||
"bin_location", "interchange", "warranty_type", "warranty_length",
|
||||
"warranty_info", "image_url",
|
||||
)
|
||||
|
||||
_CSV_TO_COL: dict[str, str] = {
|
||||
"STOCK NUMBER": "stock_number",
|
||||
"DESCRIPTION": "description",
|
||||
"Customer Price": "customer_price",
|
||||
"LIST": "list_price",
|
||||
"PART TYPE": "part_type",
|
||||
"GRADE": "grade",
|
||||
"YARD LOCATION": "yard_location",
|
||||
"AVAILABLE": "available",
|
||||
"QUANTITY": "quantity",
|
||||
"BIN LOCATION": "bin_location",
|
||||
"INTERCHANGE": "interchange",
|
||||
"WARRANTY TYPE": "warranty_type",
|
||||
"WARRANTY LENGTH": "warranty_length",
|
||||
"WARRANTY INFO": "warranty_info",
|
||||
"Image URL": "image_url",
|
||||
}
|
||||
|
||||
_CREATE_PARTS = """
|
||||
CREATE TABLE IF NOT EXISTS parts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
stock_number TEXT,
|
||||
description TEXT,
|
||||
customer_price TEXT,
|
||||
list_price TEXT,
|
||||
part_type TEXT,
|
||||
grade TEXT,
|
||||
yard_location TEXT,
|
||||
available TEXT,
|
||||
quantity TEXT,
|
||||
bin_location TEXT,
|
||||
interchange TEXT,
|
||||
warranty_type TEXT,
|
||||
warranty_length TEXT,
|
||||
warranty_info TEXT,
|
||||
image_url TEXT
|
||||
)
|
||||
"""
|
||||
|
||||
_CREATE_META = """
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
class PartsRepo:
|
||||
"""CSV-backed parts repository with fuzzy search."""
|
||||
|
||||
def __init__(self, csv_path: str = "", sqlite_path: str = "") -> None:
|
||||
self._csv_path = csv_path
|
||||
self._sqlite_path = sqlite_path or ":memory:"
|
||||
self._conn: sqlite3.Connection | None = None
|
||||
self._lock = threading.Lock()
|
||||
self._rows: list[dict] = []
|
||||
self._search_keys: list[str] = []
|
||||
|
||||
self._init_schema()
|
||||
self.reload_if_changed()
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
def count(self) -> int:
|
||||
return len(self._rows)
|
||||
|
||||
def available_makes(self) -> list[str]:
|
||||
"""Return the alphabetical list of canonical makes present in the
|
||||
loaded parts data."""
|
||||
seen: set[str] = set()
|
||||
for row in self._rows:
|
||||
for make in _detect_makes(row.get("description", "")):
|
||||
seen.add(make)
|
||||
return sorted(seen)
|
||||
|
||||
def available_models(self) -> list[str]:
|
||||
"""Return the alphabetical list of canonical models present in the
|
||||
loaded parts data."""
|
||||
seen: set[str] = set()
|
||||
for row in self._rows:
|
||||
for model in _detect_models(row.get("description", "")):
|
||||
seen.add(model)
|
||||
return sorted(seen)
|
||||
|
||||
def get(self, sku: str) -> dict | None:
|
||||
"""Return the single part matching `sku`, or None."""
|
||||
for row in self._rows:
|
||||
if row.get("stock_number", "").strip() == sku.strip():
|
||||
return row
|
||||
conn = self._conn
|
||||
if conn is None:
|
||||
return None
|
||||
r = conn.execute(
|
||||
"SELECT * FROM parts WHERE stock_number = ? LIMIT 1", (sku,)
|
||||
).fetchone()
|
||||
return dict(r) if r else None
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
makes: Iterable[str] = (),
|
||||
models: Iterable[str] = (),
|
||||
sides: Iterable[str] = (),
|
||||
limit: int = 20,
|
||||
) -> list[dict]:
|
||||
"""Fuzzy search across stock_number, description, and interchange.
|
||||
|
||||
Optional `makes`, `models`, `sides` narrow the result set before
|
||||
scoring so the caller can implement filter chips.
|
||||
"""
|
||||
query = query.strip()
|
||||
active_makes = set(makes)
|
||||
active_models = set(models)
|
||||
active_sides = set(sides)
|
||||
|
||||
pool = self._rows
|
||||
if active_makes:
|
||||
pool = [r for r in pool
|
||||
if active_makes & set(_detect_makes(r.get("description", "")))]
|
||||
if active_models:
|
||||
pool = [r for r in pool
|
||||
if active_models & set(_detect_models(r.get("description", "")))]
|
||||
if active_sides:
|
||||
pool = [r for r in pool
|
||||
if _detect_side(r.get("stock_number", "")) in active_sides]
|
||||
|
||||
if not query:
|
||||
return pool[:limit]
|
||||
|
||||
if _HAS_RAPIDFUZZ:
|
||||
keys = [
|
||||
f"{r.get('stock_number','')} {r.get('description','')} {r.get('interchange','')}".lower()
|
||||
for r in pool
|
||||
]
|
||||
hits = process.extract(
|
||||
query.lower(),
|
||||
keys,
|
||||
scorer=fuzz.WRatio,
|
||||
limit=limit,
|
||||
score_cutoff=30,
|
||||
)
|
||||
return [pool[idx] for _, _, idx in hits]
|
||||
|
||||
# Fallback: simple LIKE filter when rapidfuzz is absent
|
||||
q = query.lower()
|
||||
results = [
|
||||
r for r in pool
|
||||
if q in (r.get("stock_number") or "").lower()
|
||||
or q in (r.get("description") or "").lower()
|
||||
or q in (r.get("interchange") or "").lower()
|
||||
]
|
||||
return results[:limit]
|
||||
|
||||
def reload_if_changed(self) -> bool:
|
||||
"""Reload the CSV into SQLite if its mtime is newer than the cached one.
|
||||
|
||||
Returns True if a reload happened.
|
||||
"""
|
||||
import os
|
||||
if not self._csv_path or not os.path.exists(self._csv_path):
|
||||
if not self._rows:
|
||||
self._load_from_db()
|
||||
return False
|
||||
|
||||
csv_mtime = str(os.path.getmtime(self._csv_path))
|
||||
cached = self._get_meta("csv_mtime")
|
||||
if cached == csv_mtime and self._rows:
|
||||
return False
|
||||
|
||||
self._reload()
|
||||
self._set_meta("csv_mtime", csv_mtime)
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
# ── Internal ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _init_schema(self) -> None:
|
||||
try:
|
||||
self._conn = sqlite3.connect(
|
||||
self._sqlite_path, check_same_thread=False
|
||||
)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute(_CREATE_PARTS)
|
||||
self._conn.execute(_CREATE_META)
|
||||
self._conn.commit()
|
||||
except Exception as exc:
|
||||
log.exception("Could not open SQLite database: %s", exc)
|
||||
self._conn = None
|
||||
|
||||
def _get_meta(self, key: str) -> str:
|
||||
conn = self._conn
|
||||
if conn is None:
|
||||
return ""
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM meta WHERE key = ?", (key,)
|
||||
).fetchone()
|
||||
return row[0] if row else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _set_meta(self, key: str, value: str) -> None:
|
||||
conn = self._conn
|
||||
if conn is None:
|
||||
return
|
||||
conn.execute(
|
||||
"INSERT INTO meta (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
(key, value),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def _reload(self) -> None:
|
||||
conn = self._conn
|
||||
if conn is None:
|
||||
return
|
||||
|
||||
import sys
|
||||
try:
|
||||
csv.field_size_limit(sys.maxsize)
|
||||
except OverflowError:
|
||||
csv.field_size_limit(2 ** 31 - 1)
|
||||
|
||||
conn.execute("DELETE FROM parts")
|
||||
conn.commit()
|
||||
|
||||
cols_db = list(_CSV_TO_COL.values())
|
||||
placeholders = ", ".join("?" * len(cols_db))
|
||||
insert_sql = (
|
||||
f"INSERT INTO parts ({', '.join(cols_db)}) VALUES ({placeholders})"
|
||||
)
|
||||
|
||||
rows_loaded: list[dict] = []
|
||||
try:
|
||||
with open(self._csv_path, newline="", encoding="utf-8-sig",
|
||||
errors="replace") as f:
|
||||
reader = csv.DictReader(f)
|
||||
batch: list[tuple] = []
|
||||
for csv_row in reader:
|
||||
values = tuple(
|
||||
str(csv_row.get(csv_col, "") or "").strip()
|
||||
for csv_col in _CSV_TO_COL
|
||||
)
|
||||
batch.append(values)
|
||||
rows_loaded.append(dict(zip(cols_db, values)))
|
||||
if len(batch) >= 500:
|
||||
conn.executemany(insert_sql, batch)
|
||||
batch.clear()
|
||||
if batch:
|
||||
conn.executemany(insert_sql, batch)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
log.exception("CSV import failed: %s", exc)
|
||||
|
||||
log.info("Loaded %d parts from %s", len(rows_loaded), self._csv_path)
|
||||
self._rows = rows_loaded
|
||||
|
||||
def _load_from_db(self) -> None:
|
||||
conn = self._conn
|
||||
if conn is None:
|
||||
return
|
||||
try:
|
||||
rows = conn.execute("SELECT * FROM parts").fetchall()
|
||||
self._rows = [dict(r) for r in rows]
|
||||
except Exception:
|
||||
self._rows = []
|
||||
|
||||
def _row_count(self) -> int:
|
||||
conn = self._conn
|
||||
if conn is None:
|
||||
return 0
|
||||
try:
|
||||
return conn.execute("SELECT COUNT(*) FROM parts").fetchone()[0]
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def _rebuild_search_index(self) -> None:
|
||||
self._search_keys = [
|
||||
f"{r.get('stock_number','')} {r.get('description','')} {r.get('interchange','')}".lower()
|
||||
for r in self._rows
|
||||
]
|
||||
|
||||
|
||||
# Backwards-compat alias so any code that uses DataLoader still works.
|
||||
DataLoader = PartsRepo
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
"""FedEx Ship API client — One Rate shipments.
|
||||
|
||||
Handles OAuth token fetch and shipment creation. Returns a tracking number
|
||||
and a temp-file path to the PDF label, which the caller opens for printing.
|
||||
|
||||
Phase 2 hook: swap _SHIP_URL / _TOKEN_URL to the sandbox URLs for testing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
import requests
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_TOKEN_URL_PROD = "https://apis.fedex.com/oauth/token"
|
||||
_SHIP_URL_PROD = "https://apis.fedex.com/ship/v1/shipments"
|
||||
_TOKEN_URL_SANDBOX = "https://apis-sandbox.fedex.com/oauth/token"
|
||||
_SHIP_URL_SANDBOX = "https://apis-sandbox.fedex.com/ship/v1/shipments"
|
||||
|
||||
# Maps UI display strings → FedEx API codes
|
||||
_SERVICE_CODES: dict[str, str] = {
|
||||
"FedEx 2Day": "FEDEX_2_DAY",
|
||||
"FedEx 2Day AM": "FEDEX_2_DAY_AM",
|
||||
}
|
||||
_PACKAGE_CODES: dict[str, str] = {
|
||||
"FedEx Envelope": "FEDEX_ENVELOPE",
|
||||
"FedEx Pak": "FEDEX_PAK",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShipmentResult:
|
||||
tracking_number: str
|
||||
label_pdf_path: str # absolute path to a temp PDF file
|
||||
|
||||
|
||||
class FedExClient:
|
||||
"""Thin wrapper around the FedEx REST Ship API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
secret: str,
|
||||
account: str,
|
||||
shipper: dict,
|
||||
sandbox: bool = False,
|
||||
) -> None:
|
||||
self._api_key = api_key
|
||||
self._secret = secret
|
||||
self._account = account
|
||||
self._shipper = shipper # keys: name, company, phone, address1, city, state, zip
|
||||
self._token_url = _TOKEN_URL_SANDBOX if sandbox else _TOKEN_URL_PROD
|
||||
self._ship_url = _SHIP_URL_SANDBOX if sandbox else _SHIP_URL_PROD
|
||||
|
||||
# ── Public API ─────────────────────────────────────────────────────────
|
||||
|
||||
def create_shipment(self, data: dict) -> ShipmentResult:
|
||||
"""Create a One Rate shipment and return tracking number + label PDF path.
|
||||
|
||||
`data` is the dict from ShippingTab._get_shipment_data().
|
||||
Raises RuntimeError with a user-readable message on any failure.
|
||||
"""
|
||||
token = self._fetch_token()
|
||||
|
||||
service = _SERVICE_CODES.get(data["service"], "FEDEX_2_DAY")
|
||||
pkg_type = _PACKAGE_CODES.get(data["package_type"], "FEDEX_PAK")
|
||||
|
||||
street_lines = [data["address1"]]
|
||||
if data.get("address2"):
|
||||
street_lines.append(data["address2"])
|
||||
|
||||
payload = self._build_payload(data, service, pkg_type, street_lines)
|
||||
|
||||
resp = requests.post(
|
||||
self._ship_url,
|
||||
json=payload,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
"X-locale": "en_US",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if not resp.ok:
|
||||
raise RuntimeError(_format_api_error(resp))
|
||||
|
||||
return self._parse_response(resp.json())
|
||||
|
||||
# ── Private helpers ────────────────────────────────────────────────────
|
||||
|
||||
def _fetch_token(self) -> str:
|
||||
try:
|
||||
resp = requests.post(
|
||||
self._token_url,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": self._api_key,
|
||||
"client_secret": self._secret,
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["access_token"]
|
||||
except requests.RequestException as exc:
|
||||
raise RuntimeError(
|
||||
f"Could not connect to FedEx (authentication step):\n{exc}"
|
||||
) from exc
|
||||
|
||||
def _build_payload(
|
||||
self,
|
||||
data: dict,
|
||||
service: str,
|
||||
pkg_type: str,
|
||||
street_lines: list[str],
|
||||
) -> dict:
|
||||
s = self._shipper
|
||||
payload: dict = {
|
||||
"labelResponseOptions": "LABEL",
|
||||
"accountNumber": {"value": self._account},
|
||||
"requestedShipment": {
|
||||
"shipper": {
|
||||
"contact": {
|
||||
"personName": s.get("name", ""),
|
||||
"companyName": s.get("company", ""),
|
||||
"phoneNumber": s.get("phone", ""),
|
||||
},
|
||||
"address": {
|
||||
"streetLines": [s.get("address1", "")],
|
||||
"city": s.get("city", ""),
|
||||
"stateOrProvinceCode": s.get("state", ""),
|
||||
"postalCode": s.get("zip", ""),
|
||||
"countryCode": "US",
|
||||
},
|
||||
},
|
||||
"recipients": [{
|
||||
"contact": {
|
||||
"personName": data["name"],
|
||||
"companyName": data.get("company", ""),
|
||||
"phoneNumber": data["phone"],
|
||||
**({"emailAddress": data["email"]}
|
||||
if data.get("email") else {}),
|
||||
},
|
||||
"address": {
|
||||
"streetLines": street_lines,
|
||||
"city": data["city"],
|
||||
"stateOrProvinceCode": data["state"],
|
||||
"postalCode": data["zip"],
|
||||
"countryCode": "US",
|
||||
"residential": False,
|
||||
},
|
||||
}],
|
||||
"serviceType": service,
|
||||
"packagingType": pkg_type,
|
||||
"pickupType": "USE_SCHEDULED_PICKUP",
|
||||
"shippingChargesPayment": {
|
||||
"paymentType": "SENDER",
|
||||
"payor": {
|
||||
"responsibleParty": {
|
||||
"accountNumber": {"value": self._account},
|
||||
},
|
||||
},
|
||||
},
|
||||
"labelSpecification": {
|
||||
"labelFormatType": "COMMON2D",
|
||||
"imageType": "PDF",
|
||||
"labelStockType": "PAPER_LETTER",
|
||||
},
|
||||
"requestedPackageLineItems": [{
|
||||
"weight": {"units": "LB", "value": 1},
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
return payload
|
||||
|
||||
def _parse_response(self, result: dict) -> ShipmentResult:
|
||||
shipments = result.get("output", {}).get("transactionShipments", [])
|
||||
if not shipments:
|
||||
raise RuntimeError("FedEx accepted the request but returned no shipment data.")
|
||||
|
||||
shipment = shipments[0]
|
||||
pieces = shipment.get("pieceResponses", [{}])
|
||||
piece = pieces[0] if pieces else {}
|
||||
|
||||
tracking = (
|
||||
shipment.get("masterTrackingNumber")
|
||||
or piece.get("trackingNumber")
|
||||
or ""
|
||||
)
|
||||
|
||||
docs = piece.get("packageDocuments", [{}])
|
||||
doc = docs[0] if docs else {}
|
||||
b64_pdf = doc.get("encodedLabel", "")
|
||||
|
||||
if not b64_pdf:
|
||||
raise RuntimeError(
|
||||
f"Shipment created (tracking: {tracking}) but FedEx sent no label."
|
||||
)
|
||||
|
||||
from datetime import datetime
|
||||
from .paths import appdata_dir
|
||||
|
||||
pdf_bytes = base64.b64decode(b64_pdf)
|
||||
labels_dir = appdata_dir() / "labels"
|
||||
labels_dir.mkdir(exist_ok=True)
|
||||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
label_path = labels_dir / f"FedEx_{tracking or 'label'}_{stamp}.pdf"
|
||||
label_path.write_bytes(pdf_bytes)
|
||||
|
||||
log.info("FedEx shipment created: tracking=%s label=%s", tracking, label_path)
|
||||
return ShipmentResult(tracking_number=tracking, label_pdf_path=str(label_path))
|
||||
|
||||
|
||||
# ── Error formatting ────────────────────────────────────────────────────────
|
||||
|
||||
def _format_api_error(resp: requests.Response) -> str:
|
||||
status = resp.status_code
|
||||
try:
|
||||
body = resp.json()
|
||||
errors = body.get("errors", [])
|
||||
msgs = [
|
||||
f"{e.get('code', '')}: {e.get('message', '')}".strip(": ")
|
||||
for e in errors
|
||||
]
|
||||
detail = " | ".join(msgs) if msgs else str(body)
|
||||
except Exception:
|
||||
detail = resp.text or "(no body)"
|
||||
|
||||
hint = ""
|
||||
if status == 401:
|
||||
hint = "\n\nCheck that the API key and secret in config.json are correct."
|
||||
elif status == 400:
|
||||
hint = "\n\nCheck the recipient address — a required field may be missing."
|
||||
|
||||
return f"FedEx returned HTTP {status}: {detail}{hint}"
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
"""Part image lookup with on-disk cache.
|
||||
|
||||
Lookup order, per part:
|
||||
1. SMB share (if `smb_image_dir` is configured) — looks for `{SKU}.{ext}`
|
||||
across common extensions.
|
||||
2. On-disk cache keyed by SKU.
|
||||
3. The CSV's Image URL (downloaded, then cached).
|
||||
4. Bundled placeholder image.
|
||||
|
||||
All network work happens off the UI thread. The UI passes a callback to
|
||||
:meth:`ImageCache.load_async` and receives the rendered Pillow image when
|
||||
ready.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import requests
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_SMB_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".bmp")
|
||||
_SAFE = re.compile(r"[^A-Za-z0-9._-]")
|
||||
|
||||
|
||||
def _safe_filename(sku: str, suffix: str) -> str:
|
||||
"""Make a filesystem-safe filename from a SKU."""
|
||||
clean = _SAFE.sub("_", sku.strip())
|
||||
if not clean:
|
||||
clean = hashlib.sha1(sku.encode()).hexdigest()[:12]
|
||||
return f"{clean}{suffix}"
|
||||
|
||||
|
||||
def _bundle_path(relpath: str) -> Path:
|
||||
"""Resolve a path relative to either the source tree or the PyInstaller bundle."""
|
||||
base = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
|
||||
return base / relpath
|
||||
|
||||
|
||||
class ImageCache:
|
||||
def __init__(self, cache_dir: str, smb_dir: str = "", placeholder_size: tuple[int, int] = (320, 240)):
|
||||
self.cache_dir = Path(cache_dir) if cache_dir else None
|
||||
self.smb_dir = Path(smb_dir) if smb_dir else None
|
||||
self.placeholder_size = placeholder_size
|
||||
|
||||
if self.cache_dir:
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._placeholder: Image.Image | None = None
|
||||
# SKU -> list of pending callbacks. When a fetch is in progress for a
|
||||
# SKU, additional load_async calls for the same SKU just queue their
|
||||
# callback here instead of starting a duplicate fetch. All queued
|
||||
# callbacks fire with the same final image when the worker completes.
|
||||
self._inflight: dict[str, list] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# ---------- placeholder ----------
|
||||
|
||||
def placeholder(self) -> Image.Image:
|
||||
if self._placeholder is not None:
|
||||
return self._placeholder.copy()
|
||||
|
||||
# Try bundled asset first.
|
||||
bundled = _bundle_path("assets/placeholder.png")
|
||||
if bundled.exists():
|
||||
self._placeholder = Image.open(bundled).convert("RGBA")
|
||||
else:
|
||||
# Draw one on the fly so the app never crashes for lack of an asset.
|
||||
w, h = self.placeholder_size
|
||||
img = Image.new("RGBA", (w, h), (235, 235, 240, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
try:
|
||||
font = ImageFont.truetype("arial.ttf", 22)
|
||||
except Exception:
|
||||
font = ImageFont.load_default()
|
||||
text = "No image"
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
draw.text(((w - tw) / 2, (h - th) / 2), text, fill=(120, 120, 130), font=font)
|
||||
self._placeholder = img
|
||||
return self._placeholder.copy()
|
||||
|
||||
# ---------- lookup paths ----------
|
||||
|
||||
def _smb_path_for(self, sku: str) -> Path | None:
|
||||
if not self.smb_dir:
|
||||
return None
|
||||
for ext in _SMB_EXTS:
|
||||
p = self.smb_dir / _safe_filename(sku, ext)
|
||||
if p.exists():
|
||||
return p
|
||||
return None
|
||||
|
||||
def _cache_path_for(self, sku: str, suffix: str = ".png") -> Path | None:
|
||||
if not self.cache_dir:
|
||||
return None
|
||||
return self.cache_dir / _safe_filename(sku, suffix)
|
||||
|
||||
# ---------- synchronous load (used internally + for tests) ----------
|
||||
|
||||
def load(self, sku: str, url: str | None) -> Image.Image:
|
||||
# 1. SMB.
|
||||
smb = self._smb_path_for(sku)
|
||||
if smb is not None:
|
||||
try:
|
||||
return Image.open(smb).convert("RGBA")
|
||||
except Exception as e:
|
||||
log.warning("SMB image failed for %s: %s", sku, e)
|
||||
|
||||
# 2. Disk cache.
|
||||
if self.cache_dir:
|
||||
for ext in _SMB_EXTS:
|
||||
p = self.cache_dir / _safe_filename(sku, ext)
|
||||
if p.exists():
|
||||
try:
|
||||
return Image.open(p).convert("RGBA")
|
||||
except Exception as e:
|
||||
log.warning("Cached image failed for %s: %s", sku, e)
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 3. URL.
|
||||
if url:
|
||||
try:
|
||||
resp = requests.get(url, timeout=8, stream=True)
|
||||
resp.raise_for_status()
|
||||
# Pick extension from URL if we can; default to .png.
|
||||
lower = url.lower().split("?", 1)[0]
|
||||
suffix = ".png"
|
||||
for ext in _SMB_EXTS:
|
||||
if lower.endswith(ext):
|
||||
suffix = ext
|
||||
break
|
||||
|
||||
if self.cache_dir:
|
||||
dest = self.cache_dir / _safe_filename(sku, suffix)
|
||||
with open(dest, "wb") as f:
|
||||
for chunk in resp.iter_content(8192):
|
||||
f.write(chunk)
|
||||
return Image.open(dest).convert("RGBA")
|
||||
|
||||
# No cache dir — load straight from bytes.
|
||||
from io import BytesIO
|
||||
|
||||
return Image.open(BytesIO(resp.content)).convert("RGBA")
|
||||
except Exception as e:
|
||||
log.warning("Image download failed for %s (%s): %s", sku, url, e)
|
||||
|
||||
# 4. Fallback.
|
||||
return self.placeholder()
|
||||
|
||||
# ---------- async load ----------
|
||||
|
||||
def load_async(
|
||||
self,
|
||||
sku: str,
|
||||
url: str | None,
|
||||
callback: Callable[[str, Image.Image], None],
|
||||
) -> None:
|
||||
"""Fetch the image on a worker thread; call `callback(sku, image)` when done.
|
||||
|
||||
The callback runs on the worker thread, so the UI layer should marshal
|
||||
it back to the Tk thread (we use Tk's `.after(0, ...)` for that).
|
||||
"""
|
||||
# Quick path: if it's already on disk, return synchronously to avoid a
|
||||
# visible flash of the placeholder.
|
||||
cached = self._fast_local(sku)
|
||||
if cached is not None:
|
||||
callback(sku, cached)
|
||||
return
|
||||
|
||||
# De-duplicate concurrent fetches: if another caller is already
|
||||
# fetching this SKU, just queue our callback to fire alongside theirs.
|
||||
# Without this, the second caller's callback would be silently dropped
|
||||
# and the UI would stay stuck on "Loading image..." because each
|
||||
# _show_part call captures its own stale-detection token.
|
||||
with self._lock:
|
||||
if sku in self._inflight:
|
||||
self._inflight[sku].append(callback)
|
||||
return
|
||||
self._inflight[sku] = [callback]
|
||||
|
||||
def worker() -> None:
|
||||
try:
|
||||
img = self.load(sku, url)
|
||||
except Exception as e:
|
||||
log.warning("Image fetch failed for %s: %s", sku, e)
|
||||
img = self.placeholder()
|
||||
# Snapshot and clear the callback list under the lock, then fire
|
||||
# them outside the lock so a slow callback can't block other SKUs.
|
||||
with self._lock:
|
||||
callbacks = self._inflight.pop(sku, [])
|
||||
for cb in callbacks:
|
||||
try:
|
||||
cb(sku, img)
|
||||
except Exception:
|
||||
log.exception("Image callback raised for %s", sku)
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _fast_local(self, sku: str) -> Image.Image | None:
|
||||
# SMB.
|
||||
smb = self._smb_path_for(sku)
|
||||
if smb is not None:
|
||||
try:
|
||||
return Image.open(smb).convert("RGBA")
|
||||
except Exception:
|
||||
pass
|
||||
# Cache.
|
||||
if self.cache_dir:
|
||||
for ext in _SMB_EXTS:
|
||||
p = self.cache_dir / _safe_filename(sku, ext)
|
||||
if p.exists():
|
||||
try:
|
||||
return Image.open(p).convert("RGBA")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Local label history store.
|
||||
|
||||
Since the Stamps SERA API has no list-labels endpoint, we persist every
|
||||
created label to appdata/label_history.json at print time. The history
|
||||
window reads from here rather than polling the API.
|
||||
|
||||
The void/refund and SCAN form actions DO hit the API, using the label_id
|
||||
stored here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LabelEntry:
|
||||
label_id: str
|
||||
tracking_number: str
|
||||
date_printed: str # ISO 8601
|
||||
recipient: str # name / first line of address block
|
||||
service: str # display name, e.g. "Ground Advantage"
|
||||
package_type: str
|
||||
amount: float | None
|
||||
notes: str # invoice numbers / free-text notes
|
||||
address_block: str
|
||||
voided: bool = False
|
||||
shipment_status: str = ""
|
||||
date_delivered: str | None = None
|
||||
carrier: str = "" # "usps" | "fedex"
|
||||
|
||||
@property
|
||||
def date_printed_display(self) -> str:
|
||||
try:
|
||||
return datetime.fromisoformat(self.date_printed).strftime("%m/%d/%Y")
|
||||
except Exception:
|
||||
return self.date_printed
|
||||
|
||||
@property
|
||||
def date_delivered_display(self) -> str:
|
||||
if not self.date_delivered:
|
||||
return ""
|
||||
try:
|
||||
return datetime.fromisoformat(self.date_delivered).strftime("%m/%d/%Y")
|
||||
except Exception:
|
||||
return self.date_delivered
|
||||
|
||||
|
||||
class LabelHistory:
|
||||
"""Load / save / query the local label history JSON file."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
from .paths import appdata_dir
|
||||
self._path: Path = appdata_dir() / "label_history.json"
|
||||
self._entries: list[LabelEntry] = self._load()
|
||||
|
||||
# ── Persistence ────────────────────────────────────────────────────────
|
||||
|
||||
def _load(self) -> list[LabelEntry]:
|
||||
try:
|
||||
if self._path.exists():
|
||||
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
return [LabelEntry(**r) for r in raw]
|
||||
except Exception as exc:
|
||||
log.warning("Could not load label history: %s", exc)
|
||||
return []
|
||||
|
||||
def _save(self) -> None:
|
||||
try:
|
||||
data = [asdict(e) for e in self._entries]
|
||||
self._path.write_text(
|
||||
json.dumps(data, indent=2, default=str),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning("Could not save label history: %s", exc)
|
||||
|
||||
# ── Mutation ───────────────────────────────────────────────────────────
|
||||
|
||||
def add(self, entry: LabelEntry) -> None:
|
||||
self._entries.insert(0, entry)
|
||||
self._save()
|
||||
|
||||
def mark_voided(self, label_id: str) -> None:
|
||||
for e in self._entries:
|
||||
if e.label_id == label_id:
|
||||
e.voided = True
|
||||
e.shipment_status = "Voided"
|
||||
self._save()
|
||||
|
||||
def update_status(self, label_id: str, status: str,
|
||||
date_delivered: str | None = None) -> None:
|
||||
for e in self._entries:
|
||||
if e.label_id == label_id:
|
||||
e.shipment_status = status
|
||||
if date_delivered:
|
||||
e.date_delivered = date_delivered
|
||||
self._save()
|
||||
|
||||
# ── Query ──────────────────────────────────────────────────────────────
|
||||
|
||||
def all(self) -> list[LabelEntry]:
|
||||
return list(self._entries)
|
||||
|
||||
def filter(
|
||||
self,
|
||||
search: str = "",
|
||||
status: str = "All",
|
||||
days: int | None = None,
|
||||
carrier: str = "",
|
||||
) -> list[LabelEntry]:
|
||||
results = self._entries
|
||||
if carrier:
|
||||
results = [e for e in results if e.carrier == carrier]
|
||||
if days is not None:
|
||||
from datetime import timedelta, timezone
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
|
||||
results = [e for e in results if e.date_printed >= cutoff]
|
||||
if status != "All":
|
||||
if status == "Voided":
|
||||
results = [e for e in results if e.voided]
|
||||
else:
|
||||
results = [e for e in results
|
||||
if not e.voided and e.shipment_status == status]
|
||||
if search:
|
||||
q = search.lower()
|
||||
results = [
|
||||
e for e in results
|
||||
if q in e.recipient.lower()
|
||||
or q in e.notes.lower()
|
||||
or q in e.tracking_number.lower()
|
||||
]
|
||||
return results
|
||||
+592
@@ -0,0 +1,592 @@
|
||||
"""Direct label printing via the Windows GDI printer API (win32print / win32ui).
|
||||
|
||||
Prints formatted address + invoice labels to any locally installed label
|
||||
printer (Dymo, Zebra, Brother, etc.) without creating a PDF — the job goes
|
||||
straight to the print queue.
|
||||
|
||||
Usage:
|
||||
from app.labels import print_address_label, list_printers, get_default_printer
|
||||
|
||||
All public functions fail gracefully when pywin32 is not installed, returning
|
||||
False / [] rather than raising.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import Callable
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# ── Optional win32 imports ────────────────────────────────────────────────────
|
||||
try:
|
||||
import win32print
|
||||
import win32ui
|
||||
import win32con
|
||||
_WIN32_AVAILABLE = True
|
||||
except ImportError:
|
||||
win32print = None # type: ignore[assignment]
|
||||
win32ui = None # type: ignore[assignment]
|
||||
win32con = None # type: ignore[assignment]
|
||||
_WIN32_AVAILABLE = False
|
||||
log.debug("pywin32 not installed — direct label printing unavailable")
|
||||
|
||||
|
||||
# ── Public helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def is_available() -> bool:
|
||||
"""Return True if pywin32 is installed and at least one printer exists."""
|
||||
if not _WIN32_AVAILABLE:
|
||||
return False
|
||||
try:
|
||||
return bool(win32print.EnumPrinters(
|
||||
win32print.PRINTER_ENUM_LOCAL | win32print.PRINTER_ENUM_CONNECTIONS
|
||||
))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def list_printers() -> list[str]:
|
||||
"""Return a list of locally available printer names."""
|
||||
if not _WIN32_AVAILABLE:
|
||||
return []
|
||||
try:
|
||||
return [
|
||||
p[2] for p in win32print.EnumPrinters(
|
||||
win32print.PRINTER_ENUM_LOCAL | win32print.PRINTER_ENUM_CONNECTIONS
|
||||
)
|
||||
]
|
||||
except Exception as exc:
|
||||
log.debug("Could not enumerate printers: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
def get_default_printer() -> str:
|
||||
"""Return the Windows default printer name, or '' if unavailable."""
|
||||
if not _WIN32_AVAILABLE:
|
||||
return ""
|
||||
try:
|
||||
return win32print.GetDefaultPrinter()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def print_address_label(
|
||||
address_lines: list[str],
|
||||
invoice_line: str = "",
|
||||
printer_name: str = "",
|
||||
copies: int = 1,
|
||||
label_width_mm: float = 89.0,
|
||||
label_height_mm: float = 36.0,
|
||||
) -> bool:
|
||||
"""Print an address label (+ optional invoice/reference line) to a printer.
|
||||
|
||||
Args:
|
||||
address_lines: Lines of the recipient address block.
|
||||
invoice_line: Optional reference/invoice number printed below the
|
||||
address in bold.
|
||||
printer_name: Printer to use; defaults to the system default.
|
||||
copies: Number of copies to print.
|
||||
label_width_mm: Label width in millimetres (default: 89 mm / 3.5 in).
|
||||
label_height_mm: Label height in millimetres (default: 36 mm / 1.4 in).
|
||||
|
||||
Returns:
|
||||
True on success, False on any failure (caller decides whether to alert).
|
||||
"""
|
||||
if not _WIN32_AVAILABLE:
|
||||
log.warning("print_address_label: pywin32 not available")
|
||||
return False
|
||||
|
||||
printer = printer_name or get_default_printer()
|
||||
if not printer:
|
||||
log.warning("print_address_label: no printer found")
|
||||
return False
|
||||
|
||||
try:
|
||||
hPrinter = win32print.OpenPrinter(printer)
|
||||
try:
|
||||
pInfo = win32print.GetPrinter(hPrinter, 2)
|
||||
finally:
|
||||
win32print.ClosePrinter(hPrinter)
|
||||
except Exception as exc:
|
||||
log.error("Could not open printer %r: %s", printer, exc)
|
||||
return False
|
||||
|
||||
try:
|
||||
hDC = win32ui.CreateDC()
|
||||
hDC.CreatePrinterDC(printer)
|
||||
except Exception as exc:
|
||||
log.error("Could not create printer DC for %r: %s", printer, exc)
|
||||
return False
|
||||
|
||||
try:
|
||||
for _ in range(max(1, copies)):
|
||||
_print_one(
|
||||
hDC = hDC,
|
||||
address_lines= address_lines,
|
||||
invoice_line = invoice_line,
|
||||
label_w_mm = label_width_mm,
|
||||
label_h_mm = label_height_mm,
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
log.exception("Label print job failed: %s", exc)
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
hDC.DeleteDC()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def print_part_label(
|
||||
sku: str,
|
||||
description: str,
|
||||
price_net: str = "",
|
||||
grade: str = "",
|
||||
location: str = "",
|
||||
printer_name: str = "",
|
||||
copies: int = 1,
|
||||
) -> bool:
|
||||
"""Print a parts-bin label (SKU prominent, description + price below).
|
||||
|
||||
Returns True on success, False otherwise.
|
||||
"""
|
||||
lines = [sku]
|
||||
if description:
|
||||
lines.append(description)
|
||||
detail_parts = [x for x in [grade, location, price_net] if x]
|
||||
if detail_parts:
|
||||
lines.append(" · ".join(detail_parts))
|
||||
|
||||
return print_address_label(
|
||||
address_lines = lines,
|
||||
printer_name = printer_name,
|
||||
copies = copies,
|
||||
label_width_mm = 57.0,
|
||||
label_height_mm = 32.0,
|
||||
)
|
||||
|
||||
|
||||
# ── Internal GDI rendering ────────────────────────────────────────────────────
|
||||
|
||||
def _print_one(
|
||||
hDC,
|
||||
address_lines: list[str],
|
||||
invoice_line: str,
|
||||
label_w_mm: float,
|
||||
label_h_mm: float,
|
||||
) -> None:
|
||||
"""Render one label page on the given printer DC."""
|
||||
# Device resolution
|
||||
dpi_x = hDC.GetDeviceCaps(win32con.LOGPIXELSX)
|
||||
dpi_y = hDC.GetDeviceCaps(win32con.LOGPIXELSY)
|
||||
|
||||
# Convert mm → pixels
|
||||
mm_per_inch = 25.4
|
||||
page_w = int(label_w_mm / mm_per_inch * dpi_x)
|
||||
page_h = int(label_h_mm / mm_per_inch * dpi_y)
|
||||
|
||||
margin_x = int(page_w * 0.04)
|
||||
margin_y = int(page_h * 0.08)
|
||||
inner_w = page_w - 2 * margin_x
|
||||
inner_h = page_h - 2 * margin_y
|
||||
|
||||
address_raw_lines = [ln for ln in address_lines if ln.strip()]
|
||||
|
||||
hDC.StartDoc("Label")
|
||||
hDC.StartPage()
|
||||
|
||||
_draw_address_block(
|
||||
hDC = hDC,
|
||||
address_raw_lines = address_raw_lines,
|
||||
invoice_line = invoice_line,
|
||||
inner_w = inner_w,
|
||||
inner_h = inner_h,
|
||||
margin_x = margin_x,
|
||||
margin_y = margin_y,
|
||||
dpi_y = dpi_y,
|
||||
)
|
||||
|
||||
hDC.EndPage()
|
||||
hDC.EndDoc()
|
||||
|
||||
|
||||
def _draw_address_block(
|
||||
hDC,
|
||||
address_raw_lines: list[str],
|
||||
invoice_line: str,
|
||||
inner_w: int,
|
||||
inner_h: int,
|
||||
margin_x: int,
|
||||
margin_y: int,
|
||||
dpi_y: int,
|
||||
start_font_pt: int = 24,
|
||||
) -> None:
|
||||
"""Fit address + invoice text into the available area, scaling font down if needed."""
|
||||
# Convert font points → logical units (negative = character height in pixels)
|
||||
font_size = -int(dpi_y * start_font_pt / 72)
|
||||
min_size = -int(dpi_y * 8 / 72) # never go below 8pt
|
||||
|
||||
# Binary-search the largest font that fits both dimensions
|
||||
while font_size >= min_size:
|
||||
addr_font_size = max(14, int(abs(font_size) * 0.80))
|
||||
font_addr = win32ui.CreateFont({
|
||||
"name": "Arial",
|
||||
"height": addr_font_size,
|
||||
"weight": 400,
|
||||
})
|
||||
|
||||
# Invoice is larger (100% of font_size), bold
|
||||
inv_font_size = font_size
|
||||
font_inv = win32ui.CreateFont({
|
||||
"name": "Arial",
|
||||
"height": inv_font_size,
|
||||
"weight": 700, # Bold
|
||||
})
|
||||
|
||||
hDC.SelectObject(font_addr)
|
||||
# Wrap each address line if it exceeds inner_w
|
||||
final_lines = []
|
||||
for raw_line in address_raw_lines:
|
||||
wrapped = _wrap_text(hDC, raw_line, inner_w)
|
||||
final_lines.extend(wrapped)
|
||||
|
||||
line_h = hDC.GetTextExtent("Ag")[1] + 2
|
||||
|
||||
# Calculate invoice metrics under this scaling
|
||||
hDC.SelectObject(font_inv)
|
||||
inv_line_h = hDC.GetTextExtent("Ag")[1] + 2 if invoice_line else 0
|
||||
gap = max(8, inv_font_size // 3) if invoice_line else 0
|
||||
|
||||
total_h = (len(final_lines) * line_h) + gap + inv_line_h
|
||||
|
||||
if total_h <= inner_h:
|
||||
# Ensure no single word overflows horizontally in address
|
||||
hDC.SelectObject(font_addr)
|
||||
any_word_overflows = False
|
||||
for line in final_lines:
|
||||
line_w, _ = hDC.GetTextExtent(line)
|
||||
if line_w > inner_w:
|
||||
any_word_overflows = True
|
||||
break
|
||||
|
||||
# Ensure invoice line doesn't overflow horizontally
|
||||
if invoice_line:
|
||||
hDC.SelectObject(font_inv)
|
||||
line_w, _ = hDC.GetTextExtent(invoice_line)
|
||||
if line_w > inner_w:
|
||||
any_word_overflows = True
|
||||
|
||||
if not any_word_overflows:
|
||||
break
|
||||
|
||||
font_size -= 1
|
||||
|
||||
# Re-ensure fonts are created if we fell through
|
||||
addr_font_size = max(14, int(font_size * 0.80))
|
||||
font_addr = win32ui.CreateFont({
|
||||
"name": "Arial",
|
||||
"height": addr_font_size,
|
||||
"weight": 400,
|
||||
})
|
||||
inv_font_size = font_size
|
||||
font_inv = win32ui.CreateFont({
|
||||
"name": "Arial",
|
||||
"height": inv_font_size,
|
||||
"weight": 700,
|
||||
})
|
||||
|
||||
# Recalculate heights for final draw
|
||||
hDC.SelectObject(font_addr)
|
||||
line_h = hDC.GetTextExtent("Ag")[1] + 2
|
||||
|
||||
hDC.SelectObject(font_inv)
|
||||
inv_line_h = hDC.GetTextExtent("Ag")[1] + 2 if invoice_line else 0
|
||||
gap = max(8, inv_font_size // 3) if invoice_line else 0
|
||||
|
||||
total_h = (len(final_lines) * line_h) + gap + inv_line_h
|
||||
|
||||
# Center vertically
|
||||
y = margin_y + max(0, (inner_h - total_h) // 2)
|
||||
bottom = margin_y + inner_h
|
||||
|
||||
# Draw address lines
|
||||
hDC.SelectObject(font_addr)
|
||||
for line in final_lines:
|
||||
if y + line_h > bottom:
|
||||
break
|
||||
line_w, _ = hDC.GetTextExtent(line)
|
||||
x = margin_x + max(0, (inner_w - line_w) // 2)
|
||||
hDC.TextOut(x, y, line)
|
||||
y += line_h
|
||||
|
||||
# Draw invoice line (centered, bold, and larger than the address)
|
||||
if invoice_line and y + gap + inv_line_h <= bottom:
|
||||
y += gap
|
||||
hDC.SelectObject(font_inv)
|
||||
line_w, _ = hDC.GetTextExtent(invoice_line)
|
||||
x = margin_x + max(0, (inner_w - line_w) // 2)
|
||||
hDC.TextOut(x, y, invoice_line)
|
||||
|
||||
|
||||
def _wrap_text(hDC, text: str, max_width: int) -> list[str]:
|
||||
"""Greedy word-wrap that respects the device context's current font."""
|
||||
words = text.split()
|
||||
if not words:
|
||||
return []
|
||||
lines: list[str] = []
|
||||
current = words[0]
|
||||
for word in words[1:]:
|
||||
candidate = current + " " + word
|
||||
w, _ = hDC.GetTextExtent(candidate)
|
||||
if w <= max_width:
|
||||
current = candidate
|
||||
else:
|
||||
lines.append(current)
|
||||
current = word
|
||||
lines.append(current)
|
||||
return lines
|
||||
|
||||
|
||||
# ── LabelPrinter ──────────────────────────────────────────────────────────────
|
||||
|
||||
class LabelPrinter:
|
||||
"""Owner of the label printer selection and the print operation itself.
|
||||
|
||||
Mirrors the CutFileManager pattern: takes a reference to the shared state
|
||||
dict and a save_fn callback so it can persist its settings immediately.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
state: dict,
|
||||
save_state: Callable[[dict], None],
|
||||
) -> None:
|
||||
self._state = state
|
||||
self._save_fn = save_state
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""True if pywin32 is importable. False on dev machines without it."""
|
||||
return _WIN32_AVAILABLE
|
||||
|
||||
def available_printers(self) -> list[str]:
|
||||
"""Return the list of installed printer names on this workstation."""
|
||||
if not _WIN32_AVAILABLE:
|
||||
return []
|
||||
try:
|
||||
return [
|
||||
p[2] for p in win32print.EnumPrinters(
|
||||
win32print.PRINTER_ENUM_LOCAL | win32print.PRINTER_ENUM_CONNECTIONS
|
||||
)
|
||||
]
|
||||
except Exception as exc:
|
||||
log.warning("Could not enumerate printers: %s", exc)
|
||||
return []
|
||||
|
||||
def get_printer(self) -> str:
|
||||
"""Return the saved printer name, or '' if none chosen yet."""
|
||||
return self._state.get("label_printer", "") or ""
|
||||
|
||||
def set_printer(self, name: str) -> None:
|
||||
"""Save the chosen printer name to ui_state.json."""
|
||||
self._state["label_printer"] = name
|
||||
try:
|
||||
self._save_fn(self._state)
|
||||
except Exception as exc:
|
||||
log.warning("Failed to persist label_printer to ui_state.json: %s", exc)
|
||||
|
||||
def get_last_printed(self) -> tuple[str, str]:
|
||||
"""Return the (sku, description) of the previously printed label."""
|
||||
return (
|
||||
self._state.get("last_printed_sku", "") or "",
|
||||
self._state.get("last_printed_desc", "") or "",
|
||||
)
|
||||
|
||||
def open_preferences(self) -> None:
|
||||
"""Open the Windows Printing Preferences dialog for the chosen printer.
|
||||
|
||||
That's where Auto Cut / Chain Printing toggles live in the driver UI.
|
||||
"""
|
||||
if not _WIN32_AVAILABLE:
|
||||
raise RuntimeError("Printer settings are only available on Windows.")
|
||||
printer = self.get_printer()
|
||||
if not printer:
|
||||
raise RuntimeError("No label printer chosen yet. Pick one from the menu first.")
|
||||
try:
|
||||
subprocess.Popen(["rundll32.exe", "printui.dll,PrintUIEntry", "/e", "/n", printer])
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Could not open printer settings for '{printer}':\n\n{exc}") from exc
|
||||
|
||||
def print_label(self, sku: str, description: str) -> None:
|
||||
"""Forward a two-line label (SKU + Description) to the bridge print queue.
|
||||
|
||||
Raises RuntimeError with a user-friendly message on any failure.
|
||||
"""
|
||||
if not sku:
|
||||
raise RuntimeError("Pick a part first, then click Print Label.")
|
||||
log.info("Forwarding print label request: sku=%r desc=%r", sku, description)
|
||||
|
||||
import requests
|
||||
url = "http://localhost:5000/api/label-jobs/enqueue"
|
||||
payload = {
|
||||
"label_data": {
|
||||
"order_id": sku,
|
||||
"source": "parts_lookup",
|
||||
"skus": [{
|
||||
"sku": sku,
|
||||
"description": description,
|
||||
"line_qty": 1,
|
||||
"unit_index": 1
|
||||
}]
|
||||
}
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, json=payload, timeout=3)
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"Server returned status {resp.status_code}: {resp.text}")
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"Could not connect to print bridge server at localhost:5000.\n\n"
|
||||
"Please verify that the QuickBooks Efficiency Bridge is running.\n\n"
|
||||
f"Error details: {exc}"
|
||||
) from exc
|
||||
|
||||
self._state["last_printed_sku"] = sku
|
||||
self._state["last_printed_desc"] = description
|
||||
try:
|
||||
self._save_fn(self._state)
|
||||
except Exception:
|
||||
log.warning("Failed to persist last printed label to state")
|
||||
|
||||
def print_address_label(self, address: str, invoice: str = "") -> None:
|
||||
"""Forward an Address + Invoice label to the bridge print queue.
|
||||
|
||||
Raises RuntimeError with a user-friendly message on any failure.
|
||||
"""
|
||||
if not address.strip():
|
||||
raise RuntimeError("Address cannot be empty.")
|
||||
log.info("Forwarding print address label request: invoice=%r", invoice)
|
||||
|
||||
# Parse company and lines
|
||||
addr_lines = [ln.strip() for ln in address.splitlines() if ln.strip()]
|
||||
company = addr_lines[0] if addr_lines else ""
|
||||
lines = addr_lines[1:] if len(addr_lines) > 1 else []
|
||||
|
||||
import requests
|
||||
url = "http://localhost:5000/api/label-jobs/enqueue"
|
||||
payload = {
|
||||
"label_data": {
|
||||
"order_id": invoice or "SHIP",
|
||||
"source": "parts_lookup",
|
||||
"qb_invoice_number": invoice,
|
||||
"ship_to": {
|
||||
"company": company,
|
||||
"lines": lines
|
||||
}
|
||||
}
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, json=payload, timeout=3)
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"Server returned status {resp.status_code}: {resp.text}")
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"Could not connect to print bridge server at localhost:5000.\n\n"
|
||||
"Please verify that the QuickBooks Efficiency Bridge is running.\n\n"
|
||||
f"Error details: {exc}"
|
||||
) from exc
|
||||
|
||||
def _render_label(self, sku: str, description: str) -> None:
|
||||
"""Lay out the label in printer logical units."""
|
||||
printer = self.get_printer()
|
||||
try:
|
||||
hDC = win32ui.CreateDC()
|
||||
hDC.CreatePrinterDC(printer)
|
||||
except Exception as exc:
|
||||
raise OSError(exc) from exc
|
||||
try:
|
||||
dpi_x = hDC.GetDeviceCaps(win32con.LOGPIXELSX)
|
||||
dpi_y = hDC.GetDeviceCaps(win32con.LOGPIXELSY)
|
||||
w = hDC.GetDeviceCaps(win32con.HORZRES)
|
||||
h = hDC.GetDeviceCaps(win32con.VERTRES)
|
||||
log.info("Label printable area: %d x %d (%d x %d DPI)", w, h, dpi_x, dpi_y)
|
||||
margin_x = max(10, int(w * 0.04))
|
||||
margin_y = max(6, int(h * 0.06))
|
||||
inner_w = w - 2 * margin_x
|
||||
inner_h = h - 2 * margin_y
|
||||
|
||||
hDC.StartDoc("Parts Lookup Label")
|
||||
hDC.StartPage()
|
||||
|
||||
# SKU (large bold) + description (smaller regular), auto-sized to fit
|
||||
y = margin_y
|
||||
for pt in range(36, 7, -1):
|
||||
sku_font_h = -int(dpi_y * pt / 72)
|
||||
desc_font_h = max(sku_font_h, -int(dpi_y * 10 / 72))
|
||||
f_sku = win32ui.CreateFont({"name": "Arial", "height": sku_font_h, "weight": 700})
|
||||
f_desc = win32ui.CreateFont({"name": "Arial", "height": desc_font_h, "weight": 400})
|
||||
hDC.SelectObject(f_sku)
|
||||
tw, th = hDC.GetTextExtent(sku or " ")
|
||||
hDC.SelectObject(f_desc)
|
||||
desc_lines = _wrap_text(hDC, description, inner_w) if description else []
|
||||
desc_h = sum(hDC.GetTextExtent(ln)[1] + 2 for ln in desc_lines) if desc_lines else 0
|
||||
total_h = th + 4 + desc_h
|
||||
if tw <= inner_w and total_h <= inner_h:
|
||||
y = margin_y + max(0, (inner_h - total_h) // 2)
|
||||
hDC.SelectObject(f_sku)
|
||||
x = margin_x + max(0, (inner_w - tw) // 2)
|
||||
hDC.TextOut(x, y, sku)
|
||||
y += th + 4
|
||||
hDC.SelectObject(f_desc)
|
||||
for line in desc_lines:
|
||||
lw, lh = hDC.GetTextExtent(line)
|
||||
hDC.TextOut(margin_x + max(0, (inner_w - lw) // 2), y, line)
|
||||
y += lh + 2
|
||||
break
|
||||
|
||||
hDC.EndPage()
|
||||
hDC.EndDoc()
|
||||
finally:
|
||||
try:
|
||||
hDC.DeleteDC()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _render_address_label(self, address: str, invoice: str = "") -> None:
|
||||
"""Lay out and print the address and invoice centered horizontally and vertically."""
|
||||
printer = self.get_printer()
|
||||
try:
|
||||
hDC = win32ui.CreateDC()
|
||||
hDC.CreatePrinterDC(printer)
|
||||
except Exception as exc:
|
||||
raise OSError(exc) from exc
|
||||
try:
|
||||
dpi_y = hDC.GetDeviceCaps(win32con.LOGPIXELSY)
|
||||
w = hDC.GetDeviceCaps(win32con.HORZRES)
|
||||
h = hDC.GetDeviceCaps(win32con.VERTRES)
|
||||
margin_x = max(10, int(w * 0.04))
|
||||
margin_y = max(6, int(h * 0.08))
|
||||
inner_w = w - 2 * margin_x
|
||||
inner_h = h - 2 * margin_y
|
||||
address_lines = [ln.strip() for ln in address.splitlines() if ln.strip()]
|
||||
hDC.StartDoc("Parts Lookup Address Label")
|
||||
hDC.StartPage()
|
||||
_draw_address_block(
|
||||
hDC=hDC,
|
||||
address_raw_lines=address_lines,
|
||||
invoice_line=invoice,
|
||||
inner_w=inner_w,
|
||||
inner_h=inner_h,
|
||||
margin_x=margin_x,
|
||||
margin_y=margin_y,
|
||||
dpi_y=dpi_y,
|
||||
)
|
||||
hDC.EndPage()
|
||||
hDC.EndDoc()
|
||||
finally:
|
||||
try:
|
||||
hDC.DeleteDC()
|
||||
except Exception:
|
||||
pass
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
"""Application entry logic — invoked by both `python -m app` and the bundled .exe.
|
||||
|
||||
Lives as a regular module (not __main__.py) so it can be imported with absolute
|
||||
imports from a top-level entry script — PyInstaller doesn't preserve package
|
||||
context for the entry script.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from app.config import Config
|
||||
from app import paths as app_paths
|
||||
from app.ui import App
|
||||
|
||||
|
||||
def _setup_logging() -> None:
|
||||
try:
|
||||
handler: logging.Handler = logging.FileHandler(
|
||||
app_paths.log_file_path(), encoding="utf-8"
|
||||
)
|
||||
except Exception:
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
handlers=[handler],
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_setup_logging()
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
cfg = Config.load()
|
||||
except FileNotFoundError as e:
|
||||
log.error("Config not found: %s", e)
|
||||
print("config.json not found. Put one next to the .exe.", file=sys.stderr)
|
||||
return 2
|
||||
except Exception as e:
|
||||
log.exception("Config load failed")
|
||||
print(f"Failed to load config.json: {e}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
log.info("Starting app — csv=%s", cfg.resolved_csv_path)
|
||||
app = App(cfg)
|
||||
app.mainloop()
|
||||
return 0
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Canonical per-user data paths for the application.
|
||||
|
||||
All persistent artifacts (SQLite DB, image cache, label PDFs, history JSON,
|
||||
package presets, log file) live under one directory so they're easy to find,
|
||||
back up, or nuke without touching the install.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_APP_SUBDIR = Path("CollisionStoneGuard") / "PartsLookup"
|
||||
|
||||
|
||||
def appdata_dir() -> Path:
|
||||
"""Return (and create) the per-user appdata directory.
|
||||
|
||||
Resolution order:
|
||||
1. %LOCALAPPDATA% (Windows standard)
|
||||
2. ~/AppData/Local (fallback — same folder, explicit)
|
||||
3. ~ (last resort)
|
||||
"""
|
||||
local = os.environ.get("LOCALAPPDATA", "")
|
||||
base = Path(local) if local else Path.home() / "AppData" / "Local"
|
||||
d = base / _APP_SUBDIR
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def default_sqlite_path() -> str:
|
||||
"""Default path for the parts/invoices SQLite database."""
|
||||
return str(appdata_dir() / "parts.db")
|
||||
|
||||
|
||||
def default_image_cache_dir() -> str:
|
||||
"""Default directory for cached part images."""
|
||||
p = appdata_dir() / "images"
|
||||
p.mkdir(exist_ok=True)
|
||||
return str(p)
|
||||
|
||||
|
||||
def log_file_path() -> Path:
|
||||
"""Path for the application log file."""
|
||||
return appdata_dir() / "app.log"
|
||||
@@ -0,0 +1,473 @@
|
||||
"""Background Flask server for the Android and PC barcode picking application.
|
||||
|
||||
Serves the mobile picking frontend, looks up invoice details from the
|
||||
QuickBooks Web Connector results cache/ledger, and resolves parts physical locations
|
||||
directly from the qb_efficiency/data/OnPart_inv.csv spreadsheet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from flask import Flask, jsonify, request, Response, send_from_directory
|
||||
|
||||
log = logging.getLogger("picker_server")
|
||||
|
||||
# Default qb_efficiency path configurations
|
||||
DEFAULT_BASE_DIR = Path("C:/Users/mario/OneDrive/Documents/Claude/Projects/qb_efficiency")
|
||||
DEFAULT_RESULTS_DIR = DEFAULT_BASE_DIR / "qb-bridge" / "state" / "results"
|
||||
DEFAULT_STATE_DIR = DEFAULT_BASE_DIR / "qb-bridge" / "state"
|
||||
DEFAULT_CSV_PATH = DEFAULT_BASE_DIR / "data" / "OnPart_inv.csv"
|
||||
DEFAULT_CONFIG_PATH = Path("C:/Users/mario/OneDrive/Documents/Claude/Projects/Part Lookup Program (1)/config.json")
|
||||
|
||||
# Create Flask app
|
||||
app = Flask(__name__, static_folder=None)
|
||||
|
||||
# Global variables for paths and cache (will be resolved on server start)
|
||||
results_dir = DEFAULT_RESULTS_DIR
|
||||
state_dir = DEFAULT_STATE_DIR
|
||||
csv_path = DEFAULT_CSV_PATH
|
||||
bridge_url = "http://localhost:5000/api/label-jobs/enqueue"
|
||||
|
||||
parts_cache: dict[str, dict] = {}
|
||||
csv_mtime = 0.0
|
||||
|
||||
# Invoice index: ref_number (str) → enriched invoice dict (with lines).
|
||||
# Built from invoice_query_*.json and invoice_add_*.json in results_dir.
|
||||
# Updated every 15 s by the background watcher thread.
|
||||
invoice_index: dict[str, dict] = {}
|
||||
_index_lock = threading.Lock()
|
||||
_results_seen_mtimes: dict[str, float] = {} # filename → mtime, for change detection
|
||||
|
||||
|
||||
def resolve_paths():
|
||||
"""Load config overrides if available."""
|
||||
global results_dir, state_dir, csv_path, bridge_url
|
||||
config_file = Path("config.json")
|
||||
if not config_file.exists() and DEFAULT_CONFIG_PATH.exists():
|
||||
config_file = DEFAULT_CONFIG_PATH
|
||||
|
||||
if config_file.exists():
|
||||
try:
|
||||
cfg = json.loads(config_file.read_text(encoding="utf-8"))
|
||||
if cfg.get("qb_results_dir"):
|
||||
results_dir = Path(cfg["qb_results_dir"])
|
||||
if cfg.get("qb_state_dir"):
|
||||
state_dir = Path(cfg["qb_state_dir"])
|
||||
if cfg.get("qb_bridge_url"):
|
||||
bridge_url = cfg["qb_bridge_url"]
|
||||
if cfg.get("qb_csv_path"):
|
||||
csv_path = Path(cfg["qb_csv_path"])
|
||||
|
||||
log.info("Resolved qb_results_dir: %s", results_dir)
|
||||
log.info("Resolved qb_state_dir: %s", state_dir)
|
||||
log.info("Resolved qb_csv_path: %s", csv_path)
|
||||
except Exception as e:
|
||||
log.warning("Could not read config.json: %s", e)
|
||||
|
||||
|
||||
def load_parts_cache():
|
||||
"""Parse OnPart_inv.csv spreadsheet directly into a memory cache dict."""
|
||||
global parts_cache, csv_mtime
|
||||
if not csv_path.exists():
|
||||
log.warning("Parts CSV not found at %s", csv_path)
|
||||
return
|
||||
|
||||
try:
|
||||
temp_cache = {}
|
||||
with open(csv_path, "r", encoding="utf-8") as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
sku = row.get("STOCK NUMBER", "").strip()
|
||||
if not sku:
|
||||
sku = row.get("STOCK_NUMBER", "").strip()
|
||||
if sku:
|
||||
temp_cache[sku.upper()] = {
|
||||
"bin_location": row.get("BIN LOCATION", "Unknown").strip(),
|
||||
"yard_location": row.get("YARD LOCATION", "CSG").strip(),
|
||||
"price": row.get("NET", "").strip(),
|
||||
"grade": row.get("GRADE", "").strip(),
|
||||
"available": row.get("AVAILABLE", "Y").strip(),
|
||||
"quantity": row.get("QUANTITY", "0").strip(),
|
||||
"description": row.get("DESCRIPTION", "").strip()
|
||||
}
|
||||
parts_cache = temp_cache
|
||||
csv_mtime = csv_path.stat().st_mtime
|
||||
log.info("Loaded %d parts from %s into memory.", len(parts_cache), csv_path.name)
|
||||
except Exception as e:
|
||||
log.error("Failed to parse parts CSV %s: %s", csv_path, e)
|
||||
|
||||
|
||||
def query_part_details(sku: str) -> dict:
|
||||
"""Get part details from memory cache, auto-reloading if CSV modified."""
|
||||
global csv_mtime
|
||||
if csv_path.exists():
|
||||
try:
|
||||
mtime = csv_path.stat().st_mtime
|
||||
if mtime != csv_mtime:
|
||||
log.info("Detected change in %s. Reloading parts cache...", csv_path.name)
|
||||
load_parts_cache()
|
||||
except Exception as e:
|
||||
log.warning("Failed to check mtime: %s", e)
|
||||
|
||||
return parts_cache.get(sku.upper(), {})
|
||||
|
||||
|
||||
def _enrich_lines(raw_lines: list) -> list:
|
||||
"""Attach bin/location data from parts_cache to each invoice line."""
|
||||
enriched = []
|
||||
for li in raw_lines:
|
||||
item = li.get("item") or {}
|
||||
sku = item.get("full_name") or li.get("sku") or ""
|
||||
if not sku and li.get("desc"):
|
||||
desc = li.get("desc") or ""
|
||||
if "\n" in desc:
|
||||
sku = desc.split("\n")[-1].strip()
|
||||
line_data = {
|
||||
"sku": sku,
|
||||
"description": li.get("desc") or li.get("description") or "",
|
||||
"qty": li.get("qty") or 1,
|
||||
"rate": li.get("rate") or li.get("unit_price") or 0.0,
|
||||
}
|
||||
line_data.update(query_part_details(sku))
|
||||
enriched.append(line_data)
|
||||
return enriched
|
||||
|
||||
|
||||
def _index_file(path: Path) -> None:
|
||||
"""Parse a single results JSON file and add any invoices with lines to the index."""
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
log.debug("Could not read results file %s: %s", path.name, e)
|
||||
return
|
||||
|
||||
# invoice_query_*.json → {"invoices": [...]}
|
||||
invoices_raw = data.get("invoices") or []
|
||||
for inv in invoices_raw:
|
||||
lines_raw = inv.get("lines") or []
|
||||
if not lines_raw:
|
||||
continue # skip analytics-only queries that have no lines
|
||||
ref = inv.get("ref_number") or ""
|
||||
if not ref:
|
||||
continue
|
||||
enriched = _enrich_lines(lines_raw)
|
||||
record = {
|
||||
"invoice_number": ref,
|
||||
"customer": inv.get("customer_name") or "QuickBooks Customer",
|
||||
"date": inv.get("txn_date") or "",
|
||||
"lines": enriched,
|
||||
}
|
||||
with _index_lock:
|
||||
invoice_index[ref] = record
|
||||
|
||||
# invoice_add_*.json → {"invoice": {...}}
|
||||
inv = data.get("invoice")
|
||||
if inv:
|
||||
ref = inv.get("ref_number") or ""
|
||||
lines_raw = inv.get("lines") or []
|
||||
if ref and lines_raw:
|
||||
enriched = _enrich_lines(lines_raw)
|
||||
cust = inv.get("customer") or {}
|
||||
record = {
|
||||
"invoice_number": ref,
|
||||
"customer": cust.get("full_name") if isinstance(cust, dict) else str(cust),
|
||||
"date": inv.get("txn_date") or "",
|
||||
"lines": enriched,
|
||||
}
|
||||
with _index_lock:
|
||||
invoice_index[ref] = record
|
||||
|
||||
|
||||
def _rebuild_invoice_index() -> None:
|
||||
"""Scan all results files and rebuild the full invoice index."""
|
||||
if not results_dir.exists():
|
||||
return
|
||||
count_before = len(invoice_index)
|
||||
for p in results_dir.glob("invoice_*.json"):
|
||||
_index_file(p)
|
||||
added = len(invoice_index) - count_before
|
||||
if added:
|
||||
log.info("Invoice index rebuilt: %d invoices cached (added %d).", len(invoice_index), added)
|
||||
|
||||
|
||||
def _watch_results_dir() -> None:
|
||||
"""Background thread: poll results_dir every 15 s for new/changed files."""
|
||||
global _results_seen_mtimes
|
||||
while True:
|
||||
try:
|
||||
if results_dir.exists():
|
||||
for p in results_dir.glob("invoice_*.json"):
|
||||
try:
|
||||
mtime = p.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
prev = _results_seen_mtimes.get(p.name)
|
||||
if prev != mtime:
|
||||
_results_seen_mtimes[p.name] = mtime
|
||||
_index_file(p)
|
||||
# Also refresh CSV if it changed
|
||||
if csv_path.exists():
|
||||
try:
|
||||
if csv_path.stat().st_mtime != csv_mtime:
|
||||
load_parts_cache()
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as e:
|
||||
log.debug("Results watcher error: %s", e)
|
||||
time.sleep(15)
|
||||
|
||||
|
||||
def find_invoice_lines(ref_number: str) -> dict | None:
|
||||
"""Lookup invoice lines by ref_number.
|
||||
|
||||
Priority:
|
||||
1. In-memory invoice_index (O(1), kept hot by background watcher)
|
||||
2. review_queue.json (pending orders not yet posted to QB)
|
||||
3. Mock data (for testing)
|
||||
"""
|
||||
ref_number = ref_number.strip()
|
||||
|
||||
# 1. Hot index — O(1), covers all cached invoice_query + invoice_add results
|
||||
with _index_lock:
|
||||
hit = invoice_index.get(ref_number)
|
||||
if hit:
|
||||
return hit
|
||||
|
||||
# 2. review_queue.json — catches orders still pending QB posting
|
||||
review_queue_path = state_dir / "review_queue.json"
|
||||
if review_queue_path.exists():
|
||||
try:
|
||||
data = json.loads(review_queue_path.read_text(encoding="utf-8"))
|
||||
for entry in data:
|
||||
order = entry.get("order") or {}
|
||||
if order.get("source_order_id") == ref_number:
|
||||
lines = _enrich_lines([
|
||||
{
|
||||
"sku": li.get("sku") or li.get("oem_number") or "",
|
||||
"description": li.get("our_description") or li.get("description") or "",
|
||||
"qty": li.get("qty") or 1,
|
||||
"unit_price": li.get("unit_price") or 0.0,
|
||||
}
|
||||
for li in order.get("line_items", [])
|
||||
])
|
||||
return {
|
||||
"invoice_number": order.get("source_order_id") or ref_number,
|
||||
"customer": (order.get("customer") or {}).get("company") or "Pending Review",
|
||||
"date": order.get("order_date") or "",
|
||||
"lines": lines,
|
||||
}
|
||||
except Exception as e:
|
||||
log.warning("Failed to parse review_queue.json: %s", e)
|
||||
|
||||
# 3. Mock data for testing/development
|
||||
if ref_number.upper().startswith("MOCK") or ref_number in ("12345", "242990_MOCK"):
|
||||
lines = _enrich_lines([
|
||||
{"sku": "8620843-0423L", "description": "Front Bumper Cover", "qty": 1},
|
||||
{"sku": "SKU-999-MOCK", "description": "Rear Taillight Left", "qty": 2},
|
||||
])
|
||||
for li in lines:
|
||||
if not li.get("bin_location"):
|
||||
li.update({"bin_location": "A-12-B", "yard_location": "CSG", "price": "150.00"})
|
||||
return {
|
||||
"invoice_number": ref_number,
|
||||
"customer": "Mock Customer Inc.",
|
||||
"date": "2026-07-01",
|
||||
"lines": lines,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Web routes ──────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/picker")
|
||||
def picker_home():
|
||||
"""Serve picker web UI."""
|
||||
assets_dir = Path(__file__).resolve().parent / "assets"
|
||||
return send_from_directory(str(assets_dir), "picker.html")
|
||||
|
||||
|
||||
@app.route("/picker/setup-labels")
|
||||
def setup_labels_home():
|
||||
"""Serve the first-time setup label generator UI."""
|
||||
assets_dir = Path(__file__).resolve().parent / "assets"
|
||||
return send_from_directory(str(assets_dir), "setup_labels.html")
|
||||
|
||||
|
||||
@app.route("/assets/<path:filename>")
|
||||
def picker_assets(filename):
|
||||
"""Serve assets folder (picker.js, style.css, images, etc.)."""
|
||||
assets_dir = Path(__file__).resolve().parent / "assets"
|
||||
return send_from_directory(str(assets_dir), filename)
|
||||
|
||||
|
||||
@app.route("/api/parts", methods=["GET"])
|
||||
def list_parts():
|
||||
"""Return all parts from the memory cache."""
|
||||
return jsonify({
|
||||
"count": len(parts_cache),
|
||||
"parts": [{"sku": k, **v} for k, v in sorted(parts_cache.items())]
|
||||
})
|
||||
|
||||
|
||||
|
||||
@app.route("/api/part/<sku>", methods=["GET"])
|
||||
def get_part(sku):
|
||||
"""Look up a single part by SKU from the parts CSV cache."""
|
||||
details = query_part_details(sku.strip())
|
||||
if not details:
|
||||
return jsonify({"error": f"SKU '{sku}' not found in parts list."}), 404
|
||||
return jsonify({"sku": sku.upper(), **details})
|
||||
|
||||
|
||||
@app.route("/api/part/<sku>/unavailable", methods=["POST"])
|
||||
def mark_part_unavailable(sku):
|
||||
"""Mark a part as unavailable (AVAILABLE = 'N', QUANTITY = '0') in CSV and reload cache."""
|
||||
sku = sku.strip().upper()
|
||||
if not sku:
|
||||
return jsonify({"error": "SKU required"}), 400
|
||||
|
||||
if not csv_path.exists():
|
||||
return jsonify({"error": "CSV file not found"}), 500
|
||||
|
||||
try:
|
||||
updated = False
|
||||
rows = []
|
||||
|
||||
# Read all rows
|
||||
with open(csv_path, "r", encoding="utf-8") as f:
|
||||
reader = csv.DictReader(f)
|
||||
fieldnames = reader.fieldnames
|
||||
for row in reader:
|
||||
row_sku = row.get("STOCK NUMBER", "").strip()
|
||||
if not row_sku:
|
||||
row_sku = row.get("STOCK_NUMBER", "").strip()
|
||||
|
||||
if row_sku.upper() == sku:
|
||||
row["AVAILABLE"] = "N"
|
||||
row["QUANTITY"] = "0"
|
||||
updated = True
|
||||
rows.append(row)
|
||||
|
||||
if not updated:
|
||||
return jsonify({"error": f"SKU '{sku}' not found in CSV inventory."}), 404
|
||||
|
||||
# Write back atomically
|
||||
tmp_path = csv_path.with_suffix(".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
# Replace original file safely
|
||||
import os
|
||||
if os.path.exists(csv_path):
|
||||
os.remove(csv_path)
|
||||
os.rename(tmp_path, csv_path)
|
||||
|
||||
# Reload memory cache
|
||||
load_parts_cache()
|
||||
|
||||
log.info("Part %s successfully marked unavailable (Qty set to 0).", sku)
|
||||
return jsonify({"ok": True, "message": f"Part {sku} marked unavailable in CSV."})
|
||||
except Exception as e:
|
||||
log.error("Failed to mark part %s unavailable: %s", sku, e)
|
||||
return jsonify({"error": f"Failed to write CSV: {e}"}), 500
|
||||
|
||||
|
||||
|
||||
@app.route("/api/invoices", methods=["GET"])
|
||||
def list_invoices():
|
||||
"""Return the list of all cached invoice numbers (for browsing/testing)."""
|
||||
with _index_lock:
|
||||
items = [
|
||||
{"invoice_number": k, "customer": v.get("customer", ""), "date": v.get("date", ""), "line_count": len(v.get("lines", []))}
|
||||
for k, v in sorted(invoice_index.items(), reverse=True)
|
||||
]
|
||||
return jsonify({"count": len(items), "invoices": items})
|
||||
|
||||
|
||||
@app.route("/api/invoice/<ref_number>", methods=["GET"])
|
||||
def get_invoice(ref_number):
|
||||
"""Fetch invoice lines and enrichment details."""
|
||||
invoice_data = find_invoice_lines(ref_number)
|
||||
if not invoice_data:
|
||||
try:
|
||||
import requests
|
||||
bridge_base = "/".join(bridge_url.split("/")[:3]) # e.g., http://localhost:5000
|
||||
enqueue_url = f"{bridge_base}/enqueue/invoice_query"
|
||||
payload = {"ref_number": ref_number, "include_lines": True}
|
||||
resp = requests.post(enqueue_url, json=payload, timeout=2)
|
||||
if resp.status_code == 200:
|
||||
log.info("Auto-enqueued invoice_query on bridge for RefNumber %s", ref_number)
|
||||
return jsonify({
|
||||
"status": "sync_pending",
|
||||
"message": f"Invoice {ref_number} not cached. Sync enqueued in QuickBooks. Please run the QuickBooks Web Connector to fetch results."
|
||||
}), 202
|
||||
except Exception as e:
|
||||
log.warning("Failed to auto-enqueue invoice query: %s", e)
|
||||
|
||||
return jsonify({"error": f"Invoice {ref_number} not found in Web Connector cache or review queue."}), 404
|
||||
return jsonify(invoice_data)
|
||||
|
||||
|
||||
@app.route("/api/print-label", methods=["POST"])
|
||||
def print_label_via_bridge():
|
||||
"""Forward a reprint request to the qb_efficiency queue."""
|
||||
import requests
|
||||
body = request.get_json(silent=True) or {}
|
||||
label_data = body.get("label_data")
|
||||
if not label_data:
|
||||
return Response("label_data required", status=400, content_type="text/plain")
|
||||
|
||||
station = body.get("station") or ""
|
||||
payload = {
|
||||
"label_data": label_data,
|
||||
"station": station
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(bridge_url, json=payload, timeout=3)
|
||||
if resp.status_code == 200:
|
||||
return jsonify({"ok": True, "bridge_response": resp.json()})
|
||||
else:
|
||||
return jsonify({"error": f"Bridge server returned status {resp.status_code}: {resp.text}"}), 502
|
||||
except Exception as e:
|
||||
log.warning("Failed to contact qb_efficiency print bridge at %s: %s", bridge_url, e)
|
||||
return jsonify({"error": f"Could not connect to bridge printer server: {e}"}), 503
|
||||
|
||||
|
||||
# ── Thread manager ──────────────────────────────────────────────────────────
|
||||
|
||||
def run_server():
|
||||
"""Main thread loop running Flask."""
|
||||
resolve_paths()
|
||||
load_parts_cache()
|
||||
|
||||
# Build the initial invoice index from all existing cached files
|
||||
_rebuild_invoice_index()
|
||||
log.info("Invoice index ready: %d invoices cached.", len(invoice_index))
|
||||
|
||||
# Start background watcher: picks up new files written by QBWC every ~2 min
|
||||
watcher = threading.Thread(target=_watch_results_dir, name="InvoiceIndexWatcher", daemon=True)
|
||||
watcher.start()
|
||||
log.info("Invoice index watcher started (polling every 15 s).")
|
||||
|
||||
log.info("Starting background picker server on port 5001...")
|
||||
try:
|
||||
app.run(host="0.0.0.0", port=5001, debug=False, use_reloader=False)
|
||||
except Exception as e:
|
||||
log.exception("Flask server thread crash")
|
||||
|
||||
|
||||
def start_server():
|
||||
"""Invoke the server on a daemon background thread."""
|
||||
t = threading.Thread(target=run_server, name="PickerServerThread", daemon=True)
|
||||
t.start()
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
"""USB HID scale reader.
|
||||
|
||||
Supports any scale that implements the USB HID Weighing Device specification
|
||||
(Usage Page 0x8D). This covers most common shipping scales including:
|
||||
- Dymo / Stamps.com USB scales (VID 0x0922)
|
||||
- Mettler Toledo postal scales
|
||||
- Generic USB postal scales
|
||||
|
||||
Gracefully no-ops when the `hid` package is not installed or no scale is found,
|
||||
so the rest of the app runs normally on machines without a scale connected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import hid as _hid
|
||||
_HID_AVAILABLE = True
|
||||
except ImportError:
|
||||
_hid = None # type: ignore[assignment]
|
||||
_HID_AVAILABLE = False
|
||||
|
||||
# USB HID Usage Page for Weighing Devices
|
||||
_WEIGHING_USAGE_PAGE = 0x8D
|
||||
|
||||
# HID weight unit codes
|
||||
_UNIT_GRAM = 2
|
||||
_UNIT_OUNCE = 11 # avoirdupois oz — most US postal scales report this
|
||||
_UNIT_POUND = 12
|
||||
|
||||
# HID status codes
|
||||
_STATUS_FAULT = 1
|
||||
_STATUS_STABLE = 2
|
||||
_STATUS_IN_MOTION = 3
|
||||
_STATUS_OVER = 4
|
||||
_STATUS_UNDER_ZERO = 5
|
||||
_STATUS_CALIBRATE = 6
|
||||
_STATUS_TARE = 7
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
"""True if the hid package is importable."""
|
||||
return _HID_AVAILABLE
|
||||
|
||||
|
||||
def find_scale() -> tuple[int, int] | None:
|
||||
"""Return (vendor_id, product_id) of the first HID weighing device found, or None."""
|
||||
if not _HID_AVAILABLE:
|
||||
return None
|
||||
try:
|
||||
for dev in _hid.enumerate():
|
||||
if dev.get("usage_page") == _WEIGHING_USAGE_PAGE:
|
||||
log.info(
|
||||
"Scale found: %s (VID=%04x PID=%04x)",
|
||||
dev.get("product_string", "Unknown"),
|
||||
dev["vendor_id"],
|
||||
dev["product_id"],
|
||||
)
|
||||
return dev["vendor_id"], dev["product_id"]
|
||||
except Exception as exc:
|
||||
log.warning("Scale enumeration error: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def read_weight_oz(vendor_id: int, product_id: int) -> float | None:
|
||||
"""Open the scale, read one report, return weight in ounces (or None on failure).
|
||||
|
||||
Returns None when the scale is in motion, over capacity, or not responding.
|
||||
"""
|
||||
if not _HID_AVAILABLE:
|
||||
return None
|
||||
try:
|
||||
with _hid.Device(vendor_id, product_id) as dev:
|
||||
data = dev.read(64, timeout=2000)
|
||||
except Exception as exc:
|
||||
log.debug("Scale read error: %s", exc)
|
||||
return None
|
||||
|
||||
if not data or len(data) < 6:
|
||||
return None
|
||||
|
||||
# Standard HID scale report:
|
||||
# byte 0: report ID
|
||||
# byte 1: status
|
||||
# byte 2: weight unit
|
||||
# byte 3: exponent (signed)
|
||||
# byte 4: weight LSB
|
||||
# byte 5: weight MSB
|
||||
status = data[1]
|
||||
unit = data[2]
|
||||
exponent = data[3] if data[3] < 128 else data[3] - 256 # treat as signed
|
||||
raw = (data[5] << 8) | data[4]
|
||||
weight = raw * (10.0 ** exponent)
|
||||
|
||||
if status not in (_STATUS_STABLE, _STATUS_IN_MOTION):
|
||||
return None
|
||||
if weight < 0:
|
||||
return None
|
||||
|
||||
if unit == _UNIT_OUNCE:
|
||||
return weight
|
||||
if unit == _UNIT_GRAM:
|
||||
return round(weight * 0.035274, 2)
|
||||
if unit == _UNIT_POUND:
|
||||
return round(weight * 16, 2)
|
||||
|
||||
log.debug("Unknown scale unit code: %d", unit)
|
||||
return None
|
||||
|
||||
|
||||
class ScalePoller:
|
||||
"""Background thread that polls the scale and fires a callback with weight in oz.
|
||||
|
||||
Usage:
|
||||
poller = ScalePoller(on_weight=my_callback)
|
||||
poller.start() # begin polling
|
||||
poller.stop() # stop polling
|
||||
"""
|
||||
|
||||
POLL_INTERVAL = 0.4 # seconds between reads
|
||||
|
||||
def __init__(self, on_weight: Callable[[float], None]) -> None:
|
||||
self._on_weight = on_weight
|
||||
self._running = False
|
||||
self._thread: threading.Thread | None = None
|
||||
self._device: tuple[int, int] | None = None
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start polling. Returns False if no scale is found."""
|
||||
if self._running:
|
||||
return True
|
||||
self._device = find_scale()
|
||||
if self._device is None:
|
||||
log.warning("No USB scale found — auto-weigh unavailable.")
|
||||
return False
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._run, daemon=True, name="ScalePoller")
|
||||
self._thread.start()
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
|
||||
def read_once(self) -> float | None:
|
||||
"""Single blocking read — used by the Weigh button."""
|
||||
dev = self._device or find_scale()
|
||||
if dev is None:
|
||||
return None
|
||||
self._device = dev
|
||||
return read_weight_oz(*dev)
|
||||
|
||||
def _run(self) -> None:
|
||||
last: float | None = None
|
||||
while self._running:
|
||||
if self._device:
|
||||
oz = read_weight_oz(*self._device)
|
||||
if oz is not None and oz != last:
|
||||
last = oz
|
||||
try:
|
||||
self._on_weight(oz)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(self.POLL_INTERVAL)
|
||||
@@ -0,0 +1,331 @@
|
||||
"""ShipEngine API client — USPS label creation.
|
||||
|
||||
Authentication uses a simple API key header (no OAuth / token refresh).
|
||||
Keys prefixed with TEST_ create non-mailable test labels with no postage
|
||||
charged — same endpoint, same code path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
import requests
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_BASE_URL = "https://api.shipengine.com"
|
||||
|
||||
# Our internal package codes → ShipEngine package codes
|
||||
_PACKAGE_CODES: dict[str, str] = {
|
||||
"package": "package",
|
||||
"large_envelope": "large_envelope_or_flat",
|
||||
"letter": "letter",
|
||||
"thick_envelope": "thick_envelope",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class LabelResult:
|
||||
label_id: str
|
||||
tracking_number: str
|
||||
label_pdf_path: str
|
||||
|
||||
|
||||
class ShipEngineClient:
|
||||
"""Thin wrapper around the ShipEngine REST API."""
|
||||
|
||||
def __init__(self, api_key: str) -> None:
|
||||
self._key = api_key
|
||||
self._headers = {
|
||||
"API-Key": api_key,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# ── Address validation ─────────────────────────────────────────────────
|
||||
|
||||
def validate_address(self, addr: dict) -> dict:
|
||||
"""Validate and normalise a structured address dict.
|
||||
|
||||
Input keys: name, company_name, address_line1, address_line2,
|
||||
city, state_province, postal_code
|
||||
Returns the same key set with corrected values, plus a
|
||||
'validation_status' key ("verified" | "warning" | "unverified" | "error").
|
||||
"""
|
||||
payload = [{
|
||||
"name": addr.get("name", ""),
|
||||
"company_name": addr.get("company_name", ""),
|
||||
"address_line1": addr.get("address_line1", ""),
|
||||
"address_line2": addr.get("address_line2", ""),
|
||||
"city_locality": addr.get("city", ""),
|
||||
"state_province": addr.get("state_province", ""),
|
||||
"postal_code": addr.get("postal_code", ""),
|
||||
"country_code": "US",
|
||||
}]
|
||||
resp = requests.post(
|
||||
f"{_BASE_URL}/v1/addresses/validate",
|
||||
json=payload,
|
||||
headers=self._headers,
|
||||
timeout=15,
|
||||
)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(_format_error(resp, "Address validation"))
|
||||
|
||||
result = resp.json()
|
||||
if not result:
|
||||
return addr
|
||||
|
||||
item = result[0]
|
||||
status = item.get("status", "unverified")
|
||||
matched = item.get("matched_address") or {}
|
||||
|
||||
return {
|
||||
"name": matched.get("name", addr.get("name", "")),
|
||||
"company_name": matched.get("company_name", addr.get("company_name", "")),
|
||||
"address_line1": matched.get("address_line1", addr.get("address_line1", "")),
|
||||
"address_line2": matched.get("address_line2", addr.get("address_line2", "")),
|
||||
"city": matched.get("city_locality", addr.get("city", "")),
|
||||
"state_province": matched.get("state_province", addr.get("state_province", "")),
|
||||
"postal_code": matched.get("postal_code", addr.get("postal_code", "")),
|
||||
"validation_status": status,
|
||||
}
|
||||
|
||||
# ── Rate fetch ─────────────────────────────────────────────────────────
|
||||
|
||||
def get_rate(
|
||||
self,
|
||||
from_zip: str,
|
||||
to_zip: str,
|
||||
weight_oz: int,
|
||||
service_code: str,
|
||||
package_code: str,
|
||||
dim_l: float = 0,
|
||||
dim_w: float = 0,
|
||||
dim_h: float = 0,
|
||||
) -> float | None:
|
||||
"""Return total shipment cost in USD, or None on any failure."""
|
||||
package: dict = {
|
||||
"weight": {"value": max(1, weight_oz), "unit": "ounce"},
|
||||
}
|
||||
if dim_l and dim_w and dim_h:
|
||||
package["dimensions"] = {
|
||||
"length": dim_l, "width": dim_w, "height": dim_h,
|
||||
"unit": "inch",
|
||||
}
|
||||
if package_code and package_code != "package":
|
||||
package["package_code"] = _PACKAGE_CODES.get(package_code, package_code)
|
||||
|
||||
payload = {
|
||||
"rate_options": {"service_codes": [service_code]},
|
||||
"shipment": {
|
||||
"service_code": service_code,
|
||||
"ship_from": {"postal_code": from_zip, "country_code": "US"},
|
||||
"ship_to": {"postal_code": to_zip, "country_code": "US"},
|
||||
"packages": [package],
|
||||
},
|
||||
}
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{_BASE_URL}/v1/rates",
|
||||
json=payload,
|
||||
headers=self._headers,
|
||||
timeout=12,
|
||||
)
|
||||
if not resp.ok:
|
||||
return None
|
||||
data = resp.json()
|
||||
rate_list = (data.get("rate_response") or {}).get("rates") or []
|
||||
if not rate_list:
|
||||
return None
|
||||
total = rate_list[0].get("shipping_amount", {}).get("amount")
|
||||
return float(total) if total is not None else None
|
||||
except Exception as exc:
|
||||
log.debug("Rate fetch failed: %s", exc)
|
||||
return None
|
||||
|
||||
# ── Label creation ─────────────────────────────────────────────────────
|
||||
|
||||
def create_label(self, data: dict, parsed_addr: dict) -> LabelResult:
|
||||
"""Create a USPS label and save the PDF locally.
|
||||
|
||||
`data` is the dict from _UspsPanel._on_create().
|
||||
`parsed_addr` is a structured address dict (from parse_address_text
|
||||
+ validate_address).
|
||||
"""
|
||||
pkg_code = _PACKAGE_CODES.get(data["package_code"], data["package_code"])
|
||||
|
||||
package: dict = {
|
||||
"weight": {"value": max(1, data["weight_oz"]), "unit": "ounce"},
|
||||
"package_code": pkg_code,
|
||||
}
|
||||
if data.get("dim_l") and data.get("dim_w") and data.get("dim_h"):
|
||||
package["dimensions"] = {
|
||||
"length": data["dim_l"],
|
||||
"width": data["dim_w"],
|
||||
"height": data["dim_h"],
|
||||
"unit": "inch",
|
||||
}
|
||||
if data.get("print_message"):
|
||||
package["label_messages"] = {"reference1": data["print_message"][:60]}
|
||||
|
||||
ship_to: dict = {
|
||||
"name": parsed_addr.get("name", ""),
|
||||
"company_name": parsed_addr.get("company_name", ""),
|
||||
"address_line1": parsed_addr.get("address_line1", ""),
|
||||
"address_line2": parsed_addr.get("address_line2", ""),
|
||||
"city_locality": parsed_addr.get("city", ""),
|
||||
"state_province": parsed_addr.get("state_province", ""),
|
||||
"postal_code": parsed_addr.get("postal_code", ""),
|
||||
"country_code": "US",
|
||||
}
|
||||
if data.get("email"):
|
||||
ship_to["email"] = data["email"]
|
||||
|
||||
payload = {
|
||||
"label_format": "pdf",
|
||||
"label_layout": "4x6",
|
||||
"shipment": {
|
||||
"service_code": data["service_code"],
|
||||
"ship_from": self._shipper,
|
||||
"ship_to": ship_to,
|
||||
"packages": [package],
|
||||
},
|
||||
}
|
||||
|
||||
resp = requests.post(
|
||||
f"{_BASE_URL}/v1/labels",
|
||||
json=payload,
|
||||
headers=self._headers,
|
||||
timeout=30,
|
||||
)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(_format_error(resp, "Label creation"))
|
||||
|
||||
result = resp.json()
|
||||
label_id = result.get("label_id", "")
|
||||
tracking = result.get("tracking_number", "")
|
||||
pdf_url = (result.get("label_download") or {}).get("pdf", "")
|
||||
|
||||
if not pdf_url:
|
||||
raise RuntimeError(
|
||||
f"Label created (tracking: {tracking}) but ShipEngine returned no PDF URL."
|
||||
)
|
||||
|
||||
pdf_resp = requests.get(pdf_url, timeout=20)
|
||||
pdf_resp.raise_for_status()
|
||||
|
||||
from datetime import datetime
|
||||
from .paths import appdata_dir
|
||||
labels_dir = appdata_dir() / "labels"
|
||||
labels_dir.mkdir(exist_ok=True)
|
||||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
label_path = labels_dir / f"USPS_{tracking or 'label'}_{stamp}.pdf"
|
||||
label_path.write_bytes(pdf_resp.content)
|
||||
|
||||
log.info("USPS label created: tracking=%s label=%s", tracking, label_path)
|
||||
return LabelResult(
|
||||
label_id = label_id,
|
||||
tracking_number = tracking,
|
||||
label_pdf_path = str(label_path),
|
||||
)
|
||||
|
||||
def set_shipper(self, cfg) -> None:
|
||||
"""Build the ship_from block from config and cache it."""
|
||||
self._shipper = {
|
||||
"name": cfg.usps_shipper_name,
|
||||
"company_name": cfg.usps_shipper_company,
|
||||
"phone": cfg.usps_shipper_phone,
|
||||
"address_line1": cfg.usps_shipper_address1,
|
||||
"city_locality": cfg.usps_shipper_city,
|
||||
"state_province": cfg.usps_shipper_state,
|
||||
"postal_code": cfg.usps_shipper_zip,
|
||||
"country_code": "US",
|
||||
}
|
||||
|
||||
|
||||
# ── Error formatting ────────────────────────────────────────────────────────
|
||||
|
||||
def _format_error(resp: requests.Response, context: str) -> str:
|
||||
status = resp.status_code
|
||||
try:
|
||||
body = resp.json()
|
||||
errors = body.get("errors") or []
|
||||
msgs = [e.get("message", "") for e in errors if e.get("message")]
|
||||
detail = " | ".join(msgs) if msgs else str(body)
|
||||
except Exception:
|
||||
detail = resp.text or "(no body)"
|
||||
return f"{context} failed — ShipEngine HTTP {status}: {detail}"
|
||||
|
||||
|
||||
# ── Freeform address parser ─────────────────────────────────────────────────
|
||||
|
||||
_CSZ_RE = re.compile(
|
||||
r'^(.+?),?\s+([A-Za-z]{2})\s+(\d{5}(?:-\d{4})?)$'
|
||||
)
|
||||
_STREET_RE = re.compile(r'^\d')
|
||||
|
||||
|
||||
def parse_address_text(text: str) -> dict:
|
||||
"""Parse a freeform US address block into a structured dict.
|
||||
|
||||
Handles the common copy-paste format:
|
||||
Name
|
||||
[Company]
|
||||
Street Address [Apt/Suite]
|
||||
City, ST ZIP
|
||||
"""
|
||||
lines = [l.strip() for l in text.strip().splitlines() if l.strip()]
|
||||
if not lines:
|
||||
return {}
|
||||
|
||||
# Find the city/state/zip line (search from bottom)
|
||||
csz_idx = None
|
||||
city = state = zip_ = ""
|
||||
for i in range(len(lines) - 1, -1, -1):
|
||||
m = _CSZ_RE.match(lines[i])
|
||||
if m:
|
||||
csz_idx = i
|
||||
city = m.group(1).strip()
|
||||
state = m.group(2).upper()
|
||||
zip_ = m.group(3)
|
||||
break
|
||||
|
||||
if csz_idx is None:
|
||||
# Can't find city/state/zip — return what we have
|
||||
return {"address_line1": lines[0]}
|
||||
|
||||
before = lines[:csz_idx]
|
||||
name = company = addr1 = addr2 = ""
|
||||
|
||||
if len(before) == 0:
|
||||
pass
|
||||
elif len(before) == 1:
|
||||
addr1 = before[0]
|
||||
elif len(before) == 2:
|
||||
name = before[0]
|
||||
addr1 = before[1]
|
||||
elif len(before) == 3:
|
||||
name = before[0]
|
||||
# Middle line: company or address depending on whether last line is street
|
||||
if _STREET_RE.match(before[2]):
|
||||
company = before[1]
|
||||
addr1 = before[2]
|
||||
else:
|
||||
addr1 = before[1]
|
||||
addr2 = before[2]
|
||||
else:
|
||||
name = before[0]
|
||||
company = before[1]
|
||||
addr1 = before[2]
|
||||
addr2 = before[3]
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"company_name": company,
|
||||
"address_line1": addr1,
|
||||
"address_line2": addr2,
|
||||
"city": city,
|
||||
"state_province": state,
|
||||
"postal_code": zip_,
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Shared theme palette.
|
||||
|
||||
All colors here are (light, dark) tuples — CustomTkinter picks the right value
|
||||
based on the current appearance mode.
|
||||
|
||||
Light mode tuned to be warm rather than blindingly bright: a soft off-white
|
||||
window background with subtle gray panels and visible 1px borders to give the
|
||||
chrome a layered, sectioned feel.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# ---- Accent (Navy blue) ------------------------------------------------------
|
||||
# Used for primary buttons, selected tabs, focus rings, copy buttons.
|
||||
ACCENT = ("#1e3a8a", "#3b82f6")
|
||||
ACCENT_HOVER = ("#1e40af", "#60a5fa")
|
||||
ACCENT_PRESSED = ("#1d3567", "#2563eb")
|
||||
ACCENT_BORDER = ("#1e3a8a", "#3b82f6")
|
||||
ACCENT_TEXT = ("#ffffff", "#ffffff")
|
||||
|
||||
# ---- Surfaces ----------------------------------------------------------------
|
||||
# A layered set of grays so the eye can pick out section boundaries even
|
||||
# without strong borders. Lighter sits on top of darker.
|
||||
WINDOW_BG = ("#e8e6e3", "#141414") # outermost — a warm soft gray, not pure white
|
||||
TOOLBAR_BG = ("#ddd9d4", "#1a1a1a") # toolbar strip
|
||||
PANEL_BG = ("#f6f4f1", "#1f1f1f") # main content panels
|
||||
CARD_BG = ("#ffffff", "#262626") # cards within panels (SKU card, fact tiles, result rows)
|
||||
CARD_BG_HOVER = ("#f0ede8", "#2f2f2f")
|
||||
|
||||
# ---- Borders and dividers ----------------------------------------------------
|
||||
BORDER = ("#c8c4be", "#333333") # visible but quiet — 1px around panels/cards
|
||||
BORDER_STRONG = ("#a8a39c", "#444444") # for input fields and prominent cards
|
||||
DIVIDER = ("#d7d4cf", "#2a2a2a")
|
||||
|
||||
# ---- Text --------------------------------------------------------------------
|
||||
TEXT_PRIMARY = ("#111827", "#e5e7eb")
|
||||
TEXT_MUTED = ("#6b7280", "#9ca3af")
|
||||
TEXT_SUBTLE = ("#9ca3af", "#6b7280")
|
||||
|
||||
# ---- Buttons that aren't the primary accent ---------------------------------
|
||||
NEUTRAL_BTN_BG = ("#ffffff", "#2a2a2a")
|
||||
NEUTRAL_BTN_HOVER = ("#f0ede8", "#363636")
|
||||
NEUTRAL_BTN_BORDER = ("#bcb7af", "#3f3f3f")
|
||||
|
||||
# ---- Price emphasis (NET) ----------------------------------------------------
|
||||
NET_COLOR = ("#15803d", "#34d399")
|
||||
|
||||
# ---- Copy-success flash ------------------------------------------------------
|
||||
COPY_OK = ("#15803d", "#16a34a")
|
||||
|
||||
# ---- Fonts ------------------------------------------------------------------
|
||||
FONT_FAMILY = "Segoe UI"
|
||||
|
||||
FONT_HEADER = (FONT_FAMILY, 22, "bold")
|
||||
FONT_SKU = (FONT_FAMILY, 28, "bold")
|
||||
FONT_PRICE_NET = (FONT_FAMILY, 34, "bold")
|
||||
FONT_PRICE_LIST = (FONT_FAMILY, 14)
|
||||
FONT_LABEL = (FONT_FAMILY, 12, "bold")
|
||||
FONT_BODY = (FONT_FAMILY, 14)
|
||||
FONT_BODY_BOLD = (FONT_FAMILY, 14, "bold")
|
||||
FONT_FACT_VALUE = (FONT_FAMILY, 17, "bold")
|
||||
FONT_SEARCH = (FONT_FAMILY, 18)
|
||||
FONT_RESULT = (FONT_FAMILY, 12)
|
||||
FONT_RESULT_TITLE = (FONT_FAMILY, 13, "bold")
|
||||
FONT_FOOTER = (FONT_FAMILY, 11, "italic")
|
||||
FONT_STATUS = (FONT_FAMILY, 11)
|
||||
@@ -0,0 +1,415 @@
|
||||
"""Main application window: top toolbar, tabs, status bar."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from tkinter import filedialog
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import tkinter as tk
|
||||
import customtkinter as ctk
|
||||
|
||||
from .config import Config
|
||||
from .cut_files import CutFileManager, CUT_FILE_EXTENSIONS, LIGHTBURN_EXTENSIONS
|
||||
from .data import PartsRepo
|
||||
from .images import ImageCache, _bundle_path
|
||||
from .labels import LabelPrinter
|
||||
from . import paths as app_paths
|
||||
from . import theme as T
|
||||
from .ui_parts_tab import PartsTab
|
||||
from .ui_shipping_tab import ShippingTab
|
||||
from .ui_compact_mode import CompactModeWindow
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_TAB_PARTS = "Parts"
|
||||
_TAB_SHIPPING = "Shipping"
|
||||
_STATE_FILE = "ui_state.json"
|
||||
|
||||
|
||||
def _state_path() -> Path:
|
||||
return Path(app_paths.appdata_dir()) / _STATE_FILE
|
||||
|
||||
|
||||
def _load_state() -> dict:
|
||||
try:
|
||||
with open(_state_path(), "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_state(data: dict) -> None:
|
||||
p = _state_path()
|
||||
try:
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(p, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
log.debug("Saved ui_state.json to %s", p)
|
||||
except Exception as exc:
|
||||
log.warning("Failed to save ui_state.json to %s: %s", p, exc)
|
||||
|
||||
|
||||
def _apply_segmented_text_colors(widget) -> None:
|
||||
"""Color the selected segment's text white, others dark."""
|
||||
try:
|
||||
seg = getattr(widget, "_segmented_button", widget)
|
||||
for name, btn in seg._buttons_dict.items():
|
||||
if name == seg.get():
|
||||
btn.configure(text_color=T.ACCENT_TEXT)
|
||||
else:
|
||||
btn.configure(text_color=T.TEXT_PRIMARY)
|
||||
except Exception as exc:
|
||||
log.debug("Could not apply segmented text colors: %s", exc)
|
||||
|
||||
|
||||
class App(ctk.CTk):
|
||||
def __init__(self, cfg: Config) -> None:
|
||||
ctk.set_appearance_mode(cfg.appearance_mode or "light")
|
||||
ctk.set_widget_scaling(cfg.ui_scale or 1.2)
|
||||
|
||||
super().__init__()
|
||||
self._cfg = cfg
|
||||
|
||||
self.title("Parts & Shipping Lookup - CollisionStoneGuard")
|
||||
self.geometry("1280x820")
|
||||
self.minsize(900, 560)
|
||||
|
||||
# Load persisted state
|
||||
self._state = _load_state()
|
||||
if self._state.get("parts_folder"):
|
||||
cfg.parts_folder_override = self._state["parts_folder"]
|
||||
cfg.resolve_csv()
|
||||
|
||||
# Build cut file managers from state (parameterized: Roland and LightBurn)
|
||||
self.cut_files = CutFileManager(
|
||||
state=self._state,
|
||||
save_fn=_save_state,
|
||||
extensions=CUT_FILE_EXTENSIONS,
|
||||
state_key_paths="cut_file_paths",
|
||||
state_key_active="active_cut_file_path",
|
||||
)
|
||||
self.lightburn_files = CutFileManager(
|
||||
state=self._state,
|
||||
save_fn=_save_state,
|
||||
extensions=LIGHTBURN_EXTENSIONS,
|
||||
state_key_paths="lb_file_paths",
|
||||
state_key_active="active_lb_file_path",
|
||||
)
|
||||
self.labels = LabelPrinter(state=self._state, save_state=_save_state)
|
||||
self.repo: PartsRepo | None = None
|
||||
self.images: ImageCache | None = None
|
||||
|
||||
self._load_icon()
|
||||
self._build_layout()
|
||||
|
||||
# Kick off async CSV load after event loop starts
|
||||
self.after(100, self._initial_load)
|
||||
|
||||
# ── Icon ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _load_icon(self) -> None:
|
||||
for relpath in ("assets/icon.ico", "assets/icon_small.png"):
|
||||
p = _bundle_path(relpath)
|
||||
if not p.exists():
|
||||
p = Path(__file__).resolve().parents[1] / relpath
|
||||
if p.exists():
|
||||
try:
|
||||
if relpath.endswith(".ico"):
|
||||
self.iconbitmap(str(p))
|
||||
else:
|
||||
from PIL import Image, ImageTk
|
||||
img = ImageTk.PhotoImage(Image.open(str(p)))
|
||||
self.iconphoto(True, img)
|
||||
self._icon_ref = img
|
||||
return
|
||||
except Exception as exc:
|
||||
log.debug("Could not load window icon: %s", exc)
|
||||
|
||||
# ── Layout ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_layout(self) -> None:
|
||||
cfg = self._cfg
|
||||
|
||||
# ── Toolbar (pack top, height=64 fixed) ────────────────────────────
|
||||
toolbar = ctk.CTkFrame(self, fg_color=T.TOOLBAR_BG, height=64,
|
||||
corner_radius=0, border_width=0)
|
||||
toolbar.pack(side="top", fill="x")
|
||||
toolbar.pack_propagate(False)
|
||||
tk.Frame.configure(toolbar, height=64) # CTkFrame doesn't set tk.Frame reqheight
|
||||
toolbar.columnconfigure(1, weight=1)
|
||||
|
||||
ctk.CTkLabel(
|
||||
toolbar, text="Parts & Shipping Lookup",
|
||||
font=T.FONT_HEADER, text_color=T.TEXT_PRIMARY, anchor="w",
|
||||
).grid(row=0, column=0, sticky="w", padx=(20, 0), pady=14)
|
||||
|
||||
right = ctk.CTkFrame(toolbar, fg_color="transparent")
|
||||
right.grid(row=0, column=2, sticky="e", padx=(0, 16), pady=14)
|
||||
|
||||
self._mode_toggle = ctk.CTkSegmentedButton(
|
||||
right, values=["Light", "Dark"], command=self._on_mode_change,
|
||||
selected_color=T.ACCENT, selected_hover_color=T.ACCENT_HOVER,
|
||||
unselected_color=T.NEUTRAL_BTN_BG,
|
||||
unselected_hover_color=T.NEUTRAL_BTN_HOVER,
|
||||
text_color=T.TEXT_PRIMARY, font=T.FONT_BODY_BOLD,
|
||||
height=36, width=170, corner_radius=8,
|
||||
)
|
||||
self._mode_toggle.set(
|
||||
"Dark" if ctk.get_appearance_mode().lower() == "dark" else "Light"
|
||||
)
|
||||
self._mode_toggle.pack(side="right")
|
||||
_apply_segmented_text_colors(self._mode_toggle)
|
||||
|
||||
ctk.CTkButton(
|
||||
right, text="Compact Mode", width=140, height=36,
|
||||
font=T.FONT_BODY_BOLD,
|
||||
fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER,
|
||||
text_color=T.TEXT_PRIMARY, border_width=1,
|
||||
border_color=T.NEUTRAL_BTN_BORDER, corner_radius=8,
|
||||
command=self._open_compact_mode,
|
||||
).pack(side="right", padx=(0, 12))
|
||||
|
||||
# ── Status bar (pack bottom first so tabs expand into remaining space) ──
|
||||
_mode = ctk.get_appearance_mode().lower()
|
||||
_sb_bg = T.TOOLBAR_BG[1] if _mode == "dark" else T.TOOLBAR_BG[0]
|
||||
self._status_frame = tk.Frame(self, height=34, bg=_sb_bg)
|
||||
self._status_frame.pack(side="bottom", fill="x")
|
||||
self._status_frame.pack_propagate(False)
|
||||
status = self._status_frame
|
||||
status.columnconfigure(0, weight=1)
|
||||
|
||||
# ── Separators (after toolbar/status, before tabs) ────────────────────
|
||||
ctk.CTkFrame(self, fg_color=T.BORDER, height=1,
|
||||
corner_radius=0).pack(side="bottom", fill="x")
|
||||
ctk.CTkFrame(self, fg_color=T.BORDER, height=1,
|
||||
corner_radius=0).pack(side="top", fill="x")
|
||||
|
||||
# ── Tab view (fills remaining space between toolbar and status bar) ───
|
||||
self._tabs = ctk.CTkTabview(
|
||||
self, anchor="nw",
|
||||
segmented_button_selected_color=T.ACCENT,
|
||||
segmented_button_selected_hover_color=T.ACCENT_HOVER,
|
||||
segmented_button_unselected_color=T.NEUTRAL_BTN_BG,
|
||||
segmented_button_unselected_hover_color=T.NEUTRAL_BTN_HOVER,
|
||||
text_color=T.TEXT_PRIMARY,
|
||||
fg_color=T.WINDOW_BG, border_width=0, corner_radius=10,
|
||||
)
|
||||
self._tabs.pack(side="top", fill="both", expand=True, padx=10, pady=(8, 6))
|
||||
self._tabs.add(_TAB_PARTS)
|
||||
self._tabs.add(_TAB_SHIPPING)
|
||||
|
||||
self._parts_tab = PartsTab(
|
||||
self._tabs.tab(_TAB_PARTS),
|
||||
repo=self.repo, images=self.images,
|
||||
max_results=getattr(cfg, "max_results", 15),
|
||||
cut_files=self.cut_files, lightburn_files=self.lightburn_files,
|
||||
labels=self.labels,
|
||||
)
|
||||
self._parts_tab.pack(fill="both", expand=True)
|
||||
|
||||
self._shipping_tab = ShippingTab(self._tabs.tab(_TAB_SHIPPING), cfg=cfg)
|
||||
self._shipping_tab.pack(fill="both", expand=True)
|
||||
|
||||
self._tabs.set(
|
||||
_TAB_SHIPPING if getattr(cfg, "default_tab", "parts") == "shipping"
|
||||
else _TAB_PARTS
|
||||
)
|
||||
|
||||
try:
|
||||
tab_seg = self._tabs._segmented_button
|
||||
orig_cmd = tab_seg.cget("command")
|
||||
|
||||
def _on_tab_change(value, *, _orig=orig_cmd, _seg=tab_seg):
|
||||
_apply_segmented_text_colors(_seg)
|
||||
if callable(_orig):
|
||||
_orig(value)
|
||||
|
||||
tab_seg.configure(command=_on_tab_change)
|
||||
_apply_segmented_text_colors(tab_seg)
|
||||
except Exception as exc:
|
||||
log.warning("Could not wire tab text color refresh: %s", exc)
|
||||
|
||||
self._status_var = tk.StringVar(value="Starting up...")
|
||||
ctk.CTkLabel(
|
||||
status, textvariable=self._status_var,
|
||||
anchor="w", font=T.FONT_STATUS, text_color=T.TEXT_MUTED,
|
||||
).grid(row=0, column=0, sticky="ew", padx=14, pady=6)
|
||||
|
||||
_btn_kw = dict(
|
||||
height=24, font=T.FONT_STATUS, border_width=1, corner_radius=6,
|
||||
fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER,
|
||||
text_color=T.TEXT_PRIMARY, border_color=T.NEUTRAL_BTN_BORDER,
|
||||
)
|
||||
|
||||
self._open_csv_btn = ctk.CTkButton(
|
||||
status, text="Open CSV", width=90,
|
||||
command=self._open_csv, **_btn_kw)
|
||||
self._open_csv_btn.grid(row=0, column=1, sticky="e", padx=(10, 4), pady=4)
|
||||
|
||||
self._change_folder_btn = ctk.CTkButton(
|
||||
status, text="Change folder", width=120,
|
||||
command=self._change_parts_folder, **_btn_kw)
|
||||
self._change_folder_btn.grid(row=0, column=2, sticky="e", padx=(0, 4), pady=4)
|
||||
|
||||
self._open_appdata_btn = ctk.CTkButton(
|
||||
status, text="App data folder", width=120,
|
||||
command=self._open_appdata_folder, **_btn_kw)
|
||||
self._open_appdata_btn.grid(row=0, column=3, sticky="e", padx=(0, 4), pady=4)
|
||||
|
||||
self._reload_btn = ctk.CTkButton(
|
||||
status, text="Reload data", width=100,
|
||||
command=self._manual_reload,
|
||||
height=24, font=T.FONT_STATUS, border_width=1, corner_radius=6,
|
||||
fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER,
|
||||
text_color=T.ACCENT_TEXT, border_color=T.ACCENT_BORDER)
|
||||
self._reload_btn.grid(row=0, column=4, sticky="e", padx=(0, 10), pady=4)
|
||||
|
||||
# ── Toolbar actions ───────────────────────────────────────────────────────
|
||||
|
||||
def _open_appdata_folder(self) -> None:
|
||||
folder = app_paths.appdata_dir()
|
||||
try:
|
||||
import sys, subprocess
|
||||
if os.name == "nt":
|
||||
os.startfile(folder)
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.Popen(["open", folder])
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", folder])
|
||||
except Exception as exc:
|
||||
log.warning("Could not open app data folder %s: %s", folder, exc)
|
||||
|
||||
def _open_csv(self) -> None:
|
||||
path = getattr(self._cfg, "resolved_csv_path", "") or ""
|
||||
if not path or not os.path.isfile(path):
|
||||
self._set_status("No CSV loaded — use 'Change folder' to locate one.")
|
||||
return
|
||||
try:
|
||||
os.startfile(path)
|
||||
except Exception as exc:
|
||||
log.warning("Could not open CSV %s: %s", path, exc)
|
||||
|
||||
def _change_parts_folder(self) -> None:
|
||||
"""Show a folder picker, persist the chosen folder to ui_state.json, reload."""
|
||||
folder = filedialog.askdirectory(
|
||||
parent=self,
|
||||
title="Select Parts Folder",
|
||||
)
|
||||
if not folder:
|
||||
return
|
||||
self._cfg.parts_folder_override = folder
|
||||
self._cfg.resolve_csv()
|
||||
self._state["parts_folder"] = folder
|
||||
try:
|
||||
_save_state(self._state)
|
||||
log.info("Parts folder set to %s", folder)
|
||||
except Exception as exc:
|
||||
log.warning("Folder set, but failed to save state: %s", exc)
|
||||
self._set_status(f"Parts folder set to {os.path.basename(folder)}. Reloading...")
|
||||
self.after(80, self._do_reload)
|
||||
|
||||
def _on_mode_change(self, mode: str) -> None:
|
||||
ctk.set_appearance_mode(mode.lower())
|
||||
self._cfg.appearance_mode = mode.lower()
|
||||
try:
|
||||
self._cfg.save()
|
||||
except Exception:
|
||||
pass
|
||||
self._state["appearance_mode"] = mode.lower()
|
||||
_save_state(self._state)
|
||||
_sb_bg = T.TOOLBAR_BG[1] if mode.lower() == "dark" else T.TOOLBAR_BG[0]
|
||||
self._status_frame.configure(bg=_sb_bg)
|
||||
|
||||
# ── Data loading ──────────────────────────────────────────────────────────
|
||||
|
||||
def _initial_load(self) -> None:
|
||||
csv_path = getattr(self._cfg, "resolved_csv_path", "")
|
||||
if not csv_path:
|
||||
self._prompt_missing_csv()
|
||||
return
|
||||
self._set_status("Loading parts CSV...")
|
||||
self._do_reload()
|
||||
|
||||
def _prompt_missing_csv(self) -> None:
|
||||
"""Show an explanation popup and open a folder picker to choose the parts CSV directory."""
|
||||
from tkinter import messagebox
|
||||
messagebox.showinfo(
|
||||
"Parts Database Not Found",
|
||||
"No parts CSV was found at the configured path.\n\n"
|
||||
"Please choose the folder that contains your dated parts files\n"
|
||||
"(e.g. '06-16-26 OnPart.csv').",
|
||||
parent=self,
|
||||
)
|
||||
self._change_parts_folder()
|
||||
|
||||
def _manual_reload(self) -> None:
|
||||
self._cfg.resolve_csv()
|
||||
self._do_reload()
|
||||
|
||||
def _do_reload(self) -> None:
|
||||
self._set_status("Reloading...")
|
||||
cfg = self._cfg
|
||||
|
||||
def worker() -> None:
|
||||
try:
|
||||
repo = PartsRepo(
|
||||
csv_path=getattr(cfg, "resolved_csv_path", ""),
|
||||
sqlite_path=getattr(cfg, "sqlite_path", ""),
|
||||
)
|
||||
image_cache = ImageCache(
|
||||
cache_dir=getattr(cfg, "image_cache_dir", ""),
|
||||
smb_dir=getattr(cfg, "smb_image_dir", ""),
|
||||
)
|
||||
n = repo.count()
|
||||
csv_name = os.path.basename(
|
||||
getattr(cfg, "resolved_csv_path", "")) or ""
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
def _done() -> None:
|
||||
self.repo = repo
|
||||
self.images = image_cache
|
||||
self._parts_tab.set_repo(repo, image_cache)
|
||||
verb = "Loaded"
|
||||
msg = (f"{verb} {n:,} parts at {ts}"
|
||||
if not csv_name
|
||||
else f"{verb} {n:,} parts from {csv_name}")
|
||||
self._set_status(msg)
|
||||
log.info("Loaded %d parts", n)
|
||||
|
||||
self.after(0, _done)
|
||||
except Exception as exc:
|
||||
log.exception("Initial load failed")
|
||||
self.after(0, lambda: self._set_status(f"Load failed: {exc}"))
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _set_status(self, text: str) -> None:
|
||||
self.after(0, lambda: self._status_var.set(text))
|
||||
|
||||
# ── Compact Mode ──────────────────────────────────────────────────────────
|
||||
|
||||
def _open_compact_mode(self) -> None:
|
||||
CompactModeWindow(
|
||||
self,
|
||||
repo=self.repo,
|
||||
labels=self.labels,
|
||||
state=self._state,
|
||||
save_state=_save_state,
|
||||
)
|
||||
|
||||
# ── Cleanup ───────────────────────────────────────────────────────────────
|
||||
|
||||
def destroy(self) -> None:
|
||||
try:
|
||||
self._state.update(self.cut_files.to_state())
|
||||
self._state.update(self.lightburn_files.to_state())
|
||||
self._state["appearance_mode"] = ctk.get_appearance_mode().lower()
|
||||
_save_state(self._state)
|
||||
except Exception:
|
||||
pass
|
||||
super().destroy()
|
||||
@@ -0,0 +1,557 @@
|
||||
"""Compact Mode window — floating parts lookup for packing station use.
|
||||
|
||||
Standalone CTkToplevel with clipboard watcher, configurable layout (vertical /
|
||||
horizontal), label printing, and optional always-on-top pinning.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from .data import PartsRepo
|
||||
from . import theme as T
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_SKU_RE = re.compile(r'\b(\d{6,}-\d{3,}[LR]?)\b')
|
||||
_ADDR_RE = re.compile(r'[A-Za-z][A-Za-z\s]+,\s*[A-Z]{2}\s+\d{5}')
|
||||
|
||||
_WIN_W_VERT = 420
|
||||
_WIN_H_VERT = 640
|
||||
_WIN_W_HORIZ = 800
|
||||
_WIN_H_HORIZ = 440
|
||||
|
||||
|
||||
class CompactModeWindow(ctk.CTkToplevel):
|
||||
"""Compact floating parts-lookup window."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent,
|
||||
repo: PartsRepo | None,
|
||||
labels,
|
||||
state: dict,
|
||||
save_state,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._repo = repo
|
||||
self._labels = labels
|
||||
self._state = state
|
||||
self._save_state = save_state
|
||||
|
||||
self._auto_paste = bool(state.get("compact_auto_paste", True))
|
||||
self._auto_clear = bool(state.get("compact_auto_clear", False))
|
||||
self._horizontal = bool(state.get("compact_horizontal", False))
|
||||
self._pinned = bool(state.get("compact_pinned", False))
|
||||
|
||||
self._results: list[dict] = []
|
||||
self._sel_part: dict | None = None
|
||||
self._clip_last: str = ""
|
||||
self._clip_job: str | None = None
|
||||
self._hint_job: str | None = None
|
||||
|
||||
self.title("Compact Mode — Parts Lookup")
|
||||
self.minsize(340, 400)
|
||||
if self._pinned:
|
||||
self.attributes("-topmost", True)
|
||||
|
||||
self._apply_window_geometry()
|
||||
self._build()
|
||||
self._start_clipboard_watcher()
|
||||
self.lift()
|
||||
self.focus_set()
|
||||
|
||||
# ── Geometry ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _apply_window_geometry(self) -> None:
|
||||
if self._horizontal:
|
||||
self.geometry(f"{_WIN_W_HORIZ}x{_WIN_H_HORIZ}")
|
||||
else:
|
||||
self.geometry(f"{_WIN_W_VERT}x{_WIN_H_VERT}")
|
||||
|
||||
# ── Build ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build(self) -> None:
|
||||
self.columnconfigure(0, weight=1)
|
||||
self.rowconfigure(0, weight=1)
|
||||
if self._horizontal:
|
||||
self._build_horizontal()
|
||||
else:
|
||||
self._build_vertical()
|
||||
|
||||
def _teardown_layout(self) -> None:
|
||||
for child in self.winfo_children():
|
||||
child.destroy()
|
||||
|
||||
def _build_vertical(self) -> None:
|
||||
outer = ctk.CTkFrame(self, fg_color=T.WINDOW_BG, corner_radius=0)
|
||||
outer.grid(row=0, column=0, sticky="nsew")
|
||||
outer.columnconfigure(0, weight=1)
|
||||
outer.rowconfigure(3, weight=1) # results list expands
|
||||
|
||||
# Row 0: search entry
|
||||
self._search_var = ctk.StringVar()
|
||||
self._search_var.trace_add("write", self._on_search_change)
|
||||
self._search_entry = ctk.CTkEntry(
|
||||
outer, textvariable=self._search_var,
|
||||
placeholder_text="Search SKU or description...",
|
||||
font=T.FONT_LABEL, height=32,
|
||||
)
|
||||
self._search_entry.grid(row=0, column=0, sticky="ew", padx=8, pady=(8, 4))
|
||||
|
||||
# Row 1: Clear + Print Label buttons
|
||||
btn_row = ctk.CTkFrame(outer, fg_color="transparent")
|
||||
btn_row.grid(row=1, column=0, sticky="ew", padx=8, pady=(0, 4))
|
||||
btn_row.columnconfigure(0, weight=1)
|
||||
btn_row.columnconfigure(1, weight=1)
|
||||
|
||||
ctk.CTkButton(
|
||||
btn_row, text="Clear", command=self.clear_search, height=30,
|
||||
font=T.FONT_LABEL,
|
||||
fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER,
|
||||
text_color=T.TEXT_PRIMARY, border_width=1,
|
||||
border_color=T.NEUTRAL_BTN_BORDER, corner_radius=5,
|
||||
).grid(row=0, column=0, sticky="ew", padx=(0, 3))
|
||||
|
||||
ctk.CTkButton(
|
||||
btn_row, text="Print Label", command=self.print_label, height=30,
|
||||
font=T.FONT_LABEL,
|
||||
fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER,
|
||||
text_color=T.ACCENT_TEXT, border_width=1,
|
||||
border_color=T.ACCENT_BORDER, corner_radius=5,
|
||||
).grid(row=0, column=1, sticky="ew", padx=(3, 0))
|
||||
|
||||
# Row 2: clipboard hint (hidden when empty)
|
||||
self._clip_hint = ctk.CTkLabel(
|
||||
outer, text="", font=T.FONT_STATUS, text_color=T.TEXT_MUTED, anchor="w",
|
||||
)
|
||||
self._clip_hint.grid(row=2, column=0, sticky="w", padx=10)
|
||||
|
||||
# Row 3: results list (expands)
|
||||
self._results_frame = ctk.CTkScrollableFrame(
|
||||
outer, fg_color=T.PANEL_BG, corner_radius=6,
|
||||
)
|
||||
self._results_frame.grid(row=3, column=0, sticky="nsew", padx=8, pady=(0, 4))
|
||||
self._results_frame.columnconfigure(0, weight=1)
|
||||
|
||||
# Row 4: Ship-to Address
|
||||
addr_card = ctk.CTkFrame(outer, fg_color=T.PANEL_BG, corner_radius=6)
|
||||
addr_card.grid(row=4, column=0, sticky="ew", padx=8, pady=(0, 4))
|
||||
addr_card.columnconfigure(0, weight=1)
|
||||
|
||||
ctk.CTkLabel(
|
||||
addr_card, text="Ship-to Address",
|
||||
font=T.FONT_LABEL, text_color=T.TEXT_MUTED, anchor="w",
|
||||
).grid(row=0, column=0, sticky="w", padx=10, pady=(8, 2))
|
||||
|
||||
self._address_box = ctk.CTkTextbox(
|
||||
addr_card, height=80, font=T.FONT_LABEL,
|
||||
fg_color=T.PANEL_BG, border_width=1, border_color=T.BORDER, corner_radius=4,
|
||||
)
|
||||
self._address_box.grid(row=1, column=0, sticky="ew", padx=8, pady=(0, 6))
|
||||
|
||||
ctk.CTkButton(
|
||||
addr_card, text="Print Address", command=self.print_address, height=32,
|
||||
font=T.FONT_LABEL,
|
||||
fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER,
|
||||
text_color=T.ACCENT_TEXT, border_width=1,
|
||||
border_color=T.ACCENT_BORDER, corner_radius=5,
|
||||
).grid(row=2, column=0, sticky="ew", padx=8, pady=(0, 8))
|
||||
|
||||
# Row 5: bottom bar — Auto Paste toggle + gear
|
||||
bottom = ctk.CTkFrame(outer, fg_color=T.PANEL_BG, corner_radius=0, height=36)
|
||||
bottom.grid(row=5, column=0, sticky="ew")
|
||||
bottom.pack_propagate(False)
|
||||
bottom.columnconfigure(0, weight=1)
|
||||
|
||||
self._auto_paste_switch = ctk.CTkSwitch(
|
||||
bottom, text="Auto Paste",
|
||||
font=T.FONT_STATUS, text_color=T.TEXT_MUTED,
|
||||
command=self._on_auto_paste_toggle,
|
||||
)
|
||||
if self._auto_paste:
|
||||
self._auto_paste_switch.select()
|
||||
self._auto_paste_switch.pack(side="left", padx=(10, 0), pady=8)
|
||||
|
||||
ctk.CTkButton(
|
||||
bottom, text="⚙", width=28, height=24,
|
||||
font=T.FONT_LABEL,
|
||||
fg_color="transparent", hover_color=T.NEUTRAL_BTN_HOVER,
|
||||
text_color=T.TEXT_MUTED, border_width=0, corner_radius=4,
|
||||
command=self.show_label_settings,
|
||||
).pack(side="right", padx=(0, 8), pady=6)
|
||||
|
||||
def _build_horizontal(self) -> None:
|
||||
outer = ctk.CTkFrame(self, fg_color=T.WINDOW_BG, corner_radius=0)
|
||||
outer.grid(row=0, column=0, sticky="nsew")
|
||||
outer.columnconfigure(0, weight=1)
|
||||
outer.columnconfigure(1, weight=1)
|
||||
outer.rowconfigure(1, weight=1)
|
||||
|
||||
left = ctk.CTkFrame(outer, fg_color="transparent")
|
||||
left.grid(row=0, column=0, sticky="nsew", padx=(0, 2))
|
||||
left.columnconfigure(0, weight=1)
|
||||
left.rowconfigure(3, weight=1)
|
||||
|
||||
self._search_var = ctk.StringVar()
|
||||
self._search_var.trace_add("write", self._on_search_change)
|
||||
self._search_entry = ctk.CTkEntry(
|
||||
left, textvariable=self._search_var,
|
||||
placeholder_text="Search SKU or description...",
|
||||
font=T.FONT_LABEL, height=32,
|
||||
)
|
||||
self._search_entry.grid(row=0, column=0, sticky="ew", padx=8, pady=(8, 4))
|
||||
|
||||
btn_row = ctk.CTkFrame(left, fg_color="transparent")
|
||||
btn_row.grid(row=1, column=0, sticky="ew", padx=8, pady=(0, 4))
|
||||
btn_row.columnconfigure(0, weight=1)
|
||||
btn_row.columnconfigure(1, weight=1)
|
||||
ctk.CTkButton(
|
||||
btn_row, text="Clear", command=self.clear_search, height=30,
|
||||
font=T.FONT_LABEL,
|
||||
fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER,
|
||||
text_color=T.TEXT_PRIMARY, border_width=1,
|
||||
border_color=T.NEUTRAL_BTN_BORDER, corner_radius=5,
|
||||
).grid(row=0, column=0, sticky="ew", padx=(0, 3))
|
||||
ctk.CTkButton(
|
||||
btn_row, text="Print Label", command=self.print_label, height=30,
|
||||
font=T.FONT_LABEL,
|
||||
fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER,
|
||||
text_color=T.ACCENT_TEXT, border_width=1,
|
||||
border_color=T.ACCENT_BORDER, corner_radius=5,
|
||||
).grid(row=0, column=1, sticky="ew", padx=(3, 0))
|
||||
|
||||
self._clip_hint = ctk.CTkLabel(
|
||||
left, text="", font=T.FONT_STATUS, text_color=T.TEXT_MUTED, anchor="w",
|
||||
)
|
||||
self._clip_hint.grid(row=2, column=0, sticky="w", padx=10)
|
||||
|
||||
self._results_frame = ctk.CTkScrollableFrame(
|
||||
left, fg_color=T.PANEL_BG, corner_radius=6,
|
||||
)
|
||||
self._results_frame.grid(row=3, column=0, sticky="nsew", padx=8, pady=(0, 8))
|
||||
self._results_frame.columnconfigure(0, weight=1)
|
||||
|
||||
right = ctk.CTkFrame(outer, fg_color="transparent")
|
||||
right.grid(row=0, column=1, sticky="nsew")
|
||||
right.columnconfigure(0, weight=1)
|
||||
right.rowconfigure(1, weight=1)
|
||||
|
||||
ctk.CTkLabel(
|
||||
right, text="Ship-to Address",
|
||||
font=T.FONT_LABEL, text_color=T.TEXT_MUTED, anchor="w",
|
||||
).grid(row=0, column=0, sticky="w", padx=10, pady=(8, 2))
|
||||
|
||||
self._address_box = ctk.CTkTextbox(
|
||||
right, font=T.FONT_LABEL, fg_color=T.PANEL_BG,
|
||||
border_width=1, border_color=T.BORDER, corner_radius=4,
|
||||
)
|
||||
self._address_box.grid(row=1, column=0, sticky="nsew", padx=8, pady=(0, 6))
|
||||
|
||||
ctk.CTkButton(
|
||||
right, text="Print Address", command=self.print_address, height=32,
|
||||
font=T.FONT_LABEL,
|
||||
fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER,
|
||||
text_color=T.ACCENT_TEXT, border_width=1,
|
||||
border_color=T.ACCENT_BORDER, corner_radius=5,
|
||||
).grid(row=2, column=0, sticky="ew", padx=8, pady=(0, 8))
|
||||
|
||||
# ── Toggle actions ────────────────────────────────────────────────────────
|
||||
|
||||
def _on_auto_paste_toggle(self) -> None:
|
||||
self._auto_paste = not self._auto_paste
|
||||
self._state["compact_auto_paste"] = self._auto_paste
|
||||
self._save_state(self._state)
|
||||
|
||||
# ── Search ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _on_search_change(self, *_) -> None:
|
||||
self.after(80, self._run_search)
|
||||
|
||||
def _run_search(self) -> None:
|
||||
query = self._search_var.get().strip()
|
||||
if not query or self._repo is None:
|
||||
self._render_results([])
|
||||
return
|
||||
results = self._repo.search(query, limit=10)
|
||||
self._render_results(results)
|
||||
|
||||
def _render_results(self, results: list[dict]) -> None:
|
||||
self._results = results
|
||||
self._cm_rows: list[dict] = []
|
||||
for child in self._results_frame.winfo_children():
|
||||
child.destroy()
|
||||
self._results_frame.columnconfigure(0, weight=1)
|
||||
for i, part in enumerate(results):
|
||||
card = ctk.CTkFrame(
|
||||
self._results_frame, fg_color=T.CARD_BG,
|
||||
border_color=T.BORDER, border_width=1,
|
||||
corner_radius=6, cursor="hand2",
|
||||
)
|
||||
card.grid(row=i, column=0, sticky="ew", padx=8, pady=(0, 4))
|
||||
card.columnconfigure(0, weight=1)
|
||||
|
||||
sku = part.get("stock_number", "") or part.get("STOCK NUMBER", "")
|
||||
desc = part.get("description", "") or part.get("DESCRIPTION", "")
|
||||
|
||||
sku_lbl = ctk.CTkLabel(
|
||||
card, text=sku, font=T.FONT_RESULT_TITLE,
|
||||
text_color=T.TEXT_PRIMARY, anchor="w",
|
||||
)
|
||||
sku_lbl.grid(row=0, column=0, sticky="w", padx=14, pady=(9, 1))
|
||||
|
||||
desc_lbl = ctk.CTkLabel(
|
||||
card, text=desc[:72] + ("…" if len(desc) > 72 else ""),
|
||||
font=T.FONT_RESULT, text_color=T.TEXT_MUTED,
|
||||
anchor="w", wraplength=260, justify="left",
|
||||
)
|
||||
desc_lbl.grid(row=1, column=0, sticky="w", padx=14, pady=(0, 9))
|
||||
|
||||
row_widgets = {"card": card, "sku_lbl": sku_lbl, "desc_lbl": desc_lbl}
|
||||
self._cm_rows.append(row_widgets)
|
||||
|
||||
def _make_handlers(idx, p):
|
||||
def _click(_e=None):
|
||||
self._on_result_click(p, idx)
|
||||
def _enter(_e=None):
|
||||
if self._cm_sel_idx != idx:
|
||||
card.configure(fg_color=T.CARD_BG_HOVER)
|
||||
def _leave(_e=None):
|
||||
if self._cm_sel_idx != idx:
|
||||
card.configure(fg_color=T.CARD_BG)
|
||||
return _click, _enter, _leave
|
||||
|
||||
click_fn, enter_fn, leave_fn = _make_handlers(i, part)
|
||||
for w in (card, sku_lbl, desc_lbl):
|
||||
w.bind("<Button-1>", click_fn)
|
||||
w.bind("<Enter>", enter_fn)
|
||||
w.bind("<Leave>", leave_fn)
|
||||
|
||||
self._cm_sel_idx: int | None = None
|
||||
if results:
|
||||
self._on_result_click(results[0], 0)
|
||||
|
||||
def _cm_highlight(self, index: int) -> None:
|
||||
for i, row_w in enumerate(self._cm_rows):
|
||||
selected = (i == index)
|
||||
card = row_w["card"]
|
||||
sku_lbl = row_w["sku_lbl"]
|
||||
desc_lbl = row_w["desc_lbl"]
|
||||
if selected:
|
||||
card.configure(fg_color=T.ACCENT, border_color=T.ACCENT)
|
||||
sku_lbl.configure(text_color=T.ACCENT_TEXT)
|
||||
desc_lbl.configure(text_color=T.ACCENT_TEXT)
|
||||
else:
|
||||
card.configure(fg_color=T.CARD_BG, border_color=T.BORDER)
|
||||
sku_lbl.configure(text_color=T.TEXT_PRIMARY)
|
||||
desc_lbl.configure(text_color=T.TEXT_MUTED)
|
||||
|
||||
def _on_result_click(self, part: dict, index: int | None = None) -> None:
|
||||
self._sel_part = part
|
||||
if index is not None:
|
||||
self._cm_sel_idx = index
|
||||
self._cm_highlight(index)
|
||||
addr = self.assembled_address()
|
||||
if addr:
|
||||
try:
|
||||
self._address_box.delete("1.0", "end")
|
||||
self._address_box.insert("1.0", addr)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def assembled_address(self) -> str:
|
||||
if not self._sel_part:
|
||||
return ""
|
||||
lines = []
|
||||
for key in ("address1", "address2", "city", "state", "zip"):
|
||||
val = self._sel_part.get(key, "")
|
||||
if val:
|
||||
lines.append(str(val))
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── Clipboard watcher ─────────────────────────────────────────────────────
|
||||
|
||||
def _start_clipboard_watcher(self) -> None:
|
||||
try:
|
||||
self._clip_last = self.clipboard_get()
|
||||
except Exception:
|
||||
self._clip_last = ""
|
||||
self._schedule_clipboard_poll()
|
||||
|
||||
def _schedule_clipboard_poll(self) -> None:
|
||||
try:
|
||||
self._clip_job = self.after(500, self._poll_clipboard)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _poll_clipboard(self) -> None:
|
||||
try:
|
||||
text = self.clipboard_get()
|
||||
except Exception:
|
||||
text = self._clip_last
|
||||
if text != self._clip_last:
|
||||
self._clip_last = text
|
||||
self._handle_clipboard(text)
|
||||
self._schedule_clipboard_poll()
|
||||
|
||||
def _handle_clipboard(self, text: str) -> None:
|
||||
if not self._auto_paste:
|
||||
return
|
||||
sku_m = _SKU_RE.search(text)
|
||||
if sku_m:
|
||||
sku = sku_m.group(1)
|
||||
self._show_clipboard_hint(f"Pasted SKU: {sku}")
|
||||
if self._auto_clear:
|
||||
self._search_var.set("")
|
||||
self._search_var.set(sku)
|
||||
try:
|
||||
self._search_entry.icursor("end")
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
addr_m = _ADDR_RE.search(text)
|
||||
if addr_m:
|
||||
self._show_clipboard_hint("Address detected")
|
||||
try:
|
||||
self._address_box.delete("1.0", "end")
|
||||
self._address_box.insert("1.0", text.strip())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _show_clipboard_hint(self, msg: str) -> None:
|
||||
try:
|
||||
self._clip_hint.configure(text=msg)
|
||||
if self._hint_job:
|
||||
self.after_cancel(self._hint_job)
|
||||
self._hint_job = self.after(3000, self._hide_clipboard_hint)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _hide_clipboard_hint(self) -> None:
|
||||
try:
|
||||
self._clip_hint.configure(text="")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Print actions ─────────────────────────────────────────────────────────
|
||||
|
||||
def print_label(self) -> None:
|
||||
if not self._labels or not self._sel_part:
|
||||
return
|
||||
sku = self._sel_part.get("sku", "")
|
||||
desc = self._sel_part.get("description", "")
|
||||
try:
|
||||
self._labels.print_label(sku, desc)
|
||||
except Exception as exc:
|
||||
log.warning("Compact mode print_label failed: %s", exc)
|
||||
|
||||
def print_address(self) -> None:
|
||||
if not self._labels:
|
||||
return
|
||||
try:
|
||||
address = self._address_box.get("1.0", "end").strip()
|
||||
except Exception:
|
||||
address = ""
|
||||
if not address:
|
||||
return
|
||||
try:
|
||||
self._labels.print_address_label(address)
|
||||
except Exception as exc:
|
||||
log.warning("Compact mode print_address failed: %s", exc)
|
||||
|
||||
def show_label_settings(self) -> None:
|
||||
if not self._labels:
|
||||
return
|
||||
popup = ctk.CTkToplevel(self)
|
||||
popup.title("Label Printer Settings")
|
||||
popup.geometry("360x200")
|
||||
popup.grab_set()
|
||||
popup.columnconfigure(0, weight=1)
|
||||
|
||||
ctk.CTkLabel(
|
||||
popup, text="Select Label Printer",
|
||||
font=T.FONT_LABEL, text_color=T.TEXT_PRIMARY,
|
||||
).grid(row=0, column=0, pady=(16, 4))
|
||||
|
||||
printers: list[str] = []
|
||||
try:
|
||||
printers = self._labels.available_printers()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
current = ""
|
||||
try:
|
||||
current = self._labels.get_printer() or ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
var = ctk.StringVar(value=current)
|
||||
if printers:
|
||||
ctk.CTkOptionMenu(
|
||||
popup, values=printers, variable=var,
|
||||
font=T.FONT_LABEL, width=280,
|
||||
).grid(row=1, column=0, pady=6)
|
||||
else:
|
||||
ctk.CTkLabel(
|
||||
popup, text="No printers found",
|
||||
font=T.FONT_LABEL, text_color=T.TEXT_MUTED,
|
||||
).grid(row=1, column=0, pady=6)
|
||||
|
||||
def _apply() -> None:
|
||||
self._set_printer(var.get())
|
||||
popup.destroy()
|
||||
|
||||
btn_row = ctk.CTkFrame(popup, fg_color="transparent")
|
||||
btn_row.grid(row=2, column=0, pady=8)
|
||||
ctk.CTkButton(btn_row, text="Apply", width=80, command=_apply).pack(
|
||||
side="left", padx=4)
|
||||
ctk.CTkButton(
|
||||
btn_row, text="Preferences...", width=108,
|
||||
command=self._open_printer_preferences,
|
||||
).pack(side="left", padx=4)
|
||||
|
||||
def _set_printer(self, name: str) -> None:
|
||||
if self._labels and name:
|
||||
try:
|
||||
self._labels.set_printer(name)
|
||||
except Exception as exc:
|
||||
log.warning("Could not set printer: %s", exc)
|
||||
|
||||
def _open_printer_preferences(self) -> None:
|
||||
if self._labels:
|
||||
try:
|
||||
self._labels.open_preferences()
|
||||
except Exception as exc:
|
||||
log.warning("Could not open printer preferences: %s", exc)
|
||||
|
||||
# ── Search layout sync (called after resize) ──────────────────────────────
|
||||
|
||||
def _on_window_resize(self, event) -> None:
|
||||
pass
|
||||
|
||||
def _sync_search_layout(self) -> None:
|
||||
pass
|
||||
|
||||
def _apply_search_layout(self) -> None:
|
||||
pass
|
||||
|
||||
# ── Public ────────────────────────────────────────────────────────────────
|
||||
|
||||
def clear_search(self) -> None:
|
||||
try:
|
||||
self._search_var.set("")
|
||||
self._search_entry.focus_set()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def destroy(self) -> None:
|
||||
for job in (self._clip_job, self._hint_job):
|
||||
if job:
|
||||
try:
|
||||
self.after_cancel(job)
|
||||
except Exception:
|
||||
pass
|
||||
super().destroy()
|
||||
@@ -0,0 +1,400 @@
|
||||
"""Shipping history window.
|
||||
|
||||
Left sidebar: search + filters over the local label history.
|
||||
Main toolbar: opens authenticated Stamps.com hosted pages (refund, SCAN form,
|
||||
pickup, full history) — the API returns a single-use URL we open in the
|
||||
browser so the user gets the real Stamps UI without re-logging in.
|
||||
Export: writes the current filtered view to CSV from our local store.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import webbrowser
|
||||
from tkinter import ttk
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from . import theme as T
|
||||
from .label_history import LabelEntry, LabelHistory
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# ── Table columns ──────────────────────────────────────────────────────────
|
||||
_COLUMNS = [
|
||||
("date", "Date Printed", 105),
|
||||
("name", "Recipient", 155),
|
||||
("tracking", "Tracking #", 165),
|
||||
("service", "Service", 140),
|
||||
("amount", "Amount", 80),
|
||||
("status", "Status", 110),
|
||||
("notes", "Notes", 165),
|
||||
]
|
||||
|
||||
_DATE_FILTERS = ["All time", "Today", "Last 7 days", "Last 30 days"]
|
||||
_STATUS_FILTERS = ["All", "Delivered", "In Transit", "Accepted", "Exception", "Voided"]
|
||||
|
||||
|
||||
def _resolve(color) -> str:
|
||||
"""Resolve a (light, dark) tuple to the current mode's hex string."""
|
||||
if isinstance(color, (list, tuple)) and len(color) == 2:
|
||||
mode = ctk.get_appearance_mode().lower()
|
||||
return color[1] if mode == "dark" else color[0]
|
||||
return color
|
||||
|
||||
|
||||
class HistoryWindow(ctk.CTkToplevel):
|
||||
def __init__(self, parent, cfg=None, carrier: str = ""):
|
||||
super().__init__(parent)
|
||||
self._cfg = cfg
|
||||
self._carrier = carrier
|
||||
self._history = LabelHistory()
|
||||
self._entries: list[LabelEntry] = []
|
||||
|
||||
_titles = {"usps": "USPS Shipping History", "fedex": "FedEx Shipping History"}
|
||||
self.title(_titles.get(carrier, "Shipping History"))
|
||||
self.geometry("1100x600")
|
||||
self.minsize(800, 400)
|
||||
|
||||
self.columnconfigure(1, weight=1)
|
||||
self.rowconfigure(0, weight=1)
|
||||
|
||||
self._build_sidebar()
|
||||
self._build_main()
|
||||
self._apply_treeview_style()
|
||||
self._refresh_table()
|
||||
|
||||
# ── Sidebar ────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_sidebar(self) -> None:
|
||||
sidebar = ctk.CTkFrame(self, fg_color=T.TOOLBAR_BG,
|
||||
corner_radius=0, width=180)
|
||||
sidebar.grid(row=0, column=0, sticky="nsew")
|
||||
sidebar.columnconfigure(0, weight=1)
|
||||
sidebar.grid_propagate(False)
|
||||
|
||||
ctk.CTkLabel(sidebar, text="Search Prints",
|
||||
font=T.FONT_BODY_BOLD, text_color=T.TEXT_PRIMARY).grid(
|
||||
row=0, column=0, sticky="w", padx=14, pady=(16, 6))
|
||||
|
||||
self._search_var = ctk.StringVar()
|
||||
self._search_var.trace_add("write", lambda *_: self._refresh_table())
|
||||
search = ctk.CTkEntry(sidebar, textvariable=self._search_var,
|
||||
placeholder_text="Name, notes, tracking…",
|
||||
height=30, font=T.FONT_BODY,
|
||||
fg_color=T.CARD_BG, border_color=T.BORDER,
|
||||
border_width=1, corner_radius=6,
|
||||
text_color=T.TEXT_PRIMARY,
|
||||
placeholder_text_color=T.TEXT_SUBTLE)
|
||||
search.grid(row=1, column=0, sticky="ew", padx=14, pady=(0, 16))
|
||||
|
||||
ctk.CTkFrame(sidebar, fg_color=T.DIVIDER, height=1).grid(
|
||||
row=2, column=0, sticky="ew", padx=14)
|
||||
|
||||
ctk.CTkLabel(sidebar, text="Date Printed",
|
||||
font=T.FONT_LABEL, text_color=T.TEXT_MUTED).grid(
|
||||
row=3, column=0, sticky="w", padx=14, pady=(12, 4))
|
||||
self._date_var = ctk.StringVar(value=_DATE_FILTERS[0])
|
||||
ctk.CTkOptionMenu(sidebar, variable=self._date_var,
|
||||
values=_DATE_FILTERS, height=28,
|
||||
font=T.FONT_BODY, fg_color=T.CARD_BG,
|
||||
button_color=T.ACCENT, button_hover_color=T.ACCENT_HOVER,
|
||||
text_color=T.TEXT_PRIMARY,
|
||||
dropdown_fg_color=T.CARD_BG,
|
||||
dropdown_text_color=T.TEXT_PRIMARY,
|
||||
dropdown_hover_color=T.CARD_BG_HOVER,
|
||||
command=lambda _: self._refresh_table()).grid(
|
||||
row=4, column=0, sticky="ew", padx=14, pady=(0, 10))
|
||||
|
||||
ctk.CTkLabel(sidebar, text="Status",
|
||||
font=T.FONT_LABEL, text_color=T.TEXT_MUTED).grid(
|
||||
row=5, column=0, sticky="w", padx=14, pady=(6, 4))
|
||||
self._status_var = ctk.StringVar(value=_STATUS_FILTERS[0])
|
||||
ctk.CTkOptionMenu(sidebar, variable=self._status_var,
|
||||
values=_STATUS_FILTERS, height=28,
|
||||
font=T.FONT_BODY, fg_color=T.CARD_BG,
|
||||
button_color=T.ACCENT, button_hover_color=T.ACCENT_HOVER,
|
||||
text_color=T.TEXT_PRIMARY,
|
||||
dropdown_fg_color=T.CARD_BG,
|
||||
dropdown_text_color=T.TEXT_PRIMARY,
|
||||
dropdown_hover_color=T.CARD_BG_HOVER,
|
||||
command=lambda _: self._refresh_table()).grid(
|
||||
row=6, column=0, sticky="ew", padx=14, pady=(0, 10))
|
||||
|
||||
ctk.CTkLabel(sidebar, text="", text_color=T.TEXT_MUTED,
|
||||
font=T.FONT_LABEL).grid(row=7, column=0, sticky="w",
|
||||
padx=14, pady=(6, 4))
|
||||
|
||||
# ── Main area ──────────────────────────────────────────────────────────
|
||||
|
||||
def _build_main(self) -> None:
|
||||
main = ctk.CTkFrame(self, fg_color=T.WINDOW_BG, corner_radius=0)
|
||||
main.grid(row=0, column=1, sticky="nsew")
|
||||
main.columnconfigure(0, weight=1)
|
||||
main.rowconfigure(1, weight=1)
|
||||
|
||||
# Action toolbar
|
||||
toolbar = ctk.CTkFrame(main, fg_color=T.PANEL_BG,
|
||||
corner_radius=0, height=48,
|
||||
border_color=T.BORDER, border_width=1)
|
||||
toolbar.grid(row=0, column=0, sticky="ew")
|
||||
toolbar.grid_propagate(False)
|
||||
|
||||
btn_kw = dict(
|
||||
height=32, font=T.FONT_LABEL, corner_radius=6,
|
||||
fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER,
|
||||
text_color=T.TEXT_PRIMARY,
|
||||
border_width=1, border_color=T.NEUTRAL_BTN_BORDER,
|
||||
)
|
||||
|
||||
# USPS-only Stamps.com actions
|
||||
if self._carrier == "usps":
|
||||
ctk.CTkButton(toolbar, text="Refund", width=90,
|
||||
command=lambda: self._open_stamps_page("online_reporting_refund"),
|
||||
**btn_kw).pack(side="left", padx=(10, 4), pady=8)
|
||||
ctk.CTkButton(toolbar, text="Schedule Pickup", width=130,
|
||||
command=lambda: self._open_stamps_page("online_reporting_pickup"),
|
||||
**btn_kw).pack(side="left", padx=4, pady=8)
|
||||
ctk.CTkButton(toolbar, text="Create SCAN Form", width=140,
|
||||
command=lambda: self._open_stamps_page("online_reporting_scan"),
|
||||
**btn_kw).pack(side="left", padx=4, pady=8)
|
||||
|
||||
# Right side
|
||||
if self._carrier == "usps":
|
||||
ctk.CTkButton(toolbar, text="View on Stamps.com", width=155,
|
||||
command=lambda: self._open_stamps_page("online_reporting_history"),
|
||||
fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER,
|
||||
text_color=T.ACCENT_TEXT,
|
||||
height=32, font=T.FONT_LABEL, corner_radius=6,
|
||||
border_width=1, border_color=T.ACCENT_BORDER).pack(
|
||||
side="right", padx=(4, 10), pady=8)
|
||||
ctk.CTkButton(toolbar, text="↑ Export CSV", width=100,
|
||||
command=self._export_csv, **btn_kw).pack(
|
||||
side="right", padx=4, pady=8)
|
||||
ctk.CTkButton(toolbar, text="Backup…", width=90,
|
||||
command=self._backup_history, **btn_kw).pack(
|
||||
side="right", padx=(4, 0), pady=8)
|
||||
|
||||
# Count label
|
||||
self._count_lbl = ctk.CTkLabel(toolbar, text="",
|
||||
font=T.FONT_LABEL, text_color=T.TEXT_MUTED)
|
||||
self._count_lbl.pack(side="right", padx=8)
|
||||
|
||||
# Table container
|
||||
table_frame = ctk.CTkFrame(main, fg_color=T.PANEL_BG,
|
||||
corner_radius=0)
|
||||
table_frame.grid(row=1, column=0, sticky="nsew")
|
||||
table_frame.columnconfigure(0, weight=1)
|
||||
table_frame.rowconfigure(0, weight=1)
|
||||
|
||||
cols = [c[0] for c in _COLUMNS]
|
||||
self._tree = ttk.Treeview(
|
||||
table_frame,
|
||||
columns=cols,
|
||||
show="headings",
|
||||
selectmode="extended",
|
||||
)
|
||||
|
||||
for col_id, col_title, col_w in _COLUMNS:
|
||||
self._tree.heading(col_id, text=col_title,
|
||||
command=lambda c=col_id: self._sort(c))
|
||||
self._tree.column(col_id, width=col_w, minwidth=60,
|
||||
stretch=(col_id == "name"))
|
||||
|
||||
vsb = ttk.Scrollbar(table_frame, orient="vertical",
|
||||
command=self._tree.yview)
|
||||
hsb = ttk.Scrollbar(table_frame, orient="horizontal",
|
||||
command=self._tree.xview)
|
||||
self._tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
|
||||
|
||||
self._tree.grid(row=0, column=0, sticky="nsew")
|
||||
vsb.grid(row=0, column=1, sticky="ns")
|
||||
hsb.grid(row=1, column=0, sticky="ew")
|
||||
|
||||
self._tree.tag_configure("voided", foreground=_resolve(T.TEXT_MUTED))
|
||||
self._tree.tag_configure("delivered",foreground=_resolve(T.NET_COLOR))
|
||||
|
||||
def _apply_treeview_style(self) -> None:
|
||||
bg = _resolve(T.PANEL_BG)
|
||||
fg = _resolve(T.TEXT_PRIMARY)
|
||||
muted= _resolve(T.TEXT_MUTED)
|
||||
sel = _resolve(T.ACCENT)
|
||||
hdr = _resolve(T.TOOLBAR_BG)
|
||||
|
||||
style = ttk.Style(self)
|
||||
style.theme_use("clam")
|
||||
style.configure("Treeview",
|
||||
background=bg, foreground=fg,
|
||||
fieldbackground=bg, borderwidth=0,
|
||||
rowheight=30, font=("Segoe UI", 11),
|
||||
)
|
||||
style.configure("Treeview.Heading",
|
||||
background=hdr, foreground=muted, relief="flat",
|
||||
font=("Segoe UI", 11, "bold"), padding=(6, 4),
|
||||
)
|
||||
style.map("Treeview",
|
||||
background=[("selected", sel)],
|
||||
foreground=[("selected", "#ffffff")],
|
||||
)
|
||||
style.configure("Vertical.TScrollbar",
|
||||
background=hdr, troughcolor=bg, relief="flat")
|
||||
style.configure("Horizontal.TScrollbar",
|
||||
background=hdr, troughcolor=bg, relief="flat")
|
||||
|
||||
# ── Data ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _refresh_table(self) -> None:
|
||||
search = self._search_var.get().strip()
|
||||
status = self._status_var.get()
|
||||
date_f = self._date_var.get()
|
||||
days = {"Today": 1, "Last 7 days": 7, "Last 30 days": 30}.get(date_f)
|
||||
|
||||
self._entries = self._history.filter(search=search, status=status, days=days,
|
||||
carrier=self._carrier)
|
||||
|
||||
for row in self._tree.get_children():
|
||||
self._tree.delete(row)
|
||||
|
||||
for e in self._entries:
|
||||
amt = f"${e.amount:.2f}" if e.amount is not None else "—"
|
||||
tag = "voided" if e.voided else ("delivered" if e.shipment_status == "Delivered" else "")
|
||||
self._tree.insert("", "end",
|
||||
iid=e.label_id,
|
||||
values=(
|
||||
e.date_printed_display,
|
||||
e.recipient,
|
||||
e.tracking_number or "—",
|
||||
e.service or "—",
|
||||
amt,
|
||||
"Voided" if e.voided else e.shipment_status or "—",
|
||||
e.notes or "—",
|
||||
),
|
||||
tags=(tag,),
|
||||
)
|
||||
|
||||
n = len(self._entries)
|
||||
self._count_lbl.configure(
|
||||
text=f"{n} result{'s' if n != 1 else ''}")
|
||||
|
||||
def _sort(self, col: str) -> None:
|
||||
items = [(self._tree.set(iid, col), iid)
|
||||
for iid in self._tree.get_children()]
|
||||
items.sort(key=lambda x: x[0])
|
||||
for idx, (_, iid) in enumerate(items):
|
||||
self._tree.move(iid, "", idx)
|
||||
|
||||
def _selected_entries(self) -> list[LabelEntry]:
|
||||
sel = self._tree.selection()
|
||||
by_id = {e.label_id: e for e in self._entries}
|
||||
return [by_id[iid] for iid in sel if iid in by_id]
|
||||
|
||||
# ── Actions ────────────────────────────────────────────────────────────
|
||||
|
||||
def _open_stamps_page(self, url_type: str) -> None:
|
||||
"""Fetch a single-use authenticated URL from SERA and open it in the browser."""
|
||||
cfg = self._cfg
|
||||
if not cfg or not getattr(cfg, "stamps_client_id", ""):
|
||||
self._msg("Stamps.com",
|
||||
"Stamps.com credentials are not configured yet.\n\n"
|
||||
"Add stamps_client_id and stamps_refresh_token to config.json "
|
||||
"once your developer application is approved.")
|
||||
return
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
url = _get_hosted_url(cfg, url_type)
|
||||
self.after(0, lambda: webbrowser.open(url))
|
||||
except Exception as exc:
|
||||
msg = str(exc)
|
||||
self.after(0, lambda: self._msg("Stamps.com", msg))
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
def _export_csv(self) -> None:
|
||||
if not self._entries:
|
||||
self._msg("Export", "No results to export.")
|
||||
return
|
||||
|
||||
path = os.path.join(
|
||||
tempfile.gettempdir(),
|
||||
f"shipping_history_{len(self._entries)}_labels.csv",
|
||||
)
|
||||
with open(path, "w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow([
|
||||
"Date Printed", "Recipient", "Tracking #", "Service",
|
||||
"Amount", "Status", "Notes", "Date Delivered",
|
||||
])
|
||||
for e in self._entries:
|
||||
amt = f"${e.amount:.2f}" if e.amount is not None else ""
|
||||
writer.writerow([
|
||||
e.date_printed_display,
|
||||
e.recipient,
|
||||
e.tracking_number,
|
||||
e.service,
|
||||
amt,
|
||||
"Voided" if e.voided else e.shipment_status,
|
||||
e.notes,
|
||||
e.date_delivered_display,
|
||||
])
|
||||
try:
|
||||
os.startfile(path)
|
||||
except Exception:
|
||||
pass
|
||||
self._msg("Export", f"Exported {len(self._entries)} records.\n\n{path}")
|
||||
|
||||
def _backup_history(self) -> None:
|
||||
import shutil
|
||||
from tkinter import filedialog
|
||||
src = self._history._path
|
||||
if not src.exists():
|
||||
self._msg("Backup", "No history file to back up yet.")
|
||||
return
|
||||
dest = filedialog.asksaveasfilename(
|
||||
parent=self,
|
||||
defaultextension=".json",
|
||||
filetypes=[("JSON backup", "*.json"), ("All files", "*.*")],
|
||||
initialfile="label_history_backup.json",
|
||||
title="Save History Backup",
|
||||
)
|
||||
if dest:
|
||||
shutil.copy2(src, dest)
|
||||
self._msg("Backup", f"Backed up {len(self._entries)} records to:\n{dest}")
|
||||
|
||||
def _msg(self, title: str, text: str) -> None:
|
||||
from tkinter import messagebox
|
||||
messagebox.showinfo(title, text, parent=self)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# API helpers
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _get_hosted_url(cfg, url_type: str) -> str:
|
||||
"""Call GET /v1/url to get a single-use authenticated URL for a Stamps hosted page."""
|
||||
import requests as _r
|
||||
from .ui_shipping_tab import _get_stamps_token
|
||||
|
||||
token = _get_stamps_token(cfg)
|
||||
base = ("https://api.testing.stampsendicia.com/sera"
|
||||
if cfg.stamps_sandbox
|
||||
else "https://api.stampsendicia.com/sera")
|
||||
|
||||
resp = _r.get(
|
||||
f"{base}/v1/url",
|
||||
params={"url_type": url_type},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=10,
|
||||
)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(
|
||||
f"Could not get Stamps.com URL (HTTP {resp.status_code}).")
|
||||
|
||||
url = resp.json().get("url", "")
|
||||
if not url:
|
||||
raise RuntimeError("Stamps.com returned an empty URL.")
|
||||
return url
|
||||
+1849
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user