Initial commit

This commit is contained in:
Jonah
2026-07-06 00:24:30 -05:00
commit 085f8e0c1e
1112 changed files with 131611 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""Parts & Invoice Lookup application package."""
__version__ = "0.1.0"
+14
View File
@@ -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
+965
View File
@@ -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

+677
View File
@@ -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
View File
@@ -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)
+428
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+139
View File
@@ -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
View File
@@ -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
View File
@@ -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
+45
View File
@@ -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"
+473
View File
@@ -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
View File
@@ -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)
+331
View File
@@ -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_,
}
+66
View File
@@ -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)
+415
View File
@@ -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()
+557
View File
@@ -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()
+400
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff