# POS Inventory + Catalog Implementation Plan (2 of 5) > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build the inventory management system (CRUD, operations engine, purchases, adjustments, transfers, physical count, barcodes, alerts) and the catalog UI (browsable inventory with cart that jumps to POS, online bodega availability lookup). **Architecture:** Two Flask blueprints (`inventory_bp.py`, `catalog_bp.py`) with two services (`inventory_engine.py`, `barcode_generator.py`). The inventory engine handles all stock mutations as append-only operations. The catalog is the tenant's own inventory presented as a browsable/searchable storefront with a cart. All endpoints require tenant auth from Plan 1. **Tech Stack:** Python 3, Flask blueprints, psycopg2, HTML/JS/CSS (vanilla) **Spec:** `/home/Autopartes/docs/plans/2026-03-27-pos-inventario-design.md` (sections 3 + 4) **Depends on:** Plan 1 Foundation (complete) — tenant_db, middleware, audit service, config blueprint **Sub-plans:** 1. Foundation (complete) 2. **Inventory + Catalog** (this plan) 3. POS + Cash Register 4. CFDI + Accounting 5. PWA + Sync --- ## File Structure ``` /home/Autopartes/pos/ ├── app.py # MODIFY: register inventory_bp + catalog_bp, add catalog/inventory page routes ├── blueprints/ │ ├── inventory_bp.py # CREATE: inventory CRUD + operations endpoints │ └── catalog_bp.py # CREATE: catalog browsing + cart + external availability ├── services/ │ ├── inventory_engine.py # CREATE: append-only operations, stock calculation, cost averaging │ └── barcode_generator.py # CREATE: generate NX-{tenant}-{seq} barcodes (PostgreSQL sequence) ├── templates/ │ ├── catalog.html # CREATE: browsable catalog with cart │ └── inventory.html # CREATE: inventory management dashboard └── static/ ├── js/ │ ├── catalog.js # CREATE: catalog UI + cart logic + external lookup │ └── inventory.js # CREATE: inventory management UI └── css/ └── common.css # MODIFY: add inventory/catalog specific utility classes ``` --- ### Task 1: Inventory engine service **Files:** - Create: `/home/Autopartes/pos/services/inventory_engine.py` This is the core service that all inventory mutations go through. No direct INSERT to `inventory_operations` should happen outside this service. > **Note (record_sale):** `record_sale()` is not exposed via any HTTP endpoint in the inventory blueprint. > It is called directly by the POS blueprint (Plan 3) which imports `inventory_engine` and calls > `record_sale()` as part of the full sale transaction flow. Sales go through the POS flow which > handles payment, receipt generation, and cash register session accounting — the inventory mutation > is just one step in that pipeline. - [ ] **Step 1: Create inventory_engine.py** ```python # /home/Autopartes/pos/services/inventory_engine.py """Inventory operations engine. All stock mutations go through here. Stock is NEVER stored as a field — it is always computed as: SUM(inventory_operations.quantity) WHERE inventory_id = X AND branch_id = Y Operations are append-only. No UPDATE, no DELETE on inventory_operations. """ from flask import g from services.audit import log_action def get_stock(conn, inventory_id, branch_id=None): """Get current stock for an inventory item. Optionally filter by branch.""" cur = conn.cursor() if branch_id: cur.execute( "SELECT COALESCE(SUM(quantity), 0) FROM inventory_operations WHERE inventory_id = %s AND branch_id = %s", (inventory_id, branch_id) ) else: cur.execute( "SELECT COALESCE(SUM(quantity), 0) FROM inventory_operations WHERE inventory_id = %s", (inventory_id,) ) stock = cur.fetchone()[0] cur.close() return stock def get_stock_bulk(conn, branch_id=None): """Get stock for all items. Returns dict {inventory_id: stock_quantity}.""" cur = conn.cursor() if branch_id: cur.execute(""" SELECT inventory_id, COALESCE(SUM(quantity), 0) FROM inventory_operations WHERE branch_id = %s GROUP BY inventory_id """, (branch_id,)) else: cur.execute(""" SELECT inventory_id, COALESCE(SUM(quantity), 0) FROM inventory_operations GROUP BY inventory_id """) stock_map = {r[0]: r[1] for r in cur.fetchall()} cur.close() return stock_map def record_operation(conn, inventory_id, branch_id, operation_type, quantity, reference_id=None, reference_type=None, cost_at_time=None, notes=None): """Record a single inventory operation. Does NOT commit — caller controls transaction. Args: quantity: positive for entries (PURCHASE, RETURN, INITIAL), negative for exits (SALE) operation_type: SALE, PURCHASE, RETURN, ADJUST, TRANSFER, INITIAL """ cur = conn.cursor() cur.execute(""" INSERT INTO inventory_operations (inventory_id, branch_id, operation_type, quantity, reference_id, reference_type, cost_at_time, employee_id, device_id, notes) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( inventory_id, branch_id, operation_type, quantity, reference_id, reference_type, cost_at_time, getattr(g, 'employee_id', None), getattr(g, 'device_id', None), notes )) op_id = cur.fetchone()[0] cur.close() return op_id def record_purchase(conn, inventory_id, branch_id, quantity, unit_cost, supplier_invoice=None, notes=None): """Record a purchase entry. Updates weighted average cost on the inventory item. IMPORTANT: Cost is stored globally on the inventory item (not per-branch), so we must use TOTAL stock across ALL branches when computing the weighted average. Using branch-scoped stock would produce incorrect averages when the same item exists in multiple branches. """ cur = conn.cursor() cur.execute("SELECT cost FROM inventory WHERE id = %s", (inventory_id,)) current_cost = float(cur.fetchone()[0] or 0) # Use GLOBAL stock (all branches) because cost is a global field on the inventory item current_stock = get_stock(conn, inventory_id, branch_id=None) # Weighted average cost if current_stock + quantity > 0: new_cost = ((current_cost * max(current_stock, 0)) + (unit_cost * quantity)) / (max(current_stock, 0) + quantity) else: new_cost = unit_cost # Update cost on inventory item cur.execute("UPDATE inventory SET cost = %s WHERE id = %s", (round(new_cost, 2), inventory_id)) cur.close() ref_note = f"Compra: {quantity} uds @ ${unit_cost:.2f}" if supplier_invoice: ref_note += f" | Factura: {supplier_invoice}" if notes: ref_note += f" | {notes}" return record_operation( conn, inventory_id, branch_id, 'PURCHASE', quantity, cost_at_time=unit_cost, notes=ref_note ) def record_sale(conn, inventory_id, branch_id, quantity, sale_id=None, cost_at_time=None): """Record a sale (negative quantity). NOT exposed via HTTP endpoint — called directly by the POS blueprint (Plan 3) which imports inventory_engine as part of the full sale transaction. """ return record_operation( conn, inventory_id, branch_id, 'SALE', -abs(quantity), reference_id=sale_id, reference_type='sale', cost_at_time=cost_at_time ) def record_return(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None): """Record a customer return (positive quantity).""" return record_operation( conn, inventory_id, branch_id, 'RETURN', abs(quantity), reference_id=sale_id, reference_type='return', notes=notes ) def record_adjustment(conn, inventory_id, branch_id, quantity, reason): """Record a manual stock adjustment. Reason is mandatory.""" if not reason or len(reason.strip()) < 3: raise ValueError("Adjustment reason is mandatory (min 3 characters)") log_action(conn, 'STOCK_ADJUST', 'inventory', inventory_id, old_value={'stock': get_stock(conn, inventory_id, branch_id)}, new_value={'adjustment': quantity, 'reason': reason}) return record_operation( conn, inventory_id, branch_id, 'ADJUST', quantity, notes=f"Ajuste: {reason}" ) def record_transfer(conn, inventory_id, from_branch_id, to_branch_id, quantity, notes=None): """Transfer stock between branches. Creates two operations (out + in).""" out_id = record_operation( conn, inventory_id, from_branch_id, 'TRANSFER', -abs(quantity), notes=f"Transferencia a sucursal {to_branch_id}" + (f" | {notes}" if notes else "") ) in_id = record_operation( conn, inventory_id, to_branch_id, 'TRANSFER', abs(quantity), notes=f"Transferencia desde sucursal {from_branch_id}" + (f" | {notes}" if notes else "") ) return out_id, in_id def record_initial(conn, inventory_id, branch_id, quantity, cost=None): """Record initial stock load.""" return record_operation( conn, inventory_id, branch_id, 'INITIAL', quantity, cost_at_time=cost, notes="Carga inicial de inventario" ) def get_alerts(conn, branch_id=None): """Get stock alerts: zero stock, below minimum, above maximum.""" stock_map = get_stock_bulk(conn, branch_id) cur = conn.cursor() where = "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.min_stock, i.max_stock, i.branch_id FROM inventory i {where} """, params) alerts = [] for row in cur.fetchall(): inv_id, part_num, name, min_s, max_s, br_id = row stock = stock_map.get(inv_id, 0) if stock <= 0: alerts.append({'type': 'zero', 'severity': 'critical', 'inventory_id': inv_id, 'part_number': part_num, 'name': name, 'stock': stock, 'branch_id': br_id}) elif min_s and stock < min_s: alerts.append({'type': 'low', 'severity': 'warning', 'inventory_id': inv_id, 'part_number': part_num, 'name': name, 'stock': stock, 'min_stock': min_s, 'branch_id': br_id}) elif max_s and stock > max_s: alerts.append({'type': 'over', 'severity': 'info', 'inventory_id': inv_id, 'part_number': part_num, 'name': name, 'stock': stock, 'max_stock': max_s, 'branch_id': br_id}) cur.close() return alerts def get_movement_history(conn, inventory_id, limit=50): """Get operation history for a specific item.""" cur = conn.cursor() cur.execute(""" SELECT io.id, io.operation_type, io.quantity, io.cost_at_time, io.notes, io.created_at, e.name as employee_name, io.branch_id FROM inventory_operations io LEFT JOIN employees e ON io.employee_id = e.id WHERE io.inventory_id = %s ORDER BY io.created_at DESC LIMIT %s """, (inventory_id, limit)) history = [] for r in cur.fetchall(): history.append({ 'id': r[0], 'type': r[1], 'quantity': r[2], 'cost': float(r[3]) if r[3] else None, 'notes': r[4], 'date': str(r[5]), 'employee': r[6], 'branch_id': r[7] }) cur.close() return history ``` - [ ] **Step 2: Commit** ```bash cd /home/Autopartes git add pos/services/inventory_engine.py git commit -m "feat(pos): add inventory operations engine — append-only stock mutations" ``` --- ### Task 2: Barcode generator service **Files:** - Create: `/home/Autopartes/pos/services/barcode_generator.py` Uses a PostgreSQL sequence for barcode numbering to prevent race conditions under concurrent requests. The sequence is created once per tenant schema and used via `nextval()`. - [ ] **Step 1: Create barcode_generator.py** ```python # /home/Autopartes/pos/services/barcode_generator.py """Generate internal barcodes for parts that don't have manufacturer barcodes. Format: NX-{tenant_short}-{sequential_number} Uses a PostgreSQL sequence (barcode_seq) per tenant schema to guarantee uniqueness under concurrent requests. No MAX+1 race conditions. """ def _ensure_sequence(conn): """Create the barcode sequence if it doesn't exist yet.""" cur = conn.cursor() cur.execute(""" DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_class WHERE relkind = 'S' AND relname = 'barcode_seq' ) THEN CREATE SEQUENCE barcode_seq START WITH 1 INCREMENT BY 1 NO MAXVALUE; END IF; END $$; """) cur.close() def generate_barcode(conn, tenant_db_name): """Generate the next barcode for this tenant. Format: NX-{tenant_short}-{NNNNNNN} Example: NX-REFLOPEZ-0000001 Uses a PostgreSQL sequence to guarantee unique sequential numbers even under concurrent requests (no race conditions). """ short = tenant_db_name.replace('tenant_', '').replace('_', '').upper()[:10] prefix = f"NX-{short}-" _ensure_sequence(conn) cur = conn.cursor() cur.execute("SELECT nextval('barcode_seq')") next_num = cur.fetchone()[0] cur.close() return f"{prefix}{next_num:07d}" def generate_barcodes_batch(conn, tenant_db_name, count): """Generate multiple sequential barcodes atomically.""" short = tenant_db_name.replace('tenant_', '').replace('_', '').upper()[:10] prefix = f"NX-{short}-" _ensure_sequence(conn) cur = conn.cursor() # setval + generate range atomically via a single call cur.execute("SELECT nextval('barcode_seq') FROM generate_series(1, %s)", (count,)) nums = [r[0] for r in cur.fetchall()] cur.close() return [f"{prefix}{n:07d}" for n in nums] ``` - [ ] **Step 2: Commit** ```bash cd /home/Autopartes git add pos/services/barcode_generator.py git commit -m "feat(pos): add barcode generator — NX-{tenant}-{seq} format with PG sequence" ``` --- ### Task 3: Inventory blueprint (CRUD + operations + reports) **Files:** - Create: `/home/Autopartes/pos/blueprints/inventory_bp.py` > **Note on `brand` field:** The `brand` column on the `inventory` table represents the **part manufacturer** > (e.g., Bosch, NGK, Monroe) — NOT the vehicle brand. Vehicle compatibility is stored in the > `vehicle_compatibility` JSON field and can be searched using the `vehicle_brand` query parameter > on the catalog search endpoint. This distinction is important: a "Bosch" brake pad is made by Bosch > but fits a Toyota Hilux. - [ ] **Step 1: Create inventory_bp.py** ```python # /home/Autopartes/pos/blueprints/inventory_bp.py """Inventory blueprint: CRUD for inventory items + stock operations + reports.""" import json 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 inventory_bp = Blueprint('inventory', __name__, url_prefix='/pos/api/inventory') # ─── 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 FROM inventory i LEFT JOIN branches b ON i.branch_id = b.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 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) 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), data.get('price_1', 0), data.get('price_2', 0), data.get('price_3', 0), 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')) 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(); conn.close() return jsonify({'id': item_id, 'barcode': barcode, 'message': 'Item created'}), 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/', 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 # 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(); conn.close() return jsonify({'message': 'Item updated'}) # ─── 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'}) # ─── 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 }) # ─── 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) alerts = get_alerts(conn, branch_id) conn.close() return jsonify({'data': alerts, 'count': len(alerts)}) @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('/categories', methods=['GET']) @require_auth('inventory.view') def list_categories(): """Get distinct categories from inventory.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" SELECT DISTINCT category_id FROM inventory WHERE is_active = true AND category_id IS NOT NULL ORDER BY category_id """) categories = [r[0] for r in cur.fetchall()] cur.close(); conn.close() return jsonify({'data': categories}) @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}) ``` - [ ] **Step 2: Create supporting tables for physical counts** The two-phase physical count requires storage tables. Add this migration to the tenant schema setup: ```sql -- Add to tenant schema creation (in tenant_manager.py provision flow) CREATE TABLE IF NOT EXISTS physical_counts ( id SERIAL PRIMARY KEY, branch_id INTEGER NOT NULL REFERENCES branches(id), status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, approved, cancelled notes TEXT, employee_id INTEGER REFERENCES employees(id), approved_by INTEGER REFERENCES employees(id), created_at TIMESTAMP DEFAULT NOW(), approved_at TIMESTAMP ); CREATE TABLE IF NOT EXISTS physical_count_lines ( id SERIAL PRIMARY KEY, physical_count_id INTEGER NOT NULL REFERENCES physical_counts(id), inventory_id INTEGER NOT NULL REFERENCES inventory(id), expected_quantity INTEGER NOT NULL, counted_quantity INTEGER NOT NULL, difference INTEGER NOT NULL ); ``` - [ ] **Step 3: Commit** ```bash cd /home/Autopartes git add pos/blueprints/inventory_bp.py git commit -m "feat(pos): add inventory blueprint — CRUD, operations, reports, two-phase physical count" ``` --- ### Task 4: Catalog blueprint (browsing + cart + external lookup + cross-references) **Files:** - Create: `/home/Autopartes/pos/blueprints/catalog_bp.py` - [ ] **Step 1: Create catalog_bp.py** ```python # /home/Autopartes/pos/blueprints/catalog_bp.py """Catalog blueprint: browsable inventory with cart, external availability lookup, and cross-reference queries.""" from flask import Blueprint, request, jsonify, g from middleware import require_auth from tenant_db import get_tenant_conn from services.inventory_engine import get_stock_bulk catalog_bp = Blueprint('catalog', __name__, url_prefix='/pos/api/catalog') @catalog_bp.route('/search', methods=['GET']) @require_auth('catalog.view') def search_catalog(): """Search the tenant's inventory as a catalog. Returns items with stock and pricing. Query params: q (search), category, brand, vehicle_brand, page, per_page NOTE on filtering: - `brand` filters by part manufacturer (Bosch, NGK, etc.) — the `brand` column. - `vehicle_brand` filters by vehicle compatibility (Toyota, Nissan, etc.) — searches inside the `vehicle_compatibility` JSON field via ILIKE on the cast text. """ conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() q = request.args.get('q', '') category = request.args.get('category', '') brand = request.args.get('brand', '') vehicle_brand = request.args.get('vehicle_brand', '') branch_id = request.args.get('branch_id', g.branch_id) in_stock_only = request.args.get('in_stock', '') == 'true' page = int(request.args.get('page', 1)) per_page = min(int(request.args.get('per_page', 30)), 100) where = ["i.is_active = true"] params = [] if branch_id: where.append("i.branch_id = %s") params.append(branch_id) if q: where.append("(i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode = %s)") params.extend([f'%{q}%', f'%{q}%', q]) if category: where.append("i.category_id = %s") params.append(int(category)) if brand: where.append("i.brand ILIKE %s") params.append(f'%{brand}%') if vehicle_brand: where.append("i.vehicle_compatibility::text ILIKE %s") params.append(f'%{vehicle_brand}%') where_sql = " AND ".join(where) cur.execute(f"SELECT count(*) FROM inventory i WHERE {where_sql}", params) total = cur.fetchone()[0] cur.execute(f""" SELECT i.id, i.part_number, i.barcode, i.name, i.brand, i.unit, i.price_1, i.price_2, i.price_3, i.tax_rate, i.image_url, i.category_id, i.location, i.min_stock FROM inventory i WHERE {where_sql} ORDER BY i.name LIMIT %s OFFSET %s """, params + [per_page, (page - 1) * per_page]) rows = cur.fetchall() inv_ids = [r[0] for r in rows] # Bulk stock lookup 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 rows: stock = stock_map.get(r[0], 0) if in_stock_only and stock <= 0: continue items.append({ 'id': r[0], 'part_number': r[1], 'barcode': r[2], 'name': r[3], 'brand': r[4], 'unit': r[5], 'price_1': float(r[6]) if r[6] else 0, 'price_2': float(r[7]) if r[7] else 0, 'price_3': float(r[8]) if r[8] else 0, 'tax_rate': float(r[9]) if r[9] else 0.16, 'image_url': r[10], 'category_id': r[11], 'location': r[12], 'stock': stock, 'low_stock': r[13] and stock < r[13] if r[13] else False }) 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} }) @catalog_bp.route('/categories', methods=['GET']) @require_auth('catalog.view') def catalog_categories(): """Get categories with item counts for catalog navigation.""" 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.category_id, COUNT(*) as item_count FROM inventory i WHERE {where} AND i.category_id IS NOT NULL GROUP BY i.category_id ORDER BY item_count DESC """, params) categories = [{'id': r[0], 'count': r[1]} for r in cur.fetchall()] cur.close(); conn.close() return jsonify({'data': categories}) @catalog_bp.route('/brands', methods=['GET']) @require_auth('catalog.view') def catalog_brands(): """Get part manufacturer brands with item counts for catalog navigation.""" 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.brand, COUNT(*) as item_count FROM inventory i WHERE {where} AND i.brand IS NOT NULL AND i.brand != '' GROUP BY i.brand ORDER BY item_count DESC """, params) brands = [{'name': r[0], 'count': r[1]} for r in cur.fetchall()] cur.close(); conn.close() return jsonify({'data': brands}) @catalog_bp.route('/barcode/', methods=['GET']) @require_auth('catalog.view') def lookup_barcode(barcode): """Lookup a part by barcode (for scanner). Returns item with stock.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" SELECT i.id, i.part_number, i.barcode, i.name, i.brand, i.unit, i.price_1, i.price_2, i.price_3, i.tax_rate, i.cost, i.image_url, i.branch_id FROM inventory i WHERE (i.barcode = %s OR i.part_number = %s) AND i.is_active = true LIMIT 1 """, (barcode, barcode)) row = cur.fetchone() if not row: cur.close(); conn.close() return jsonify({'error': 'Part not found'}), 404 from services.inventory_engine import get_stock item = { 'id': row[0], 'part_number': row[1], 'barcode': row[2], 'name': row[3], 'brand': row[4], 'unit': row[5], 'price_1': float(row[6]) if row[6] else 0, 'price_2': float(row[7]) if row[7] else 0, 'price_3': float(row[8]) if row[8] else 0, 'tax_rate': float(row[9]) if row[9] else 0.16, 'cost': float(row[10]) if row[10] else 0, 'image_url': row[11], 'stock': get_stock(conn, row[0], row[12]) } cur.close(); conn.close() return jsonify(item) @catalog_bp.route('/external-availability/', methods=['GET']) @require_auth('catalog.view') def external_availability(part_number): """Check part availability in external bodegas (Nexus marketplace). Requires internet. Calls the main Nexus API. """ import requests try: # Query the Nexus master API for warehouse inventory # This calls the existing /api/search endpoint on the main Nexus server resp = requests.get( 'http://localhost:5000/api/search', params={'q': part_number}, timeout=5 ) if resp.status_code != 200: return jsonify({'data': [], 'source': 'nexus', 'error': 'Catalog unavailable'}), 200 results = resp.json() return jsonify({'data': results.get('results', []), 'source': 'nexus'}) except requests.RequestException: return jsonify({'data': [], 'source': 'nexus', 'error': 'No internet connection'}), 200 @catalog_bp.route('/cross-references/', methods=['GET']) @require_auth('catalog.view') def cross_references(part_number): """Get OEM <-> aftermarket cross-references for a part number. Calls the Nexus master API which has the full cross-reference database (part_cross_references table). Returns OEM equivalents and aftermarket alternatives. This follows the same pattern as external-availability: the tenant POS calls the central Nexus server which holds the master catalog data. """ import requests try: resp = requests.get( 'http://localhost:5000/api/cross-references', params={'part_number': part_number}, timeout=5 ) if resp.status_code != 200: return jsonify({'data': [], 'source': 'nexus', 'error': 'Cross-reference service unavailable'}), 200 results = resp.json() return jsonify({ 'part_number': part_number, 'cross_references': results.get('cross_references', []), 'source': 'nexus' }) except requests.RequestException: return jsonify({ 'part_number': part_number, 'cross_references': [], 'source': 'nexus', 'error': 'No internet connection' }), 200 ``` - [ ] **Step 2: Commit** ```bash cd /home/Autopartes git add pos/blueprints/catalog_bp.py git commit -m "feat(pos): add catalog blueprint — search, barcode lookup, external availability, cross-references" ``` --- ### Task 5: Register blueprints and add page routes **Files:** - Modify: `/home/Autopartes/pos/app.py` - [ ] **Step 1: Update app.py** Instead of replacing the entire file, add only the new lines to the existing `app.py`. The current file already has `auth_bp` and `config_bp` registered, the health check, login route, and static route. **Add these lines** (shown as a diff against the current file): ```diff --- a/pos/app.py +++ b/pos/app.py @@ -9,6 +9,12 @@ from blueprints.config_bp import config_bp app.register_blueprint(config_bp) + from blueprints.inventory_bp import inventory_bp + app.register_blueprint(inventory_bp) + + from blueprints.catalog_bp import catalog_bp + app.register_blueprint(catalog_bp) + # Health check @app.route('/pos/health') def health(): @@ -20,6 +26,14 @@ def pos_login(): return render_template('login.html') + @app.route('/pos/catalog') + def pos_catalog(): + return render_template('catalog.html') + + @app.route('/pos/inventory') + def pos_inventory(): + return render_template('inventory.html') + @app.route('/pos/static/') def pos_static(filename): return send_from_directory('static', filename) ``` This adds: 1. Two blueprint imports + registrations (`inventory_bp`, `catalog_bp`) after the existing `config_bp` block 2. Two page routes (`/pos/catalog`, `/pos/inventory`) after the existing `/pos/login` route - [ ] **Step 2: Verify compile** ```bash cd /home/Autopartes/pos && python3 -c "from app import create_app; app = create_app(); print('Routes:', [r.rule for r in app.url_map.iter_rules() if '/pos/' in r.rule])" ``` - [ ] **Step 3: Commit** ```bash cd /home/Autopartes git add pos/app.py git commit -m "feat(pos): register inventory + catalog blueprints, add page routes" ``` --- ### Task 6: Catalog frontend (HTML + JS + CSS) **Files:** - Create: `/home/Autopartes/pos/templates/catalog.html` - Create: `/home/Autopartes/pos/static/js/catalog.js` - Modify: `/home/Autopartes/pos/static/css/common.css` This is the browsable catalog with cart that the cashier uses to find parts and jump to POS. - [ ] **Step 1: Create catalog.html** ```html Catalogo - Nexus POS

Catalogo

Carrito

Carrito vacio

Subtotal$0.00
IVA$0.00
Total$0.00
``` - [ ] **Step 2: Create catalog.js** ```javascript // /home/Autopartes/pos/static/js/catalog.js // Catalog UI: browsable inventory with cart, barcode scanner, external lookup (function () { 'use strict'; const API = '/pos/api'; const token = localStorage.getItem('pos_token'); if (!token) { window.location.href = '/pos/login'; return; } const headers = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }; // ─── State ─── let currentPage = 1; let currentFilters = {}; let cartItems = JSON.parse(localStorage.getItem('pos_cart') || '[]'); let barcodeBuffer = ''; let barcodeTimeout = null; // ─── API helpers ─── async function apiFetch(url, opts) { const resp = await fetch(url, Object.assign({ headers: headers }, opts || {})); if (resp.status === 401) { localStorage.removeItem('pos_token'); window.location.href = '/pos/login'; return null; } return resp.json(); } // ─── Catalog loading ─── async function loadCatalog(page, filters) { currentPage = page || 1; currentFilters = filters || currentFilters; const params = new URLSearchParams({ page: currentPage, per_page: 30 }); if (currentFilters.q) params.set('q', currentFilters.q); if (currentFilters.category) params.set('category', currentFilters.category); if (currentFilters.brand) params.set('brand', currentFilters.brand); if (currentFilters.vehicle_brand) params.set('vehicle_brand', currentFilters.vehicle_brand); const data = await apiFetch(API + '/catalog/search?' + params.toString()); if (!data) return; renderGrid(data.data || []); renderPagination(data.pagination || {}); renderActiveFilters(); } function renderGrid(items) { const grid = document.getElementById('catalogGrid'); if (!items.length) { grid.innerHTML = '

No se encontraron productos

'; return; } grid.innerHTML = items.map(function (it) { var stockClass = it.stock <= 0 ? 'stock-badge--zero' : (it.low_stock ? 'stock-badge--low' : 'stock-badge--ok'); var stockLabel = it.stock <= 0 ? 'Agotado' : it.stock + ' ' + (it.unit || 'PZA'); return '
' + (it.image_url ? '' : '
Sin imagen
') + '
' + escHtml(it.name) + '
' + '
' + escHtml(it.part_number) + (it.brand ? ' · ' + escHtml(it.brand) : '') + '
' + '
' + '$' + fmt(it.price_1) + '' + '' + stockLabel + '' + '
'; }).join(''); // Store items for cart lookup window._catalogItems = {}; items.forEach(function (it) { window._catalogItems[it.id] = it; }); } function renderPagination(pg) { var el = document.getElementById('pagination'); if (!pg || pg.total_pages <= 1) { el.innerHTML = ''; return; } var html = ''; html += '' + pg.page + ' / ' + pg.total_pages + ''; html += ''; el.innerHTML = html; } function renderActiveFilters() { var el = document.getElementById('activeFilters'); var chips = []; if (currentFilters.category) chips.push('Cat: ' + currentFilters.category + ' ×'); if (currentFilters.brand) chips.push('' + escHtml(currentFilters.brand) + ' ×'); if (currentFilters.vehicle_brand) chips.push('Vehiculo: ' + escHtml(currentFilters.vehicle_brand) + ' ×'); el.innerHTML = chips.join(''); } // ─── Sidebar filters ─── async function loadCategories() { var data = await apiFetch(API + '/catalog/categories'); if (!data) return; var ul = document.getElementById('categoryList'); var cats = data.data || []; if (!cats.length) { ul.innerHTML = '
  • Sin categorias
  • '; return; } ul.innerHTML = '
  • Todas
  • ' + cats.map(function (c) { return '
  • Cat #' + c.id + ' (' + c.count + ')
  • '; }).join(''); } async function loadBrands() { var data = await apiFetch(API + '/catalog/brands'); if (!data) return; var ul = document.getElementById('brandList'); var brands = data.data || []; if (!brands.length) { ul.innerHTML = '
  • Sin marcas
  • '; return; } ul.innerHTML = '
  • Todas
  • ' + brands.map(function (b) { return '
  • ' + escHtml(b.name) + ' (' + b.count + ')
  • '; }).join(''); } // ─── Barcode scanner ─── async function lookupBarcode(code) { var data = await apiFetch(API + '/catalog/barcode/' + encodeURIComponent(code)); if (!data || data.error) { alert('Parte no encontrada: ' + code); return; } addToCart(data); } // Listen for rapid keypress (barcode scanners type fast, then Enter) document.addEventListener('keydown', function (e) { if (e.key === 'F1') { e.preventDefault(); document.getElementById('searchInput').focus(); return; } if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') return; if (e.key === 'Enter' && barcodeBuffer.length >= 4) { lookupBarcode(barcodeBuffer.trim()); barcodeBuffer = ''; return; } if (e.key.length === 1) { barcodeBuffer += e.key; clearTimeout(barcodeTimeout); barcodeTimeout = setTimeout(function () { barcodeBuffer = ''; }, 200); } }); // ─── Cart ─── function addToCart(item) { var existing = cartItems.find(function (c) { return c.id === item.id; }); if (existing) { existing.quantity += 1; } else { cartItems.push({ id: item.id, part_number: item.part_number, name: item.name, brand: item.brand, price: item.price_1, tax_rate: item.tax_rate || 0.16, unit: item.unit || 'PZA', stock: item.stock, quantity: 1 }); } saveCart(); renderCart(); } function removeFromCart(index) { cartItems.splice(index, 1); saveCart(); renderCart(); } function updateQuantity(index, qty) { qty = parseInt(qty); if (qty <= 0) { removeFromCart(index); return; } cartItems[index].quantity = qty; saveCart(); renderCart(); } function clearCartFn() { cartItems = []; saveCart(); renderCart(); } function saveCart() { localStorage.setItem('pos_cart', JSON.stringify(cartItems)); } function renderCart() { var badge = document.getElementById('cartBadge'); var total = cartItems.reduce(function (s, c) { return s + c.quantity; }, 0); badge.textContent = total; badge.style.display = total > 0 ? 'flex' : 'none'; var container = document.getElementById('cartItems'); var empty = document.getElementById('cartEmpty'); var checkoutBtn = document.getElementById('checkoutBtn'); if (!cartItems.length) { container.innerHTML = ''; empty.style.display = 'block'; checkoutBtn.disabled = true; document.getElementById('cartSubtotal').textContent = '$0.00'; document.getElementById('cartTax').textContent = '$0.00'; document.getElementById('cartTotal').textContent = '$0.00'; return; } empty.style.display = 'none'; checkoutBtn.disabled = false; var subtotal = 0; var tax = 0; container.innerHTML = cartItems.map(function (c, i) { var lineTotal = c.price * c.quantity; var lineTax = lineTotal * c.tax_rate; subtotal += lineTotal; tax += lineTax; return '
    ' + '
    ' + '
    ' + escHtml(c.name) + '
    ' + '
    ' + escHtml(c.part_number) + '
    ' + '
    ' + '' + '' + c.quantity + '' + '' + '
    ' + '
    ' + '
    $' + fmt(lineTotal) + '
    ' + '' + '
    '; }).join(''); document.getElementById('cartSubtotal').textContent = '$' + fmt(subtotal); document.getElementById('cartTax').textContent = '$' + fmt(tax); document.getElementById('cartTotal').textContent = '$' + fmt(subtotal + tax); } function toggleCart() { document.getElementById('cartSidebar').classList.toggle('open'); } function goToCheckout() { localStorage.setItem('pos_cart', JSON.stringify(cartItems)); window.location.href = '/pos/sale'; } // ─── External availability ─── async function checkExternalAvailability(partNumber) { var pn = partNumber || currentFilters.q || ''; if (!pn) return; var section = document.getElementById('externalSection'); var results = document.getElementById('externalResults'); section.style.display = 'block'; results.innerHTML = '

    Buscando en bodegas...

    '; var data = await apiFetch(API + '/catalog/external-availability/' + encodeURIComponent(pn)); if (!data || !data.data || !data.data.length) { results.innerHTML = '

    No se encontraron resultados externos para "' + escHtml(pn) + '"

    '; return; } results.innerHTML = '
      ' + data.data.map(function (r) { return '
    • ' + escHtml(r.name || r.part_number || pn) + ' — Stock: ' + (r.stock || 'N/A') + '
    • '; }).join('') + '
    '; } // ─── Helpers ─── function fmt(n) { return (parseFloat(n) || 0).toFixed(2); } function escHtml(s) { if (!s) return ''; var d = document.createElement('div'); d.textContent = s; return d.innerHTML; } // ─── Search input ─── var searchInput = document.getElementById('searchInput'); var searchTimeout = null; searchInput.addEventListener('input', function () { clearTimeout(searchTimeout); searchTimeout = setTimeout(function () { currentFilters.q = searchInput.value.trim(); loadCatalog(1, currentFilters); }, 350); }); searchInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); clearTimeout(searchTimeout); currentFilters.q = searchInput.value.trim(); loadCatalog(1, currentFilters); } }); // ─── Expose globals for inline handlers ─── window._addToCart = function (id) { var it = window._catalogItems && window._catalogItems[id]; if (it) addToCart(it); }; window._loadPage = function (p) { loadCatalog(p); }; window._removeFilter = function (key) { delete currentFilters[key]; loadCatalog(1); loadCategories(); loadBrands(); }; window._filterCat = function (id) { if (id) currentFilters.category = id; else delete currentFilters.category; loadCatalog(1); loadCategories(); }; window._filterBrand = function (name) { if (name) currentFilters.brand = name; else delete currentFilters.brand; loadCatalog(1); loadBrands(); }; window._removeFromCart = removeFromCart; window._updateQty = updateQuantity; window.toggleCart = toggleCart; window.goToCheckout = goToCheckout; window.clearCart = clearCartFn; window.checkExternalAvailability = checkExternalAvailability; // ─── Init ─── renderCart(); loadCatalog(1, {}); loadCategories(); loadBrands(); })(); ``` - [ ] **Step 3: Add catalog utility classes to common.css** Append to `/home/Autopartes/pos/static/css/common.css`: ```css /* Catalog grid */ .catalog-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; } .catalog-card { cursor: pointer; transition: all 0.2s; } .catalog-card:hover { border-color: var(--color-primary); transform: translateY(-2px); box-shadow: var(--shadow); } .stock-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; } .stock-badge--ok { background: #dcfce7; color: #166534; } .stock-badge--low { background: #fef9c3; color: #854d0e; } .stock-badge--zero { background: #fecaca; color: #991b1b; } /* Cart sidebar */ .cart-sidebar { position: fixed; right: 0; top: 0; bottom: 0; width: 360px; background: var(--color-surface); border-left: 1px solid var(--color-border); padding: 20px; overflow-y: auto; transform: translateX(100%); transition: transform 0.3s; z-index: 50; } .cart-sidebar.open { transform: translateX(0); } .cart-item { display: flex; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--color-border); } .cart-total { font-family: var(--font-mono); font-size: 1.3rem; font-weight: 700; } /* Search bar */ .search-bar { display: flex; gap: 8px; margin-bottom: 20px; } .search-bar input { flex: 1; padding: 10px 16px; border: 1px solid var(--color-border); border-radius: var(--radius); font-size: 1rem; } .search-bar input:focus { outline: none; border-color: var(--color-primary); } /* Filter chips */ .filter-chips { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; } .chip { padding: 4px 12px; border-radius: 20px; border: 1px solid var(--color-border); font-size: 0.8rem; cursor: pointer; background: transparent; } .chip.active { background: var(--color-primary); color: white; border-color: var(--color-primary); } /* External availability */ .external-results { background: #eff6ff; border: 1px solid #bfdbfe; border-radius: var(--radius); padding: 16px; margin-top: 12px; } ``` - [ ] **Step 4: Commit** ```bash cd /home/Autopartes git add pos/templates/catalog.html pos/static/js/catalog.js pos/static/css/common.css git commit -m "feat(pos): add catalog UI — browsable inventory with cart and barcode scanner" ``` --- ### Task 7: Inventory management frontend **Files:** - Create: `/home/Autopartes/pos/templates/inventory.html` - Create: `/home/Autopartes/pos/static/js/inventory.js` This is the admin/warehouse view for managing inventory — adding items, recording purchases, adjustments, transfers, physical counts. - [ ] **Step 1: Create inventory.html** ```html Inventario - Nexus POS

    Gestion de Inventario

    Productos
    Entradas
    Ajustes
    Transferencias
    Toma Fisica
    Alertas
    Cod. BarrasNo. ParteNombreMarcaStockCostoP1P2P3UbicacionAcciones

    Registrar Entrada de Compra

    Ajuste Manual de Stock

    Transferencia entre Sucursales

    Toma Fisica de Inventario

    Fase 1: Ingrese los conteos. Se generara un borrador con comparacion esperado vs contado. Fase 2: Apruebe para aplicar ajustes.


    Alertas de Stock

    ``` - [ ] **Step 2: Create inventory.js** ```javascript // /home/Autopartes/pos/static/js/inventory.js // Inventory management UI: CRUD, purchases, adjustments, transfers, physical count, alerts (function () { 'use strict'; const API = '/pos/api/inventory'; const token = localStorage.getItem('pos_token'); if (!token) { window.location.href = '/pos/login'; return; } const headers = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }; let currentPage = 1; let currentSearch = ''; let draftCountId = null; // ─── API helper ─── async function apiFetch(url, opts) { const resp = await fetch(url, Object.assign({ headers: headers }, opts || {})); if (resp.status === 401) { localStorage.removeItem('pos_token'); window.location.href = '/pos/login'; return null; } return resp.json(); } // ─── Tab switching ─── document.querySelectorAll('.tab').forEach(function (tab) { tab.addEventListener('click', function () { document.querySelectorAll('.tab').forEach(function (t) { t.classList.remove('active'); }); document.querySelectorAll('.tab-content').forEach(function (c) { c.classList.remove('active'); }); tab.classList.add('active'); document.getElementById('tab-' + tab.dataset.tab).classList.add('active'); if (tab.dataset.tab === 'alerts') loadAlerts(); }); }); // ─── Products ─── async function loadItems(page, search) { currentPage = page || 1; currentSearch = search !== undefined ? search : currentSearch; var params = new URLSearchParams({ page: currentPage, per_page: 50 }); if (currentSearch) params.set('q', currentSearch); var data = await apiFetch(API + '/items?' + params.toString()); if (!data) return; var tbody = document.getElementById('productTableBody'); var items = data.data || []; if (!items.length) { tbody.innerHTML = 'Sin productos'; return; } tbody.innerHTML = items.map(function (it) { return '' + '' + esc(it.barcode) + '' + '' + esc(it.part_number) + '' + '' + esc(it.name) + '' + '' + esc(it.brand) + '' + '' + it.stock + '' + '$' + fmt(it.cost) + '' + '$' + fmt(it.price_1) + '' + '$' + fmt(it.price_2) + '' + '$' + fmt(it.price_3) + '' + '' + esc(it.location) + '' + ' ' + '' + ''; }).join(''); // Pagination var pg = data.pagination || {}; var pgEl = document.getElementById('productPagination'); if (pg.total_pages > 1) { pgEl.innerHTML = '' + '' + pg.page + ' / ' + pg.total_pages + ' (' + pg.total + ' items)' + ''; } else { pgEl.innerHTML = '' + (pg.total || 0) + ' productos'; } } // Search var searchInput = document.getElementById('productSearch'); var searchTimeout; searchInput.addEventListener('input', function () { clearTimeout(searchTimeout); searchTimeout = setTimeout(function () { loadItems(1, searchInput.value.trim()); }, 350); }); // ─── Create item ─── async function createItem() { var data = { part_number: document.getElementById('newPartNumber').value.trim(), name: document.getElementById('newName').value.trim(), brand: document.getElementById('newBrand').value.trim(), barcode: document.getElementById('newBarcode').value.trim() || undefined, cost: parseFloat(document.getElementById('newCost').value) || 0, price_1: parseFloat(document.getElementById('newPrice1').value) || 0, price_2: parseFloat(document.getElementById('newPrice2').value) || 0, price_3: parseFloat(document.getElementById('newPrice3').value) || 0, min_stock: parseInt(document.getElementById('newMinStock').value) || 0, initial_stock: parseInt(document.getElementById('newInitialStock').value) || 0, location: document.getElementById('newLocation').value.trim() }; if (!data.part_number || !data.name) { document.getElementById('createResult').innerHTML = 'Numero de parte y nombre son obligatorios'; return; } var result = await apiFetch(API + '/items', { method: 'POST', body: JSON.stringify(data) }); if (result && result.id) { document.getElementById('createResult').innerHTML = 'Creado ID ' + result.id + ' | Barcode: ' + result.barcode + ''; loadItems(currentPage); } else { document.getElementById('createResult').innerHTML = '' + (result ? result.error || 'Error' : 'Error de red') + ''; } } // ─── Purchase ─── async function recordPurchase() { var data = { inventory_id: parseInt(document.getElementById('purchaseItemId').value), quantity: parseInt(document.getElementById('purchaseQty').value), unit_cost: parseFloat(document.getElementById('purchaseCost').value), supplier_invoice: document.getElementById('purchaseInvoice').value.trim(), notes: document.getElementById('purchaseNotes').value.trim() }; if (!data.inventory_id || !data.quantity || !data.unit_cost) { document.getElementById('purchaseResult').innerHTML = 'Complete todos los campos'; return; } var result = await apiFetch(API + '/purchase', { method: 'POST', body: JSON.stringify(data) }); document.getElementById('purchaseResult').innerHTML = result && result.operation_id ? 'Compra registrada (op #' + result.operation_id + ')' : '' + (result ? result.error || 'Error' : 'Error de red') + ''; } // ─── Adjustment ─── async function recordAdjustment() { var data = { inventory_id: parseInt(document.getElementById('adjustItemId').value), quantity: parseInt(document.getElementById('adjustQty').value), reason: document.getElementById('adjustReason').value.trim() }; if (!data.inventory_id || data.quantity === undefined || !data.reason) { document.getElementById('adjustResult').innerHTML = 'Complete todos los campos (razon obligatoria)'; return; } var result = await apiFetch(API + '/adjustment', { method: 'POST', body: JSON.stringify(data) }); document.getElementById('adjustResult').innerHTML = result && result.operation_id ? 'Ajuste registrado (op #' + result.operation_id + ')' : '' + (result ? result.error || 'Error' : 'Error de red') + ''; } // ─── Transfer ─── async function recordTransfer() { var data = { inventory_id: parseInt(document.getElementById('transferItemId').value), from_branch_id: parseInt(document.getElementById('transferFrom').value), to_branch_id: parseInt(document.getElementById('transferTo').value), quantity: parseInt(document.getElementById('transferQty').value), notes: document.getElementById('transferNotes').value.trim() }; if (!data.inventory_id || !data.from_branch_id || !data.to_branch_id || !data.quantity) { document.getElementById('transferResult').innerHTML = 'Complete todos los campos'; return; } var result = await apiFetch(API + '/transfer', { method: 'POST', body: JSON.stringify(data) }); document.getElementById('transferResult').innerHTML = result && result.out_operation_id ? 'Transferencia registrada' : '' + (result ? result.error || 'Error' : 'Error de red') + ''; } // ─── Physical Count (two-phase) ─── function addCountLine() { var container = document.getElementById('countLines'); var row = document.createElement('div'); row.className = 'count-row'; row.innerHTML = '' + '' + ''; container.appendChild(row); } async function startPhysicalCount() { var rows = document.querySelectorAll('.count-row'); var items = []; rows.forEach(function (row) { var invId = parseInt(row.querySelector('.count-inv-id').value); var qty = parseInt(row.querySelector('.count-qty').value); if (invId && !isNaN(qty)) items.push({ inventory_id: invId, counted_quantity: qty }); }); if (!items.length) { alert('Agregue al menos una linea'); return; } var result = await apiFetch(API + '/physical-count/start', { method: 'POST', body: JSON.stringify({ items: items }) }); if (!result || !result.count_id) { document.getElementById('countResults').innerHTML = '' + (result ? result.error || 'Error' : 'Error de red') + ''; return; } draftCountId = result.count_id; var html = '

    Borrador #' + result.count_id + ' — ' + result.message + '

    '; html += ''; (result.results || []).forEach(function (r) { var color = r.difference === 0 ? '#16a34a' : (r.difference < 0 ? '#dc2626' : '#ca8a04'); html += ''; }); html += '
    IDEsperadoContadoDiferencia
    ' + r.inventory_id + '' + r.expected + '' + r.counted + '' + (r.difference > 0 ? '+' : '') + r.difference + '
    '; html += ''; html += ' '; document.getElementById('countResults').innerHTML = html; } async function approvePhysicalCount() { if (!draftCountId) { alert('No hay borrador activo'); return; } var result = await apiFetch(API + '/physical-count/approve', { method: 'POST', body: JSON.stringify({ count_id: draftCountId }) }); if (result && result.status === 'approved') { document.getElementById('countResults').innerHTML = '' + result.message + ''; draftCountId = null; } else { document.getElementById('countResults').innerHTML += '
    ' + (result ? result.error || 'Error' : 'Error de red') + ''; } } function cancelDraft() { draftCountId = null; document.getElementById('countResults').innerHTML = 'Borrador cancelado'; } // ─── Alerts ─── async function loadAlerts() { var data = await apiFetch(API + '/alerts'); if (!data) return; var el = document.getElementById('alertsList'); var alerts = data.data || []; if (!alerts.length) { el.innerHTML = '

    Sin alertas activas

    '; return; } el.innerHTML = alerts.map(function (a) { var cls = a.severity === 'critical' ? 'alert-critical' : (a.severity === 'warning' ? 'alert-warning' : 'alert-info'); var icon = a.type === 'zero' ? 'AGOTADO' : (a.type === 'low' ? 'BAJO' : 'EXCESO'); return '
    ' + '
    [' + icon + '] ' + esc(a.part_number) + ' — ' + esc(a.name) + ' | Stock: ' + a.stock + (a.min_stock ? ' (min: ' + a.min_stock + ')' : '') + (a.max_stock ? ' (max: ' + a.max_stock + ')' : '') + '
    ' + 'Sucursal ' + a.branch_id + '
    '; }).join(''); } // ─── History modal ─── async function viewHistory(itemId) { var data = await apiFetch(API + '/items/' + itemId + '/history'); if (!data) return; var history = data.data || []; var html = ''; if (!history.length) { html = '

    Sin movimientos

    '; } else { html = ''; history.forEach(function (h) { var qtyColor = h.quantity > 0 ? '#16a34a' : '#dc2626'; html += ''; }); html += '
    FechaTipoCantidadCostoEmpleadoNotas
    ' + h.date + '' + h.type + '' + (h.quantity > 0 ? '+' : '') + h.quantity + '' + (h.cost ? '$' + fmt(h.cost) : '-') + '' + esc(h.employee) + '' + esc(h.notes) + '
    '; } document.getElementById('historyContent').innerHTML = html; document.getElementById('historyModal').classList.add('show'); } function closeHistoryModal() { document.getElementById('historyModal').classList.remove('show'); } // ─── Create modal ─── function showCreateModal() { document.getElementById('createModal').classList.add('show'); } function closeCreateModal() { document.getElementById('createModal').classList.remove('show'); } // ─── Barcode label ─── function printBarcode(barcode, partNumber, name) { var w = window.open('', '_blank', 'width=400,height=250'); w.document.write('Etiqueta'); w.document.write('

    ' + barcode + '

    '); w.document.write('

    ' + partNumber + '

    '); w.document.write('

    ' + name + '

    '); w.document.write(''); w.document.close(); w.print(); } // ─── Helpers ─── function fmt(n) { return (parseFloat(n) || 0).toFixed(2); } function esc(s) { if (!s) return ''; var d = document.createElement('div'); d.textContent = s; return d.innerHTML; } // ─── Expose globals ─── window._loadItems = function (p) { loadItems(p); }; window.viewHistory = viewHistory; window.closeHistoryModal = closeHistoryModal; window.showCreateModal = showCreateModal; window.closeCreateModal = closeCreateModal; window.createItem = createItem; window.recordPurchase = recordPurchase; window.recordAdjustment = recordAdjustment; window.recordTransfer = recordTransfer; window.addCountLine = addCountLine; window.startPhysicalCount = startPhysicalCount; window.approvePhysicalCount = approvePhysicalCount; window.cancelDraft = cancelDraft; window.loadAlerts = loadAlerts; window.printBarcode = printBarcode; // ─── Init ─── loadItems(1); })(); ``` - [ ] **Step 3: Commit** ```bash cd /home/Autopartes git add pos/templates/inventory.html pos/static/js/inventory.js git commit -m "feat(pos): add inventory management UI — products, purchases, adjustments, alerts" ``` --- ### Task 8: Integration tests - [ ] **Step 1: Ensure a test tenant exists** ```bash cd /home/Autopartes/pos python3 -c " from services.tenant_manager import list_tenants tenants = list_tenants() if tenants: print('Existing tenant:', tenants[0]) else: from services.tenant_manager import provision_tenant result = provision_tenant('Test Inv', owner_pin='5678') print('Created:', result) " ``` - [ ] **Step 2: Start server and get token** ```bash cd /home/Autopartes/pos nohup python3 app.py > /tmp/pos_inv_test.log 2>&1 & sleep 3 TENANT_ID=$(python3 -c " from services.tenant_manager import list_tenants print(list_tenants()[0]['id']) ") TOKEN=$(curl -s -X POST http://localhost:5001/pos/api/auth/login \ -H 'Content-Type: application/json' \ -d "{\"tenant_id\": $TENANT_ID, \"pin\": \"5678\", \"device_id\": \"test\"}" \ | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])") echo "Token: ${TOKEN:0:20}..." ``` - [ ] **Step 3: Test inventory CRUD** ```bash H="Authorization: Bearer $TOKEN" # Create item echo "=== Create Item ===" curl -s -X POST http://localhost:5001/pos/api/inventory/items \ -H "$H" -H 'Content-Type: application/json' \ -d '{"part_number":"04465-26420","name":"Balatas delanteras Bosch","brand":"Bosch","price_1":385,"price_2":350,"price_3":320,"cost":220,"min_stock":5,"max_stock":50,"initial_stock":20,"branch_id":1}' # List items echo "=== List Items ===" curl -s "http://localhost:5001/pos/api/inventory/items" -H "$H" # Record purchase echo "=== Purchase ===" curl -s -X POST http://localhost:5001/pos/api/inventory/purchase \ -H "$H" -H 'Content-Type: application/json' \ -d '{"inventory_id":1,"quantity":10,"unit_cost":210,"branch_id":1,"supplier_invoice":"FAC-001"}' # Record adjustment echo "=== Adjustment ===" curl -s -X POST http://localhost:5001/pos/api/inventory/adjustment \ -H "$H" -H 'Content-Type: application/json' \ -d '{"inventory_id":1,"quantity":-2,"reason":"Merma por dano en almacen","branch_id":1}' # Check stock (should be 20 + 10 - 2 = 28) echo "=== Check Stock ===" curl -s "http://localhost:5001/pos/api/inventory/items/1" -H "$H" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Stock: {d[\"stock\"]} (expected: 28)')" # Get movement history echo "=== History ===" curl -s "http://localhost:5001/pos/api/inventory/items/1/history" -H "$H" # Alerts echo "=== Alerts ===" curl -s "http://localhost:5001/pos/api/inventory/alerts" -H "$H" # Catalog search echo "=== Catalog Search ===" curl -s "http://localhost:5001/pos/api/catalog/search?q=balatas" -H "$H" # Barcode lookup echo "=== Barcode Lookup ===" BARCODE=$(curl -s "http://localhost:5001/pos/api/inventory/items/1" -H "$H" | python3 -c "import sys,json; print(json.load(sys.stdin)['barcode'])") curl -s "http://localhost:5001/pos/api/catalog/barcode/$BARCODE" -H "$H" # Catalog page echo "=== Catalog Page ===" curl -s -o /dev/null -w "%{http_code}" http://localhost:5001/pos/catalog # Inventory page echo "=== Inventory Page ===" curl -s -o /dev/null -w "%{http_code}" http://localhost:5001/pos/inventory ``` - [ ] **Step 4: Test transfer** ```bash # Create a second item for transfer testing echo "=== Create Item 2 ===" curl -s -X POST http://localhost:5001/pos/api/inventory/items \ -H "$H" -H 'Content-Type: application/json' \ -d '{"part_number":"90919-01253","name":"Bujia NGK Iridium","brand":"NGK","price_1":125,"cost":70,"initial_stock":30,"branch_id":1}' # Transfer 5 units from branch 1 to branch 2 echo "=== Transfer ===" curl -s -X POST http://localhost:5001/pos/api/inventory/transfer \ -H "$H" -H 'Content-Type: application/json' \ -d '{"inventory_id":2,"from_branch_id":1,"to_branch_id":2,"quantity":5,"notes":"Resurtido sucursal 2"}' # Verify stock after transfer (should be 25 at branch 1) echo "=== Stock after transfer ===" curl -s "http://localhost:5001/pos/api/inventory/items/2" -H "$H" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Stock: {d[\"stock\"]}')" ``` - [ ] **Step 5: Test physical count (start + approve)** ```bash # Phase 1: Start count (draft only, no adjustments yet) echo "=== Physical Count Start ===" COUNT_RESULT=$(curl -s -X POST http://localhost:5001/pos/api/inventory/physical-count/start \ -H "$H" -H 'Content-Type: application/json' \ -d '{"items":[{"inventory_id":1,"counted_quantity":25}],"notes":"Conteo mensual"}') echo "$COUNT_RESULT" # Extract count_id COUNT_ID=$(echo "$COUNT_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin)['count_id'])") echo "Draft count_id: $COUNT_ID" # Phase 2: Approve (this actually creates the adjustments) echo "=== Physical Count Approve ===" curl -s -X POST http://localhost:5001/pos/api/inventory/physical-count/approve \ -H "$H" -H 'Content-Type: application/json' \ -d "{\"count_id\": $COUNT_ID}" # Verify stock after count approval echo "=== Stock after count ===" curl -s "http://localhost:5001/pos/api/inventory/items/1" -H "$H" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Stock: {d[\"stock\"]} (expected: 25)')" ``` - [ ] **Step 6: Test return** ```bash echo "=== Return ===" curl -s -X POST http://localhost:5001/pos/api/inventory/return \ -H "$H" -H 'Content-Type: application/json' \ -d '{"inventory_id":1,"quantity":2,"notes":"Cliente devolvio piezas sin usar"}' # Verify stock after return (should be 25 + 2 = 27) echo "=== Stock after return ===" curl -s "http://localhost:5001/pos/api/inventory/items/1" -H "$H" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Stock: {d[\"stock\"]} (expected: 27)')" ``` - [ ] **Step 7: Test cross-references** ```bash echo "=== Cross References ===" curl -s "http://localhost:5001/pos/api/catalog/cross-references/04465-26420" -H "$H" # Expected: returns cross_references array (may be empty if master API is not running, but endpoint should respond 200) ``` - [ ] **Step 8: Cleanup and commit** ```bash pkill -f "python3 app.py" 2>/dev/null cd /home/Autopartes git add -A pos/ git commit -m "feat(pos): inventory + catalog complete — CRUD, operations, reports, catalog UI with cart" git push origin main ``` --- ## Summary This plan builds: - **Inventory engine** — append-only operations (PURCHASE, SALE, RETURN, ADJUST, TRANSFER, INITIAL), weighted average cost (using global stock across all branches), stock alerts - **Barcode generator** — NX-{tenant}-{sequential} format using PostgreSQL sequence (no race conditions) - **Inventory blueprint** — 20+ endpoints: item CRUD, purchase, adjustment, transfer, return, two-phase physical count (start draft + approve), alerts, history, categories, brands, barcode generation, 5 report endpoints (valuation, ABC, no-movement, low-stock, branch-comparison) - **Catalog blueprint** — 7 endpoints: search, categories, brands, barcode lookup, external availability, cross-references (OEM/aftermarket) - **Catalog UI** — full HTML + JS: browsable inventory with cart, barcode scanner support, external bodega lookup, filter sidebar, pagination - **Inventory UI** — full HTML + JS: product table with search, purchase/adjustment/transfer forms, two-phase physical count with draft preview, alerts dashboard, history modal, barcode label printing **Key design notes:** - `brand` on inventory = part manufacturer (Bosch, NGK), NOT vehicle brand. Vehicle compatibility is in `vehicle_compatibility` JSON field, searched via `vehicle_brand` parameter. - `record_sale()` is not exposed via HTTP — it is called by the POS blueprint (Plan 3) directly. - Physical count is two-phase: `start` creates a draft with comparison, `approve` creates the actual ADJUST operations (requires `inventory.adjust` permission). - `low_stock` filtering is done in SQL (LEFT JOIN + HAVING), keeping pagination accurate. - app.py changes are additive only (2 blueprint registrations + 2 page routes). **Next plan**: POS Plan 3 — POS + Cash Register (sales, payments, quotations, layaways, F-keys, margin display, cash register sessions)