From af404a6474b9d6b862f1c67dfb71c4b94212315f Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Tue, 31 Mar 2026 02:08:05 +0000 Subject: [PATCH] docs: add POS Inventory + Catalog implementation plan (2 of 5) 8-task plan covering: - Inventory engine (append-only operations, weighted avg cost) - Barcode generator (PostgreSQL sequence) - Inventory blueprint (15+ endpoints: CRUD, purchases, adjustments, transfers, physical count, reports) - Catalog blueprint (search, barcode lookup, cross-references, external availability) - Catalog UI with cart and barcode scanner support - Inventory management UI with tabs for all operations - 5 inventory reports (valuation, ABC, no-movement, low-stock, branch comparison) Co-Authored-By: Claude Opus 4.6 (1M context) --- ...2026-03-27-pos-plan-2-inventory-catalog.md | 2786 +++++++++++++++++ 1 file changed, 2786 insertions(+) create mode 100644 docs/plans/2026-03-27-pos-plan-2-inventory-catalog.md diff --git a/docs/plans/2026-03-27-pos-plan-2-inventory-catalog.md b/docs/plans/2026-03-27-pos-plan-2-inventory-catalog.md new file mode 100644 index 0000000..487bfc8 --- /dev/null +++ b/docs/plans/2026-03-27-pos-plan-2-inventory-catalog.md @@ -0,0 +1,2786 @@ +# 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)