474 lines
18 KiB
Python
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()
|