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