Initial commit
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user