Files
Autoparts-DB/docs/plans/2026-03-27-pos-plan-2-inventory-catalog.md
consultoria-as af404a6474 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) <noreply@anthropic.com>
2026-03-31 02:08:05 +00:00

116 KiB

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
# /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
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
# /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
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
# /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/<int:item_id>', 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/<int:item_id>', 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/<int:item_id>/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:

-- 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
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

# /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/<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/<part_number>', 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/<part_number>', 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
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):

--- 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/<path:filename>')
     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
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
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
<!-- /home/Autopartes/pos/templates/catalog.html -->
<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Catalogo - Nexus POS</title>
    <link rel="stylesheet" href="/pos/static/css/common.css">
    <style>
        body { margin: 0; font-family: var(--font-sans, 'Inter', system-ui, sans-serif); background: var(--color-bg, #f5f5f5); }
        .top-bar { display: flex; align-items: center; gap: 12px; padding: 12px 20px; background: var(--color-surface, #fff); border-bottom: 1px solid var(--color-border, #e5e5e5); position: sticky; top: 0; z-index: 40; }
        .top-bar h1 { font-size: 1.1rem; margin: 0; white-space: nowrap; }
        .cart-btn { position: relative; background: var(--color-primary, #2563eb); color: #fff; border: none; padding: 8px 16px; border-radius: var(--radius, 6px); cursor: pointer; font-size: 0.9rem; }
        .cart-btn .badge { position: absolute; top: -6px; right: -6px; background: #ef4444; color: #fff; border-radius: 50%; width: 20px; height: 20px; font-size: 0.7rem; display: flex; align-items: center; justify-content: center; }
        .main-layout { display: flex; gap: 0; min-height: calc(100vh - 60px); }
        .sidebar-filters { width: 220px; background: var(--color-surface, #fff); border-right: 1px solid var(--color-border, #e5e5e5); padding: 16px; overflow-y: auto; flex-shrink: 0; }
        .sidebar-filters h3 { font-size: 0.85rem; text-transform: uppercase; color: #666; margin: 16px 0 8px; }
        .sidebar-filters ul { list-style: none; padding: 0; margin: 0; }
        .sidebar-filters li { padding: 6px 8px; cursor: pointer; border-radius: 4px; font-size: 0.85rem; }
        .sidebar-filters li:hover, .sidebar-filters li.active { background: var(--color-primary-light, #eff6ff); color: var(--color-primary, #2563eb); }
        .content { flex: 1; padding: 20px; overflow-y: auto; }
        .pagination { display: flex; justify-content: center; gap: 8px; margin-top: 20px; }
        .pagination button { padding: 6px 14px; border: 1px solid var(--color-border, #e5e5e5); border-radius: var(--radius, 6px); background: #fff; cursor: pointer; }
        .pagination button.active { background: var(--color-primary, #2563eb); color: #fff; border-color: var(--color-primary, #2563eb); }
        .pagination button:disabled { opacity: 0.4; cursor: default; }
        .external-section { margin-top: 20px; }
        .empty-state { text-align: center; padding: 60px 20px; color: #888; }
        .kbd { display: inline-block; padding: 2px 6px; background: #e5e5e5; border-radius: 3px; font-size: 0.75rem; font-family: monospace; }
    </style>
</head>
<body>
    <div class="top-bar">
        <h1>Catalogo</h1>
        <div class="search-bar" style="flex:1">
            <input type="text" id="searchInput" placeholder="Buscar por nombre, numero de parte o codigo de barras... (F1)" autocomplete="off">
        </div>
        <button class="cart-btn" id="cartToggle" onclick="toggleCart()">
            Carrito <span class="badge" id="cartBadge" style="display:none">0</span>
        </button>
    </div>

    <div class="main-layout">
        <aside class="sidebar-filters" id="sidebarFilters">
            <h3>Categorias</h3>
            <ul id="categoryList"><li>Cargando...</li></ul>
            <h3>Marcas (fabricante)</h3>
            <ul id="brandList"><li>Cargando...</li></ul>
        </aside>

        <main class="content">
            <div class="filter-chips" id="activeFilters"></div>
            <div class="catalog-grid" id="catalogGrid"></div>
            <div class="pagination" id="pagination"></div>

            <div class="external-section" id="externalSection" style="display:none">
                <h3>Disponibilidad en Bodegas Nexus</h3>
                <div class="external-results" id="externalResults"></div>
            </div>
        </main>
    </div>

    <!-- Cart sidebar -->
    <div class="cart-sidebar" id="cartSidebar">
        <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
            <h2 style="margin:0; font-size:1.1rem;">Carrito</h2>
            <button onclick="toggleCart()" style="background:none; border:none; font-size:1.3rem; cursor:pointer;">&times;</button>
        </div>
        <div id="cartItems"></div>
        <div id="cartEmpty" class="empty-state" style="padding:30px 0;">Carrito vacio</div>
        <hr style="margin:16px 0;">
        <div style="display:flex; justify-content:space-between; margin-bottom:4px;">
            <span>Subtotal</span><span id="cartSubtotal">$0.00</span>
        </div>
        <div style="display:flex; justify-content:space-between; margin-bottom:4px;">
            <span>IVA</span><span id="cartTax">$0.00</span>
        </div>
        <div style="display:flex; justify-content:space-between; font-weight:700; font-size:1.2rem; margin-bottom:16px;">
            <span>Total</span><span class="cart-total" id="cartTotal">$0.00</span>
        </div>
        <button onclick="goToCheckout()" id="checkoutBtn" disabled
                style="width:100%; padding:12px; background:var(--color-primary, #2563eb); color:#fff; border:none; border-radius:var(--radius, 6px); font-size:1rem; cursor:pointer;">
            Ir a cobrar
        </button>
        <button onclick="clearCart()" style="width:100%; padding:8px; background:none; border:1px solid #ddd; border-radius:var(--radius, 6px); margin-top:8px; cursor:pointer; font-size:0.85rem;">
            Vaciar carrito
        </button>
    </div>

    <script src="/pos/static/js/catalog.js"></script>
</body>
</html>
  • Step 2: Create catalog.js
// /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 = '<div class="empty-state"><p>No se encontraron productos</p><button onclick="checkExternalAvailability()" style="margin-top:12px; padding:8px 16px; cursor:pointer;">Buscar en bodegas Nexus</button></div>';
            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 '<div class="card catalog-card" onclick="window._addToCart(' + it.id + ')" data-id="' + it.id + '">' +
                (it.image_url ? '<img src="' + it.image_url + '" alt="" style="width:100%;height:120px;object-fit:contain;margin-bottom:8px;">' : '<div style="height:120px;background:#f0f0f0;display:flex;align-items:center;justify-content:center;margin-bottom:8px;border-radius:4px;color:#aaa;">Sin imagen</div>') +
                '<div style="font-weight:600;font-size:0.9rem;margin-bottom:4px;">' + escHtml(it.name) + '</div>' +
                '<div style="font-size:0.8rem;color:#666;margin-bottom:4px;">' + escHtml(it.part_number) + (it.brand ? ' &middot; ' + escHtml(it.brand) : '') + '</div>' +
                '<div style="display:flex;justify-content:space-between;align-items:center;">' +
                '<span style="font-weight:700;font-size:1rem;">$' + fmt(it.price_1) + '</span>' +
                '<span class="stock-badge ' + stockClass + '">' + stockLabel + '</span>' +
                '</div></div>';
        }).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 = '<button ' + (pg.page <= 1 ? 'disabled' : 'onclick="window._loadPage(' + (pg.page - 1) + ')"') + '>&laquo; Anterior</button>';
        html += '<span style="padding:6px 12px; font-size:0.85rem;">' + pg.page + ' / ' + pg.total_pages + '</span>';
        html += '<button ' + (pg.page >= pg.total_pages ? 'disabled' : 'onclick="window._loadPage(' + (pg.page + 1) + ')"') + '>Siguiente &raquo;</button>';
        el.innerHTML = html;
    }

    function renderActiveFilters() {
        var el = document.getElementById('activeFilters');
        var chips = [];
        if (currentFilters.category) chips.push('<span class="chip active" onclick="window._removeFilter(\'category\')">Cat: ' + currentFilters.category + ' &times;</span>');
        if (currentFilters.brand) chips.push('<span class="chip active" onclick="window._removeFilter(\'brand\')">' + escHtml(currentFilters.brand) + ' &times;</span>');
        if (currentFilters.vehicle_brand) chips.push('<span class="chip active" onclick="window._removeFilter(\'vehicle_brand\')">Vehiculo: ' + escHtml(currentFilters.vehicle_brand) + ' &times;</span>');
        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 = '<li style="color:#999;">Sin categorias</li>'; return; }
        ul.innerHTML = '<li onclick="window._filterCat(null)" class="' + (!currentFilters.category ? 'active' : '') + '">Todas</li>' +
            cats.map(function (c) { return '<li onclick="window._filterCat(' + c.id + ')" class="' + (currentFilters.category == c.id ? 'active' : '') + '">Cat #' + c.id + ' <small>(' + c.count + ')</small></li>'; }).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 = '<li style="color:#999;">Sin marcas</li>'; return; }
        ul.innerHTML = '<li onclick="window._filterBrand(null)" class="' + (!currentFilters.brand ? 'active' : '') + '">Todas</li>' +
            brands.map(function (b) { return '<li onclick="window._filterBrand(\'' + escHtml(b.name) + '\')" class="' + (currentFilters.brand === b.name ? 'active' : '') + '">' + escHtml(b.name) + ' <small>(' + b.count + ')</small></li>'; }).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 '<div class="cart-item">' +
                '<div style="flex:1;">' +
                '<div style="font-weight:600;font-size:0.85rem;">' + escHtml(c.name) + '</div>' +
                '<div style="font-size:0.75rem;color:#666;">' + escHtml(c.part_number) + '</div>' +
                '<div style="margin-top:4px;display:flex;align-items:center;gap:6px;">' +
                '<button onclick="window._updateQty(' + i + ',' + (c.quantity - 1) + ')" style="width:24px;height:24px;border:1px solid #ddd;background:#fff;border-radius:4px;cursor:pointer;">-</button>' +
                '<span style="font-weight:600;">' + c.quantity + '</span>' +
                '<button onclick="window._updateQty(' + i + ',' + (c.quantity + 1) + ')" style="width:24px;height:24px;border:1px solid #ddd;background:#fff;border-radius:4px;cursor:pointer;">+</button>' +
                '</div></div>' +
                '<div style="text-align:right;">' +
                '<div style="font-weight:600;">$' + fmt(lineTotal) + '</div>' +
                '<button onclick="window._removeFromCart(' + i + ')" style="font-size:0.75rem;color:#ef4444;background:none;border:none;cursor:pointer;margin-top:4px;">Quitar</button>' +
                '</div></div>';
        }).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 = '<p>Buscando en bodegas...</p>';

        var data = await apiFetch(API + '/catalog/external-availability/' + encodeURIComponent(pn));
        if (!data || !data.data || !data.data.length) {
            results.innerHTML = '<p>No se encontraron resultados externos para "' + escHtml(pn) + '"</p>';
            return;
        }
        results.innerHTML = '<ul>' + data.data.map(function (r) {
            return '<li><strong>' + escHtml(r.name || r.part_number || pn) + '</strong> — Stock: ' + (r.stock || 'N/A') + '</li>';
        }).join('') + '</ul>';
    }

    // ─── 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:

/* 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
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
<!-- /home/Autopartes/pos/templates/inventory.html -->
<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Inventario - Nexus POS</title>
    <link rel="stylesheet" href="/pos/static/css/common.css">
    <style>
        body { margin: 0; font-family: var(--font-sans, 'Inter', system-ui, sans-serif); background: var(--color-bg, #f5f5f5); }
        .header { padding: 12px 20px; background: var(--color-surface, #fff); border-bottom: 1px solid var(--color-border, #e5e5e5); }
        .header h1 { margin: 0; font-size: 1.2rem; }
        .tabs { display: flex; gap: 0; border-bottom: 2px solid var(--color-border, #e5e5e5); background: var(--color-surface, #fff); padding: 0 20px; overflow-x: auto; }
        .tab { padding: 10px 18px; cursor: pointer; font-size: 0.85rem; border-bottom: 2px solid transparent; margin-bottom: -2px; white-space: nowrap; color: #666; }
        .tab:hover { color: #333; }
        .tab.active { color: var(--color-primary, #2563eb); border-bottom-color: var(--color-primary, #2563eb); font-weight: 600; }
        .tab-content { display: none; padding: 20px; }
        .tab-content.active { display: block; }
        .inv-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
        .inv-table th { text-align: left; padding: 8px 10px; background: #f9f9f9; border-bottom: 2px solid #e5e5e5; font-weight: 600; font-size: 0.8rem; text-transform: uppercase; color: #666; }
        .inv-table td { padding: 8px 10px; border-bottom: 1px solid #eee; }
        .inv-table tr:hover { background: #f9fafb; }
        .form-group { margin-bottom: 14px; }
        .form-group label { display: block; font-size: 0.8rem; font-weight: 600; color: #555; margin-bottom: 4px; }
        .form-group input, .form-group select, .form-group textarea { width: 100%; padding: 8px 10px; border: 1px solid var(--color-border, #ddd); border-radius: var(--radius, 6px); font-size: 0.9rem; box-sizing: border-box; }
        .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
        .btn { padding: 8px 18px; border: none; border-radius: var(--radius, 6px); cursor: pointer; font-size: 0.85rem; }
        .btn-primary { background: var(--color-primary, #2563eb); color: #fff; }
        .btn-secondary { background: #f3f4f6; color: #333; border: 1px solid #ddd; }
        .btn-danger { background: #ef4444; color: #fff; }
        .alert-card { padding: 12px 16px; border-radius: var(--radius, 6px); margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; }
        .alert-critical { background: #fef2f2; border: 1px solid #fecaca; }
        .alert-warning { background: #fefce8; border: 1px solid #fef08a; }
        .alert-info { background: #eff6ff; border: 1px solid #bfdbfe; }
        .count-results { margin-top: 16px; }
        .count-row { display: flex; gap: 12px; align-items: center; margin-bottom: 8px; padding: 8px; background: #fff; border: 1px solid #eee; border-radius: 4px; }
        .modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 100; }
        .modal-overlay.show { display: flex; align-items: center; justify-content: center; }
        .modal { background: #fff; border-radius: 8px; padding: 24px; width: 600px; max-width: 90vw; max-height: 80vh; overflow-y: auto; }
        .search-row { display: flex; gap: 8px; margin-bottom: 16px; }
        .search-row input { flex: 1; padding: 8px 12px; border: 1px solid #ddd; border-radius: var(--radius, 6px); }
    </style>
</head>
<body>
    <div class="header">
        <h1>Gestion de Inventario</h1>
    </div>

    <div class="tabs">
        <div class="tab active" data-tab="products">Productos</div>
        <div class="tab" data-tab="purchases">Entradas</div>
        <div class="tab" data-tab="adjustments">Ajustes</div>
        <div class="tab" data-tab="transfers">Transferencias</div>
        <div class="tab" data-tab="count">Toma Fisica</div>
        <div class="tab" data-tab="alerts">Alertas</div>
    </div>

    <!-- Products tab -->
    <div class="tab-content active" id="tab-products">
        <div class="search-row">
            <input type="text" id="productSearch" placeholder="Buscar productos...">
            <button class="btn btn-primary" onclick="showCreateModal()">+ Nuevo producto</button>
        </div>
        <div style="overflow-x:auto;">
            <table class="inv-table">
                <thead>
                    <tr><th>Cod. Barras</th><th>No. Parte</th><th>Nombre</th><th>Marca</th><th>Stock</th><th>Costo</th><th>P1</th><th>P2</th><th>P3</th><th>Ubicacion</th><th>Acciones</th></tr>
                </thead>
                <tbody id="productTableBody"></tbody>
            </table>
        </div>
        <div id="productPagination" class="pagination" style="margin-top:12px;"></div>
    </div>

    <!-- Purchases tab -->
    <div class="tab-content" id="tab-purchases">
        <h3>Registrar Entrada de Compra</h3>
        <div style="max-width:500px;">
            <div class="form-group"><label>Producto (ID)</label><input type="number" id="purchaseItemId" placeholder="ID del producto"></div>
            <div class="form-row">
                <div class="form-group"><label>Cantidad</label><input type="number" id="purchaseQty" min="1" value="1"></div>
                <div class="form-group"><label>Costo unitario</label><input type="number" id="purchaseCost" step="0.01" placeholder="0.00"></div>
            </div>
            <div class="form-group"><label>Factura proveedor</label><input type="text" id="purchaseInvoice" placeholder="FAC-001"></div>
            <div class="form-group"><label>Notas</label><textarea id="purchaseNotes" rows="2"></textarea></div>
            <button class="btn btn-primary" onclick="recordPurchase()">Registrar compra</button>
            <div id="purchaseResult" style="margin-top:12px;"></div>
        </div>
    </div>

    <!-- Adjustments tab -->
    <div class="tab-content" id="tab-adjustments">
        <h3>Ajuste Manual de Stock</h3>
        <div style="max-width:500px;">
            <div class="form-group"><label>Producto (ID)</label><input type="number" id="adjustItemId" placeholder="ID del producto"></div>
            <div class="form-group"><label>Cantidad (+/-)</label><input type="number" id="adjustQty" placeholder="-2 o +5"></div>
            <div class="form-group"><label>Razon (obligatoria)</label><textarea id="adjustReason" rows="2" placeholder="Merma, error de conteo, etc."></textarea></div>
            <button class="btn btn-primary" onclick="recordAdjustment()">Registrar ajuste</button>
            <div id="adjustResult" style="margin-top:12px;"></div>
        </div>
    </div>

    <!-- Transfers tab -->
    <div class="tab-content" id="tab-transfers">
        <h3>Transferencia entre Sucursales</h3>
        <div style="max-width:500px;">
            <div class="form-group"><label>Producto (ID)</label><input type="number" id="transferItemId" placeholder="ID del producto"></div>
            <div class="form-row">
                <div class="form-group"><label>Sucursal origen (ID)</label><input type="number" id="transferFrom"></div>
                <div class="form-group"><label>Sucursal destino (ID)</label><input type="number" id="transferTo"></div>
            </div>
            <div class="form-group"><label>Cantidad</label><input type="number" id="transferQty" min="1" value="1"></div>
            <div class="form-group"><label>Notas</label><textarea id="transferNotes" rows="2"></textarea></div>
            <button class="btn btn-primary" onclick="recordTransfer()">Transferir</button>
            <div id="transferResult" style="margin-top:12px;"></div>
        </div>
    </div>

    <!-- Physical Count tab -->
    <div class="tab-content" id="tab-count">
        <h3>Toma Fisica de Inventario</h3>
        <p style="font-size:0.85rem;color:#666;">Fase 1: Ingrese los conteos. Se generara un borrador con comparacion esperado vs contado. Fase 2: Apruebe para aplicar ajustes.</p>
        <div id="countForm">
            <div id="countLines">
                <div class="count-row">
                    <input type="number" placeholder="ID producto" class="count-inv-id" style="width:120px;">
                    <input type="number" placeholder="Cantidad contada" class="count-qty" style="width:140px;">
                    <button class="btn btn-secondary" onclick="this.parentElement.remove()">Quitar</button>
                </div>
            </div>
            <button class="btn btn-secondary" onclick="addCountLine()" style="margin:8px 0;">+ Agregar linea</button>
            <br>
            <button class="btn btn-primary" onclick="startPhysicalCount()">Crear borrador</button>
        </div>
        <div id="countResults" class="count-results"></div>
    </div>

    <!-- Alerts tab -->
    <div class="tab-content" id="tab-alerts">
        <h3>Alertas de Stock</h3>
        <button class="btn btn-secondary" onclick="loadAlerts()" style="margin-bottom:12px;">Actualizar</button>
        <div id="alertsList"></div>
    </div>

    <!-- History modal -->
    <div class="modal-overlay" id="historyModal">
        <div class="modal">
            <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
                <h3 style="margin:0;">Historial de movimientos</h3>
                <button onclick="closeHistoryModal()" style="background:none;border:none;font-size:1.3rem;cursor:pointer;">&times;</button>
            </div>
            <div id="historyContent"></div>
        </div>
    </div>

    <!-- Create/Edit modal -->
    <div class="modal-overlay" id="createModal">
        <div class="modal">
            <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
                <h3 style="margin:0;" id="createModalTitle">Nuevo Producto</h3>
                <button onclick="closeCreateModal()" style="background:none;border:none;font-size:1.3rem;cursor:pointer;">&times;</button>
            </div>
            <div class="form-row">
                <div class="form-group"><label>No. Parte *</label><input type="text" id="newPartNumber"></div>
                <div class="form-group"><label>Nombre *</label><input type="text" id="newName"></div>
            </div>
            <div class="form-row">
                <div class="form-group"><label>Marca (fabricante)</label><input type="text" id="newBrand" placeholder="Bosch, NGK..."></div>
                <div class="form-group"><label>Codigo de barras</label><input type="text" id="newBarcode" placeholder="Auto-generado si vacio"></div>
            </div>
            <div class="form-row">
                <div class="form-group"><label>Costo</label><input type="number" id="newCost" step="0.01" value="0"></div>
                <div class="form-group"><label>Precio 1</label><input type="number" id="newPrice1" step="0.01" value="0"></div>
            </div>
            <div class="form-row">
                <div class="form-group"><label>Precio 2</label><input type="number" id="newPrice2" step="0.01" value="0"></div>
                <div class="form-group"><label>Precio 3</label><input type="number" id="newPrice3" step="0.01" value="0"></div>
            </div>
            <div class="form-row">
                <div class="form-group"><label>Stock minimo</label><input type="number" id="newMinStock" value="0"></div>
                <div class="form-group"><label>Stock inicial</label><input type="number" id="newInitialStock" value="0"></div>
            </div>
            <div class="form-group"><label>Ubicacion</label><input type="text" id="newLocation" placeholder="Pasillo A, Estante 3"></div>
            <button class="btn btn-primary" onclick="createItem()">Guardar</button>
            <div id="createResult" style="margin-top:8px;"></div>
        </div>
    </div>

    <script src="/pos/static/js/inventory.js"></script>
</body>
</html>
  • Step 2: Create inventory.js
// /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 = '<tr><td colspan="11" style="text-align:center;padding:30px;color:#999;">Sin productos</td></tr>'; return; }

        tbody.innerHTML = items.map(function (it) {
            return '<tr>' +
                '<td style="font-family:monospace;font-size:0.8rem;">' + esc(it.barcode) + '</td>' +
                '<td>' + esc(it.part_number) + '</td>' +
                '<td><strong>' + esc(it.name) + '</strong></td>' +
                '<td>' + esc(it.brand) + '</td>' +
                '<td style="font-weight:600;">' + it.stock + '</td>' +
                '<td>$' + fmt(it.cost) + '</td>' +
                '<td>$' + fmt(it.price_1) + '</td>' +
                '<td>$' + fmt(it.price_2) + '</td>' +
                '<td>$' + fmt(it.price_3) + '</td>' +
                '<td>' + esc(it.location) + '</td>' +
                '<td><button class="btn btn-secondary" onclick="viewHistory(' + it.id + ')" style="padding:4px 8px;font-size:0.75rem;">Historial</button> ' +
                '<button class="btn btn-secondary" onclick="printBarcode(\'' + esc(it.barcode) + '\',\'' + esc(it.part_number) + '\',\'' + esc(it.name) + '\')" style="padding:4px 8px;font-size:0.75rem;">Etiqueta</button></td>' +
                '</tr>';
        }).join('');

        // Pagination
        var pg = data.pagination || {};
        var pgEl = document.getElementById('productPagination');
        if (pg.total_pages > 1) {
            pgEl.innerHTML = '<button class="btn btn-secondary" ' + (pg.page <= 1 ? 'disabled' : 'onclick="window._loadItems(' + (pg.page - 1) + ')"') + '>&laquo;</button>' +
                '<span style="padding:6px 12px;font-size:0.85rem;">' + pg.page + ' / ' + pg.total_pages + ' (' + pg.total + ' items)</span>' +
                '<button class="btn btn-secondary" ' + (pg.page >= pg.total_pages ? 'disabled' : 'onclick="window._loadItems(' + (pg.page + 1) + ')"') + '>&raquo;</button>';
        } else {
            pgEl.innerHTML = '<span style="font-size:0.85rem;color:#999;">' + (pg.total || 0) + ' productos</span>';
        }
    }

    // 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 = '<span style="color:red;">Numero de parte y nombre son obligatorios</span>'; return; }

        var result = await apiFetch(API + '/items', { method: 'POST', body: JSON.stringify(data) });
        if (result && result.id) {
            document.getElementById('createResult').innerHTML = '<span style="color:green;">Creado ID ' + result.id + ' | Barcode: ' + result.barcode + '</span>';
            loadItems(currentPage);
        } else {
            document.getElementById('createResult').innerHTML = '<span style="color:red;">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
        }
    }

    // ─── 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 = '<span style="color:red;">Complete todos los campos</span>'; return;
        }
        var result = await apiFetch(API + '/purchase', { method: 'POST', body: JSON.stringify(data) });
        document.getElementById('purchaseResult').innerHTML = result && result.operation_id
            ? '<span style="color:green;">Compra registrada (op #' + result.operation_id + ')</span>'
            : '<span style="color:red;">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
    }

    // ─── 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 = '<span style="color:red;">Complete todos los campos (razon obligatoria)</span>'; return;
        }
        var result = await apiFetch(API + '/adjustment', { method: 'POST', body: JSON.stringify(data) });
        document.getElementById('adjustResult').innerHTML = result && result.operation_id
            ? '<span style="color:green;">Ajuste registrado (op #' + result.operation_id + ')</span>'
            : '<span style="color:red;">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
    }

    // ─── 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 = '<span style="color:red;">Complete todos los campos</span>'; return;
        }
        var result = await apiFetch(API + '/transfer', { method: 'POST', body: JSON.stringify(data) });
        document.getElementById('transferResult').innerHTML = result && result.out_operation_id
            ? '<span style="color:green;">Transferencia registrada</span>'
            : '<span style="color:red;">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
    }

    // ─── Physical Count (two-phase) ───
    function addCountLine() {
        var container = document.getElementById('countLines');
        var row = document.createElement('div');
        row.className = 'count-row';
        row.innerHTML = '<input type="number" placeholder="ID producto" class="count-inv-id" style="width:120px;">' +
            '<input type="number" placeholder="Cantidad contada" class="count-qty" style="width:140px;">' +
            '<button class="btn btn-secondary" onclick="this.parentElement.remove()">Quitar</button>';
        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 = '<span style="color:red;">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
            return;
        }

        draftCountId = result.count_id;
        var html = '<h4>Borrador #' + result.count_id + ' — ' + result.message + '</h4>';
        html += '<table class="inv-table"><thead><tr><th>ID</th><th>Esperado</th><th>Contado</th><th>Diferencia</th></tr></thead><tbody>';
        (result.results || []).forEach(function (r) {
            var color = r.difference === 0 ? '#16a34a' : (r.difference < 0 ? '#dc2626' : '#ca8a04');
            html += '<tr><td>' + r.inventory_id + '</td><td>' + r.expected + '</td><td>' + r.counted + '</td><td style="color:' + color + ';font-weight:600;">' + (r.difference > 0 ? '+' : '') + r.difference + '</td></tr>';
        });
        html += '</tbody></table>';
        html += '<button class="btn btn-primary" onclick="approvePhysicalCount()" style="margin-top:12px;">Aprobar y aplicar ajustes</button>';
        html += ' <button class="btn btn-secondary" onclick="cancelDraft()" style="margin-top:12px;">Cancelar</button>';
        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 = '<span style="color:green;">' + result.message + '</span>';
            draftCountId = null;
        } else {
            document.getElementById('countResults').innerHTML += '<br><span style="color:red;">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
        }
    }

    function cancelDraft() {
        draftCountId = null;
        document.getElementById('countResults').innerHTML = '<span style="color:#999;">Borrador cancelado</span>';
    }

    // ─── 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 = '<p style="color:#999;">Sin alertas activas</p>'; 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 '<div class="alert-card ' + cls + '">' +
                '<div><strong>[' + icon + ']</strong> ' + esc(a.part_number) + ' — ' + esc(a.name) + ' | Stock: ' + a.stock +
                (a.min_stock ? ' (min: ' + a.min_stock + ')' : '') + (a.max_stock ? ' (max: ' + a.max_stock + ')' : '') + '</div>' +
                '<span style="font-size:0.8rem;color:#888;">Sucursal ' + a.branch_id + '</span></div>';
        }).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 = '<p style="color:#999;">Sin movimientos</p>'; }
        else {
            html = '<table class="inv-table"><thead><tr><th>Fecha</th><th>Tipo</th><th>Cantidad</th><th>Costo</th><th>Empleado</th><th>Notas</th></tr></thead><tbody>';
            history.forEach(function (h) {
                var qtyColor = h.quantity > 0 ? '#16a34a' : '#dc2626';
                html += '<tr><td style="font-size:0.8rem;">' + h.date + '</td><td>' + h.type + '</td><td style="color:' + qtyColor + ';font-weight:600;">' + (h.quantity > 0 ? '+' : '') + h.quantity + '</td><td>' + (h.cost ? '$' + fmt(h.cost) : '-') + '</td><td>' + esc(h.employee) + '</td><td style="font-size:0.8rem;">' + esc(h.notes) + '</td></tr>';
            });
            html += '</tbody></table>';
        }
        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('<html><head><title>Etiqueta</title><style>body{font-family:monospace;text-align:center;padding:20px;}h1{font-size:1.5rem;margin:8px 0;}p{margin:4px 0;}</style></head><body>');
        w.document.write('<h1>' + barcode + '</h1>');
        w.document.write('<p>' + partNumber + '</p>');
        w.document.write('<p style="font-size:0.85rem;">' + name + '</p>');
        w.document.write('</body></html>');
        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
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
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
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
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
# 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)
# 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
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
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
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)