""" Peer-to-peer inventory service for multi-instance Nexus deployments. Each Nexus instance is autonomous (own DB, own POS) but can see inventory from other instances on the network. The marketplace fans out to all peers and merges results so users see stock from the whole Nexus network. Architecture: - peers.json: config file listing known peer instances (name + URL) - /pos/api/peer/inventory: public endpoint each instance exposes (no auth) - search_all_peers(): fan-out query to all enabled peers + local DB For the demo (LAN), peers are static IPs in peers.json. For production (clients on own networks), this will evolve into a central hub model where each instance reports to a cloud server. """ import json import os import requests from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Optional # ─── Config ────────────────────────────────────────────────────────────── _CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'peers.json') _config_cache = None def _load_config(): """Load peers.json, cached in memory after first read.""" global _config_cache if _config_cache is not None: return _config_cache try: with open(_CONFIG_PATH, 'r') as f: _config_cache = json.load(f) except (FileNotFoundError, json.JSONDecodeError) as e: print(f'[peer] Warning: could not load {_CONFIG_PATH}: {e}') _config_cache = {'instance_name': 'Unknown', 'peers': [], 'peer_timeout_seconds': 3} return _config_cache def reload_config(): """Force-reload peers.json (call after editing the file).""" global _config_cache _config_cache = None return _load_config() def get_instance_name() -> str: return _load_config().get('instance_name', 'Unknown') def get_instance_id() -> str: return _load_config().get('instance_id', 'unknown') def get_peers() -> list[dict]: """Return list of enabled peers: [{name, url, enabled}]""" cfg = _load_config() return [p for p in cfg.get('peers', []) if p.get('enabled', True)] def get_timeout() -> int: return _load_config().get('peer_timeout_seconds', 3) # ─── Local inventory query (what WE expose to peers) ───────────────────── def get_local_inventory(tenant_conn, query: str = None, limit: int = 50) -> list[dict]: """Query this instance's inventory for the peer endpoint. Returns parts WITH stock > 0, with enough detail for the marketplace to render results (part number, name, brand, price, stock hint). No exact stock numbers — just 'En stock' (per business decision). """ cur = tenant_conn.cursor() # Build WHERE clause clauses = ["COALESCE(s.stock, 0) > 0", "i.is_active = TRUE"] params = [] if query: clauses.append("(i.part_number ILIKE %s OR i.name ILIKE %s OR i.brand ILIKE %s)") like = f'%{query}%' params.extend([like, like, like]) where = " AND ".join(clauses) cur.execute(f""" SELECT i.id, i.part_number, i.name, i.brand, i.price_1, COALESCE(s.stock, 0) AS stock, i.unit, i.catalog_part_id FROM inventory i LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id WHERE {where} ORDER BY i.name LIMIT %s """, params + [limit]) rows = cur.fetchall() cur.close() return [ { 'id': r[0], 'part_number': r[1], 'name': r[2], 'brand': r[3] or '', 'price': float(r[4]) if r[4] else None, 'stock_hint': 'En stock' if r[5] > 0 else 'Agotado', 'unit': r[6] or 'PZA', 'catalog_part_id': r[7], } for r in rows ] # ─── Peer fan-out query ────────────────────────────────────────────────── def _query_one_peer(peer: dict, query: str, limit: int) -> dict: """Send a search request to one peer instance. Returns results or error.""" url = peer['url'].rstrip('/') + '/pos/api/peer/inventory' params = {'limit': limit} if query: params['q'] = query try: resp = requests.get(url, params=params, timeout=get_timeout()) if resp.status_code == 200: data = resp.json() # Tag each result with the source instance name items = data.get('data', []) for item in items: item['source_instance'] = peer['name'] item['source_url'] = peer['url'] return {'ok': True, 'name': peer['name'], 'data': items} else: return {'ok': False, 'name': peer['name'], 'error': f'HTTP {resp.status_code}'} except requests.exceptions.Timeout: return {'ok': False, 'name': peer['name'], 'error': 'timeout'} except requests.exceptions.ConnectionError: return {'ok': False, 'name': peer['name'], 'error': 'offline'} except Exception as e: return {'ok': False, 'name': peer['name'], 'error': str(e)[:100]} def search_all_peers(tenant_conn, query: str = None, limit: int = 50) -> dict: """Search local inventory + all enabled peers in parallel. Returns: { "local": { "name": "...", "data": [...] }, "peers": [ {"name": "Refac B", "data": [...], "ok": True}, {"name": "Refac C", "data": [...], "ok": True}, ... ], "merged": [...], # all results combined, local first "total": N, "errors": [...] # peers that failed } """ peers = get_peers() # Local results local_data = get_local_inventory(tenant_conn, query=query, limit=limit) for item in local_data: item['source_instance'] = get_instance_name() item['source_url'] = 'local' # Fan-out to peers in parallel peer_results = [] errors = [] if peers: with ThreadPoolExecutor(max_workers=min(len(peers), 5)) as executor: futures = { executor.submit(_query_one_peer, p, query, limit): p for p in peers } for future in as_completed(futures): result = future.result() if result['ok']: peer_results.append(result) else: errors.append(result) print(f'[peer] {result["name"]}: {result["error"]}') # Merge: local first, then peers (sorted by name within each source) merged = list(local_data) for pr in peer_results: merged.extend(pr.get('data', [])) return { 'local': { 'name': get_instance_name(), 'data': local_data, 'count': len(local_data), }, 'peers': peer_results, 'merged': merged, 'total': len(merged), 'errors': errors, } # ─── Health check for the peer network ─────────────────────────────────── def check_peer_health() -> list[dict]: """Ping all peers and return status. Useful for the admin dashboard.""" peers = get_peers() results = [] def _ping(peer): try: url = peer['url'].rstrip('/') + '/pos/api/peer/health' resp = requests.get(url, timeout=get_timeout()) if resp.status_code == 200: data = resp.json() return { 'name': peer['name'], 'url': peer['url'], 'status': 'online', 'instance_name': data.get('instance_name'), 'inventory_count': data.get('inventory_count'), } return {'name': peer['name'], 'url': peer['url'], 'status': f'error:{resp.status_code}'} except Exception as e: return {'name': peer['name'], 'url': peer['url'], 'status': f'offline:{str(e)[:50]}'} if peers: with ThreadPoolExecutor(max_workers=min(len(peers), 5)) as executor: results = list(executor.map(_ping, peers)) return results