# /home/Autopartes/pos/blueprints/inventory_bp.py """Inventory blueprint: CRUD for inventory items + stock operations + reports.""" import io import json import os import csv from datetime import datetime, timedelta from flask import Blueprint, request, jsonify, g from middleware import require_auth, has_permission from tenant_db import get_tenant_conn from services.inventory_engine import ( get_stock, get_stock_bulk, record_purchase, record_return, record_adjustment, record_transfer, record_initial, get_alerts, get_movement_history ) from services.barcode_generator import generate_barcode from services.audit import log_action from tenant_db import get_master_conn from services.inventory_vehicle_compat import ( auto_match_vehicle_compatibility, add_compatibility, remove_compatibility, remove_compatibility_by_id, remove_all_compatibility, get_compatibility, search_mye, get_compat_source, ) from tasks import sync_vehicle_compatibility_task inventory_bp = Blueprint('inventory', __name__, url_prefix='/pos/api/inventory') def _get_tier_discounts(conn): """Read global tier discounts from DB. Returns dict {tier_id: discount_pct}.""" cur = conn.cursor() cur.execute("SELECT tier_id, discount_pct FROM tier_discounts") rows = cur.fetchall() cur.close() return {r[0]: float(r[1]) for r in rows} def _apply_tier_discounts(price_1, discounts): """Given a base price and discount dict, return (price_2, price_3).""" if not price_1: return 0, 0 disc2 = discounts.get(2, 0) disc3 = discounts.get(3, 0) p2 = round(float(price_1) * (1 - disc2 / 100), 2) p3 = round(float(price_1) * (1 - disc3 / 100), 2) return p2, p3 def _to_decimal(val, default=0): if val is None or val == '': return default try: return float(str(val).replace(',', '')) except (ValueError, TypeError): return default def _to_int(val, default=0): if val is None or val == '': return default try: return int(float(str(val).replace(',', ''))) except (ValueError, TypeError): return default # ─── AI Classification ─────────────────────────── @inventory_bp.route('/classify/', methods=['GET']) @require_auth('inventory.create') def classify_part_endpoint(part_number): """Ask AI to identify a part by its OEM number.""" from services.ai_chat import classify_part result = classify_part(part_number) return jsonify(result) # ─── Item CRUD ────────────────────────────────── @inventory_bp.route('/items', methods=['GET']) @require_auth('inventory.view') def list_items(): """List inventory items with current stock. Supports search, pagination, filtering. The low_stock filter is applied at the SQL level via a LEFT JOIN + HAVING clause, so pagination counts remain accurate. """ conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() page = int(request.args.get('page', 1)) per_page = min(int(request.args.get('per_page', 50)), 200) search = request.args.get('q', '') category = request.args.get('category', '') brand = request.args.get('brand', '') branch_id = request.args.get('branch_id', g.branch_id) low_stock = request.args.get('low_stock', '') == 'true' where_clauses = ["i.is_active = true"] params = [] if branch_id: where_clauses.append("i.branch_id = %s") params.append(branch_id) if search: where_clauses.append("(i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s)") params.extend([f'%{search}%', f'%{search}%', f'%{search}%']) if category: where_clauses.append("i.category_id = %s") params.append(int(category)) if brand: where_clauses.append("i.brand ILIKE %s") params.append(f'%{brand}%') where = " AND ".join(where_clauses) if low_stock: # low_stock filter: JOIN with stock subquery, filter items where stock < min_stock # This keeps pagination accurate because the filter is in the SQL WHERE clause. count_sql = f""" SELECT count(*) FROM inventory i LEFT JOIN ( SELECT inventory_id, COALESCE(SUM(quantity), 0) AS stock FROM inventory_operations GROUP BY inventory_id ) s ON s.inventory_id = i.id WHERE {where} AND i.min_stock IS NOT NULL AND i.min_stock > 0 AND COALESCE(s.stock, 0) < i.min_stock """ cur.execute(count_sql, params) total = cur.fetchone()[0] cur.execute(f""" SELECT i.id, i.branch_id, i.part_number, i.barcode, i.name, i.description, i.category_id, i.brand, i.unit, i.cost, i.price_1, i.price_2, i.price_3, i.tax_rate, i.min_stock, i.max_stock, i.location, i.image_url, i.catalog_part_id, COALESCE(s.stock, 0) AS stock FROM inventory i LEFT JOIN ( SELECT inventory_id, COALESCE(SUM(quantity), 0) AS stock FROM inventory_operations GROUP BY inventory_id ) s ON s.inventory_id = i.id WHERE {where} AND i.min_stock IS NOT NULL AND i.min_stock > 0 AND COALESCE(s.stock, 0) < i.min_stock ORDER BY i.name LIMIT %s OFFSET %s """, params + [per_page, (page - 1) * per_page]) items = [] for r in cur.fetchall(): items.append({ 'id': r[0], 'branch_id': r[1], 'part_number': r[2], 'barcode': r[3], 'name': r[4], 'description': r[5], 'category_id': r[6], 'brand': r[7], 'unit': r[8], 'cost': float(r[9]) if r[9] else 0, 'price_1': float(r[10]) if r[10] else 0, 'price_2': float(r[11]) if r[11] else 0, 'price_3': float(r[12]) if r[12] else 0, 'tax_rate': float(r[13]) if r[13] else 0.16, 'min_stock': r[14], 'max_stock': r[15], 'location': r[16], 'image_url': r[17], 'catalog_part_id': r[18], 'stock': r[19] }) else: # Normal path: count, fetch items, then bulk-lookup stock cur.execute(f"SELECT count(*) FROM inventory i WHERE {where}", params) total = cur.fetchone()[0] cur.execute(f""" SELECT i.id, i.branch_id, i.part_number, i.barcode, i.name, i.description, i.category_id, i.brand, i.unit, i.cost, i.price_1, i.price_2, i.price_3, i.tax_rate, i.min_stock, i.max_stock, i.location, i.image_url, i.catalog_part_id FROM inventory i WHERE {where} ORDER BY i.name LIMIT %s OFFSET %s """, params + [per_page, (page - 1) * per_page]) items_raw = cur.fetchall() # Get stock for all returned items inv_ids = [r[0] for r in items_raw] stock_map = {} if inv_ids: cur.execute(""" SELECT inventory_id, COALESCE(SUM(quantity), 0) FROM inventory_operations WHERE inventory_id = ANY(%s) GROUP BY inventory_id """, (inv_ids,)) stock_map = {r[0]: r[1] for r in cur.fetchall()} items = [] for r in items_raw: stock = stock_map.get(r[0], 0) items.append({ 'id': r[0], 'branch_id': r[1], 'part_number': r[2], 'barcode': r[3], 'name': r[4], 'description': r[5], 'category_id': r[6], 'brand': r[7], 'unit': r[8], 'cost': float(r[9]) if r[9] else 0, 'price_1': float(r[10]) if r[10] else 0, 'price_2': float(r[11]) if r[11] else 0, 'price_3': float(r[12]) if r[12] else 0, 'tax_rate': float(r[13]) if r[13] else 0.16, 'min_stock': r[14], 'max_stock': r[15], 'location': r[16], 'image_url': r[17], 'catalog_part_id': r[18], 'stock': stock }) cur.close() conn.close() total_pages = (total + per_page - 1) // per_page return jsonify({ 'data': items, 'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages} }) @inventory_bp.route('/items/', methods=['GET']) @require_auth('inventory.view') def get_item(item_id): """Get a single inventory item with stock and movement history.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" SELECT i.*, b.name as branch_name, c.name as category_name FROM inventory i LEFT JOIN branches b ON i.branch_id = b.id LEFT JOIN categories c ON i.category_id = c.id WHERE i.id = %s """, (item_id,)) row = cur.fetchone() if not row: cur.close(); conn.close() return jsonify({'error': 'Item not found'}), 404 cols = [desc[0] for desc in cur.description] item = dict(zip(cols, row)) # Convert Decimal to float for k in ('cost', 'price_1', 'price_2', 'price_3', 'tax_rate'): if item.get(k) is not None: item[k] = float(item[k]) item['stock'] = get_stock(conn, item_id, item.get('branch_id')) item['history'] = get_movement_history(conn, item_id, limit=20) cur.close() conn.close() return jsonify(item) @inventory_bp.route('/items', methods=['POST']) @require_auth('inventory.create') def create_item(): """Create a new inventory item. Optionally set initial stock.""" data = request.get_json() or {} required = ['part_number', 'name'] for f in required: if not data.get(f): return jsonify({'error': f'{f} required'}), 400 branch_id = data.get('branch_id', g.branch_id) if not branch_id: return jsonify({'error': 'branch_id required'}), 400 # Plan limit check from services.billing import check_limit, next_plan, PLANS, get_plan conn = get_tenant_conn(g.tenant_id) cur_count = conn.cursor() cur_count.execute("SELECT count(*) FROM inventory WHERE is_active = true") current_products = cur_count.fetchone()[0] cur_count.close() allowed, limit, current = check_limit(g.tenant_id, 'max_products', current_products) if not allowed: conn.close() plan_key = get_plan(g.tenant_id) nxt = next_plan(plan_key) nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise' return jsonify({'error': f'Plan limit reached ({limit} products). Upgrade to {nxt_name}.'}), 403 conn.close() conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() # Generate barcode if not provided barcode = data.get('barcode') if not barcode: # Look up tenant db_name from tenant_db import get_master_conn mconn = get_master_conn() mcur = mconn.cursor() mcur.execute("SELECT db_name FROM tenants WHERE id = %s", (g.tenant_id,)) db_name = mcur.fetchone()[0] mcur.close(); mconn.close() barcode = generate_barcode(conn, db_name) # Auto-calculate tier prices from global discounts discounts = _get_tier_discounts(conn) price_1 = data.get('price_1', 0) price_2, price_3 = _apply_tier_discounts(price_1, discounts) # Allow override if explicitly sent (backward compat) if 'price_2' in data: price_2 = data['price_2'] if 'price_3' in data: price_3 = data['price_3'] try: cur.execute(""" INSERT INTO inventory (branch_id, part_number, barcode, name, description, category_id, brand, vehicle_compatibility, unit, cost, price_1, price_2, price_3, tax_rate, min_stock, max_stock, location, image_url, catalog_part_id) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING id """, ( branch_id, data['part_number'], barcode, data['name'], data.get('description'), data.get('category_id'), data.get('brand'), json.dumps(data.get('vehicle_compatibility')) if data.get('vehicle_compatibility') else None, data.get('unit', 'PZA'), data.get('cost', 0), price_1, price_2, price_3, data.get('tax_rate', 0.16), data.get('min_stock', 0), data.get('max_stock', 0), data.get('location'), data.get('image_url'), data.get('catalog_part_id') )) item_id = cur.fetchone()[0] # Record initial stock if provided initial_stock = data.get('initial_stock', 0) if initial_stock > 0: record_initial(conn, item_id, branch_id, initial_stock, data.get('cost')) # Insert SKU aliases if provided sku_aliases = data.get('sku_aliases', []) if sku_aliases: for alias in sku_aliases: sku = (alias.get('sku') or '').strip() label = (alias.get('label') or '').strip() if sku: cur.execute( """ INSERT INTO inventory_sku_aliases (inventory_id, sku, label) VALUES (%s, %s, %s) ON CONFLICT (inventory_id, sku) DO UPDATE SET is_active = true, label = EXCLUDED.label """, (item_id, sku, label or None), ) log_action(conn, 'INVENTORY_CREATE', 'inventory', item_id, new_value={'part_number': data['part_number'], 'name': data['name'], 'initial_stock': initial_stock}) conn.commit() cur.close() # ── Vehicle compatibility (respects tenant config) ──────────────── compat_source = get_compat_source(g.tenant_id) qwen_added = 0 # Offload to Celery background task if possible (QWEN can take 90s+) try: sync_vehicle_compatibility_task.delay( g.tenant_id, item_id, data['part_number'], data['name'], data.get('brand', ''), compat_source ) compat_background = True except Exception as celery_err: print(f"[celery] Failed to queue compatibility task for item {item_id}: {celery_err}") compat_background = False if not compat_background: # Fallback: synchronous processing if compat_source in ('tecdoc', 'both'): master = None try: master = get_master_conn() auto_match_vehicle_compatibility(master, conn, item_id, data['part_number'], brand=data.get('brand'), name=data.get('name')) except Exception as am_err: print(f"[auto_match] Error for item {item_id}: {am_err}") finally: if master: try: master.close() except Exception: pass if compat_source in ('qwen', 'both'): try: from services.qwen_fitment import get_vehicle_fitment from services.inventory_vehicle_compat import save_qwen_fitment fitment = get_vehicle_fitment( data['part_number'], data['name'], data.get('brand', '') ) qwen_added = save_qwen_fitment(conn, item_id, fitment) except Exception as qwen_err: print(f"[qwen_fitment] Error for item {item_id}: {qwen_err}") conn.close() return jsonify({ 'id': item_id, 'barcode': barcode, 'message': 'Item created', 'vehicle_compatibilities_added': qwen_added, 'vehicle_compat_queued': compat_background, }), 201 except Exception as e: conn.rollback() cur.close(); conn.close() if 'idx_inventory_branch_part' in str(e): return jsonify({'error': 'Part number already exists in this branch'}), 409 return jsonify({'error': str(e)}), 500 @inventory_bp.route('/items/bulk-import', methods=['POST']) @require_auth('inventory.edit') def bulk_import_items(): """ Bulk import inventory items with optional vehicle compatibility. Expects multipart/form-data with a 'file' (CSV/Excel) or JSON body. Headers: X-Import-Mode: 'strict' (default) aborts on first error; 'lenient' skips bad rows. X-Import-Strategy: 'qwen' (default) auto-generates missing compat via QWEN; 'skip' ignores missing compat; 'reject' requires all compat. Expected CSV columns (case-insensitive): sku/part_number, name, brand, price, stock, cost, location, description, category, make, model, year, engine, engine_code Optional compat columns: make, model, year, engine, engine_code """ from services.qwen_fitment import get_vehicle_fitment from services.inventory_vehicle_compat import save_qwen_fitment import services.inventory_vehicle_compat as ivc_service mode = request.headers.get('X-Import-Mode', 'strict').lower() strategy = request.headers.get('X-Import-Strategy', 'qwen').lower() errors = [] warnings = [] created_ids = [] skipped = 0 created = 0 # ---------- 1. Parse input ---------- rows = [] if request.content_type and 'multipart/form-data' in request.content_type: file = request.files.get('file') if not file: return jsonify({'error': 'No file uploaded'}), 400 try: ext = os.path.splitext(file.filename)[1].lower() if ext == '.csv': stream = io.TextIOWrapper(file.stream, encoding='utf-8-sig') reader = csv.DictReader(stream) rows = list(reader) elif ext in ('.xls', '.xlsx', '.xlsm'): try: import openpyxl except ImportError: return jsonify({'error': 'Excel support requires openpyxl. Please convert to CSV or install openpyxl.'}), 400 wb = openpyxl.load_workbook(file.stream, data_only=True) ws = wb.active headers = [str(c).strip().lower().replace(' ', '_') if c else '' for c in next(ws.iter_rows(values_only=True))] for raw in ws.iter_rows(min_row=2, values_only=True): rows.append({headers[i]: (str(v) if v is not None else '') for i, v in enumerate(raw) if i < len(headers)}) else: return jsonify({'error': 'Unsupported file type. Use CSV or Excel.'}), 400 except Exception as e: return jsonify({'error': f'Failed to parse file: {e}'}), 400 else: body = request.get_json() or {} rows = body.get('items') if not rows or not isinstance(rows, list): return jsonify({'error': 'Expected JSON body with an "items" array'}), 400 if not rows: return jsonify({'error': 'No data rows found'}), 400 # Normalise column names on first row if rows: first = rows[0] normalised_keys = {} for k in list(first.keys()): nk = str(k).strip().lower().replace(' ', '_') normalised_keys[k] = nk for r in rows: for old_k, new_k in normalised_keys.items(): if old_k in r: r[new_k] = r.pop(old_k) # Map common synonyms col_map = { 'sku': 'part_number', 'numero_de_parte': 'part_number', 'parte': 'part_number', 'nombre': 'name', 'producto': 'name', 'descripcion': 'name', 'marca': 'brand', 'precio': 'price', 'costo': 'cost', 'cantidad': 'stock', 'existencia': 'stock', 'inventario': 'stock', 'ubicacion': 'location', 'categoria': 'category', 'fabricante': 'make', 'vehiculo': 'make', 'auto': 'make', 'modelo': 'model', 'anio': 'year', 'ano': 'year', 'motor': 'engine', 'codigo_motor': 'engine_code', } for r in rows: for old_k, new_k in col_map.items(): if old_k in r and new_k not in r: r[new_k] = r.pop(old_k) required = ['part_number', 'name'] first_keys = set(rows[0].keys()) if rows else set() missing_required = [c for c in required if c not in first_keys] if missing_required: return jsonify({'error': f'Missing required columns: {missing_required}'}), 400 conn = get_tenant_conn(g.tenant_id) try: cur = conn.cursor() from services.barcode_generator import generate_barcode from tenant_db import get_master_conn from services.inventory_engine import record_initial # Pre-fetch tenant db_name for barcode generation mconn = get_master_conn() mcur = mconn.cursor() mcur.execute("SELECT db_name FROM tenants WHERE id = %s", (g.tenant_id,)) db_name_row = mcur.fetchone() db_name = db_name_row[0] if db_name_row else None mcur.close(); mconn.close() for row_num, row in enumerate(rows, start=1): part_number = str(row.get('part_number', '')).strip() name = str(row.get('name', '')).strip() if not part_number or not name: msg = f'Row {row_num}: part_number and name are required' if mode == 'strict': conn.rollback(); cur.close(); conn.close() return jsonify({'error': msg}), 400 warnings.append(msg) skipped += 1 continue branch_id = _to_int(row.get('branch_id'), g.branch_id) if not branch_id: msg = f'Row {row_num}: branch_id required (not set in row or session)' if mode == 'strict': conn.rollback(); cur.close(); conn.close() return jsonify({'error': msg}), 400 warnings.append(msg) skipped += 1 continue brand = str(row.get('brand', '')).strip() price_1 = _to_decimal(row.get('price'), 0) stock = _to_int(row.get('stock'), 0) cost = _to_decimal(row.get('cost'), 0) location = str(row.get('location', '')).strip() description = str(row.get('description', '')).strip() category = str(row.get('category', '')).strip() # Check if item already exists for this branch cur.execute("SELECT id FROM inventory WHERE branch_id = %s AND part_number = %s", (branch_id, part_number)) existing = cur.fetchone() if existing: item_id = existing[0] # Update existing item — add stock if provided cur.execute( """ UPDATE inventory SET name = %s, brand = COALESCE(NULLIF(%s,''), brand), cost = CASE WHEN %s > 0 THEN %s ELSE cost END, price_1 = CASE WHEN %s > 0 THEN %s ELSE price_1 END, stock = stock + %s, location = COALESCE(NULLIF(%s,''), location), description = COALESCE(NULLIF(%s,''), description), category = COALESCE(NULLIF(%s,''), category) WHERE id = %s """, (name, brand, cost, cost, price_1, price_1, stock, location, description, category, item_id) ) was_inserted = False # Record stock adjustment for existing item if stock > 0 if stock > 0: record_initial(conn, item_id, branch_id, stock, cost if cost > 0 else None) else: # Generate barcode for new item barcode = generate_barcode(conn, db_name) cur.execute( """ INSERT INTO inventory (branch_id, part_number, barcode, name, brand, cost, price_1, location, description, category, unit) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, (branch_id, part_number, barcode, name, brand, cost, price_1, location, description, category, 'PZA') ) item_id = cur.fetchone()[0] was_inserted = True # Record initial stock if provided and new item if was_inserted and stock > 0: record_initial(conn, item_id, branch_id, stock, cost if cost > 0 else None) conn.commit() created_ids.append(item_id) created += 1 # ---------- 2. Vehicle compatibility ---------- make = str(row.get('make', '')).strip() model = str(row.get('model', '')).strip() year_str = str(row.get('year', '')).strip() engine = str(row.get('engine', '')).strip() engine_code = str(row.get('engine_code', '')).strip() has_compat = any([make, model, year_str, engine, engine_code]) if has_compat: # Validate / resolve against vehicle tables year = _to_int(year_str, None) mye_id = None if make and model and year: # Try exact match against model_year_engine cur.execute( """ SELECT mye.id FROM model_year_engine mye JOIN models m ON m.id = mye.model_id JOIN brands b ON b.id = m.brand_id JOIN years y ON y.id = mye.year_id WHERE LOWER(b.name) = LOWER(%s) AND LOWER(m.name) = LOWER(%s) AND y.year = %s LIMIT 1 """, (make, model, year) ) r = cur.fetchone() if r: mye_id = r[0] else: warnings.append( f'Row {row_num}: vehicle "{make} {model} {year}" not found in catalog; ' 'saving as text-only compatibility.' ) if mye_id: cur.execute( """ INSERT INTO inventory_vehicle_compat (inventory_id, model_year_engine_id, make, model, year, engine, engine_code) VALUES (%s, %s, %s, %s, %s, %s, %s) ON CONFLICT DO NOTHING """, (item_id, mye_id, make, model, year_str, engine, engine_code) ) else: cur.execute( """ INSERT INTO inventory_vehicle_compat (inventory_id, make, model, year, engine, engine_code) VALUES (%s, %s, %s, %s, %s, %s) ON CONFLICT DO NOTHING """, (item_id, make, model, year_str, engine, engine_code) ) conn.commit() else: # No compatibility provided if strategy == 'reject': msg = f'Row {row_num}: missing vehicle compatibility (strategy=reject)' if mode == 'strict': conn.rollback(); cur.close(); conn.close() return jsonify({'error': msg}), 400 warnings.append(msg) elif strategy == 'qwen': try: fitment = get_vehicle_fitment(part_number, name, brand) save_qwen_fitment(conn, item_id, fitment) conn.commit() except Exception as qe: warnings.append(f'Row {row_num}: QWEN fitment failed: {qe}') # strategy == 'skip' → do nothing cur.close() conn.close() return jsonify({ 'created': created, 'skipped': skipped, 'item_ids': created_ids, 'warnings': warnings, 'errors': errors, }), 200 except Exception as e: conn.rollback() try: cur.close() except Exception: pass conn.close() return jsonify({'error': str(e)}), 500 @inventory_bp.route('/items/', methods=['PUT']) @require_auth('inventory.edit') def update_item(item_id): """Update inventory item fields (not stock — use operations for that).""" data = request.get_json() or {} conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() # Get current values for audit cur.execute("SELECT * FROM inventory WHERE id = %s", (item_id,)) old = cur.fetchone() if not old: cur.close(); conn.close() return jsonify({'error': 'Item not found'}), 404 old_cols = [desc[0] for desc in cur.description] old_dict = dict(zip(old_cols, old)) # Price change requires special permission price_fields = {'price_1', 'price_2', 'price_3', 'cost'} changing_prices = price_fields & set(data.keys()) if changing_prices and not has_permission('config.edit_prices'): return jsonify({'error': 'Permission config.edit_prices required to change prices'}), 403 # Auto-calculate tier prices if price_1 changes and no explicit override discounts = _get_tier_discounts(conn) if 'price_1' in data and ('price_2' not in data or 'price_3' not in data): p2, p3 = _apply_tier_discounts(data['price_1'], discounts) if 'price_2' not in data: data['price_2'] = p2 if 'price_3' not in data: data['price_3'] = p3 # Build dynamic update allowed = ['part_number', 'barcode', 'name', 'description', 'category_id', 'brand', 'vehicle_compatibility', 'unit', 'cost', 'price_1', 'price_2', 'price_3', 'tax_rate', 'min_stock', 'max_stock', 'location', 'image_url', 'catalog_part_id', 'is_active'] sets = [] vals = [] for field in allowed: if field in data: val = data[field] if field == 'vehicle_compatibility' and isinstance(val, (dict, list)): val = json.dumps(val) sets.append(f"{field} = %s") vals.append(val) if not sets: cur.close(); conn.close() return jsonify({'error': 'No fields to update'}), 400 vals.append(item_id) cur.execute(f"UPDATE inventory SET {', '.join(sets)} WHERE id = %s", vals) if changing_prices: log_action(conn, 'PRICE_CHANGE', 'inventory', item_id, old_value={k: float(old_dict[k]) if old_dict[k] else 0 for k in changing_prices}, new_value={k: data[k] for k in changing_prices}) conn.commit() cur.close() # Re-run auto-match if part_number changed if 'part_number' in data and data['part_number'] != old_dict.get('part_number'): try: master = get_master_conn() # Clear old compatibilities and re-match remove_all_compatibility(conn, item_id) auto_match_vehicle_compatibility(master, conn, item_id, data['part_number'], brand=data.get('brand'), name=data.get('name')) master.close() except Exception as am_err: print(f"[auto_match] Re-match error for item {item_id}: {am_err}") conn.close() return jsonify({'message': 'Item updated'}) # ─── Image Upload / Delete ───────────────────── IMAGES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'images', 'parts') ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'webp'} MAX_IMAGE_BYTES = 5 * 1024 * 1024 # 5 MB def _process_image(file_data, max_size=800): """Resize image to max_size and convert to JPEG.""" from PIL import Image img = Image.open(io.BytesIO(file_data)) img.thumbnail((max_size, max_size), Image.LANCZOS) if img.mode not in ('RGB', 'L'): img = img.convert('RGB') output = io.BytesIO() img.save(output, format='JPEG', quality=85) return output.getvalue() def _process_thumbnail(file_data, size=300): """Generate a smaller thumbnail.""" return _process_image(file_data, max_size=size) def _delete_image_files(tenant_id, item_id): """Remove image and thumbnail for the given item from disk.""" for suffix in ('', '_thumb'): path = os.path.join(IMAGES_DIR, f'{tenant_id}_{item_id}{suffix}.jpg') if os.path.exists(path): os.remove(path) @inventory_bp.route('/items//image', methods=['POST']) @require_auth('inventory.edit') def upload_image(item_id): """Upload an image for an inventory item. Accepts multipart file upload. Validates file type (jpg, png, webp) and size (max 5 MB). Saves resized image + thumbnail, updates inventory.image_url. """ if 'file' not in request.files: return jsonify({'error': 'No file provided'}), 400 f = request.files['file'] if not f.filename: return jsonify({'error': 'Empty filename'}), 400 ext = f.filename.rsplit('.', 1)[-1].lower() if '.' in f.filename else '' if ext not in ALLOWED_EXTENSIONS: return jsonify({'error': f'File type not allowed. Use: {", ".join(ALLOWED_EXTENSIONS)}'}), 400 raw = f.read() if len(raw) > MAX_IMAGE_BYTES: return jsonify({'error': 'File too large (max 5 MB)'}), 400 # Verify item exists conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute("SELECT id FROM inventory WHERE id = %s", (item_id,)) if not cur.fetchone(): cur.close(); conn.close() return jsonify({'error': 'Item not found'}), 404 try: # Process and save main image os.makedirs(IMAGES_DIR, exist_ok=True) main_data = _process_image(raw) main_filename = f'{g.tenant_id}_{item_id}.jpg' main_path = os.path.join(IMAGES_DIR, main_filename) with open(main_path, 'wb') as out: out.write(main_data) # Process and save thumbnail thumb_data = _process_thumbnail(raw) thumb_filename = f'{g.tenant_id}_{item_id}_thumb.jpg' thumb_path = os.path.join(IMAGES_DIR, thumb_filename) with open(thumb_path, 'wb') as out: out.write(thumb_data) # Update DB image_url = f'/pos/static/images/parts/{main_filename}' cur.execute("UPDATE inventory SET image_url = %s WHERE id = %s", (image_url, item_id)) conn.commit() log_action(conn, 'IMAGE_UPLOAD', 'inventory', item_id, new_value={'image_url': image_url}) cur.close(); conn.close() return jsonify({ 'image_url': image_url, 'thumbnail_url': f'/pos/static/images/parts/{thumb_filename}', 'message': 'Image uploaded' }) except Exception as e: conn.rollback() cur.close(); conn.close() return jsonify({'error': str(e)}), 500 @inventory_bp.route('/items//image', methods=['DELETE']) @require_auth('inventory.edit') def delete_image(item_id): """Delete the image for an inventory item. Removes files from disk and sets image_url = NULL.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute("SELECT image_url FROM inventory WHERE id = %s", (item_id,)) row = cur.fetchone() if not row: cur.close(); conn.close() return jsonify({'error': 'Item not found'}), 404 # Remove files from disk _delete_image_files(g.tenant_id, item_id) # Clear DB cur.execute("UPDATE inventory SET image_url = NULL WHERE id = %s", (item_id,)) conn.commit() log_action(conn, 'IMAGE_DELETE', 'inventory', item_id, old_value={'image_url': row[0]}) cur.close(); conn.close() return jsonify({'message': 'Image deleted'}) @inventory_bp.route('/items/', methods=['DELETE']) @require_auth('inventory.edit') def delete_item(item_id): """Soft-delete an inventory item (mark is_active = false). Keeps historical data (sales, movements) intact while removing the item from the active catalog and stock views. """ conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute("SELECT id, part_number, name FROM inventory WHERE id = %s", (item_id,)) row = cur.fetchone() if not row: cur.close(); conn.close() return jsonify({'error': 'Item not found'}), 404 cur.execute("UPDATE inventory SET is_active = false WHERE id = %s", (item_id,)) conn.commit() log_action(conn, 'INVENTORY_DELETE', 'inventory', item_id, old_value={'part_number': row[1], 'name': row[2]}) cur.close(); conn.close() return jsonify({'message': 'Item deleted', 'id': item_id}) # ─── Bulk Image Import ───────────────────────── @inventory_bp.route('/bulk-images', methods=['POST']) @require_auth('inventory.edit') def bulk_upload_images(): """Bulk import images from URLs for multiple inventory items. Accepts JSON: {items: [{part_number, image_url}, ...]} Downloads each image, resizes/optimizes, saves to disk, updates DB. Returns {imported: N, errors: [...]} """ data = request.get_json() or {} items_list = data.get('items', []) if not items_list: return jsonify({'error': 'items array required'}), 400 if len(items_list) > 500: return jsonify({'error': 'Maximum 500 items per request'}), 400 from services.image_scraper import bulk_import conn = get_tenant_conn(g.tenant_id) try: result = bulk_import(conn, g.tenant_id, items_list) log_action(conn, 'BULK_IMAGE_IMPORT', 'inventory', None, new_value={'imported': result['imported'], 'error_count': len(result['errors'])}) return jsonify(result) except Exception as e: return jsonify({'error': str(e)}), 500 finally: conn.close() @inventory_bp.route('/items//auto-image', methods=['POST']) @require_auth('inventory.edit') def auto_image(item_id): """Generate a placeholder image for an inventory item. Creates a branded placeholder with the part number text. Useful when no real product image is available. """ conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute("SELECT part_number, name FROM inventory WHERE id = %s", (item_id,)) row = cur.fetchone() if not row: cur.close(); conn.close() return jsonify({'error': 'Item not found'}), 404 part_number, name = row try: from services.image_scraper import generate_placeholder rel_url = generate_placeholder(g.tenant_id, item_id, part_number, name or '') cur.execute("UPDATE inventory SET image_url = %s WHERE id = %s", (rel_url, item_id)) conn.commit() log_action(conn, 'AUTO_IMAGE_GENERATED', 'inventory', item_id, new_value={'image_url': rel_url}) cur.close(); conn.close() return jsonify({ 'image_url': rel_url, 'message': 'Placeholder image generated' }) except Exception as e: conn.rollback() cur.close(); conn.close() return jsonify({'error': str(e)}), 500 # ─── Stock Operations ────────────────────────── @inventory_bp.route('/purchase', methods=['POST']) @require_auth('inventory.create') def api_purchase(): """Record a purchase entry (stock in).""" data = request.get_json() or {} required = ['inventory_id', 'quantity', 'unit_cost'] for f in required: if not data.get(f) and data.get(f) != 0: return jsonify({'error': f'{f} required'}), 400 conn = get_tenant_conn(g.tenant_id) branch_id = data.get('branch_id', g.branch_id) op_id = record_purchase( conn, data['inventory_id'], branch_id, data['quantity'], data['unit_cost'], supplier_invoice=data.get('supplier_invoice'), notes=data.get('notes') ) conn.commit() conn.close() return jsonify({'operation_id': op_id, 'message': 'Purchase recorded'}) @inventory_bp.route('/adjustment', methods=['POST']) @require_auth('inventory.adjust') def api_adjustment(): """Record a manual stock adjustment.""" data = request.get_json() or {} if not data.get('inventory_id') or data.get('quantity') is None or not data.get('reason'): return jsonify({'error': 'inventory_id, quantity, and reason required'}), 400 conn = get_tenant_conn(g.tenant_id) try: branch_id = data.get('branch_id', g.branch_id) op_id = record_adjustment(conn, data['inventory_id'], branch_id, data['quantity'], data['reason']) conn.commit() conn.close() return jsonify({'operation_id': op_id, 'message': 'Adjustment recorded'}) except ValueError as e: conn.close() return jsonify({'error': str(e)}), 400 @inventory_bp.route('/transfer', methods=['POST']) @require_auth('inventory.transfer') def api_transfer(): """Transfer stock between branches.""" data = request.get_json() or {} required = ['inventory_id', 'from_branch_id', 'to_branch_id', 'quantity'] for f in required: if not data.get(f): return jsonify({'error': f'{f} required'}), 400 conn = get_tenant_conn(g.tenant_id) out_id, in_id = record_transfer( conn, data['inventory_id'], data['from_branch_id'], data['to_branch_id'], data['quantity'], data.get('notes') ) conn.commit() conn.close() return jsonify({'out_operation_id': out_id, 'in_operation_id': in_id, 'message': 'Transfer recorded'}) @inventory_bp.route('/return', methods=['POST']) @require_auth('inventory.create') def api_return(): """Record a customer return.""" data = request.get_json() or {} if not data.get('inventory_id') or not data.get('quantity'): return jsonify({'error': 'inventory_id and quantity required'}), 400 conn = get_tenant_conn(g.tenant_id) branch_id = data.get('branch_id', g.branch_id) op_id = record_return(conn, data['inventory_id'], branch_id, data['quantity'], data.get('sale_id'), data.get('notes')) conn.commit() conn.close() return jsonify({'operation_id': op_id, 'message': 'Return recorded'}) @inventory_bp.route('/operations', methods=['GET']) @require_auth('inventory.view') def api_operations(): """List inventory operations (purchases, sales, transfers, adjustments). Supports filtering by operation_type, pagination, and date range. """ conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() op_type = request.args.get('type') page = int(request.args.get('page', 1)) per_page = int(request.args.get('per_page', 50)) branch_id = request.args.get('branch_id', g.branch_id) date_from = request.args.get('date_from') date_to = request.args.get('date_to') offset = (page - 1) * per_page # Build query dynamically where_clauses = ['1=1'] params = [] if op_type: where_clauses.append('io.operation_type = %s') params.append(op_type) if branch_id: where_clauses.append('(io.branch_id = %s OR io.branch_id IS NULL)') params.append(branch_id) if date_from: where_clauses.append('io.created_at >= %s') params.append(date_from) if date_to: where_clauses.append('io.created_at <= %s') params.append(date_to + ' 23:59:59') where_sql = ' AND '.join(where_clauses) # Get total count cur.execute(f""" SELECT COUNT(*) FROM inventory_operations io WHERE {where_sql} """, params) total = cur.fetchone()[0] # Get operations with product and employee info cur.execute(f""" SELECT io.id, io.operation_type, io.quantity, io.cost_at_time, io.notes, io.created_at, io.employee_id, e.name as employee_name, i.id as inventory_id, i.part_number, i.name as product_name, i.barcode, io.branch_id, b.name as branch_name FROM inventory_operations io LEFT JOIN inventory i ON io.inventory_id = i.id LEFT JOIN employees e ON io.employee_id = e.id LEFT JOIN branches b ON io.branch_id = b.id WHERE {where_sql} ORDER BY io.created_at DESC LIMIT %s OFFSET %s """, params + [per_page, offset]) rows = cur.fetchall() operations = [] for row in rows: operations.append({ 'id': row[0], 'operation_type': row[1], 'quantity': row[2], 'cost_at_time': float(row[3]) if row[3] else None, 'notes': row[4], 'created_at': row[5].isoformat() if row[5] else None, 'employee_id': row[6], 'employee_name': row[7], 'inventory_id': row[8], 'part_number': row[9], 'product_name': row[10], 'barcode': row[11], 'branch_id': row[12], 'branch_name': row[13], 'total': float(row[3] * row[2]) if row[3] and row[2] else None }) cur.close() conn.close() return jsonify({ 'data': operations, 'pagination': { 'page': page, 'per_page': per_page, 'total': total, 'total_pages': (total + per_page - 1) // per_page } }) # ─── Physical Count (two-phase: start → approve) ────────── @inventory_bp.route('/physical-count/start', methods=['POST']) @require_auth('inventory.view') def physical_count_start(): """Start a physical count. Creates a draft that compares expected vs counted WITHOUT making any adjustments. Returns a draft ID and comparison results. Body: { items: [{inventory_id, counted_quantity}, ...], branch_id, notes } """ data = request.get_json() or {} items = data.get('items', []) branch_id = data.get('branch_id', g.branch_id) notes = data.get('notes', 'Toma fisica') if not items: return jsonify({'error': 'items array required'}), 400 conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() # Create a draft physical count record cur.execute(""" INSERT INTO physical_counts (branch_id, status, notes, employee_id, created_at) VALUES (%s, 'draft', %s, %s, NOW()) RETURNING id """, (branch_id, notes, getattr(g, 'employee_id', None))) count_id = cur.fetchone()[0] results = [] for item in items: inv_id = item.get('inventory_id') counted = item.get('counted_quantity', 0) expected = get_stock(conn, inv_id, branch_id) diff = counted - expected # Store each line in the draft cur.execute(""" INSERT INTO physical_count_lines (physical_count_id, inventory_id, expected_quantity, counted_quantity, difference) VALUES (%s, %s, %s, %s, %s) """, (count_id, inv_id, expected, counted, diff)) results.append({ 'inventory_id': inv_id, 'expected': expected, 'counted': counted, 'difference': diff, 'needs_adjustment': diff != 0 }) conn.commit() cur.close() conn.close() adjustments_needed = sum(1 for r in results if r['needs_adjustment']) return jsonify({ 'count_id': count_id, 'status': 'draft', 'message': f'Draft created. {adjustments_needed} items need adjustment.', 'results': results }) @inventory_bp.route('/physical-count/approve', methods=['POST']) @require_auth('inventory.adjust') def physical_count_approve(): """Approve a draft physical count and create ADJUST operations for all differences. Body: { count_id: int } Requires inventory.adjust permission. """ data = request.get_json() or {} count_id = data.get('count_id') if not count_id: return jsonify({'error': 'count_id required'}), 400 conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() # Verify draft exists and is still draft cur.execute("SELECT branch_id, status, notes FROM physical_counts WHERE id = %s", (count_id,)) row = cur.fetchone() if not row: cur.close(); conn.close() return jsonify({'error': 'Physical count not found'}), 404 branch_id, status, notes = row if status != 'draft': cur.close(); conn.close() return jsonify({'error': f'Count already {status} — cannot approve again'}), 409 # Get all lines with differences cur.execute(""" SELECT inventory_id, expected_quantity, counted_quantity, difference FROM physical_count_lines WHERE physical_count_id = %s AND difference != 0 """, (count_id,)) lines = cur.fetchall() results = [] for inv_id, expected, counted, diff in lines: record_adjustment( conn, inv_id, branch_id, diff, f"{notes}: contado={counted}, esperado={expected}, diferencia={diff}" ) results.append({ 'inventory_id': inv_id, 'expected': expected, 'counted': counted, 'difference': diff, 'adjusted': True }) # Mark count as approved cur.execute(""" UPDATE physical_counts SET status = 'approved', approved_at = NOW(), approved_by = %s WHERE id = %s """, (getattr(g, 'employee_id', None), count_id)) conn.commit() cur.close() conn.close() return jsonify({ 'count_id': count_id, 'status': 'approved', 'message': f'Physical count approved. {len(results)} adjustments created.', 'results': results }) # ─── Stats Summary ───────────────────────────── @inventory_bp.route('/stats', methods=['GET']) @require_auth('inventory.view') def api_inventory_stats(): """Get inventory summary counts for dashboard badges.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() branch_id = getattr(g, 'branch_id', None) # Stock count cur.execute("SELECT COUNT(*) FROM inventory WHERE is_active = true AND (branch_id = %s OR %s IS NULL)", (branch_id, branch_id)) stock = cur.fetchone()[0] # Operations counts by type cur.execute(""" SELECT operation_type, COUNT(*) FROM inventory_operations WHERE (branch_id = %s OR %s IS NULL) GROUP BY operation_type """, (branch_id, branch_id)) op_counts = {row[0]: row[1] for row in cur.fetchall()} # Physical counts cur.execute("SELECT COUNT(*) FROM physical_counts WHERE (branch_id = %s OR %s IS NULL)", (branch_id, branch_id)) physical = cur.fetchone()[0] cur.close() conn.close() # Alerts (reuse existing function) conn2 = get_tenant_conn(g.tenant_id) alerts_list = get_alerts(conn2, branch_id) conn2.close() return jsonify({ 'stock': stock, 'entradas': op_counts.get('PURCHASE', 0), 'salidas': op_counts.get('SALE', 0), 'traspasos': op_counts.get('TRANSFER', 0), 'ajustes': op_counts.get('ADJUST', 0), 'conteos': physical, 'alertas': len(alerts_list) }) @inventory_bp.route('/summary', methods=['GET']) @require_auth('inventory.view') def api_inventory_summary(): """Get high-level summary counts for the inventory dashboard badges.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() branch_id = getattr(g, 'branch_id', None) where_branch = "" params = [] if branch_id: where_branch = "AND i.branch_id = %s" params.append(branch_id) # 1. Total active SKUs cur.execute(f""" SELECT COUNT(*) FROM inventory i WHERE i.is_active = true {where_branch} """, params.copy()) total_skus = cur.fetchone()[0] or 0 # 2. Total inventory value (cost * stock) cur.execute(f""" SELECT COALESCE(SUM(i.cost * COALESCE(s.stock, 0)), 0) FROM inventory i LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id WHERE i.is_active = true {where_branch} """, params.copy()) total_value = float(cur.fetchone()[0] or 0) # 3. Low stock count (below min_stock) cur.execute(f""" SELECT COUNT(*) FROM inventory i LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id WHERE i.is_active = true {where_branch} AND i.min_stock IS NOT NULL AND i.min_stock > 0 AND COALESCE(s.stock, 0) < i.min_stock """, params.copy()) low_stock = cur.fetchone()[0] or 0 # 4. No movement in last 60 days cutoff = datetime.utcnow() - timedelta(days=60) cur.execute(f""" SELECT COUNT(*) FROM inventory i WHERE i.is_active = true {where_branch} AND i.id NOT IN ( SELECT inventory_id FROM inventory_operations WHERE created_at > %s ) """, params + [cutoff]) no_movement = cur.fetchone()[0] or 0 cur.close(); conn.close() return jsonify({ 'total_skus': total_skus, 'total_value': round(total_value, 2), 'low_stock': low_stock, 'no_movement': no_movement, }) # ─── Alerts and History ──────────────────────── @inventory_bp.route('/alerts', methods=['GET']) @require_auth('inventory.view') def api_alerts(): """Get stock alerts (zero, low, over).""" conn = get_tenant_conn(g.tenant_id) branch_id = request.args.get('branch_id', g.branch_id) limit = min(int(request.args.get('limit', 500)), 2000) alerts = get_alerts(conn, branch_id, limit_per_type=limit) conn.close() # Count totals by severity for UI summary counts = {'critical': 0, 'warning': 0, 'info': 0, 'total': 0} for a in alerts: counts['total'] += 1 if a['severity'] in counts: counts[a['severity']] += 1 return jsonify({'data': alerts, 'count': len(alerts), 'counts': counts, 'limit_per_type': limit}) @inventory_bp.route('/items//history', methods=['GET']) @require_auth('inventory.view') def api_history(item_id): """Get movement history for an item.""" conn = get_tenant_conn(g.tenant_id) limit = min(int(request.args.get('limit', 50)), 200) history = get_movement_history(conn, item_id, limit) conn.close() return jsonify({'data': history}) # ─── Inventory Reports ──────────────────────── @inventory_bp.route('/reports/valuation', methods=['GET']) @require_auth('inventory.view') def report_valuation(): """Inventory valuation report: stock x cost per item, with totals. Returns each active item with its current stock and cost, plus the line-level value (stock * cost) and a grand total across all items. """ conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() branch_id = request.args.get('branch_id', g.branch_id) where = "i.is_active = true" params = [] if branch_id: where += " AND i.branch_id = %s" params.append(branch_id) cur.execute(f""" SELECT i.id, i.part_number, i.name, i.brand, i.cost, i.branch_id, COALESCE(s.stock, 0) AS stock, COALESCE(s.stock, 0) * COALESCE(i.cost, 0) AS value FROM inventory i LEFT JOIN ( SELECT inventory_id, SUM(quantity) AS stock FROM inventory_operations GROUP BY inventory_id ) s ON s.inventory_id = i.id WHERE {where} ORDER BY value DESC """, params) items = [] grand_total = 0 for r in cur.fetchall(): val = float(r[7]) grand_total += val items.append({ 'id': r[0], 'part_number': r[1], 'name': r[2], 'brand': r[3], 'cost': float(r[4]) if r[4] else 0, 'branch_id': r[5], 'stock': r[6], 'value': round(val, 2) }) cur.close(); conn.close() return jsonify({'data': items, 'grand_total': round(grand_total, 2), 'item_count': len(items)}) @inventory_bp.route('/reports/abc', methods=['GET']) @require_auth('inventory.view') def report_abc(): """ABC classification by sales volume (last 90 days). A = top 80% of sales volume, B = next 15%, C = remaining 5%. """ conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() branch_id = request.args.get('branch_id', g.branch_id) days = int(request.args.get('days', 90)) where_branch = "" params = [datetime.utcnow() - timedelta(days=days)] if branch_id: where_branch = "AND io.branch_id = %s" params.append(branch_id) cur.execute(f""" SELECT i.id, i.part_number, i.name, i.brand, COALESCE(ABS(SUM(io.quantity)), 0) AS sales_volume FROM inventory i LEFT JOIN inventory_operations io ON io.inventory_id = i.id AND io.operation_type = 'SALE' AND io.created_at >= %s {where_branch} WHERE i.is_active = true GROUP BY i.id, i.part_number, i.name, i.brand ORDER BY sales_volume DESC """, params) rows = cur.fetchall() total_volume = sum(r[4] for r in rows) items = [] cumulative = 0 for r in rows: vol = r[4] cumulative += vol pct = (cumulative / total_volume * 100) if total_volume > 0 else 0 if pct <= 80: cls = 'A' elif pct <= 95: cls = 'B' else: cls = 'C' items.append({ 'id': r[0], 'part_number': r[1], 'name': r[2], 'brand': r[3], 'sales_volume': vol, 'cumulative_pct': round(pct, 1), 'classification': cls }) cur.close(); conn.close() a_count = sum(1 for i in items if i['classification'] == 'A') b_count = sum(1 for i in items if i['classification'] == 'B') c_count = sum(1 for i in items if i['classification'] == 'C') return jsonify({ 'data': items, 'summary': {'A': a_count, 'B': b_count, 'C': c_count, 'total_volume': total_volume, 'days': days} }) @inventory_bp.route('/reports/no-movement', methods=['GET']) @require_auth('inventory.view') def report_no_movement(): """Products with no inventory operations in the last N days (default 60).""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() days = int(request.args.get('days', 60)) branch_id = request.args.get('branch_id', g.branch_id) cutoff = datetime.utcnow() - timedelta(days=days) where_branch = "" params_main = [] if branch_id: where_branch = "AND i.branch_id = %s" params_main.append(branch_id) cur.execute(f""" SELECT i.id, i.part_number, i.name, i.brand, i.cost, COALESCE(s.stock, 0) AS stock, last_op.last_date FROM inventory i LEFT JOIN ( SELECT inventory_id, SUM(quantity) AS stock FROM inventory_operations GROUP BY inventory_id ) s ON s.inventory_id = i.id LEFT JOIN ( SELECT inventory_id, MAX(created_at) AS last_date FROM inventory_operations GROUP BY inventory_id ) last_op ON last_op.inventory_id = i.id WHERE i.is_active = true {where_branch} AND (last_op.last_date IS NULL OR last_op.last_date < %s) ORDER BY last_op.last_date ASC NULLS FIRST """, params_main + [cutoff]) items = [] for r in cur.fetchall(): items.append({ 'id': r[0], 'part_number': r[1], 'name': r[2], 'brand': r[3], 'cost': float(r[4]) if r[4] else 0, 'stock': r[5], 'last_movement': str(r[6]) if r[6] else None }) cur.close(); conn.close() return jsonify({'data': items, 'days_threshold': days, 'count': len(items)}) @inventory_bp.route('/reports/low-stock', methods=['GET']) @require_auth('inventory.view') def report_low_stock(): """Items below their min_stock threshold.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() branch_id = request.args.get('branch_id', g.branch_id) where_branch = "" params = [] if branch_id: where_branch = "AND i.branch_id = %s" params.append(branch_id) cur.execute(f""" SELECT i.id, i.part_number, i.name, i.brand, i.min_stock, COALESCE(s.stock, 0) AS stock, i.min_stock - COALESCE(s.stock, 0) AS deficit FROM inventory i LEFT JOIN ( SELECT inventory_id, SUM(quantity) AS stock FROM inventory_operations GROUP BY inventory_id ) s ON s.inventory_id = i.id WHERE i.is_active = true {where_branch} AND i.min_stock IS NOT NULL AND i.min_stock > 0 AND COALESCE(s.stock, 0) < i.min_stock ORDER BY deficit DESC """, params) items = [] for r in cur.fetchall(): items.append({ 'id': r[0], 'part_number': r[1], 'name': r[2], 'brand': r[3], 'min_stock': r[4], 'stock': r[5], 'deficit': r[6] }) cur.close(); conn.close() return jsonify({'data': items, 'count': len(items)}) @inventory_bp.route('/reports/branch-comparison', methods=['GET']) @require_auth('inventory.view') def report_branch_comparison(): """Stock comparison across all branches for each item.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" SELECT i.id, i.part_number, i.name, i.brand, i.branch_id, b.name AS branch_name, COALESCE(s.stock, 0) AS stock FROM inventory i LEFT JOIN branches b ON i.branch_id = b.id LEFT JOIN ( SELECT inventory_id, SUM(quantity) AS stock FROM inventory_operations GROUP BY inventory_id ) s ON s.inventory_id = i.id WHERE i.is_active = true ORDER BY i.part_number, b.name """) # Group by part_number for comparison by_part = {} for r in cur.fetchall(): pn = r[1] if pn not in by_part: by_part[pn] = {'part_number': pn, 'name': r[2], 'brand': r[3], 'branches': []} by_part[pn]['branches'].append({ 'inventory_id': r[0], 'branch_id': r[4], 'branch_name': r[5], 'stock': r[6] }) cur.close(); conn.close() items = list(by_part.values()) return jsonify({'data': items, 'count': len(items)}) # ─── Categories and Brands ───────────────────── @inventory_bp.route('/brands', methods=['GET']) @require_auth('inventory.view') def list_brands(): """Get distinct part manufacturer brands from inventory. NOTE: These are PART manufacturers (Bosch, NGK, Monroe), not vehicle brands. Vehicle compatibility is stored in the vehicle_compatibility JSON field and searched via the vehicle_brand parameter on the catalog search endpoint. """ conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" SELECT DISTINCT brand FROM inventory WHERE is_active = true AND brand IS NOT NULL AND brand != '' ORDER BY brand """) brands = [r[0] for r in cur.fetchall()] cur.close(); conn.close() return jsonify({'data': brands}) # ─── Barcode ─────────────────────────────────── @inventory_bp.route('/generate-barcode', methods=['POST']) @require_auth('inventory.create') def api_generate_barcode(): """Generate a new internal barcode.""" conn = get_tenant_conn(g.tenant_id) from tenant_db import get_master_conn mconn = get_master_conn() mcur = mconn.cursor() mcur.execute("SELECT db_name FROM tenants WHERE id = %s", (g.tenant_id,)) db_name = mcur.fetchone()[0] mcur.close(); mconn.close() barcode = generate_barcode(conn, db_name) conn.close() return jsonify({'barcode': barcode}) # ─── Multi-branch sync ────────────────────────────────────────────────────── @inventory_bp.route('/stock-by-branch', methods=['GET']) @require_auth('inventory.view') def api_stock_by_branch(): """Get stock for a specific inventory item across all branches.""" inventory_id = request.args.get('inventory_id', type=int) if not inventory_id: return jsonify({'error': 'inventory_id is required'}), 400 conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" SELECT b.id, b.name, b.address, COALESCE(SUM(io.quantity), 0) as stock FROM branches b LEFT JOIN inventory_operations io ON io.branch_id = b.id AND io.inventory_id = %s WHERE b.is_active = true GROUP BY b.id, b.name, b.address ORDER BY b.name """, (inventory_id,)) data = [] for r in cur.fetchall(): data.append({ 'branch_id': r[0], 'branch_name': r[1], 'address': r[2], 'stock': r[3], }) cur.close(); conn.close() return jsonify({'data': data}) @inventory_bp.route('/transfers', methods=['GET']) @require_auth('inventory.view') def api_transfers(): """List stock transfer operations.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() branch_id = request.args.get('branch_id', g.branch_id) limit = request.args.get('limit', 50, type=int) offset = request.args.get('offset', 0, type=int) cur.execute(""" SELECT io.id, io.inventory_id, i.part_number, i.name, io.branch_id, io.quantity, io.notes, io.created_at, e.name as employee_name FROM inventory_operations io JOIN inventory i ON io.inventory_id = i.id LEFT JOIN employees e ON io.employee_id = e.id WHERE io.operation_type = 'TRANSFER' AND (%s IS NULL OR io.branch_id = %s) ORDER BY io.created_at DESC LIMIT %s OFFSET %s """, (branch_id, branch_id, limit, offset)) data = [] for r in cur.fetchall(): data.append({ 'id': r[0], 'inventory_id': r[1], 'part_number': r[2], 'name': r[3], 'branch_id': r[4], 'quantity': r[5], 'notes': r[6], 'created_at': str(r[7]), 'employee': r[8], }) cur.close(); conn.close() return jsonify({'data': data}) @inventory_bp.route('/sync-prices', methods=['POST']) @require_auth('inventory.edit') def api_sync_prices(): """Sync prices from one inventory item to others with the same part_number.""" data = request.get_json() or {} source_id = data.get('source_inventory_id') if not source_id: return jsonify({'error': 'source_inventory_id is required'}), 400 conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute("SELECT part_number, price_1, price_2, price_3, cost FROM inventory WHERE id = %s", (source_id,)) source = cur.fetchone() if not source: cur.close(); conn.close() return jsonify({'error': 'Source item not found'}), 404 part_number, p1, p2, p3, cost = source cur.execute(""" UPDATE inventory SET price_1 = %s, price_2 = %s, price_3 = %s, cost = %s, updated_at = NOW() WHERE part_number = %s AND id != %s """, (p1, p2, p3, cost, part_number, source_id)) updated = cur.rowcount conn.commit() cur.close(); conn.close() return jsonify({'message': f'Synced prices to {updated} items', 'updated': updated}) # ─── Reorder alerts ───────────────────────────────────────────────────────── @inventory_bp.route('/generate-alerts', methods=['POST']) @require_auth('inventory.view') def api_generate_alerts(): """Scan inventory and generate reorder alerts.""" conn = get_tenant_conn(g.tenant_id) try: result = generate_alerts(conn, branch_id=g.branch_id, auto_notify=True) conn.commit() conn.close() return jsonify(result) except Exception as e: conn.rollback() conn.close() return jsonify({'error': str(e)}), 500 @inventory_bp.route('/reorder-alerts', methods=['GET']) @require_auth('inventory.view') def api_reorder_alerts(): """List reorder alerts.""" conn = get_tenant_conn(g.tenant_id) status = request.args.get('status') branch_id = request.args.get('branch_id', g.branch_id) limit = request.args.get('limit', 50, type=int) offset = request.args.get('offset', 0, type=int) data = list_alerts(conn, status=status, branch_id=branch_id, limit=limit, offset=offset) conn.close() return jsonify({'data': data, 'count': len(data)}) @inventory_bp.route('/reorder-alerts//acknowledge', methods=['PUT']) @require_auth('inventory.edit') def api_ack_alert(alert_id): """Acknowledge a reorder alert.""" conn = get_tenant_conn(g.tenant_id) data = request.get_json() or {} try: ok = acknowledge_alert(conn, alert_id, employee_id=g.employee_id, notes=data.get('notes')) conn.commit() conn.close() if not ok: return jsonify({'error': 'Alert not found or already acknowledged'}), 404 return jsonify({'message': 'Alert acknowledged'}) except Exception as e: conn.rollback() conn.close() return jsonify({'error': str(e)}), 500 @inventory_bp.route('/reorder-alerts//resolve', methods=['PUT']) @require_auth('inventory.edit') def api_resolve_alert(alert_id): """Resolve a reorder alert.""" conn = get_tenant_conn(g.tenant_id) data = request.get_json() or {} try: ok = resolve_alert(conn, alert_id, po_id=data.get('po_id'), notes=data.get('notes')) conn.commit() conn.close() if not ok: return jsonify({'error': 'Alert not found'}), 404 return jsonify({'message': 'Alert resolved'}) except Exception as e: conn.rollback() conn.close() return jsonify({'error': str(e)}), 500 @inventory_bp.route('/reorder-suggest-po', methods=['GET']) @require_auth('inventory.edit') def api_reorder_suggest_po(): """Suggest a purchase order based on open low/zero stock alerts.""" conn = get_tenant_conn(g.tenant_id) supplier_id = request.args.get('supplier_id', type=int) branch_id = request.args.get('branch_id', g.branch_id) suggestion = suggest_po_from_alerts(conn, supplier_id=supplier_id, branch_id=branch_id) conn.close() return jsonify(suggestion) # ─── Vehicle Compatibility ─────────────────────────── @inventory_bp.route('/items//vehicles', methods=['GET']) @require_auth('inventory.view') def get_item_vehicles(item_id): """Get all vehicle compatibilities for an inventory item.""" tenant = get_tenant_conn(g.tenant_id) master = get_master_conn() try: vehicles = get_compatibility(tenant, master, item_id) return jsonify({'vehicles': vehicles}) finally: tenant.close() master.close() @inventory_bp.route('/items//vehicles', methods=['POST']) @require_auth('inventory.edit') def add_item_vehicle(item_id): """Manually add a vehicle compatibility.""" data = request.get_json() or {} mye_id = data.get('model_year_engine_id') if not mye_id: return jsonify({'error': 'model_year_engine_id required'}), 400 conn = get_tenant_conn(g.tenant_id) try: cid = add_compatibility(conn, item_id, mye_id, source='manual') return jsonify({'id': cid, 'message': 'Compatibility added'}), 201 finally: conn.close() @inventory_bp.route('/items//vehicles/', methods=['DELETE']) @require_auth('inventory.edit') def delete_item_vehicle(item_id, compat_id): """Remove a vehicle compatibility by its row id. Works for both TecDoc-linked (mye_id present) and text-only QWEN records. """ conn = get_tenant_conn(g.tenant_id) try: deleted = remove_compatibility_by_id(conn, compat_id) return jsonify({'message': 'Compatibility removed', 'deleted': deleted}) finally: conn.close() @inventory_bp.route('/items//vehicles/auto-match', methods=['POST']) @require_auth('inventory.edit') def auto_match_item_vehicles(item_id): """Run auto-match for an existing inventory item.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute("SELECT part_number, brand, name FROM inventory WHERE id = %s", (item_id,)) row = cur.fetchone() cur.close() if not row: conn.close() return jsonify({'error': 'Item not found'}), 404 part_number, brand, name = row compat_source = get_compat_source(g.tenant_id) tecdoc_result = None qwen_result = None # TecDoc auto-match if compat_source in ('tecdoc', 'both'): master = get_master_conn() try: tecdoc_result = auto_match_vehicle_compatibility(master, conn, item_id, part_number, brand=brand, name=name) finally: master.close() # QWEN AI auto-match if compat_source in ('qwen', 'both'): try: from services.qwen_fitment import get_vehicle_fitment from services.inventory_vehicle_compat import save_qwen_fitment fitment = get_vehicle_fitment(part_number, name, brand) inserted = save_qwen_fitment(conn, item_id, fitment) qwen_myes = [v['mye_id'] for v in fitment.get('vehicles', []) if v.get('mye_id')] qwen_result = { 'matched': len(qwen_myes) > 0, 'matches': [], 'myes': qwen_myes, 'inserted': inserted, 'total_qwen': len(qwen_myes), 'confidence': fitment.get('confidence', 0), 'notes': fitment.get('notes', ''), } except Exception as e: qwen_result = {'error': str(e)} conn.close() # Return combined or single-source result if compat_source == 'both': return jsonify({ 'tecdoc': tecdoc_result, 'qwen': qwen_result, 'matched': bool( (tecdoc_result and tecdoc_result.get('matched')) or (qwen_result and qwen_result.get('matched')) ), }) if compat_source == 'tecdoc': return jsonify(tecdoc_result) if compat_source == 'qwen': return jsonify(qwen_result) return jsonify({'error': 'No compatibility source configured'}), 400 # ─── SKU Aliases (multiple part numbers per item) ─────────────────────── @inventory_bp.route('/items//skus', methods=['GET']) @require_auth('inventory.view') def get_item_sku_aliases(item_id): """Return active SKU aliases for an inventory item.""" conn = get_tenant_conn(g.tenant_id) try: cur = conn.cursor() cur.execute( """ SELECT id, sku, label, created_at FROM inventory_sku_aliases WHERE inventory_id = %s AND is_active = true ORDER BY created_at """, (item_id,), ) rows = cur.fetchall() cur.close() return jsonify({ 'aliases': [ {'id': r[0], 'sku': r[1], 'label': r[2], 'created_at': r[3]} for r in rows ] }) finally: conn.close() @inventory_bp.route('/items//skus', methods=['POST']) @require_auth('inventory.edit') def add_item_sku_alias(item_id): """Add an SKU alias to an inventory item.""" data = request.get_json() or {} sku = (data.get('sku') or '').strip() label = (data.get('label') or '').strip() if not sku: return jsonify({'error': 'sku is required'}), 400 conn = get_tenant_conn(g.tenant_id) try: cur = conn.cursor() cur.execute( """ INSERT INTO inventory_sku_aliases (inventory_id, sku, label) VALUES (%s, %s, %s) ON CONFLICT (inventory_id, sku) DO UPDATE SET is_active = true, label = EXCLUDED.label RETURNING id """, (item_id, sku, label or None), ) row = cur.fetchone() conn.commit() cur.close() return jsonify({'id': row[0], 'message': 'SKU alias added'}), 201 except Exception as e: conn.rollback() return jsonify({'error': str(e)}), 500 finally: conn.close() @inventory_bp.route('/items//skus/', methods=['DELETE']) @require_auth('inventory.edit') def delete_item_sku_alias(item_id, alias_id): """Soft-delete an SKU alias.""" conn = get_tenant_conn(g.tenant_id) try: cur = conn.cursor() cur.execute( """ UPDATE inventory_sku_aliases SET is_active = false WHERE id = %s AND inventory_id = %s """, (alias_id, item_id), ) conn.commit() cur.close() return jsonify({'message': 'SKU alias removed'}) finally: conn.close() @inventory_bp.route('/mye/search', methods=['GET']) @require_auth() def search_mye_endpoint(): """Search model_year_engine records for manual compatibility assignment.""" brand_id = request.args.get('brand_id', type=int) model_id = request.args.get('model_id', type=int) year_id = request.args.get('year_id', type=int) engine_id = request.args.get('engine_id', type=int) master = get_master_conn() try: results = search_mye(master, brand_id=brand_id, model_id=model_id, year_id=year_id, engine_id=engine_id) return jsonify({'data': results}) finally: master.close() # ─── Manual Vehicle Compatibility (text-based) ──────────────────────────── @inventory_bp.route('/items//vehicles/manual', methods=['POST']) @require_auth('inventory.edit') def add_manual_vehicle_compat(item_id): """Add a manual vehicle compatibility using free-text fields.""" data = request.get_json() or {} make = (data.get('make') or '').strip() model = (data.get('model') or '').strip() year = data.get('year') engine = (data.get('engine') or '').strip() engine_code = (data.get('engine_code') or '').strip() if not make or not model or not year: return jsonify({'error': 'make, model and year are required'}), 400 conn = get_tenant_conn(g.tenant_id) try: cur = conn.cursor() cur.execute( """ INSERT INTO inventory_vehicle_compat (inventory_id, make, model, year, engine, engine_code, source, model_year_engine_id) VALUES (%s, %s, %s, %s, %s, %s, 'manual', NULL) ON CONFLICT DO NOTHING RETURNING id """, (item_id, make, model, year, engine or None, engine_code or None), ) row = cur.fetchone() conn.commit() cur.close() if not row: return jsonify({'error': 'Compatibility already exists or item not found'}), 409 return jsonify({'id': row[0], 'message': 'Compatibility added'}), 201 finally: conn.close() @inventory_bp.route('/vehicles/makes', methods=['GET']) @require_auth() def get_vehicle_makes(): """Return distinct vehicle makes from master DB.""" master = get_master_conn() try: cur = master.cursor() cur.execute("SELECT id_brand, name_brand FROM brands ORDER BY name_brand") rows = cur.fetchall() cur.close() return jsonify({'makes': [{'id': r[0], 'name': r[1]} for r in rows]}) finally: master.close() @inventory_bp.route('/vehicles/models', methods=['GET']) @require_auth() def get_vehicle_models(): """Return models for a given brand.""" brand_id = request.args.get('brand_id', type=int) if not brand_id: return jsonify({'error': 'brand_id required'}), 400 master = get_master_conn() try: cur = master.cursor() cur.execute( "SELECT id_model, name_model FROM models WHERE brand_id = %s ORDER BY name_model", (brand_id,), ) rows = cur.fetchall() cur.close() return jsonify({'models': [{'id': r[0], 'name': r[1]} for r in rows]}) finally: master.close() @inventory_bp.route('/vehicles/years', methods=['GET']) @require_auth() def get_vehicle_years(): """Return distinct years available for a model.""" model_id = request.args.get('model_id', type=int) if not model_id: return jsonify({'error': 'model_id required'}), 400 master = get_master_conn() try: cur = master.cursor() cur.execute( """ SELECT DISTINCT y.id_year, y.year_car FROM model_year_engine mye JOIN years y ON y.id_year = mye.year_id WHERE mye.model_id = %s ORDER BY y.year_car DESC """, (model_id,), ) rows = cur.fetchall() cur.close() return jsonify({'years': [{'id': r[0], 'year': r[1]} for r in rows]}) finally: master.close() @inventory_bp.route('/vehicles/engines', methods=['GET']) @require_auth() def get_vehicle_engines(): """Return engines available for a model+year.""" model_id = request.args.get('model_id', type=int) year_id = request.args.get('year_id', type=int) if not model_id or not year_id: return jsonify({'error': 'model_id and year_id required'}), 400 master = get_master_conn() try: cur = master.cursor() cur.execute( """ SELECT DISTINCT e.id_engine, e.name_engine, e.engine_code FROM model_year_engine mye JOIN engines e ON e.id_engine = mye.engine_id WHERE mye.model_id = %s AND mye.year_id = %s ORDER BY e.name_engine """, (model_id, year_id), ) rows = cur.fetchall() cur.close() return jsonify({'engines': [{'id': r[0], 'name': r[1], 'code': r[2]} for r in rows]}) finally: master.close() # ─── Categories ────────────────────────────────── @inventory_bp.route('/categories', methods=['GET']) @require_auth() def list_inventory_categories(): """Return active categories (root only). Optional ?parent_id= for subcategories.""" parent_id = request.args.get('parent_id') conn = get_tenant_conn(g.tenant_id) try: cur = conn.cursor() if parent_id: cur.execute( "SELECT id, name FROM categories WHERE parent_id = %s AND is_active = true ORDER BY name", (parent_id,) ) else: cur.execute( "SELECT id, name FROM categories WHERE parent_id IS NULL AND is_active = true ORDER BY name" ) rows = cur.fetchall() return jsonify({'categories': [{'id': r[0], 'name': r[1]} for r in rows]}) finally: conn.close() @inventory_bp.route('/categories//subcategories', methods=['GET']) @require_auth() def list_inventory_subcategories(category_id): """Return subcategories for a given category.""" conn = get_tenant_conn(g.tenant_id) try: cur = conn.cursor() cur.execute( "SELECT id, name FROM categories WHERE parent_id = %s AND is_active = true ORDER BY name", (category_id,) ) rows = cur.fetchall() return jsonify({'subcategories': [{'id': r[0], 'name': r[1]} for r in rows]}) finally: conn.close() # ─── Global Tier Discounts ─────────────────────── @inventory_bp.route('/tier-discounts', methods=['GET']) @require_auth() def get_tier_discounts_endpoint(): """Return global tier discount percentages.""" conn = get_tenant_conn(g.tenant_id) try: discounts = _get_tier_discounts(conn) return jsonify({ 'data': [ {'tier_id': 2, 'tier_name': 'Taller', 'discount_pct': discounts.get(2, 0)}, {'tier_id': 3, 'tier_name': 'Mayoreo', 'discount_pct': discounts.get(3, 0)}, ] }) finally: conn.close() @inventory_bp.route('/tier-discounts', methods=['PUT']) @require_auth('config.edit_prices') def update_tier_discounts_endpoint(): """Update global tier discount percentages.""" data = request.get_json() or {} conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() try: for tier_id in (2, 3): key = f'discount_pct_{tier_id}' if key in data: val = max(0, min(100, float(data[key]))) cur.execute(""" INSERT INTO tier_discounts (tier_id, tier_name, discount_pct) VALUES (%s, %s, %s) ON CONFLICT (tier_id) DO UPDATE SET discount_pct = EXCLUDED.discount_pct """, (tier_id, 'Taller' if tier_id == 2 else 'Mayoreo', val)) conn.commit() return jsonify({'message': 'Descuentos actualizados'}) finally: cur.close() conn.close()