Files
2026-07-06 00:24:30 -05:00

474 lines
18 KiB
Python

"""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()