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