Files
Autoparts-DB/docs/plans/2026-03-27-pos-plan-3-pos-cashregister.md
consultoria-as 7036a18601 docs: add POS + Cash Register implementation plan (3 of 5)
9-task plan covering:
- POS engine (sale processing, Decimal totals, margin info)
- Customers blueprint (CRUD, credit, vehicle history, statements, payments)
- POS blueprint (sales, quotations, layaways with stock reservation)
- Cash register (open/close, X/Z cuts, daily consolidated summary)
- POS frontend (F1-F6 shortcuts, margin display, payment modal)
- Customers frontend (search, credit status, account statements)
- Full integration test (20+ tests covering entire sale lifecycle)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 03:32:00 +00:00

207 KiB

POS + Cash Register Implementation Plan (3 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 complete Point of Sale module: sale processing engine, customer management, quotations, layaways, cash register operations, and the POS frontend with F-key shortcuts for rapid counter operation.

Architecture: Three Flask blueprints (pos_bp.py, customers_bp.py, cashregister_bp.py) with one core service (pos_engine.py). The POS engine calls record_sale() from inventory_engine for stock deduction -- it does NOT create its own inventory operations. The frontend is a split-panel layout (search/items left, current sale right) with keyboard-driven operation.

Tech Stack: Python 3, Flask blueprints, psycopg2, HTML/JS/CSS (vanilla)

Spec: /home/Autopartes/docs/plans/2026-03-27-pos-inventario-design.md (section 2: POS module)

Depends on: Plan 1 Foundation (complete) -- tenant_db, middleware, audit service. Plan 2 Inventory + Catalog (complete) -- inventory_engine, catalog with cart.

Sub-plans:

  1. Foundation (complete)
  2. Inventory + Catalog (complete)
  3. POS + Cash Register (this plan)
  4. CFDI + Accounting
  5. PWA + Sync

File Structure

/home/Autopartes/pos/
├── app.py                              # MODIFY: register pos_bp, customers_bp, cashregister_bp, add page routes
├── blueprints/
│   ├── pos_bp.py                       # CREATE: sales, quotations, layaways endpoints
│   ├── customers_bp.py                 # CREATE: customer CRUD, credit, vehicles, statements
│   └── cashregister_bp.py              # CREATE: register open/close, movements, cuts X/Z
├── services/
│   └── pos_engine.py                   # CREATE: sale processing, totals, pricing, cancellation
├── templates/
│   ├── pos.html                        # CREATE: POS page (split layout, cart, payment modal)
│   └── customers.html                  # CREATE: customer management page
└── static/
    └── js/
        ├── pos.js                      # CREATE: POS UI, F-keys, payment flow, ticket print
        └── customers.js                # CREATE: customer CRUD UI, credit, vehicles

Task 1: POS engine service (pos/services/pos_engine.py)

Files:

  • Create: /home/Autopartes/pos/services/pos_engine.py

Core sale processing logic. All monetary calculations happen here. This service calls record_sale() from inventory_engine for stock deduction -- it never creates inventory operations directly.

  • Step 1: Create pos_engine.py
# /home/Autopartes/pos/services/pos_engine.py
"""POS engine: sale processing, totals calculation, pricing, cancellation.

All sale operations go through this service. Stock deductions are delegated
to inventory_engine.record_sale() — this service NEVER creates inventory
operations directly.

Monetary amounts: NUMERIC(12,2) in DB, float in Python.
Tax: 16% IVA per item (from item.tax_rate field).
"""

from datetime import datetime, timedelta
from decimal import Decimal, ROUND_HALF_UP
from flask import g
from services.audit import log_action
from services.inventory_engine import (
    record_sale as inventory_record_sale,
    record_operation,
    get_stock,
)


def _to_dec(val):
    """Convert a value to Decimal for precise arithmetic."""
    return Decimal(str(val))


def calculate_totals(items):
    """Compute subtotal, discount amounts, tax, and total for a list of items.

    Uses Python Decimal for all intermediate calculations to avoid
    floating-point accumulation errors. Each line item is rounded
    individually before summing. Converts back to float only at
    the end for JSON serialization.

    Each item dict must have: unit_price, quantity, discount_pct, tax_rate.
    Returns dict with computed values and enriched items list.

    Args:
        items: list of dicts with keys:
            - unit_price (float): price per unit
            - quantity (int): number of units
            - discount_pct (float): discount percentage (0-100)
            - tax_rate (float): tax rate as decimal (e.g., 0.16 for 16%)

    Returns:
        dict: {subtotal, discount_total, tax_total, total, items: [...enriched...]}
    """
    subtotal = Decimal('0')
    discount_total = Decimal('0')
    tax_total = Decimal('0')
    enriched_items = []
    TWO = Decimal('0.01')

    for item in items:
        qty = int(item['quantity'])
        price = _to_dec(item['unit_price'])
        discount_pct = _to_dec(item.get('discount_pct', 0))
        tax_rate = _to_dec(item.get('tax_rate', '0.16'))

        line_gross = (price * qty).quantize(TWO, rounding=ROUND_HALF_UP)
        line_discount = (line_gross * discount_pct / Decimal('100')).quantize(TWO, rounding=ROUND_HALF_UP)
        line_after_discount = (line_gross - line_discount).quantize(TWO, rounding=ROUND_HALF_UP)
        line_tax = (line_after_discount * tax_rate).quantize(TWO, rounding=ROUND_HALF_UP)
        line_subtotal = (line_after_discount + line_tax).quantize(TWO, rounding=ROUND_HALF_UP)

        subtotal += line_after_discount
        discount_total += line_discount
        tax_total += line_tax

        enriched_items.append({
            **item,
            'line_gross': float(line_gross),
            'discount_amount': float(line_discount),
            'tax_amount': float(line_tax),
            'subtotal': float(line_subtotal),
        })

    return {
        'subtotal': float(subtotal.quantize(TWO, rounding=ROUND_HALF_UP)),
        'discount_total': float(discount_total.quantize(TWO, rounding=ROUND_HALF_UP)),
        'tax_total': float(tax_total.quantize(TWO, rounding=ROUND_HALF_UP)),
        'total': float((subtotal + tax_total).quantize(TWO, rounding=ROUND_HALF_UP)),
        'items': enriched_items,
    }


def get_price_for_customer(inventory_item, customer):
    """Return the correct price based on the customer's price tier.

    Args:
        inventory_item: dict with price_1, price_2, price_3
        customer: dict with price_tier (1, 2, or 3), or None for publico general

    Returns:
        float: the applicable price
    """
    if customer is None:
        return float(inventory_item.get('price_1', 0))

    tier = customer.get('price_tier', 1)
    if tier == 3:
        price = inventory_item.get('price_3', 0)
    elif tier == 2:
        price = inventory_item.get('price_2', 0)
    else:
        price = inventory_item.get('price_1', 0)

    return float(price) if price else float(inventory_item.get('price_1', 0))


def get_margin_info(inventory_item, selling_price=None, discount_pct=0):
    """Return cost, price, margin %, and max discount without losing margin.

    Only meaningful for employees with pos.view_cost permission (checked by caller).

    Args:
        inventory_item: dict with cost, price_1 (or selling_price override)
        selling_price: override price (if None, uses price_1)
        discount_pct: current discount percentage

    Returns:
        dict: {cost, price, margin_pct, max_discount_pct}
    """
    cost = float(inventory_item.get('cost', 0))
    price = float(selling_price) if selling_price else float(inventory_item.get('price_1', 0))

    if price <= 0:
        return {'cost': cost, 'price': price, 'margin_pct': 0.0, 'max_discount_pct': 0.0}

    effective_price = price * (1 - discount_pct / 100)
    margin_pct = ((effective_price - cost) / effective_price * 100) if effective_price > 0 else 0.0

    # Max discount before margin hits zero: price * (1 - d/100) = cost => d = (1 - cost/price) * 100
    max_discount_pct = ((1 - cost / price) * 100) if price > cost else 0.0

    return {
        'cost': round(cost, 2),
        'price': round(price, 2),
        'margin_pct': round(margin_pct, 2),
        'max_discount_pct': round(max_discount_pct, 2),
    }


def process_sale(conn, sale_data):
    """Process a complete sale: validate, create records, deduct inventory, record payment.

    This is the main entry point for creating a sale. It handles the full transaction:
    1. Validate all items exist and have sufficient stock
    2. Calculate totals
    3. Create sale + sale_items records
    4. Call inventory_engine.record_sale() for each item (stock deduction)
    5. Record payment on cash register
    6. Update customer credit balance (if credit sale)
    7. Create audit log entry

    Args:
        conn: psycopg2 connection to tenant DB (caller controls commit)
        sale_data: dict with keys:
            - items: [{inventory_id, quantity, unit_price, discount_pct, tax_rate}]
            - customer_id: int or None (publico general)
            - payment_method: 'efectivo' | 'transferencia' | 'tarjeta' | 'mixto'
            - sale_type: 'cash' | 'credit' | 'mixed'
            - register_id: int (cash register session ID)
            - amount_paid: float
            - payment_details: [{method, amount, reference}] (for mixed payments)
            - notes: str (optional)

    Returns:
        dict: complete sale object with id, items, totals, change

    Raises:
        ValueError: on validation errors (insufficient stock, invalid items, etc.)
    """
    cur = conn.cursor()
    items = sale_data.get('items', [])
    customer_id = sale_data.get('customer_id')
    payment_method = sale_data.get('payment_method', 'efectivo')
    sale_type = sale_data.get('sale_type', 'cash')
    register_id = sale_data.get('register_id')
    amount_paid = float(sale_data.get('amount_paid', 0))
    payment_details = sale_data.get('payment_details', [])
    notes = sale_data.get('notes')
    branch_id = getattr(g, 'branch_id', None)
    employee_id = getattr(g, 'employee_id', None)

    if not items:
        raise ValueError("No items in sale")

    # Validate register is open
    if register_id:
        cur.execute("SELECT status FROM cash_registers WHERE id = %s", (register_id,))
        reg = cur.fetchone()
        if not reg or reg[0] != 'open':
            raise ValueError("Cash register is not open")

    # Validate and enrich items from inventory
    enriched_items = []
    for item in items:
        inv_id = item.get('inventory_id')
        qty = int(item.get('quantity', 1))
        if qty <= 0:
            raise ValueError(f"Invalid quantity for inventory_id {inv_id}")

        cur.execute("""
            SELECT id, part_number, name, cost, price_1, price_2, price_3,
                   tax_rate, branch_id
            FROM inventory WHERE id = %s AND is_active = true
        """, (inv_id,))
        inv = cur.fetchone()
        if not inv:
            raise ValueError(f"Inventory item {inv_id} not found or inactive")

        # Check stock (allow negative stock for offline tolerance, but warn)
        current_stock = get_stock(conn, inv_id, inv[8])  # inv[8] = branch_id

        # Use provided price or fetch from inventory
        unit_price = float(item.get('unit_price', inv[4]))  # default to price_1
        discount_pct = float(item.get('discount_pct', 0))
        tax_rate = float(item.get('tax_rate', inv[7] or 0.16))
        unit_cost = float(inv[3]) if inv[3] else 0

        # Validate discount against employee max
        max_discount = float(getattr(g, 'max_discount_pct', 100) or 100)
        if g.employee_role not in ('owner', 'admin') and discount_pct > max_discount:
            raise ValueError(
                f"Discount {discount_pct}% exceeds your maximum allowed {max_discount}% "
                f"for item {inv[2]}"
            )

        enriched_items.append({
            'inventory_id': inv_id,
            'part_number': inv[1],
            'name': inv[2],
            'quantity': qty,
            'unit_price': unit_price,
            'unit_cost': unit_cost,
            'discount_pct': discount_pct,
            'tax_rate': tax_rate,
            'branch_id': inv[8],
            'stock_before': current_stock,
        })

    # Calculate totals
    totals = calculate_totals(enriched_items)

    # Validate credit sale
    if sale_type == 'credit' and customer_id:
        cur.execute("SELECT credit_limit, credit_balance FROM customers WHERE id = %s", (customer_id,))
        cust = cur.fetchone()
        if cust:
            credit_limit = float(cust[0] or 0)
            credit_balance = float(cust[1] or 0)
            credit_available = credit_limit - credit_balance
            if totals['total'] > credit_available and credit_limit > 0:
                raise ValueError(
                    f"Insufficient credit. Available: ${credit_available:.2f}, "
                    f"Required: ${totals['total']:.2f}"
                )

    # Calculate change
    change_given = 0.0
    if sale_type == 'cash' and payment_method == 'efectivo':
        change_given = round(max(amount_paid - totals['total'], 0), 2)

    # SAT payment method codes
    metodo_pago_sat = 'PPD' if sale_type == 'credit' else 'PUE'
    forma_pago_map = {
        'efectivo': '01', 'transferencia': '03', 'tarjeta': '04', 'mixto': '99'
    }
    forma_pago_sat = forma_pago_map.get(payment_method, '99')

    # Create sale record
    cur.execute("""
        INSERT INTO sales
            (branch_id, customer_id, employee_id, register_id, sale_type,
             payment_method, subtotal, discount_total, tax_total, total,
             amount_paid, change_given, metodo_pago_sat, forma_pago_sat,
             status, device_id, notes)
        VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'completed',%s,%s)
        RETURNING id, created_at
    """, (
        branch_id, customer_id, employee_id, register_id, sale_type,
        payment_method, totals['subtotal'], totals['discount_total'],
        totals['tax_total'], totals['total'], amount_paid, change_given,
        metodo_pago_sat, forma_pago_sat,
        getattr(g, 'device_id', None), notes
    ))
    sale_id, created_at = cur.fetchone()

    # Create sale items and deduct inventory
    sale_items = []
    for idx, item in enumerate(totals['items']):
        cur.execute("""
            INSERT INTO sale_items
                (sale_id, inventory_id, part_number, name, quantity,
                 unit_price, unit_cost, discount_pct, discount_amount,
                 tax_rate, tax_amount, subtotal)
            VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
            RETURNING id
        """, (
            sale_id, item['inventory_id'], item['part_number'], item['name'],
            item['quantity'], item['unit_price'], item.get('unit_cost', 0),
            item['discount_pct'], item['discount_amount'],
            item['tax_rate'], item['tax_amount'], item['subtotal']
        ))
        sale_item_id = cur.fetchone()[0]

        # Deduct inventory via inventory_engine (NEVER create operations directly)
        inventory_record_sale(
            conn,
            item['inventory_id'],
            item.get('branch_id', branch_id),
            item['quantity'],
            sale_id=sale_id,
            cost_at_time=item.get('unit_cost')
        )

        sale_items.append({
            'id': sale_item_id,
            'inventory_id': item['inventory_id'],
            'part_number': item['part_number'],
            'name': item['name'],
            'quantity': item['quantity'],
            'unit_price': item['unit_price'],
            'unit_cost': item.get('unit_cost', 0),
            'discount_pct': item['discount_pct'],
            'discount_amount': item['discount_amount'],
            'tax_rate': item['tax_rate'],
            'tax_amount': item['tax_amount'],
            'subtotal': item['subtotal'],
        })

    # Record payment on cash register (cash movements for efectivo)
    if register_id and payment_details:
        for pd in payment_details:
            method = pd.get('method', payment_method)
            amt = float(pd.get('amount', 0))
            ref = pd.get('reference', '')
            cur.execute("""
                INSERT INTO sale_payments
                    (sale_id, register_id, method, amount, reference)
                VALUES (%s,%s,%s,%s,%s)
            """, (sale_id, register_id, method, amt, ref))
    elif register_id:
        cur.execute("""
            INSERT INTO sale_payments
                (sale_id, register_id, method, amount, reference)
            VALUES (%s,%s,%s,%s,%s)
        """, (sale_id, register_id, payment_method, amount_paid, sale_data.get('reference', '')))

    # Update customer credit balance if credit sale
    if sale_type == 'credit' and customer_id:
        cur.execute("""
            UPDATE customers
            SET credit_balance = credit_balance + %s
            WHERE id = %s
        """, (totals['total'], customer_id))

    # Audit log
    log_action(conn, 'SALE', 'sale', sale_id,
               new_value={
                   'total': totals['total'],
                   'items_count': len(sale_items),
                   'payment_method': payment_method,
                   'sale_type': sale_type,
                   'customer_id': customer_id,
               })

    cur.close()

    return {
        'id': sale_id,
        'branch_id': branch_id,
        'customer_id': customer_id,
        'employee_id': employee_id,
        'register_id': register_id,
        'sale_type': sale_type,
        'payment_method': payment_method,
        'subtotal': totals['subtotal'],
        'discount_total': totals['discount_total'],
        'tax_total': totals['tax_total'],
        'total': totals['total'],
        'amount_paid': amount_paid,
        'change_given': change_given,
        'metodo_pago_sat': metodo_pago_sat,
        'forma_pago_sat': forma_pago_sat,
        'status': 'completed',
        'items': sale_items,
        'created_at': str(created_at),
    }


def cancel_sale(conn, sale_id, reason):
    """Cancel a sale: validate permissions, reverse inventory, update credit.

    Business rules:
    - Cashiers can only cancel their own sales within 30 minutes
    - Admins and owners can cancel any sale
    - Cancelled sales are not deleted, just marked as 'cancelled'
    - Inventory is restored via RETURN operations
    - Customer credit balance is adjusted back

    Args:
        conn: psycopg2 connection
        sale_id: int
        reason: str (mandatory, min 3 chars)

    Returns:
        dict: cancellation result

    Raises:
        ValueError: on validation errors
    """
    if not reason or len(reason.strip()) < 3:
        raise ValueError("Cancellation reason is mandatory (min 3 characters)")

    cur = conn.cursor()

    # Get sale details
    cur.execute("""
        SELECT id, employee_id, customer_id, sale_type, total, status, created_at,
               branch_id, register_id
        FROM sales WHERE id = %s
    """, (sale_id,))
    sale = cur.fetchone()
    if not sale:
        raise ValueError("Sale not found")

    s_id, s_emp_id, s_cust_id, s_type, s_total, s_status, s_created, s_branch, s_register = sale

    if s_status == 'cancelled':
        raise ValueError("Sale is already cancelled")

    # Permission check: cashiers can only cancel own sales within 30 min
    role = getattr(g, 'employee_role', 'cashier')
    emp_id = getattr(g, 'employee_id', None)

    if role == 'cashier':
        if s_emp_id != emp_id:
            raise ValueError("Cashiers can only cancel their own sales")
        if datetime.utcnow() - s_created > timedelta(minutes=30):
            raise ValueError("Cashiers can only cancel sales within 30 minutes of creation")

    # Get sale items for inventory reversal
    cur.execute("""
        SELECT inventory_id, quantity, unit_cost
        FROM sale_items WHERE sale_id = %s
    """, (sale_id,))
    sale_items = cur.fetchall()

    # Reverse inventory: create RETURN operations (positive quantity)
    from services.inventory_engine import record_return
    for inv_id, qty, cost in sale_items:
        record_return(
            conn, inv_id, s_branch, qty,
            sale_id=sale_id,
            notes=f"Cancelacion venta #{sale_id}: {reason}"
        )

    # Update sale status
    cur.execute("""
        UPDATE sales SET status = 'cancelled', notes = COALESCE(notes || ' | ', '') || %s
        WHERE id = %s
    """, (f"CANCELADA: {reason}", sale_id))

    # Reverse customer credit balance if credit sale
    if s_type == 'credit' and s_cust_id:
        cur.execute("""
            UPDATE customers
            SET credit_balance = credit_balance - %s
            WHERE id = %s
        """, (float(s_total), s_cust_id))

    # Audit log
    log_action(conn, 'CANCEL', 'sale', sale_id,
               old_value={'status': 'completed', 'total': float(s_total)},
               new_value={'status': 'cancelled', 'reason': reason})

    cur.close()

    return {
        'sale_id': sale_id,
        'status': 'cancelled',
        'reason': reason,
        'items_reversed': len(sale_items),
        'total_reversed': float(s_total),
    }

Task 2: Customers blueprint (pos/blueprints/customers_bp.py)

Files:

  • Create: /home/Autopartes/pos/blueprints/customers_bp.py

Customer CRUD, credit management, vehicle history, account statements. The customers table already exists in the tenant schema from Plan 1.

  • Step 1: Create customers_bp.py
# /home/Autopartes/pos/blueprints/customers_bp.py
"""Customers blueprint: CRUD, credit management, vehicles, account statements."""

import json
from flask import Blueprint, request, jsonify, g
from middleware import require_auth, has_permission
from tenant_db import get_tenant_conn
from services.audit import log_action

customers_bp = Blueprint('customers', __name__, url_prefix='/pos/api/customers')


# ─── Customer CRUD ─────────────────────────────

@customers_bp.route('', methods=['GET'])
@require_auth('customers.view')
def list_customers():
    """Search/list customers. Supports autocomplete-style search by name, RFC, phone.

    Query params:
        q: search string (matches name, RFC, phone via ILIKE)
        page: page number (default 1)
        per_page: items per page (default 50, max 200)
        branch_id: filter by branch (default: current user's branch)
    """
    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', '').strip()
    branch_id = request.args.get('branch_id')

    where_clauses = ["c.is_active = true"]
    params = []

    if branch_id:
        where_clauses.append("c.branch_id = %s")
        params.append(int(branch_id))
    if search:
        where_clauses.append(
            "(c.name ILIKE %s OR c.rfc ILIKE %s OR c.phone ILIKE %s OR c.razon_social ILIKE %s)"
        )
        params.extend([f'%{search}%'] * 4)

    where = " AND ".join(where_clauses)

    # Count
    cur.execute(f"SELECT count(*) FROM customers c WHERE {where}", params)
    total = cur.fetchone()[0]

    # Fetch
    cur.execute(f"""
        SELECT c.id, c.name, c.rfc, c.razon_social, c.phone, c.email,
               c.price_tier, c.credit_limit, c.credit_balance, c.vehicle_info,
               c.branch_id
        FROM customers c
        WHERE {where}
        ORDER BY c.name
        LIMIT %s OFFSET %s
    """, params + [per_page, (page - 1) * per_page])

    customers = []
    for r in cur.fetchall():
        customers.append({
            'id': r[0], 'name': r[1], 'rfc': r[2], 'razon_social': r[3],
            'phone': r[4], 'email': r[5], 'price_tier': r[6],
            'credit_limit': float(r[7]) if r[7] else 0,
            'credit_balance': float(r[8]) if r[8] else 0,
            'vehicle_info': r[9],
            'branch_id': r[10],
        })

    cur.close()
    conn.close()

    total_pages = (total + per_page - 1) // per_page
    return jsonify({
        'data': customers,
        'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
    })


@customers_bp.route('/<int:customer_id>', methods=['GET'])
@require_auth('customers.view')
def get_customer(customer_id):
    """Get customer details with credit info, vehicle history, and recent purchases."""
    conn = get_tenant_conn(g.tenant_id)
    cur = conn.cursor()

    cur.execute("""
        SELECT id, branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi,
               cp, email, phone, address, price_tier, credit_limit, credit_balance,
               is_active, vehicle_info, created_at
        FROM customers WHERE id = %s
    """, (customer_id,))
    row = cur.fetchone()
    if not row:
        cur.close(); conn.close()
        return jsonify({'error': 'Customer not found'}), 404

    cols = [desc[0] for desc in cur.description]
    customer = dict(zip(cols, row))

    # Convert Decimal to float
    for k in ('credit_limit', 'credit_balance'):
        if customer.get(k) is not None:
            customer[k] = float(customer[k])

    customer['created_at'] = str(customer['created_at']) if customer['created_at'] else None

    # Recent purchases (last 20)
    cur.execute("""
        SELECT s.id, s.total, s.payment_method, s.sale_type, s.status, s.created_at,
               e.name as employee_name
        FROM sales s
        LEFT JOIN employees e ON s.employee_id = e.id
        WHERE s.customer_id = %s AND s.status != 'cancelled'
        ORDER BY s.created_at DESC
        LIMIT 20
    """, (customer_id,))
    customer['recent_purchases'] = []
    for r in cur.fetchall():
        customer['recent_purchases'].append({
            'id': r[0], 'total': float(r[1]) if r[1] else 0,
            'payment_method': r[2], 'sale_type': r[3],
            'status': r[4], 'created_at': str(r[5]),
            'employee_name': r[6],
        })

    # Credit summary
    customer['credit_available'] = round(
        float(customer['credit_limit']) - float(customer['credit_balance']), 2
    )

    cur.close()
    conn.close()
    return jsonify(customer)


@customers_bp.route('', methods=['POST'])
@require_auth('customers.create')
def create_customer():
    """Create a new customer.

    Body: {name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp, email,
           phone, address, price_tier, credit_limit, vehicle_info}
    """
    data = request.get_json() or {}
    if not data.get('name'):
        return jsonify({'error': 'name is required'}), 400

    branch_id = data.get('branch_id', g.branch_id)

    conn = get_tenant_conn(g.tenant_id)
    cur = conn.cursor()

    try:
        cur.execute("""
            INSERT INTO customers
                (branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi,
                 cp, email, phone, address, price_tier, credit_limit, vehicle_info)
            VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
            RETURNING id
        """, (
            branch_id, data['name'], data.get('rfc'), data.get('razon_social'),
            data.get('regimen_fiscal'), data.get('uso_cfdi', 'G03'),
            data.get('cp'), data.get('email'), data.get('phone'),
            data.get('address'), data.get('price_tier', 1),
            data.get('credit_limit', 0),
            json.dumps(data['vehicle_info']) if data.get('vehicle_info') else None
        ))
        customer_id = cur.fetchone()[0]

        log_action(conn, 'CUSTOMER_CREATE', 'customer', customer_id,
                   new_value={'name': data['name'], 'rfc': data.get('rfc')})

        conn.commit()
        cur.close(); conn.close()
        return jsonify({'id': customer_id, 'message': 'Customer created'}), 201

    except Exception as e:
        conn.rollback()
        cur.close(); conn.close()
        return jsonify({'error': str(e)}), 500


@customers_bp.route('/<int:customer_id>', methods=['PUT'])
@require_auth('customers.edit')
def update_customer(customer_id):
    """Update customer fields. Credit limit changes require customers.edit_credit permission."""
    data = request.get_json() or {}
    conn = get_tenant_conn(g.tenant_id)
    cur = conn.cursor()

    # Verify customer exists
    cur.execute("SELECT id, credit_limit FROM customers WHERE id = %s", (customer_id,))
    existing = cur.fetchone()
    if not existing:
        cur.close(); conn.close()
        return jsonify({'error': 'Customer not found'}), 404

    # Credit limit change requires special permission
    if 'credit_limit' in data and float(data['credit_limit']) != float(existing[1] or 0):
        if not has_permission('customers.edit_credit'):
            cur.close(); conn.close()
            return jsonify({'error': 'Permission customers.edit_credit required to change credit limit'}), 403

        log_action(conn, 'CREDIT_CHANGE', 'customer', customer_id,
                   old_value={'credit_limit': float(existing[1] or 0)},
                   new_value={'credit_limit': float(data['credit_limit'])})

    # Build dynamic update
    allowed = ['name', 'rfc', 'razon_social', 'regimen_fiscal', 'uso_cfdi',
               'cp', 'email', 'phone', 'address', 'price_tier', 'credit_limit',
               'vehicle_info', 'is_active', 'branch_id']
    sets = []
    vals = []
    for field in allowed:
        if field in data:
            val = data[field]
            if field == 'vehicle_info' 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(customer_id)
    cur.execute(f"UPDATE customers SET {', '.join(sets)} WHERE id = %s", vals)

    conn.commit()
    cur.close(); conn.close()
    return jsonify({'message': 'Customer updated'})


@customers_bp.route('/<int:customer_id>/statement', methods=['GET'])
@require_auth('customers.view')
def customer_statement(customer_id):
    """Account statement: sales (invoices), payments, running balance.

    Query params:
        from_date: start date (YYYY-MM-DD), default 30 days ago
        to_date: end date (YYYY-MM-DD), default today
    """
    conn = get_tenant_conn(g.tenant_id)
    cur = conn.cursor()

    from_date = request.args.get('from_date')
    to_date = request.args.get('to_date')

    # Verify customer exists
    cur.execute("SELECT name, credit_limit, credit_balance FROM customers WHERE id = %s", (customer_id,))
    cust = cur.fetchone()
    if not cust:
        cur.close(); conn.close()
        return jsonify({'error': 'Customer not found'}), 404

    where_date = ""
    params = [customer_id]
    if from_date:
        where_date += " AND s.created_at >= %s"
        params.append(from_date)
    if to_date:
        where_date += " AND s.created_at < %s::date + interval '1 day'"
        params.append(to_date)

    # Get credit sales (charges / cargos)
    cur.execute(f"""
        SELECT s.id, 'charge' as type, s.total as amount, s.created_at,
               'Venta #' || s.id as description, s.status
        FROM sales s
        WHERE s.customer_id = %s AND s.sale_type = 'credit' {where_date}
        ORDER BY s.created_at
    """, params)

    entries = []
    for r in cur.fetchall():
        entries.append({
            'id': r[0], 'type': r[1], 'amount': float(r[2]) if r[2] else 0,
            'date': str(r[3]), 'description': r[4], 'status': r[5]
        })

    # Get customer payments (abonos) from the customer_payments table
    pay_params = [customer_id]
    pay_where = ""
    if from_date:
        pay_where += " AND cp.created_at >= %s"
        pay_params.append(from_date)
    if to_date:
        pay_where += " AND cp.created_at < %s::date + interval '1 day'"
        pay_params.append(to_date)

    cur.execute(f"""
        SELECT cp.id, 'payment' as type, cp.amount, cp.created_at,
               'Abono - ' || cp.payment_method as description, 'completed' as status
        FROM customer_payments cp
        WHERE cp.customer_id = %s {pay_where}
        ORDER BY cp.created_at
    """, pay_params)

    for r in cur.fetchall():
        entries.append({
            'id': r[0], 'type': r[1], 'amount': float(r[2]) if r[2] else 0,
            'date': str(r[3]), 'description': r[4], 'status': r[5]
        })

    # Sort all entries by date for correct running balance
    entries.sort(key=lambda e: e['date'])

    # Compute running balance
    balance = 0.0
    for entry in entries:
        if entry['status'] == 'cancelled':
            continue
        if entry['type'] == 'charge':
            balance += entry['amount']
        elif entry['type'] == 'payment':
            balance -= entry['amount']
        entry['running_balance'] = round(balance, 2)

    cur.close()
    conn.close()

    return jsonify({
        'customer': {
            'id': customer_id,
            'name': cust[0],
            'credit_limit': float(cust[1]) if cust[1] else 0,
            'credit_balance': float(cust[2]) if cust[2] else 0,
        },
        'entries': entries,
        'balance': round(balance, 2),
    })


@customers_bp.route('/<int:customer_id>/vehicles', methods=['GET'])
@require_auth('customers.view')
def customer_vehicles(customer_id):
    """Get customer's vehicle list with last purchases per vehicle.

    Vehicle info is stored as JSONB in customers.vehicle_info:
    [{make, model, year, vin, plates}, ...]
    """
    conn = get_tenant_conn(g.tenant_id)
    cur = conn.cursor()

    cur.execute("SELECT vehicle_info FROM customers WHERE id = %s", (customer_id,))
    row = cur.fetchone()
    if not row:
        cur.close(); conn.close()
        return jsonify({'error': 'Customer not found'}), 404

    vehicles = row[0] or []

    # Get recent purchases for this customer to match with vehicles
    cur.execute("""
        SELECT s.id, s.total, s.created_at, s.notes,
               array_agg(si.name ORDER BY si.id) as items
        FROM sales s
        JOIN sale_items si ON si.sale_id = s.id
        WHERE s.customer_id = %s AND s.status = 'completed'
        GROUP BY s.id, s.total, s.created_at, s.notes
        ORDER BY s.created_at DESC
        LIMIT 50
    """, (customer_id,))

    recent_sales = []
    for r in cur.fetchall():
        recent_sales.append({
            'sale_id': r[0], 'total': float(r[1]) if r[1] else 0,
            'date': str(r[2]), 'notes': r[3], 'items': r[4]
        })

    cur.close()
    conn.close()

    return jsonify({
        'vehicles': vehicles,
        'recent_sales': recent_sales,
    })


@customers_bp.route('/<int:customer_id>/payment', methods=['POST'])
@require_auth('customers.edit')
def record_customer_payment(customer_id):
    """Record a payment against a customer's credit balance (abono).

    Body: {amount: float, payment_method: str, reference: str, register_id: int}
    """
    data = request.get_json() or {}
    amount = float(data.get('amount', 0))
    payment_method = data.get('payment_method', 'efectivo')
    reference = data.get('reference', '')
    register_id = data.get('register_id')

    if amount <= 0:
        return jsonify({'error': 'Amount must be greater than 0'}), 400

    conn = get_tenant_conn(g.tenant_id)
    cur = conn.cursor()

    # Verify customer exists and has a balance
    cur.execute("SELECT name, credit_balance FROM customers WHERE id = %s", (customer_id,))
    cust = cur.fetchone()
    if not cust:
        cur.close(); conn.close()
        return jsonify({'error': 'Customer not found'}), 404

    credit_balance = float(cust[1] or 0)
    if amount > credit_balance:
        cur.close(); conn.close()
        return jsonify({
            'error': f'Payment ${amount:.2f} exceeds current balance ${credit_balance:.2f}'
        }), 400

    try:
        # Record the payment
        cur.execute("""
            INSERT INTO customer_payments
                (customer_id, amount, payment_method, reference, employee_id, register_id)
            VALUES (%s,%s,%s,%s,%s,%s)
            RETURNING id, created_at
        """, (
            customer_id, amount, payment_method, reference,
            getattr(g, 'employee_id', None), register_id
        ))
        payment_id, created_at = cur.fetchone()

        # Reduce customer credit balance
        new_balance = round(credit_balance - amount, 2)
        cur.execute("""
            UPDATE customers SET credit_balance = %s WHERE id = %s
        """, (new_balance, customer_id))

        # Record cash movement on register if cash payment
        if register_id and payment_method == 'efectivo':
            cur.execute("""
                INSERT INTO cash_movements (register_id, type, amount, reason, employee_id)
                VALUES (%s, 'in', %s, %s, %s)
            """, (register_id, amount, f'Abono cliente #{customer_id} - {cust[0]}',
                  getattr(g, 'employee_id', None)))

        log_action(conn, 'CUSTOMER_PAYMENT', 'customer', customer_id,
                   old_value={'credit_balance': credit_balance},
                   new_value={'credit_balance': new_balance, 'payment': amount})

        conn.commit()
        cur.close(); conn.close()

        return jsonify({
            'payment_id': payment_id,
            'amount': amount,
            'previous_balance': credit_balance,
            'new_balance': new_balance,
            'created_at': str(created_at),
            'message': 'Payment recorded'
        }), 201

    except Exception as e:
        conn.rollback()
        cur.close(); conn.close()
        return jsonify({'error': str(e)}), 500

Task 3: POS blueprint (pos/blueprints/pos_bp.py)

Files:

  • Create: /home/Autopartes/pos/blueprints/pos_bp.py

Main POS endpoints: sales, quotations, and layaways. This blueprint is the HTTP layer -- all business logic lives in pos_engine.py.

  • Step 1: Create pos_bp.py
# /home/Autopartes/pos/blueprints/pos_bp.py
"""POS blueprint: sales, quotations, layaways.

All sale business logic is in services.pos_engine. This blueprint is the HTTP layer
that validates input, calls the engine, and returns JSON responses.
"""

import json
from datetime import datetime, date, timedelta
from flask import Blueprint, request, jsonify, g
from middleware import require_auth, has_permission
from tenant_db import get_tenant_conn
from services.pos_engine import (
    process_sale, cancel_sale, calculate_totals,
    get_price_for_customer, get_margin_info
)
from services.audit import log_action

pos_bp = Blueprint('pos', __name__, url_prefix='/pos/api')


# ─── Sales ───────────────────────────────────────

@pos_bp.route('/sales', methods=['POST'])
@require_auth('pos.sell')
def create_sale():
    """Create a new sale.

    Body: {
        items: [{inventory_id, quantity, unit_price, discount_pct, tax_rate}],
        customer_id: int | null,
        payment_method: 'efectivo' | 'transferencia' | 'tarjeta' | 'mixto',
        sale_type: 'cash' | 'credit' | 'mixed',
        register_id: int,
        amount_paid: float,
        payment_details: [{method, amount, reference}],  (for mixed payments)
        notes: str
    }
    """
    data = request.get_json() or {}
    conn = get_tenant_conn(g.tenant_id)

    try:
        sale = process_sale(conn, data)
        conn.commit()
        conn.close()
        return jsonify(sale), 201
    except ValueError as e:
        conn.rollback()
        conn.close()
        return jsonify({'error': str(e)}), 400
    except Exception as e:
        conn.rollback()
        conn.close()
        return jsonify({'error': str(e)}), 500


@pos_bp.route('/sales', methods=['GET'])
@require_auth('pos.view')
def list_sales():
    """List sales with filters.

    Query params:
        date_from: YYYY-MM-DD
        date_to: YYYY-MM-DD
        employee_id: int
        customer_id: int
        status: completed | cancelled | returned
        register_id: int
        page: int (default 1)
        per_page: int (default 50, max 200)
    """
    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)

    where_clauses = ["1=1"]
    params = []

    date_from = request.args.get('date_from')
    date_to = request.args.get('date_to')
    employee_id = request.args.get('employee_id')
    customer_id = request.args.get('customer_id')
    status = request.args.get('status')
    register_id = request.args.get('register_id')

    if date_from:
        where_clauses.append("s.created_at >= %s")
        params.append(date_from)
    if date_to:
        where_clauses.append("s.created_at < %s::date + interval '1 day'")
        params.append(date_to)
    if employee_id:
        where_clauses.append("s.employee_id = %s")
        params.append(int(employee_id))
    if customer_id:
        where_clauses.append("s.customer_id = %s")
        params.append(int(customer_id))
    if status:
        where_clauses.append("s.status = %s")
        params.append(status)
    if register_id:
        where_clauses.append("s.register_id = %s")
        params.append(int(register_id))

    # Default to current branch
    if g.branch_id:
        where_clauses.append("s.branch_id = %s")
        params.append(g.branch_id)

    where = " AND ".join(where_clauses)

    cur.execute(f"SELECT count(*) FROM sales s WHERE {where}", params)
    total = cur.fetchone()[0]

    cur.execute(f"""
        SELECT s.id, s.branch_id, s.customer_id, s.employee_id, s.register_id,
               s.sale_type, s.payment_method, s.subtotal, s.discount_total,
               s.tax_total, s.total, s.amount_paid, s.change_given,
               s.status, s.created_at,
               e.name as employee_name,
               c.name as customer_name
        FROM sales s
        LEFT JOIN employees e ON s.employee_id = e.id
        LEFT JOIN customers c ON s.customer_id = c.id
        WHERE {where}
        ORDER BY s.created_at DESC
        LIMIT %s OFFSET %s
    """, params + [per_page, (page - 1) * per_page])

    sales = []
    for r in cur.fetchall():
        sales.append({
            'id': r[0], 'branch_id': r[1], 'customer_id': r[2],
            'employee_id': r[3], 'register_id': r[4],
            'sale_type': r[5], 'payment_method': r[6],
            'subtotal': float(r[7]) if r[7] else 0,
            'discount_total': float(r[8]) if r[8] else 0,
            'tax_total': float(r[9]) if r[9] else 0,
            'total': float(r[10]) if r[10] else 0,
            'amount_paid': float(r[11]) if r[11] else 0,
            'change_given': float(r[12]) if r[12] else 0,
            'status': r[13], 'created_at': str(r[14]),
            'employee_name': r[15], 'customer_name': r[16],
        })

    cur.close()
    conn.close()

    total_pages = (total + per_page - 1) // per_page
    return jsonify({
        'data': sales,
        'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
    })


@pos_bp.route('/sales/<int:sale_id>', methods=['GET'])
@require_auth('pos.view')
def get_sale(sale_id):
    """Get sale detail with items."""
    conn = get_tenant_conn(g.tenant_id)
    cur = conn.cursor()

    cur.execute("""
        SELECT s.*, e.name as employee_name, c.name as customer_name
        FROM sales s
        LEFT JOIN employees e ON s.employee_id = e.id
        LEFT JOIN customers c ON s.customer_id = c.id
        WHERE s.id = %s
    """, (sale_id,))
    row = cur.fetchone()
    if not row:
        cur.close(); conn.close()
        return jsonify({'error': 'Sale not found'}), 404

    cols = [desc[0] for desc in cur.description]
    sale = dict(zip(cols, row))
    # Convert Decimal fields
    for k in ('subtotal', 'discount_total', 'tax_total', 'total', 'amount_paid', 'change_given'):
        if sale.get(k) is not None:
            sale[k] = float(sale[k])
    if sale.get('created_at'):
        sale['created_at'] = str(sale['created_at'])

    # Get items
    cur.execute("""
        SELECT id, inventory_id, part_number, name, quantity, unit_price,
               unit_cost, discount_pct, discount_amount, tax_rate, tax_amount, subtotal,
               clave_prod_serv, clave_unidad
        FROM sale_items WHERE sale_id = %s ORDER BY id
    """, (sale_id,))
    items = []
    for r in cur.fetchall():
        items.append({
            'id': r[0], 'inventory_id': r[1], 'part_number': r[2], 'name': r[3],
            'quantity': r[4], 'unit_price': float(r[5]) if r[5] else 0,
            'unit_cost': float(r[6]) if r[6] else 0,
            'discount_pct': float(r[7]) if r[7] else 0,
            'discount_amount': float(r[8]) if r[8] else 0,
            'tax_rate': float(r[9]) if r[9] else 0,
            'tax_amount': float(r[10]) if r[10] else 0,
            'subtotal': float(r[11]) if r[11] else 0,
            'clave_prod_serv': r[12], 'clave_unidad': r[13],
        })
    sale['items'] = items

    # Get payments
    cur.execute("""
        SELECT id, method, amount, reference, created_at
        FROM sale_payments WHERE sale_id = %s ORDER BY id
    """, (sale_id,))
    payments = []
    for r in cur.fetchall():
        payments.append({
            'id': r[0], 'method': r[1], 'amount': float(r[2]) if r[2] else 0,
            'reference': r[3], 'created_at': str(r[4]) if r[4] else None,
        })
    sale['payments'] = payments

    cur.close()
    conn.close()
    return jsonify(sale)


@pos_bp.route('/sales/<int:sale_id>/cancel', methods=['PUT'])
@require_auth('pos.sell')
def api_cancel_sale(sale_id):
    """Cancel a sale. Requires mandatory reason.

    Body: {reason: str}
    """
    data = request.get_json() or {}
    reason = data.get('reason', '').strip()

    conn = get_tenant_conn(g.tenant_id)
    try:
        result = cancel_sale(conn, sale_id, reason)
        conn.commit()
        conn.close()
        return jsonify(result)
    except ValueError as e:
        conn.rollback()
        conn.close()
        return jsonify({'error': str(e)}), 400
    except Exception as e:
        conn.rollback()
        conn.close()
        return jsonify({'error': str(e)}), 500


# ─── Quotations ──────────────────────────────────

@pos_bp.route('/quotations', methods=['POST'])
@require_auth('pos.sell')
def create_quotation():
    """Save a quotation from current cart.

    Body: {
        items: [{inventory_id, quantity, unit_price, discount_pct, tax_rate}],
        customer_id: int | null,
        valid_days: int (default 7),
        notes: str
    }
    """
    data = request.get_json() or {}
    items = data.get('items', [])
    if not items:
        return jsonify({'error': 'No items in quotation'}), 400

    conn = get_tenant_conn(g.tenant_id)
    cur = conn.cursor()

    # Calculate totals
    totals = calculate_totals(items)

    valid_days = int(data.get('valid_days', 7))
    valid_until = (date.today() + timedelta(days=valid_days)).isoformat()

    try:
        cur.execute("""
            INSERT INTO quotations
                (branch_id, customer_id, employee_id, subtotal, discount_total,
                 tax_total, total, status, valid_until, notes)
            VALUES (%s,%s,%s,%s,%s,%s,%s,'active',%s,%s)
            RETURNING id, created_at
        """, (
            g.branch_id, data.get('customer_id'), g.employee_id,
            totals['subtotal'], totals['discount_total'], totals['tax_total'],
            totals['total'], valid_until, data.get('notes')
        ))
        quot_id, created_at = cur.fetchone()

        # Insert quotation items
        for item in totals['items']:
            # Look up part_number and name from inventory
            cur.execute("SELECT part_number, name FROM inventory WHERE id = %s", (item['inventory_id'],))
            inv = cur.fetchone()
            part_number = inv[0] if inv else item.get('part_number', '')
            name = inv[1] if inv else item.get('name', '')

            line_subtotal = round(
                item['unit_price'] * item['quantity'] * (1 - item['discount_pct'] / 100), 2
            )

            cur.execute("""
                INSERT INTO quotation_items
                    (quotation_id, inventory_id, part_number, name, quantity,
                     unit_price, discount_pct, tax_rate, subtotal)
                VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
            """, (
                quot_id, item['inventory_id'], part_number, name,
                item['quantity'], item['unit_price'], item['discount_pct'],
                item['tax_rate'], line_subtotal
            ))

        log_action(conn, 'QUOTATION_CREATE', 'quotation', quot_id,
                   new_value={'total': totals['total'], 'items_count': len(items)})

        conn.commit()
        cur.close(); conn.close()
        return jsonify({
            'id': quot_id,
            'total': totals['total'],
            'valid_until': valid_until,
            'created_at': str(created_at),
            'message': 'Quotation created'
        }), 201

    except Exception as e:
        conn.rollback()
        cur.close(); conn.close()
        return jsonify({'error': str(e)}), 500


@pos_bp.route('/quotations', methods=['GET'])
@require_auth('pos.view')
def list_quotations():
    """List quotations with filters.

    Query params: customer_id, status (active|converted|expired|cancelled), page, per_page
    """
    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)

    where_clauses = ["1=1"]
    params = []

    customer_id = request.args.get('customer_id')
    status = request.args.get('status')

    if customer_id:
        where_clauses.append("q.customer_id = %s")
        params.append(int(customer_id))
    if status:
        where_clauses.append("q.status = %s")
        params.append(status)
    if g.branch_id:
        where_clauses.append("q.branch_id = %s")
        params.append(g.branch_id)

    where = " AND ".join(where_clauses)

    cur.execute(f"SELECT count(*) FROM quotations q WHERE {where}", params)
    total = cur.fetchone()[0]

    cur.execute(f"""
        SELECT q.id, q.customer_id, q.employee_id, q.subtotal, q.tax_total,
               q.total, q.status, q.valid_until, q.created_at,
               c.name as customer_name, e.name as employee_name
        FROM quotations q
        LEFT JOIN customers c ON q.customer_id = c.id
        LEFT JOIN employees e ON q.employee_id = e.id
        WHERE {where}
        ORDER BY q.created_at DESC
        LIMIT %s OFFSET %s
    """, params + [per_page, (page - 1) * per_page])

    quotations = []
    for r in cur.fetchall():
        quotations.append({
            'id': r[0], 'customer_id': r[1], 'employee_id': r[2],
            'subtotal': float(r[3]) if r[3] else 0,
            'tax_total': float(r[4]) if r[4] else 0,
            'total': float(r[5]) if r[5] else 0,
            'status': r[6], 'valid_until': str(r[7]) if r[7] else None,
            'created_at': str(r[8]),
            'customer_name': r[9], 'employee_name': r[10],
        })

    cur.close(); conn.close()

    total_pages = (total + per_page - 1) // per_page
    return jsonify({
        'data': quotations,
        'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
    })


@pos_bp.route('/quotations/<int:quot_id>', methods=['GET'])
@require_auth('pos.view')
def get_quotation(quot_id):
    """Get quotation detail with items."""
    conn = get_tenant_conn(g.tenant_id)
    cur = conn.cursor()

    cur.execute("""
        SELECT q.*, c.name as customer_name, e.name as employee_name
        FROM quotations q
        LEFT JOIN customers c ON q.customer_id = c.id
        LEFT JOIN employees e ON q.employee_id = e.id
        WHERE q.id = %s
    """, (quot_id,))
    row = cur.fetchone()
    if not row:
        cur.close(); conn.close()
        return jsonify({'error': 'Quotation not found'}), 404

    cols = [desc[0] for desc in cur.description]
    quot = dict(zip(cols, row))
    for k in ('subtotal', 'tax_total', 'total'):
        if quot.get(k) is not None:
            quot[k] = float(quot[k])
    if quot.get('created_at'):
        quot['created_at'] = str(quot['created_at'])
    if quot.get('valid_until'):
        quot['valid_until'] = str(quot['valid_until'])

    # Get items
    cur.execute("""
        SELECT id, inventory_id, part_number, name, quantity, unit_price,
               discount_pct, tax_rate, subtotal
        FROM quotation_items WHERE quotation_id = %s ORDER BY id
    """, (quot_id,))
    quot['items'] = []
    for r in cur.fetchall():
        quot['items'].append({
            'id': r[0], 'inventory_id': r[1], 'part_number': r[2], 'name': r[3],
            'quantity': r[4], 'unit_price': float(r[5]) if r[5] else 0,
            'discount_pct': float(r[6]) if r[6] else 0,
            'tax_rate': float(r[7]) if r[7] else 0,
            'subtotal': float(r[8]) if r[8] else 0,
        })

    cur.close(); conn.close()
    return jsonify(quot)


@pos_bp.route('/quotations/<int:quot_id>/convert', methods=['POST'])
@require_auth('pos.sell')
def convert_quotation(quot_id):
    """Convert a quotation to a sale. Uses current stock and prices from the quotation.

    Body: {
        register_id: int,
        payment_method: str,
        sale_type: str,
        amount_paid: float,
        payment_details: [{method, amount, reference}]
    }
    """
    data = request.get_json() or {}
    conn = get_tenant_conn(g.tenant_id)
    cur = conn.cursor()

    # Get quotation
    cur.execute("SELECT id, customer_id, status FROM quotations WHERE id = %s", (quot_id,))
    quot = cur.fetchone()
    if not quot:
        cur.close(); conn.close()
        return jsonify({'error': 'Quotation not found'}), 404
    if quot[2] != 'active':
        cur.close(); conn.close()
        return jsonify({'error': f'Quotation is {quot[2]}, cannot convert'}), 400

    # Get quotation items
    cur.execute("""
        SELECT inventory_id, quantity, unit_price, discount_pct, tax_rate
        FROM quotation_items WHERE quotation_id = %s
    """, (quot_id,))
    items = []
    for r in cur.fetchall():
        items.append({
            'inventory_id': r[0], 'quantity': r[1], 'unit_price': float(r[2]),
            'discount_pct': float(r[3]) if r[3] else 0,
            'tax_rate': float(r[4]) if r[4] else 0.16,
        })

    # Build sale_data
    sale_data = {
        'items': items,
        'customer_id': quot[1],
        'payment_method': data.get('payment_method', 'efectivo'),
        'sale_type': data.get('sale_type', 'cash'),
        'register_id': data.get('register_id'),
        'amount_paid': data.get('amount_paid', 0),
        'payment_details': data.get('payment_details', []),
        'notes': f'Convertida de cotizacion #{quot_id}',
    }

    try:
        sale = process_sale(conn, sale_data)

        # Mark quotation as converted
        cur.execute("""
            UPDATE quotations SET status = 'converted', converted_sale_id = %s
            WHERE id = %s
        """, (sale['id'], quot_id))

        conn.commit()
        cur.close(); conn.close()
        return jsonify(sale), 201
    except ValueError as e:
        conn.rollback()
        cur.close(); conn.close()
        return jsonify({'error': str(e)}), 400
    except Exception as e:
        conn.rollback()
        cur.close(); conn.close()
        return jsonify({'error': str(e)}), 500


@pos_bp.route('/quotations/<int:quot_id>/cancel', methods=['PUT'])
@require_auth('pos.sell')
def cancel_quotation(quot_id):
    """Cancel a quotation."""
    conn = get_tenant_conn(g.tenant_id)
    cur = conn.cursor()

    cur.execute("SELECT id, status FROM quotations WHERE id = %s", (quot_id,))
    quot = cur.fetchone()
    if not quot:
        cur.close(); conn.close()
        return jsonify({'error': 'Quotation not found'}), 404
    if quot[1] != 'active':
        cur.close(); conn.close()
        return jsonify({'error': f'Quotation is already {quot[1]}'}), 400

    cur.execute("UPDATE quotations SET status = 'cancelled' WHERE id = %s", (quot_id,))
    conn.commit()
    cur.close(); conn.close()
    return jsonify({'message': 'Quotation cancelled'})


# ─── Layaways (Apartados) ────────────────────────

@pos_bp.route('/layaways', methods=['POST'])
@require_auth('pos.sell')
def create_layaway():
    """Create a layaway. Requires customer_id and partial payment.

    Body: {
        items: [{inventory_id, quantity, unit_price, discount_pct, tax_rate}],
        customer_id: int (required),
        initial_payment: float (required, > 0),
        payment_method: str,
        reference: str,
        register_id: int,
        expires_days: int (default 30),
        notes: str
    }
    """
    data = request.get_json() or {}
    items = data.get('items', [])
    customer_id = data.get('customer_id')
    initial_payment = float(data.get('initial_payment', 0))
    register_id = data.get('register_id')

    if not items:
        return jsonify({'error': 'No items in layaway'}), 400
    if not customer_id:
        return jsonify({'error': 'customer_id required for layaway'}), 400
    if initial_payment <= 0:
        return jsonify({'error': 'Initial payment must be greater than 0'}), 400

    conn = get_tenant_conn(g.tenant_id)
    cur = conn.cursor()

    # Calculate totals
    totals = calculate_totals(items)

    if initial_payment > totals['total']:
        cur.close(); conn.close()
        return jsonify({'error': 'Initial payment exceeds total'}), 400

    expires_days = int(data.get('expires_days', 30))
    expires_at = (date.today() + timedelta(days=expires_days)).isoformat()

    try:
        # Create layaway record
        cur.execute("""
            INSERT INTO layaways
                (branch_id, customer_id, employee_id, total, amount_paid,
                 status, expires_at, notes)
            VALUES (%s,%s,%s,%s,%s,'active',%s,%s)
            RETURNING id, created_at
        """, (
            g.branch_id, customer_id, g.employee_id,
            totals['total'], initial_payment, expires_at, data.get('notes')
        ))
        layaway_id, created_at = cur.fetchone()

        # Insert layaway items and reserve stock (table created by migration v1.1_pos_tables.sql)
        from services.inventory_engine import record_operation
        for item in totals['items']:
            cur.execute("SELECT part_number, name, branch_id FROM inventory WHERE id = %s", (item['inventory_id'],))
            inv = cur.fetchone()
            part_number = inv[0] if inv else item.get('part_number', '')
            name = inv[1] if inv else item.get('name', '')
            item_branch_id = inv[2] if inv else g.branch_id

            line_subtotal = round(
                item['unit_price'] * item['quantity'] * (1 - item['discount_pct'] / 100), 2
            )

            cur.execute("""
                INSERT INTO layaway_items
                    (layaway_id, inventory_id, part_number, name, quantity,
                     unit_price, discount_pct, tax_rate, subtotal)
                VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
            """, (
                layaway_id, item['inventory_id'], part_number, name,
                item['quantity'], item['unit_price'], item['discount_pct'],
                item['tax_rate'], line_subtotal
            ))

            # Reserve stock immediately (negative quantity = stock deduction)
            record_operation(
                conn, item['inventory_id'], item_branch_id,
                operation_type='LAYAWAY_RESERVE',
                quantity=-item['quantity'],
                notes=f'Apartado #{layaway_id} - reserva'
            )

        # Record initial payment
        cur.execute("""
            INSERT INTO layaway_payments
                (layaway_id, amount, payment_method, reference, employee_id)
            VALUES (%s,%s,%s,%s,%s)
        """, (
            layaway_id, initial_payment,
            data.get('payment_method', 'efectivo'),
            data.get('reference'), g.employee_id
        ))

        # Record cash movement on register if cash payment
        if register_id and data.get('payment_method', 'efectivo') == 'efectivo':
            cur.execute("""
                INSERT INTO cash_movements (register_id, type, amount, reason, employee_id)
                VALUES (%s, 'in', %s, %s, %s)
            """, (register_id, initial_payment, f'Apartado #{layaway_id} - anticipo', g.employee_id))

        log_action(conn, 'LAYAWAY_CREATE', 'layaway', layaway_id,
                   new_value={'total': totals['total'], 'initial_payment': initial_payment,
                              'customer_id': customer_id})

        conn.commit()
        cur.close(); conn.close()

        return jsonify({
            'id': layaway_id,
            'total': totals['total'],
            'amount_paid': initial_payment,
            'remaining': round(totals['total'] - initial_payment, 2),
            'expires_at': expires_at,
            'created_at': str(created_at),
            'message': 'Layaway created'
        }), 201

    except Exception as e:
        conn.rollback()
        cur.close(); conn.close()
        return jsonify({'error': str(e)}), 500


@pos_bp.route('/layaways', methods=['GET'])
@require_auth('pos.view')
def list_layaways():
    """List layaways. Query params: customer_id, status, page, per_page."""
    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)

    where_clauses = ["1=1"]
    params = []

    customer_id = request.args.get('customer_id')
    status = request.args.get('status')

    if customer_id:
        where_clauses.append("l.customer_id = %s")
        params.append(int(customer_id))
    if status:
        where_clauses.append("l.status = %s")
        params.append(status)
    if g.branch_id:
        where_clauses.append("l.branch_id = %s")
        params.append(g.branch_id)

    where = " AND ".join(where_clauses)

    cur.execute(f"SELECT count(*) FROM layaways l WHERE {where}", params)
    total = cur.fetchone()[0]

    cur.execute(f"""
        SELECT l.id, l.customer_id, l.employee_id, l.total, l.amount_paid,
               l.status, l.expires_at, l.created_at,
               c.name as customer_name, e.name as employee_name
        FROM layaways l
        LEFT JOIN customers c ON l.customer_id = c.id
        LEFT JOIN employees e ON l.employee_id = e.id
        WHERE {where}
        ORDER BY l.created_at DESC
        LIMIT %s OFFSET %s
    """, params + [per_page, (page - 1) * per_page])

    layaways = []
    for r in cur.fetchall():
        layaways.append({
            'id': r[0], 'customer_id': r[1], 'employee_id': r[2],
            'total': float(r[3]) if r[3] else 0,
            'amount_paid': float(r[4]) if r[4] else 0,
            'remaining': round(float(r[3] or 0) - float(r[4] or 0), 2),
            'status': r[5], 'expires_at': str(r[6]) if r[6] else None,
            'created_at': str(r[7]),
            'customer_name': r[8], 'employee_name': r[9],
        })

    cur.close(); conn.close()

    total_pages = (total + per_page - 1) // per_page
    return jsonify({
        'data': layaways,
        'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
    })


@pos_bp.route('/layaways/<int:layaway_id>', methods=['GET'])
@require_auth('pos.view')
def get_layaway(layaway_id):
    """Get layaway detail with items and payments."""
    conn = get_tenant_conn(g.tenant_id)
    cur = conn.cursor()

    cur.execute("""
        SELECT l.*, c.name as customer_name, e.name as employee_name
        FROM layaways l
        LEFT JOIN customers c ON l.customer_id = c.id
        LEFT JOIN employees e ON l.employee_id = e.id
        WHERE l.id = %s
    """, (layaway_id,))
    row = cur.fetchone()
    if not row:
        cur.close(); conn.close()
        return jsonify({'error': 'Layaway not found'}), 404

    cols = [desc[0] for desc in cur.description]
    layaway = dict(zip(cols, row))
    for k in ('total', 'amount_paid'):
        if layaway.get(k) is not None:
            layaway[k] = float(layaway[k])
    layaway['remaining'] = round(layaway['total'] - layaway['amount_paid'], 2)
    if layaway.get('created_at'):
        layaway['created_at'] = str(layaway['created_at'])
    if layaway.get('expires_at'):
        layaway['expires_at'] = str(layaway['expires_at'])

    # Get items
    cur.execute("""
        SELECT id, inventory_id, part_number, name, quantity, unit_price,
               discount_pct, tax_rate, subtotal
        FROM layaway_items WHERE layaway_id = %s ORDER BY id
    """, (layaway_id,))
    layaway['items'] = []
    for r in cur.fetchall():
        layaway['items'].append({
            'id': r[0], 'inventory_id': r[1], 'part_number': r[2], 'name': r[3],
            'quantity': r[4], 'unit_price': float(r[5]) if r[5] else 0,
            'discount_pct': float(r[6]) if r[6] else 0,
            'tax_rate': float(r[7]) if r[7] else 0,
            'subtotal': float(r[8]) if r[8] else 0,
        })

    # Get payments
    cur.execute("""
        SELECT id, amount, payment_method, reference, employee_id, created_at
        FROM layaway_payments WHERE layaway_id = %s ORDER BY created_at
    """, (layaway_id,))
    layaway['payments'] = []
    for r in cur.fetchall():
        layaway['payments'].append({
            'id': r[0], 'amount': float(r[1]) if r[1] else 0,
            'payment_method': r[2], 'reference': r[3],
            'employee_id': r[4], 'created_at': str(r[5]) if r[5] else None,
        })

    cur.close(); conn.close()
    return jsonify(layaway)


@pos_bp.route('/layaways/<int:layaway_id>/payment', methods=['POST'])
@require_auth('pos.sell')
def layaway_payment(layaway_id):
    """Add a payment to a layaway.

    Body: {amount, payment_method, reference, register_id}
    """
    data = request.get_json() or {}
    amount = float(data.get('amount', 0))
    if amount <= 0:
        return jsonify({'error': 'Amount must be greater than 0'}), 400

    conn = get_tenant_conn(g.tenant_id)
    cur = conn.cursor()

    cur.execute("SELECT total, amount_paid, status FROM layaways WHERE id = %s", (layaway_id,))
    row = cur.fetchone()
    if not row:
        cur.close(); conn.close()
        return jsonify({'error': 'Layaway not found'}), 404

    total, paid, status = float(row[0]), float(row[1]), row[2]
    if status != 'active':
        cur.close(); conn.close()
        return jsonify({'error': f'Layaway is {status}'}), 400

    remaining = round(total - paid, 2)
    if amount > remaining:
        cur.close(); conn.close()
        return jsonify({'error': f'Payment ${amount:.2f} exceeds remaining ${remaining:.2f}'}), 400

    try:
        # Record payment
        cur.execute("""
            INSERT INTO layaway_payments
                (layaway_id, amount, payment_method, reference, employee_id)
            VALUES (%s,%s,%s,%s,%s)
            RETURNING id
        """, (
            layaway_id, amount,
            data.get('payment_method', 'efectivo'),
            data.get('reference'), g.employee_id
        ))
        payment_id = cur.fetchone()[0]

        # Update amount_paid
        new_paid = round(paid + amount, 2)
        cur.execute("UPDATE layaways SET amount_paid = %s WHERE id = %s", (new_paid, layaway_id))

        # Record cash movement if applicable
        register_id = data.get('register_id')
        if register_id and data.get('payment_method', 'efectivo') == 'efectivo':
            cur.execute("""
                INSERT INTO cash_movements (register_id, type, amount, reason, employee_id)
                VALUES (%s, 'in', %s, %s, %s)
            """, (register_id, amount, f'Apartado #{layaway_id} - abono', g.employee_id))

        conn.commit()
        cur.close(); conn.close()

        new_remaining = round(total - new_paid, 2)
        return jsonify({
            'payment_id': payment_id,
            'amount': amount,
            'total_paid': new_paid,
            'remaining': new_remaining,
            'fully_paid': new_remaining <= 0,
            'message': 'Payment recorded'
        })

    except Exception as e:
        conn.rollback()
        cur.close(); conn.close()
        return jsonify({'error': str(e)}), 500


@pos_bp.route('/layaways/<int:layaway_id>/complete', methods=['POST'])
@require_auth('pos.sell')
def complete_layaway(layaway_id):
    """Convert a fully paid layaway to a sale.

    Body: {register_id: int}
    The layaway must be fully paid (amount_paid >= total).
    """
    data = request.get_json() or {}
    conn = get_tenant_conn(g.tenant_id)
    cur = conn.cursor()

    cur.execute("""
        SELECT id, customer_id, total, amount_paid, status, branch_id
        FROM layaways WHERE id = %s
    """, (layaway_id,))
    row = cur.fetchone()
    if not row:
        cur.close(); conn.close()
        return jsonify({'error': 'Layaway not found'}), 404

    l_id, cust_id, total, paid, status, branch_id = row
    total, paid = float(total), float(paid)

    if status != 'active':
        cur.close(); conn.close()
        return jsonify({'error': f'Layaway is {status}'}), 400
    if paid < total:
        cur.close(); conn.close()
        return jsonify({'error': f'Layaway not fully paid. Remaining: ${total - paid:.2f}'}), 400

    try:
        # Get layaway items
        cur.execute("""
            SELECT inventory_id, quantity, unit_price, discount_pct, tax_rate
            FROM layaway_items WHERE layaway_id = %s
        """, (layaway_id,))
        items = []
        for r in cur.fetchall():
            items.append({
                'inventory_id': r[0], 'quantity': r[1],
                'unit_price': float(r[2]) if r[2] else 0,
                'discount_pct': float(r[3]) if r[3] else 0,
                'tax_rate': float(r[4]) if r[4] else 0.16,
            })

        # Create sale record directly instead of calling process_sale(),
        # because stock was already reserved at layaway creation time via
        # LAYAWAY_RESERVE operations. Calling process_sale() would deduct
        # inventory again (double deduction).
        from services.pos_engine import calculate_totals
        totals_calc = calculate_totals(items)

        cur.execute("""
            INSERT INTO sales
                (branch_id, customer_id, employee_id, register_id, sale_type,
                 payment_method, subtotal, discount_total, tax_total, total,
                 amount_paid, change_given, metodo_pago_sat, forma_pago_sat,
                 status, notes)
            VALUES (%s,%s,%s,%s,'cash','efectivo',%s,%s,%s,%s,%s,0,'PUE','01','completed',%s)
            RETURNING id, created_at
        """, (
            branch_id, cust_id, g.employee_id, data.get('register_id'),
            totals_calc['subtotal'], totals_calc['discount_total'],
            totals_calc['tax_total'], totals_calc['total'], total,
            f'Completado de apartado #{layaway_id}',
        ))
        sale_id, sale_created = cur.fetchone()

        # Create sale_items (no inventory deduction — already reserved)
        sale_items = []
        for item in totals_calc['items']:
            cur.execute("SELECT part_number, name, cost FROM inventory WHERE id = %s",
                        (item['inventory_id'],))
            inv = cur.fetchone()
            cur.execute("""
                INSERT INTO sale_items
                    (sale_id, inventory_id, part_number, name, quantity,
                     unit_price, unit_cost, discount_pct, discount_amount,
                     tax_rate, tax_amount, subtotal)
                VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
            """, (
                sale_id, item['inventory_id'],
                inv[0] if inv else '', inv[1] if inv else '',
                item['quantity'], item['unit_price'],
                float(inv[2]) if inv and inv[2] else 0,
                item['discount_pct'], item['discount_amount'],
                item['tax_rate'], item['tax_amount'], item['subtotal']
            ))

        # Record payment on register
        register_id = data.get('register_id')
        if register_id:
            cur.execute("""
                INSERT INTO sale_payments
                    (sale_id, register_id, method, amount, reference)
                VALUES (%s,%s,'efectivo',%s,%s)
            """, (sale_id, register_id, total, f'Apartado #{layaway_id} completado'))

        sale = {
            'id': sale_id, 'status': 'completed', 'total': totals_calc['total'],
            'created_at': str(sale_created),
        }

        # Mark layaway as completed
        cur.execute("""
            UPDATE layaways SET status = 'completed', converted_sale_id = %s
            WHERE id = %s
        """, (sale['id'], layaway_id))

        log_action(conn, 'LAYAWAY_COMPLETE', 'layaway', layaway_id,
                   new_value={'sale_id': sale['id'], 'total': total})

        conn.commit()
        cur.close(); conn.close()
        return jsonify(sale), 201

    except ValueError as e:
        conn.rollback()
        cur.close(); conn.close()
        return jsonify({'error': str(e)}), 400
    except Exception as e:
        conn.rollback()
        cur.close(); conn.close()
        return jsonify({'error': str(e)}), 500


@pos_bp.route('/layaways/<int:layaway_id>/cancel', methods=['PUT'])
@require_auth('pos.sell')
def cancel_layaway(layaway_id):
    """Cancel a layaway. Refunds must be handled separately.

    Body: {reason: str}
    """
    data = request.get_json() or {}
    reason = data.get('reason', '').strip()
    if not reason or len(reason) < 3:
        return jsonify({'error': 'Reason is required (min 3 characters)'}), 400

    conn = get_tenant_conn(g.tenant_id)
    cur = conn.cursor()

    cur.execute("SELECT id, status, amount_paid, total, branch_id FROM layaways WHERE id = %s", (layaway_id,))
    row = cur.fetchone()
    if not row:
        cur.close(); conn.close()
        return jsonify({'error': 'Layaway not found'}), 404
    if row[1] != 'active':
        cur.close(); conn.close()
        return jsonify({'error': f'Layaway is already {row[1]}'}), 400

    layaway_branch = row[4] or g.branch_id

    # Reverse stock reservations (return reserved items to available stock)
    from services.inventory_engine import record_operation
    cur.execute("""
        SELECT inventory_id, quantity FROM layaway_items WHERE layaway_id = %s
    """, (layaway_id,))
    layaway_items = cur.fetchall()
    for inv_id, qty in layaway_items:
        # Positive quantity = return stock
        record_operation(
            conn, inv_id, layaway_branch,
            operation_type='LAYAWAY_CANCEL',
            quantity=qty,
            notes=f'Cancelacion apartado #{layaway_id}: {reason}'
        )

    cur.execute("""
        UPDATE layaways SET status = 'cancelled',
               notes = COALESCE(notes || ' | ', '') || %s
        WHERE id = %s
    """, (f"CANCELADO: {reason}", layaway_id))

    log_action(conn, 'LAYAWAY_CANCEL', 'layaway', layaway_id,
               old_value={'status': 'active', 'amount_paid': float(row[2])},
               new_value={'status': 'cancelled', 'reason': reason,
                          'items_unreserved': len(layaway_items)})

    conn.commit()
    cur.close(); conn.close()

    return jsonify({
        'message': 'Layaway cancelled',
        'amount_paid': float(row[2]),
        'items_unreserved': len(layaway_items),
        'note': 'Stock reservations reversed. Refund of paid amount must be processed separately.'
    })

Task 4: Cash register blueprint (pos/blueprints/cashregister_bp.py)

Files:

  • Create: /home/Autopartes/pos/blueprints/cashregister_bp.py

Cash register operations: open, close, movements, X-cut (read-only), Z-cut (closing).

  • Step 1: Create cashregister_bp.py
# /home/Autopartes/pos/blueprints/cashregister_bp.py
"""Cash register blueprint: open/close register, cash movements, X/Z cuts."""

from datetime import datetime
from flask import Blueprint, request, jsonify, g
from middleware import require_auth
from tenant_db import get_tenant_conn
from services.audit import log_action

cashregister_bp = Blueprint('cashregister', __name__, url_prefix='/pos/api/register')


@cashregister_bp.route('/open', methods=['POST'])
@require_auth('pos.sell')
def open_register():
    """Open a cash register session.

    Body: {register_number: int, opening_amount: float}

    Business rules:
    - An employee can only have one open register at a time
    - Register number identifies the physical register (1, 2, 3...)
    """
    data = request.get_json() or {}
    register_number = data.get('register_number')
    opening_amount = float(data.get('opening_amount', 0))

    if not register_number:
        return jsonify({'error': 'register_number required'}), 400
    if opening_amount < 0:
        return jsonify({'error': 'opening_amount cannot be negative'}), 400

    conn = get_tenant_conn(g.tenant_id)
    cur = conn.cursor()

    # Check if employee already has an open register
    cur.execute("""
        SELECT id, register_number FROM cash_registers
        WHERE employee_id = %s AND status = 'open'
    """, (g.employee_id,))
    existing = cur.fetchone()
    if existing:
        cur.close(); conn.close()
        return jsonify({
            'error': f'You already have register #{existing[1]} open (id={existing[0]}). Close it first.'
        }), 409

    # Check if this register number is already open at this branch
    cur.execute("""
        SELECT id, employee_id FROM cash_registers
        WHERE branch_id = %s AND register_number = %s AND status = 'open'
    """, (g.branch_id, register_number))
    in_use = cur.fetchone()
    if in_use:
        cur.close(); conn.close()
        return jsonify({'error': f'Register #{register_number} is already open by another employee'}), 409

    try:
        cur.execute("""
            INSERT INTO cash_registers
                (branch_id, employee_id, register_number, opening_amount, status)
            VALUES (%s,%s,%s,%s,'open')
            RETURNING id, opened_at
        """, (g.branch_id, g.employee_id, register_number, opening_amount))
        reg_id, opened_at = cur.fetchone()

        log_action(conn, 'REGISTER_OPEN', 'cash_register', reg_id,
                   new_value={'register_number': register_number, 'opening_amount': opening_amount})

        conn.commit()
        cur.close(); conn.close()
        return jsonify({
            'id': reg_id,
            'register_number': register_number,
            'opening_amount': opening_amount,
            'opened_at': str(opened_at),
            'message': f'Register #{register_number} opened'
        }), 201

    except Exception as e:
        conn.rollback()
        cur.close(); conn.close()
        return jsonify({'error': str(e)}), 500


@cashregister_bp.route('/current', methods=['GET'])
@require_auth('pos.sell')
def current_register():
    """Get the current open register for this employee."""
    conn = get_tenant_conn(g.tenant_id)
    cur = conn.cursor()

    cur.execute("""
        SELECT cr.id, cr.register_number, cr.opening_amount, cr.opened_at,
               cr.branch_id, b.name as branch_name
        FROM cash_registers cr
        LEFT JOIN branches b ON cr.branch_id = b.id
        WHERE cr.employee_id = %s AND cr.status = 'open'
    """, (g.employee_id,))
    row = cur.fetchone()

    if not row:
        cur.close(); conn.close()
        return jsonify({'register': None, 'message': 'No open register'})

    register = {
        'id': row[0], 'register_number': row[1],
        'opening_amount': float(row[2]) if row[2] else 0,
        'opened_at': str(row[3]),
        'branch_id': row[4], 'branch_name': row[5],
    }

    cur.close(); conn.close()
    return jsonify({'register': register})


@cashregister_bp.route('/movement', methods=['POST'])
@require_auth('pos.sell')
def cash_movement():
    """Record a cash in/out movement with mandatory reason.

    Body: {type: 'in'|'out', amount: float, reason: str}
    """
    data = request.get_json() or {}
    mov_type = data.get('type')
    amount = float(data.get('amount', 0))
    reason = data.get('reason', '').strip()

    if mov_type not in ('in', 'out'):
        return jsonify({'error': "type must be 'in' or 'out'"}), 400
    if amount <= 0:
        return jsonify({'error': 'amount must be positive'}), 400
    if not reason or len(reason) < 3:
        return jsonify({'error': 'reason required (min 3 characters)'}), 400

    conn = get_tenant_conn(g.tenant_id)
    cur = conn.cursor()

    # Get employee's open register
    cur.execute("""
        SELECT id FROM cash_registers
        WHERE employee_id = %s AND status = 'open'
    """, (g.employee_id,))
    reg = cur.fetchone()
    if not reg:
        cur.close(); conn.close()
        return jsonify({'error': 'No open register. Open a register first.'}), 400

    register_id = reg[0]

    try:
        cur.execute("""
            INSERT INTO cash_movements (register_id, type, amount, reason, employee_id)
            VALUES (%s,%s,%s,%s,%s)
            RETURNING id, created_at
        """, (register_id, mov_type, amount, reason, g.employee_id))
        mov_id, created_at = cur.fetchone()

        log_action(conn, f'CASH_{mov_type.upper()}', 'cash_register', register_id,
                   new_value={'movement_id': mov_id, 'type': mov_type,
                              'amount': amount, 'reason': reason})

        conn.commit()
        cur.close(); conn.close()
        return jsonify({
            'id': mov_id,
            'type': mov_type,
            'amount': amount,
            'reason': reason,
            'created_at': str(created_at),
            'message': 'Movement recorded'
        }), 201

    except Exception as e:
        conn.rollback()
        cur.close(); conn.close()
        return jsonify({'error': str(e)}), 500


def _compute_register_summary(conn, register_id):
    """Compute the register summary for X-cut or Z-cut.

    Returns a dict with totals by payment method, cash movements, and expected cash amount.
    """
    cur = conn.cursor()

    # Get register info
    cur.execute("""
        SELECT opening_amount, opened_at, employee_id
        FROM cash_registers WHERE id = %s
    """, (register_id,))
    reg = cur.fetchone()
    opening_amount = float(reg[0]) if reg[0] else 0

    # Sales totals by payment method
    cur.execute("""
        SELECT payment_method, COUNT(*), COALESCE(SUM(total), 0)
        FROM sales
        WHERE register_id = %s AND status = 'completed'
        GROUP BY payment_method
    """, (register_id,))
    sales_by_method = {}
    total_sales = 0.0
    total_sales_count = 0
    for r in cur.fetchall():
        method, count, amount = r[0], r[1], float(r[2])
        sales_by_method[method] = {'count': count, 'amount': amount}
        total_sales += amount
        total_sales_count += count

    # Cash sales specifically (for expected cash calculation)
    cash_from_sales = sales_by_method.get('efectivo', {}).get('amount', 0)

    # Change given (cash out)
    cur.execute("""
        SELECT COALESCE(SUM(change_given), 0) FROM sales
        WHERE register_id = %s AND status = 'completed' AND payment_method = 'efectivo'
    """, (register_id,))
    change_given = float(cur.fetchone()[0])

    # Cash movements
    cur.execute("""
        SELECT type, COALESCE(SUM(amount), 0) FROM cash_movements
        WHERE register_id = %s GROUP BY type
    """, (register_id,))
    movements = {'in': 0.0, 'out': 0.0}
    for r in cur.fetchall():
        movements[r[0]] = float(r[1])

    # Cancelled sales
    cur.execute("""
        SELECT COUNT(*), COALESCE(SUM(total), 0) FROM sales
        WHERE register_id = %s AND status = 'cancelled'
    """, (register_id,))
    cancelled = cur.fetchone()
    cancelled_count = cancelled[0]
    cancelled_amount = float(cancelled[1])

    # Expected cash = opening + cash sales - change + cash_in - cash_out
    expected_cash = round(
        opening_amount + cash_from_sales - change_given + movements['in'] - movements['out'],
        2
    )

    # Detail of cash movements
    cur.execute("""
        SELECT id, type, amount, reason, created_at
        FROM cash_movements WHERE register_id = %s ORDER BY created_at
    """, (register_id,))
    movement_detail = []
    for r in cur.fetchall():
        movement_detail.append({
            'id': r[0], 'type': r[1], 'amount': float(r[2]),
            'reason': r[3], 'created_at': str(r[4])
        })

    cur.close()

    return {
        'opening_amount': opening_amount,
        'sales_by_method': sales_by_method,
        'total_sales': round(total_sales, 2),
        'total_sales_count': total_sales_count,
        'cash_from_sales': round(cash_from_sales, 2),
        'change_given': round(change_given, 2),
        'cash_movements_in': round(movements['in'], 2),
        'cash_movements_out': round(movements['out'], 2),
        'movement_detail': movement_detail,
        'cancelled_count': cancelled_count,
        'cancelled_amount': round(cancelled_amount, 2),
        'expected_cash': expected_cash,
    }


@cashregister_bp.route('/cut-x', methods=['GET'])
@require_auth('pos.sell')
def cut_x():
    """Partial cut (corte X): read-only summary without closing the register."""
    conn = get_tenant_conn(g.tenant_id)
    cur = conn.cursor()

    cur.execute("""
        SELECT id FROM cash_registers
        WHERE employee_id = %s AND status = 'open'
    """, (g.employee_id,))
    reg = cur.fetchone()
    if not reg:
        cur.close(); conn.close()
        return jsonify({'error': 'No open register'}), 400

    register_id = reg[0]
    summary = _compute_register_summary(conn, register_id)

    cur.close(); conn.close()
    return jsonify({
        'type': 'X',
        'register_id': register_id,
        'status': 'open',
        **summary,
    })


@cashregister_bp.route('/cut-z', methods=['POST'])
@require_auth('pos.sell')
def cut_z():
    """Final cut (corte Z): close the register.

    Body: {closing_amount: float}  (the amount physically counted in the register)
    """
    data = request.get_json() or {}
    closing_amount = float(data.get('closing_amount', 0))

    conn = get_tenant_conn(g.tenant_id)
    cur = conn.cursor()

    cur.execute("""
        SELECT id FROM cash_registers
        WHERE employee_id = %s AND status = 'open'
    """, (g.employee_id,))
    reg = cur.fetchone()
    if not reg:
        cur.close(); conn.close()
        return jsonify({'error': 'No open register'}), 400

    register_id = reg[0]
    summary = _compute_register_summary(conn, register_id)

    expected = summary['expected_cash']
    difference = round(closing_amount - expected, 2)

    try:
        cur.execute("""
            UPDATE cash_registers
            SET closing_amount = %s, expected_amount = %s, difference = %s,
                status = 'closed', closed_at = NOW()
            WHERE id = %s
        """, (closing_amount, expected, difference, register_id))

        log_action(conn, 'REGISTER_CLOSE', 'cash_register', register_id,
                   new_value={
                       'closing_amount': closing_amount,
                       'expected_amount': expected,
                       'difference': difference,
                       'total_sales': summary['total_sales'],
                   })

        conn.commit()
        cur.close(); conn.close()

        return jsonify({
            'type': 'Z',
            'register_id': register_id,
            'status': 'closed',
            'closing_amount': closing_amount,
            'expected_amount': expected,
            'difference': difference,
            **summary,
        })

    except Exception as e:
        conn.rollback()
        cur.close(); conn.close()
        return jsonify({'error': str(e)}), 500


@cashregister_bp.route('/history', methods=['GET'])
@require_auth('pos.view')
def register_history():
    """List closed registers with summary.

    Query params: date_from, date_to, employee_id, page, per_page
    """
    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)

    where_clauses = ["cr.status = 'closed'"]
    params = []

    if g.branch_id:
        where_clauses.append("cr.branch_id = %s")
        params.append(g.branch_id)

    date_from = request.args.get('date_from')
    date_to = request.args.get('date_to')
    employee_id = request.args.get('employee_id')

    if date_from:
        where_clauses.append("cr.closed_at >= %s")
        params.append(date_from)
    if date_to:
        where_clauses.append("cr.closed_at < %s::date + interval '1 day'")
        params.append(date_to)
    if employee_id:
        where_clauses.append("cr.employee_id = %s")
        params.append(int(employee_id))

    where = " AND ".join(where_clauses)

    cur.execute(f"SELECT count(*) FROM cash_registers cr WHERE {where}", params)
    total = cur.fetchone()[0]

    cur.execute(f"""
        SELECT cr.id, cr.register_number, cr.opening_amount, cr.closing_amount,
               cr.expected_amount, cr.difference, cr.opened_at, cr.closed_at,
               cr.employee_id, e.name as employee_name
        FROM cash_registers cr
        LEFT JOIN employees e ON cr.employee_id = e.id
        WHERE {where}
        ORDER BY cr.closed_at DESC
        LIMIT %s OFFSET %s
    """, params + [per_page, (page - 1) * per_page])

    registers = []
    for r in cur.fetchall():
        registers.append({
            'id': r[0], 'register_number': r[1],
            'opening_amount': float(r[2]) if r[2] else 0,
            'closing_amount': float(r[3]) if r[3] else 0,
            'expected_amount': float(r[4]) if r[4] else 0,
            'difference': float(r[5]) if r[5] else 0,
            'opened_at': str(r[6]), 'closed_at': str(r[7]),
            'employee_id': r[8], 'employee_name': r[9],
        })

    cur.close(); conn.close()

    total_pages = (total + per_page - 1) // per_page
    return jsonify({
        'data': registers,
        'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
    })


@cashregister_bp.route('/daily-summary', methods=['GET'])
@require_auth('pos.view')
def daily_summary():
    """Consolidated daily summary across all registers for a given date.

    Query params:
        date: YYYY-MM-DD (default: today)

    Returns aggregated totals: total sales, total by payment method,
    total movements, and per-register breakdown.
    """
    from datetime import date as date_type
    target_date = request.args.get('date', date_type.today().isoformat())

    conn = get_tenant_conn(g.tenant_id)
    cur = conn.cursor()

    branch_clause = ""
    params = [target_date, target_date]
    if g.branch_id:
        branch_clause = "AND s.branch_id = %s"
        params.append(g.branch_id)

    # Total sales by payment method for the date
    cur.execute(f"""
        SELECT s.payment_method, COUNT(*), COALESCE(SUM(s.total), 0)
        FROM sales s
        WHERE s.created_at >= %s::date
          AND s.created_at < %s::date + interval '1 day'
          AND s.status = 'completed'
          {branch_clause}
        GROUP BY s.payment_method
    """, params)

    sales_by_method = {}
    total_sales = 0.0
    total_sales_count = 0
    for method, count, amount in cur.fetchall():
        sales_by_method[method] = {'count': count, 'amount': float(amount)}
        total_sales += float(amount)
        total_sales_count += count

    # Cancelled sales
    cancel_params = [target_date, target_date]
    if g.branch_id:
        cancel_params.append(g.branch_id)
    cur.execute(f"""
        SELECT COUNT(*), COALESCE(SUM(s.total), 0)
        FROM sales s
        WHERE s.created_at >= %s::date
          AND s.created_at < %s::date + interval '1 day'
          AND s.status = 'cancelled'
          {branch_clause}
    """, cancel_params)
    cancelled = cur.fetchone()
    cancelled_count = cancelled[0]
    cancelled_amount = float(cancelled[1])

    # Cash movements for the date
    mov_params = [target_date, target_date]
    mov_branch_clause = ""
    if g.branch_id:
        mov_branch_clause = "AND cr.branch_id = %s"
        mov_params.append(g.branch_id)

    cur.execute(f"""
        SELECT cm.type, COUNT(*), COALESCE(SUM(cm.amount), 0)
        FROM cash_movements cm
        JOIN cash_registers cr ON cm.register_id = cr.id
        WHERE cm.created_at >= %s::date
          AND cm.created_at < %s::date + interval '1 day'
          {mov_branch_clause}
        GROUP BY cm.type
    """, mov_params)
    movements = {'in': {'count': 0, 'amount': 0.0}, 'out': {'count': 0, 'amount': 0.0}}
    for mov_type, count, amount in cur.fetchall():
        movements[mov_type] = {'count': count, 'amount': float(amount)}

    # Per-register breakdown
    reg_params = [target_date, target_date]
    reg_branch_clause = ""
    if g.branch_id:
        reg_branch_clause = "AND cr.branch_id = %s"
        reg_params.append(g.branch_id)

    cur.execute(f"""
        SELECT cr.id, cr.register_number, cr.status,
               cr.opening_amount, cr.closing_amount, cr.expected_amount,
               cr.difference, cr.opened_at, cr.closed_at,
               e.name as employee_name,
               (SELECT COUNT(*) FROM sales s WHERE s.register_id = cr.id AND s.status = 'completed') as sale_count,
               (SELECT COALESCE(SUM(s.total), 0) FROM sales s WHERE s.register_id = cr.id AND s.status = 'completed') as sale_total
        FROM cash_registers cr
        LEFT JOIN employees e ON cr.employee_id = e.id
        WHERE cr.opened_at >= %s::date
          AND cr.opened_at < %s::date + interval '1 day'
          {reg_branch_clause}
        ORDER BY cr.opened_at
    """, reg_params)

    registers = []
    for r in cur.fetchall():
        registers.append({
            'id': r[0], 'register_number': r[1], 'status': r[2],
            'opening_amount': float(r[3]) if r[3] else 0,
            'closing_amount': float(r[4]) if r[4] else 0,
            'expected_amount': float(r[5]) if r[5] else 0,
            'difference': float(r[6]) if r[6] else 0,
            'opened_at': str(r[7]) if r[7] else None,
            'closed_at': str(r[8]) if r[8] else None,
            'employee_name': r[9],
            'sale_count': r[10], 'sale_total': float(r[11]),
        })

    cur.close(); conn.close()

    return jsonify({
        'date': target_date,
        'total_sales': round(total_sales, 2),
        'total_sales_count': total_sales_count,
        'sales_by_method': sales_by_method,
        'cancelled_count': cancelled_count,
        'cancelled_amount': round(cancelled_amount, 2),
        'movements_in': movements['in'],
        'movements_out': movements['out'],
        'registers': registers,
    })

Task 5: Register blueprints in app.py

Files:

  • Modify: /home/Autopartes/pos/app.py

Add the three new blueprints and page routes.

  • Step 1: Update app.py
# /home/Autopartes/pos/app.py
from flask import Flask


def create_app():
    app = Flask(__name__)

    # Register blueprints
    from blueprints.auth_bp import auth_bp
    app.register_blueprint(auth_bp)

    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)

    from blueprints.pos_bp import pos_bp
    app.register_blueprint(pos_bp)

    from blueprints.customers_bp import customers_bp
    app.register_blueprint(customers_bp)

    from blueprints.cashregister_bp import cashregister_bp
    app.register_blueprint(cashregister_bp)

    # Health check
    @app.route('/pos/health')
    def health():
        return {'status': 'ok'}

    from flask import render_template, send_from_directory

    @app.route('/pos/login')
    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/sale')
    def pos_sale():
        return render_template('pos.html')

    @app.route('/pos/customers')
    def pos_customers():
        return render_template('customers.html')

    @app.route('/pos/static/<path:filename>')
    def pos_static(filename):
        return send_from_directory('static', filename)

    return app


if __name__ == '__main__':
    app = create_app()
    app.run(host='0.0.0.0', port=5001, debug=True)

Task 6: POS frontend (pos/templates/pos.html + pos/static/js/pos.js)

Files:

  • Create: /home/Autopartes/pos/templates/pos.html
  • Create: /home/Autopartes/pos/static/js/pos.js

Complete POS page with split layout, F-key shortcuts, payment modal, and ticket printing.

  • Step 1: Create pos.html
<!-- /home/Autopartes/pos/templates/pos.html -->
<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Punto de Venta - Nexus POS</title>
    <link rel="stylesheet" href="/pos/static/css/common.css">
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: var(--font-body, 'Segoe UI', system-ui, sans-serif); background: var(--color-bg, #f0f2f5); color: var(--color-text, #1a1a2e); height: 100vh; display: flex; flex-direction: column; overflow: hidden; }

        /* Top bar */
        .pos-topbar { background: var(--color-primary, #1a1a2e); color: #fff; padding: 8px 16px; display: flex; align-items: center; justify-content: space-between; font-size: 14px; }
        .pos-topbar .employee-info { display: flex; align-items: center; gap: 12px; }
        .pos-topbar .register-info { display: flex; align-items: center; gap: 8px; opacity: 0.8; }
        .pos-topbar .register-info.no-register { color: #ff6b6b; opacity: 1; }
        .pos-topbar .shortcuts-hint { font-size: 12px; opacity: 0.6; }

        /* Main layout */
        .pos-main { display: flex; flex: 1; overflow: hidden; }

        /* Left panel: search + items */
        .pos-left { width: 55%; display: flex; flex-direction: column; border-right: 2px solid var(--color-border, #ddd); background: #fff; }

        /* Customer bar */
        .customer-bar { padding: 8px 12px; background: var(--color-surface, #f8f9fa); border-bottom: 1px solid var(--color-border, #ddd); display: flex; gap: 8px; align-items: center; }
        .customer-bar input { flex: 1; padding: 8px 12px; border: 1px solid var(--color-border, #ddd); border-radius: var(--radius, 6px); font-size: 14px; }
        .customer-bar .customer-selected { background: #e8f5e9; padding: 6px 12px; border-radius: var(--radius, 6px); font-size: 13px; display: flex; align-items: center; gap: 8px; }
        .customer-bar .customer-selected .tier-badge { font-size: 11px; padding: 2px 6px; border-radius: 3px; background: #1976d2; color: #fff; }
        .customer-bar .customer-selected .credit-info { font-size: 11px; color: #666; }
        .customer-bar .btn-new-customer { padding: 8px 12px; border: 1px dashed var(--color-border, #ddd); border-radius: var(--radius, 6px); background: none; cursor: pointer; font-size: 13px; white-space: nowrap; }
        .customer-autocomplete { position: absolute; top: 100%; left: 0; right: 0; background: #fff; border: 1px solid var(--color-border, #ddd); border-radius: 0 0 var(--radius, 6px) var(--radius, 6px); box-shadow: var(--shadow, 0 4px 12px rgba(0,0,0,0.1)); z-index: 100; max-height: 200px; overflow-y: auto; }
        .customer-autocomplete .ac-item { padding: 8px 12px; cursor: pointer; font-size: 13px; border-bottom: 1px solid #f0f0f0; }
        .customer-autocomplete .ac-item:hover { background: #f0f2f5; }
        .customer-autocomplete .ac-item .ac-meta { font-size: 11px; color: #999; }

        /* Vehicle info banner */
        .vehicle-banner { display: none; padding: 6px 12px; background: #fff3e0; border-bottom: 1px solid #ffe0b2; font-size: 12px; }
        .vehicle-banner.visible { display: flex; align-items: center; gap: 8px; }

        /* Search bar */
        .search-bar { padding: 8px 12px; border-bottom: 1px solid var(--color-border, #ddd); display: flex; gap: 8px; }
        .search-bar input { flex: 1; padding: 10px 14px; border: 2px solid var(--color-primary, #1a1a2e); border-radius: var(--radius, 6px); font-size: 15px; outline: none; }
        .search-bar input:focus { border-color: var(--color-accent, #4361ee); box-shadow: 0 0 0 3px rgba(67,97,238,0.15); }

        /* Cart items */
        .cart-items { flex: 1; overflow-y: auto; padding: 0; }
        .cart-items table { width: 100%; border-collapse: collapse; }
        .cart-items thead th { position: sticky; top: 0; background: var(--color-surface, #f8f9fa); padding: 8px 10px; text-align: left; font-size: 12px; font-weight: 600; color: #666; border-bottom: 2px solid var(--color-border, #ddd); }
        .cart-items tbody tr { border-bottom: 1px solid #f0f0f0; cursor: pointer; }
        .cart-items tbody tr:hover { background: #f8f9fa; }
        .cart-items tbody tr.selected { background: #e3f2fd; }
        .cart-items td { padding: 8px 10px; font-size: 13px; }
        .cart-items td.num { text-align: right; font-variant-numeric: tabular-nums; }
        .cart-items td .part-name { font-weight: 500; }
        .cart-items td .part-number { font-size: 11px; color: #999; }
        .cart-items td .margin-info { font-size: 11px; color: #888; }
        .cart-items td .margin-warning { color: #e53935; font-weight: 600; }
        .cart-items .qty-input { width: 50px; text-align: center; border: 1px solid #ddd; border-radius: 4px; padding: 4px; font-size: 13px; }
        .cart-items .discount-input { width: 55px; text-align: center; border: 1px solid #ddd; border-radius: 4px; padding: 4px; font-size: 13px; }
        .cart-items .btn-remove { background: none; border: none; color: #e53935; cursor: pointer; font-size: 16px; padding: 2px 6px; }

        .cart-empty { display: flex; align-items: center; justify-content: center; flex: 1; color: #999; font-size: 15px; }

        /* Right panel: totals + actions */
        .pos-right { width: 45%; display: flex; flex-direction: column; background: var(--color-surface, #f8f9fa); }

        /* Search results (right side when searching) */
        .search-results { flex: 1; overflow-y: auto; padding: 8px; display: none; }
        .search-results.active { display: block; }
        .search-result-item { padding: 10px 12px; background: #fff; border: 1px solid #eee; border-radius: var(--radius, 6px); margin-bottom: 6px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
        .search-result-item:hover { border-color: var(--color-primary, #1a1a2e); background: #fafafa; }
        .search-result-item .sr-name { font-weight: 500; font-size: 14px; }
        .search-result-item .sr-pn { font-size: 12px; color: #666; }
        .search-result-item .sr-stock { font-size: 12px; }
        .search-result-item .sr-stock.zero { color: #e53935; }
        .search-result-item .sr-price { font-weight: 600; font-size: 15px; }

        /* Totals panel */
        .totals-panel { flex: 1; display: flex; flex-direction: column; justify-content: flex-end; padding: 16px; }
        .totals-panel.hidden { display: none; }
        .totals-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; }
        .totals-row.discount { color: #e53935; }
        .totals-row.total { font-size: 28px; font-weight: 700; padding: 12px 0; border-top: 2px solid var(--color-border, #ddd); margin-top: 8px; }

        /* Global discount */
        .global-discount { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-top: 1px solid #eee; margin-top: 8px; }
        .global-discount label { font-size: 13px; color: #666; }
        .global-discount input { width: 60px; text-align: center; border: 1px solid #ddd; border-radius: 4px; padding: 4px; font-size: 13px; }

        /* Action buttons */
        .action-buttons { padding: 12px 16px; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; border-top: 2px solid var(--color-border, #ddd); }
        .action-buttons .btn { padding: 14px; border: none; border-radius: var(--radius, 6px); cursor: pointer; font-size: 14px; font-weight: 600; display: flex; align-items: center; justify-content: center; gap: 6px; transition: opacity 0.15s; }
        .action-buttons .btn:hover { opacity: 0.85; }
        .action-buttons .btn:active { transform: scale(0.98); }
        .btn-cobrar { background: #2e7d32; color: #fff; grid-column: 1 / -1; font-size: 18px; padding: 18px; }
        .btn-cotizacion { background: #1565c0; color: #fff; }
        .btn-apartado { background: #e65100; color: #fff; }
        .btn-credito { background: #6a1b9a; color: #fff; }
        .btn-last-sale { background: #455a64; color: #fff; }
        .btn-shortcut { font-size: 11px; opacity: 0.7; margin-left: 4px; }

        /* Payment modal */
        .modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 200; align-items: center; justify-content: center; }
        .modal-overlay.active { display: flex; }
        .modal { background: #fff; border-radius: 12px; padding: 24px; width: 500px; max-width: 95vw; max-height: 90vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }
        .modal h2 { margin-bottom: 16px; font-size: 20px; }
        .modal .modal-total { font-size: 32px; font-weight: 700; text-align: center; padding: 12px; background: #f5f5f5; border-radius: 8px; margin-bottom: 16px; }
        .modal .payment-methods { display: flex; gap: 8px; margin-bottom: 16px; }
        .modal .payment-methods .pm-btn { flex: 1; padding: 12px; border: 2px solid #ddd; border-radius: 8px; background: #fff; cursor: pointer; text-align: center; font-size: 13px; font-weight: 500; }
        .modal .payment-methods .pm-btn.active { border-color: var(--color-primary, #1a1a2e); background: #f0f4ff; }
        .modal .payment-field { margin-bottom: 12px; }
        .modal .payment-field label { display: block; font-size: 13px; color: #666; margin-bottom: 4px; }
        .modal .payment-field input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 16px; }
        .modal .payment-field input.amount-input { font-size: 24px; text-align: right; font-weight: 600; }
        .modal .change-display { text-align: center; padding: 12px; font-size: 20px; font-weight: 600; border-radius: 8px; margin: 12px 0; }
        .modal .change-display.positive { background: #e8f5e9; color: #2e7d32; }
        .modal .change-display.negative { background: #ffebee; color: #c62828; }

        /* Mixed payment rows */
        .mixed-payments { margin-bottom: 12px; }
        .mixed-row { display: flex; gap: 8px; margin-bottom: 8px; align-items: center; }
        .mixed-row select { padding: 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; }
        .mixed-row input { flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; }
        .mixed-row .btn-remove-row { background: none; border: none; color: #e53935; cursor: pointer; font-size: 18px; }
        .btn-add-mixed { background: none; border: 1px dashed #999; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; color: #666; }

        .modal .modal-actions { display: flex; gap: 8px; margin-top: 16px; }
        .modal .modal-actions .btn { flex: 1; padding: 14px; border: none; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; }
        .modal .btn-confirm-payment { background: #2e7d32; color: #fff; }
        .modal .btn-cancel-modal { background: #eee; color: #333; }

        /* New customer modal */
        .modal .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
        .modal .form-grid .full-width { grid-column: 1 / -1; }
        .modal .form-field { display: flex; flex-direction: column; gap: 4px; }
        .modal .form-field label { font-size: 12px; color: #666; font-weight: 500; }
        .modal .form-field input, .modal .form-field select { padding: 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; }

        /* Ticket preview */
        .ticket-preview { font-family: var(--font-mono, 'Courier New', monospace); font-size: 12px; white-space: pre-wrap; background: #fff; padding: 16px; border: 1px dashed #999; max-width: 300px; margin: 0 auto; line-height: 1.4; }

        /* Keyboard hint bar */
        .keyboard-bar { background: #263238; color: #b0bec5; padding: 6px 16px; display: flex; gap: 16px; font-size: 11px; }
        .keyboard-bar .kb-key { background: #37474f; padding: 2px 6px; border-radius: 3px; color: #e0e0e0; font-weight: 600; margin-right: 4px; }

        @media print {
            body * { visibility: hidden; }
            .ticket-preview, .ticket-preview * { visibility: visible; }
            .ticket-preview { position: absolute; left: 0; top: 0; }
        }
    </style>
</head>
<body>
    <!-- Top Bar -->
    <div class="pos-topbar">
        <div class="employee-info">
            <span id="employeeName">Cargando...</span>
            <span id="branchName" style="opacity: 0.7; font-size: 12px;"></span>
        </div>
        <div class="register-info" id="registerInfo">
            <span>Caja: --</span>
        </div>
        <div class="shortcuts-hint">F1=Buscar F2=Cliente F3=Cobrar F4=Cotizacion F5=Ult.Venta</div>
    </div>

    <!-- Main Layout -->
    <div class="pos-main">
        <!-- Left: Search + Cart -->
        <div class="pos-left">
            <!-- Customer Bar -->
            <div class="customer-bar" style="position: relative;">
                <span style="font-size: 13px; color: #666;">Cliente:</span>
                <div id="customerSearchWrap" style="flex: 1; position: relative;">
                    <input type="text" id="customerSearch" placeholder="Buscar cliente por nombre, RFC, telefono... (F2)" autocomplete="off">
                    <div class="customer-autocomplete" id="customerAutocomplete" style="display:none;"></div>
                </div>
                <div id="customerSelected" class="customer-selected" style="display:none;">
                    <span id="customerName"></span>
                    <span class="tier-badge" id="customerTier"></span>
                    <span class="credit-info" id="customerCredit"></span>
                    <button onclick="POS.clearCustomer()" style="background:none;border:none;cursor:pointer;color:#999;font-size:16px;" title="Quitar cliente">&times;</button>
                </div>
                <button class="btn-new-customer" onclick="POS.showNewCustomerModal()">+ Nuevo</button>
            </div>

            <!-- Vehicle Banner -->
            <div class="vehicle-banner" id="vehicleBanner">
                <span style="font-weight: 600;">Vehiculo:</span>
                <span id="vehicleInfo"></span>
                <span id="lastPurchaseInfo" style="margin-left: auto; font-size: 11px; color: #e65100;"></span>
            </div>

            <!-- Search Bar -->
            <div class="search-bar">
                <input type="text" id="itemSearch" placeholder="Buscar por # parte, nombre o codigo de barras... (F1)" autocomplete="off">
            </div>

            <!-- Cart Table -->
            <div class="cart-items" id="cartContainer">
                <div class="cart-empty" id="cartEmpty">
                    <div>Carrito vacio<br><span style="font-size: 13px;">Busca productos o presiona F1</span></div>
                </div>
                <table id="cartTable" style="display:none;">
                    <thead>
                        <tr>
                            <th style="width: 30px;">#</th>
                            <th>Producto</th>
                            <th style="width: 60px;">Cant</th>
                            <th style="width: 90px;">Precio</th>
                            <th style="width: 65px;">Desc%</th>
                            <th style="width: 90px;" class="num">Subtotal</th>
                            <th id="thCost" style="width: 70px; display:none;" class="num">Costo</th>
                            <th id="thMargin" style="width: 65px; display:none;" class="num">Margen</th>
                            <th style="width: 30px;"></th>
                        </tr>
                    </thead>
                    <tbody id="cartBody"></tbody>
                </table>
            </div>
        </div>

        <!-- Right: Search Results / Totals + Actions -->
        <div class="pos-right">
            <!-- Search results (shown when searching) -->
            <div class="search-results" id="searchResults"></div>

            <!-- Totals panel -->
            <div class="totals-panel" id="totalsPanel">
                <div>
                    <div class="totals-row"><span>Subtotal:</span><span id="dispSubtotal">$0.00</span></div>
                    <div class="totals-row discount" id="discountRow" style="display:none;">
                        <span>Descuento:</span><span id="dispDiscount">-$0.00</span>
                    </div>
                    <div class="totals-row"><span>IVA (16%):</span><span id="dispTax">$0.00</span></div>
                    <div class="totals-row total"><span>TOTAL:</span><span id="dispTotal">$0.00</span></div>
                </div>
                <div class="global-discount">
                    <label>Descuento global:</label>
                    <input type="number" id="globalDiscount" value="0" min="0" max="100" step="0.5">
                    <span>%</span>
                </div>
            </div>

            <!-- Action Buttons -->
            <div class="action-buttons">
                <button class="btn btn-cobrar" onclick="POS.checkout()">
                    Cobrar<span class="btn-shortcut">F3</span>
                </button>
                <button class="btn btn-cotizacion" onclick="POS.saveQuotation()">
                    Cotizacion<span class="btn-shortcut">F4</span>
                </button>
                <button class="btn btn-apartado" onclick="POS.createLayaway()">
                    Apartado
                </button>
                <button class="btn btn-credito" onclick="POS.creditSale()">
                    Credito
                </button>
                <button class="btn btn-last-sale" onclick="POS.showLastSale()">
                    Ult. Venta<span class="btn-shortcut">F5</span>
                </button>
                <button class="btn" style="background:#78909c;color:#fff;" onclick="POS.openDrawer()">
                    Cajon<span class="btn-shortcut">F6</span>
                </button>
            </div>
        </div>
    </div>

    <!-- Keyboard hints -->
    <div class="keyboard-bar">
        <span><span class="kb-key">F1</span>Buscar</span>
        <span><span class="kb-key">F2</span>Cliente</span>
        <span><span class="kb-key">F3</span>Cobrar</span>
        <span><span class="kb-key">F4</span>Cotizacion</span>
        <span><span class="kb-key">F5</span>Ult.Venta</span>
        <span><span class="kb-key">F6</span>Cajon</span>
        <span><span class="kb-key">+/-</span>Cantidad</span>
        <span><span class="kb-key">*</span>Descuento</span>
        <span><span class="kb-key">Enter</span>Agregar</span>
        <span><span class="kb-key">Del</span>Eliminar</span>
    </div>

    <!-- Payment Modal -->
    <div class="modal-overlay" id="paymentModal">
        <div class="modal">
            <h2>Cobrar Venta</h2>
            <div class="modal-total" id="modalTotal">$0.00</div>

            <div class="payment-methods">
                <button class="pm-btn active" data-method="efectivo" onclick="POS.selectPaymentMethod('efectivo', this)">Efectivo</button>
                <button class="pm-btn" data-method="transferencia" onclick="POS.selectPaymentMethod('transferencia', this)">Transferencia</button>
                <button class="pm-btn" data-method="tarjeta" onclick="POS.selectPaymentMethod('tarjeta', this)">Tarjeta</button>
                <button class="pm-btn" data-method="mixto" onclick="POS.selectPaymentMethod('mixto', this)">Mixto</button>
            </div>

            <!-- Cash payment -->
            <div id="cashPayment">
                <div class="payment-field">
                    <label>Monto recibido:</label>
                    <input type="number" id="cashReceived" class="amount-input" step="0.01" min="0" oninput="POS.updateChange()">
                </div>
                <div class="change-display positive" id="changeDisplay">Cambio: $0.00</div>
            </div>

            <!-- Transfer/Card payment -->
            <div id="refPayment" style="display:none;">
                <div class="payment-field">
                    <label>Referencia:</label>
                    <input type="text" id="paymentRef" placeholder="Numero de referencia o autorizacion">
                </div>
            </div>

            <!-- Mixed payment -->
            <div id="mixedPayment" style="display:none;">
                <div class="mixed-payments" id="mixedRows">
                    <div class="mixed-row">
                        <select><option value="efectivo">Efectivo</option><option value="transferencia">Transferencia</option><option value="tarjeta">Tarjeta</option></select>
                        <input type="number" step="0.01" min="0" placeholder="Monto" class="mixed-amount" oninput="POS.updateMixedTotal()">
                        <input type="text" placeholder="Ref." style="width: 100px;">
                    </div>
                    <div class="mixed-row">
                        <select><option value="transferencia">Transferencia</option><option value="efectivo">Efectivo</option><option value="tarjeta">Tarjeta</option></select>
                        <input type="number" step="0.01" min="0" placeholder="Monto" class="mixed-amount" oninput="POS.updateMixedTotal()">
                        <input type="text" placeholder="Ref." style="width: 100px;">
                    </div>
                </div>
                <div id="mixedRemaining" style="text-align:center; font-size: 14px; color: #666; padding: 8px 0;">Faltante: $0.00</div>
            </div>

            <div class="modal-actions">
                <button class="btn btn-cancel-modal" onclick="POS.closePaymentModal()">Cancelar (Esc)</button>
                <button class="btn btn-confirm-payment" id="btnConfirmPayment" onclick="POS.confirmPayment()">Confirmar Pago</button>
            </div>
        </div>
    </div>

    <!-- New Customer Modal -->
    <div class="modal-overlay" id="newCustomerModal">
        <div class="modal">
            <h2>Nuevo Cliente</h2>
            <div class="form-grid">
                <div class="form-field full-width"><label>Nombre *</label><input type="text" id="ncName"></div>
                <div class="form-field"><label>RFC</label><input type="text" id="ncRfc" maxlength="13" placeholder="XAXX010101000"></div>
                <div class="form-field"><label>Razon Social</label><input type="text" id="ncRazonSocial"></div>
                <div class="form-field"><label>Regimen Fiscal</label>
                    <select id="ncRegimenFiscal">
                        <option value="">Seleccionar...</option>
                        <option value="601">601 - General de Ley PM</option>
                        <option value="603">603 - Personas Morales Fines No Lucrativos</option>
                        <option value="605">605 - Sueldos y Salarios</option>
                        <option value="606">606 - Arrendamiento</option>
                        <option value="612">612 - Personas Fisicas Actividad Empresarial</option>
                        <option value="616">616 - Sin Obligaciones Fiscales</option>
                        <option value="621">621 - Incorporacion Fiscal</option>
                        <option value="625">625 - RESICO</option>
                    </select>
                </div>
                <div class="form-field"><label>Uso CFDI</label>
                    <select id="ncUsoCfdi">
                        <option value="G03">G03 - Gastos en general</option>
                        <option value="G01">G01 - Adquisicion de mercancias</option>
                        <option value="P01">P01 - Por definir</option>
                    </select>
                </div>
                <div class="form-field"><label>Telefono</label><input type="tel" id="ncPhone"></div>
                <div class="form-field"><label>Email</label><input type="email" id="ncEmail"></div>
                <div class="form-field"><label>Lista de precio</label>
                    <select id="ncPriceTier">
                        <option value="1">Mostrador (Precio 1)</option>
                        <option value="2">Taller (Precio 2)</option>
                        <option value="3">Mayoreo (Precio 3)</option>
                    </select>
                </div>
                <div class="form-field"><label>Limite de credito</label><input type="number" id="ncCreditLimit" value="0" min="0" step="100"></div>
                <div class="form-field full-width"><label>Vehiculo (opcional)</label></div>
                <div class="form-field"><label>Marca</label><input type="text" id="ncVehMake" placeholder="Nissan, Toyota..."></div>
                <div class="form-field"><label>Modelo</label><input type="text" id="ncVehModel" placeholder="Tsuru, Corolla..."></div>
                <div class="form-field"><label>Ano</label><input type="number" id="ncVehYear" placeholder="2020"></div>
                <div class="form-field"><label>Placas</label><input type="text" id="ncVehPlates"></div>
            </div>
            <div class="modal-actions" style="margin-top: 16px;">
                <button class="btn btn-cancel-modal" onclick="POS.closeNewCustomerModal()">Cancelar</button>
                <button class="btn btn-confirm-payment" onclick="POS.saveNewCustomer()">Guardar Cliente</button>
            </div>
        </div>
    </div>

    <!-- Ticket Modal -->
    <div class="modal-overlay" id="ticketModal">
        <div class="modal" style="width: 380px;">
            <h2>Venta Completada</h2>
            <div class="ticket-preview" id="ticketPreview"></div>
            <div class="modal-actions" style="margin-top: 16px;">
                <button class="btn btn-cancel-modal" onclick="POS.closeTicketModal()">Cerrar</button>
                <button class="btn btn-confirm-payment" onclick="POS.printTicket()">Imprimir</button>
            </div>
        </div>
    </div>

    <script src="/pos/static/js/pos.js"></script>
</body>
</html>
  • Step 2: Create pos.js
// /home/Autopartes/pos/static/js/pos.js
/**
 * POS Frontend: sale processing, F-key shortcuts, payment modal, ticket printing.
 *
 * Communicates with:
 *   - /pos/api/sales (pos_bp)
 *   - /pos/api/quotations (pos_bp)
 *   - /pos/api/layaways (pos_bp)
 *   - /pos/api/customers (customers_bp)
 *   - /pos/api/register (cashregister_bp)
 *   - /pos/api/inventory/items (inventory_bp) — for item search
 *   - /pos/api/catalog/search (catalog_bp) — for catalog search
 */
const POS = (() => {
    // ─── State ───────────────────────────
    let token = localStorage.getItem('pos_token') || '';
    let cart = [];
    let selectedRow = -1;
    let currentCustomer = null;
    let currentRegister = null;
    let paymentMethod = 'efectivo';
    let canViewCost = false;
    let employeeMaxDiscount = 100;
    let lastSaleId = null;
    let searchTimeout = null;
    let customerSearchTimeout = null;

    const fmt = (n) => '$' + parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });

    function headers() {
        return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
    }

    async function api(url, options = {}) {
        options.headers = headers();
        const res = await fetch(url, options);
        const data = await res.json();
        if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
        return data;
    }

    // ─── Init ────────────────────────────
    async function init() {
        // Parse JWT to get employee info
        try {
            const payload = JSON.parse(atob(token.split('.')[1]));
            document.getElementById('employeeName').textContent = payload.name || 'Empleado';
            document.getElementById('branchName').textContent = payload.branch_name || '';
            canViewCost = (payload.permissions || []).includes('pos.view_cost');
            employeeMaxDiscount = payload.max_discount_pct || 100;

            // Show cost/margin columns if permission
            if (canViewCost) {
                document.getElementById('thCost').style.display = '';
                document.getElementById('thMargin').style.display = '';
            }
        } catch (e) {
            console.warn('Could not parse token:', e);
        }

        // Load cart from localStorage (from catalog)
        const catalogCart = localStorage.getItem('pos_cart');
        if (catalogCart) {
            try {
                const items = JSON.parse(catalogCart);
                for (const item of items) {
                    addToCart(item);
                }
                localStorage.removeItem('pos_cart');
            } catch (e) { console.warn('Could not load catalog cart:', e); }
        }

        // Load current register
        await loadRegister();

        // Setup event listeners
        setupKeyboard();
        setupSearch();
        setupCustomerSearch();
    }

    // ─── Register ────────────────────────
    async function loadRegister() {
        try {
            const data = await api('/pos/api/register/current');
            if (data.register) {
                currentRegister = data.register;
                document.getElementById('registerInfo').innerHTML =
                    `<span>Caja #${data.register.register_number}</span>`;
                document.getElementById('registerInfo').classList.remove('no-register');
            } else {
                document.getElementById('registerInfo').innerHTML =
                    '<span>Sin caja abierta</span>';
                document.getElementById('registerInfo').classList.add('no-register');
            }
        } catch (e) {
            console.warn('Register check failed:', e);
        }
    }

    // ─── Cart ────────────────────────────
    function addToCart(item) {
        // Check if item already in cart
        const existing = cart.find(c => c.inventory_id === item.inventory_id);
        if (existing) {
            existing.quantity += (item.quantity || 1);
            renderCart();
            return;
        }

        cart.push({
            inventory_id: item.inventory_id || item.id,
            part_number: item.part_number || '',
            name: item.name || '',
            quantity: item.quantity || 1,
            unit_price: parseFloat(item.unit_price || item.price_1 || 0),
            unit_cost: parseFloat(item.cost || 0),
            discount_pct: parseFloat(item.discount_pct || 0),
            tax_rate: parseFloat(item.tax_rate || 0.16),
            stock: item.stock || 0,
        });

        renderCart();
    }

    function removeFromCart(index) {
        cart.splice(index, 1);
        if (selectedRow >= cart.length) selectedRow = cart.length - 1;
        renderCart();
    }

    function renderCart() {
        const tbody = document.getElementById('cartBody');
        const table = document.getElementById('cartTable');
        const empty = document.getElementById('cartEmpty');

        if (cart.length === 0) {
            table.style.display = 'none';
            empty.style.display = 'flex';
            updateTotals();
            return;
        }

        table.style.display = '';
        empty.style.display = 'none';

        let html = '';
        cart.forEach((item, i) => {
            const lineGross = item.unit_price * item.quantity;
            const lineDiscount = lineGross * item.discount_pct / 100;
            const lineSubtotal = lineGross - lineDiscount;

            const costHtml = canViewCost ? `<td class="num">${fmt(item.unit_cost)}</td>` : '';
            let marginHtml = '';
            if (canViewCost) {
                const effectivePrice = item.unit_price * (1 - item.discount_pct / 100);
                const marginPct = effectivePrice > 0
                    ? ((effectivePrice - item.unit_cost) / effectivePrice * 100).toFixed(1)
                    : '0.0';
                const cls = parseFloat(marginPct) < 5 ? 'margin-warning' : 'margin-info';
                marginHtml = `<td class="num"><span class="${cls}">${marginPct}%</span></td>`;
            }

            html += `<tr class="${i === selectedRow ? 'selected' : ''}" onclick="POS.selectRow(${i})">
                <td>${i + 1}</td>
                <td>
                    <div class="part-name">${item.name}</div>
                    <div class="part-number">${item.part_number} | Stock: ${item.stock}</div>
                </td>
                <td><input type="number" class="qty-input" value="${item.quantity}" min="1"
                    onchange="POS.updateQty(${i}, this.value)" onclick="event.stopPropagation()"></td>
                <td class="num">${fmt(item.unit_price)}</td>
                <td><input type="number" class="discount-input" value="${item.discount_pct}" min="0" max="100" step="0.5"
                    onchange="POS.updateDiscount(${i}, this.value)" onclick="event.stopPropagation()">%</td>
                <td class="num">${fmt(lineSubtotal)}</td>
                ${costHtml}
                ${marginHtml}
                <td><button class="btn-remove" onclick="event.stopPropagation(); POS.removeFromCart(${i})">&times;</button></td>
            </tr>`;
        });
        tbody.innerHTML = html;
        updateTotals();
    }

    function updateQty(index, val) {
        const qty = Math.max(1, parseInt(val) || 1);
        cart[index].quantity = qty;
        renderCart();
    }

    function updateDiscount(index, val) {
        let disc = Math.max(0, Math.min(100, parseFloat(val) || 0));
        if (disc > employeeMaxDiscount) {
            alert(`Descuento maximo permitido: ${employeeMaxDiscount}%`);
            disc = employeeMaxDiscount;
        }
        cart[index].discount_pct = disc;
        renderCart();
    }

    function selectRow(index) {
        selectedRow = index;
        renderCart();
    }

    function updateTotals() {
        let subtotal = 0, discountTotal = 0, taxTotal = 0;

        cart.forEach(item => {
            const lineGross = item.unit_price * item.quantity;
            const lineDiscount = lineGross * item.discount_pct / 100;
            const lineAfterDiscount = lineGross - lineDiscount;
            const lineTax = lineAfterDiscount * item.tax_rate;
            subtotal += lineAfterDiscount;
            discountTotal += lineDiscount;
            taxTotal += lineTax;
        });

        const total = subtotal + taxTotal;

        document.getElementById('dispSubtotal').textContent = fmt(subtotal);
        document.getElementById('dispTax').textContent = fmt(taxTotal);
        document.getElementById('dispTotal').textContent = fmt(total);

        if (discountTotal > 0) {
            document.getElementById('discountRow').style.display = '';
            document.getElementById('dispDiscount').textContent = '-' + fmt(discountTotal);
        } else {
            document.getElementById('discountRow').style.display = 'none';
        }
    }

    function getTotal() {
        let subtotal = 0, taxTotal = 0;
        cart.forEach(item => {
            const lineGross = item.unit_price * item.quantity;
            const lineDiscount = lineGross * item.discount_pct / 100;
            const lineAfterDiscount = lineGross - lineDiscount;
            const lineTax = lineAfterDiscount * item.tax_rate;
            subtotal += lineAfterDiscount;
            taxTotal += lineTax;
        });
        return Math.round((subtotal + taxTotal) * 100) / 100;
    }

    // ─── Search ──────────────────────────
    function setupSearch() {
        const input = document.getElementById('itemSearch');
        input.addEventListener('input', () => {
            clearTimeout(searchTimeout);
            searchTimeout = setTimeout(() => searchItems(input.value.trim()), 300);
        });
        input.addEventListener('keydown', (e) => {
            if (e.key === 'Enter') {
                e.preventDefault();
                searchItems(input.value.trim());
            }
            if (e.key === 'Escape') {
                input.value = '';
                hideSearchResults();
            }
        });
    }

    async function searchItems(q) {
        if (!q || q.length < 2) { hideSearchResults(); return; }

        try {
            const data = await api(`/pos/api/inventory/items?q=${encodeURIComponent(q)}&per_page=20`);
            const container = document.getElementById('searchResults');
            const totals = document.getElementById('totalsPanel');

            if (data.data.length === 0) {
                container.innerHTML = '<div style="padding:20px;text-align:center;color:#999;">Sin resultados</div>';
            } else {
                let html = '';
                data.data.forEach(item => {
                    // Determine price for current customer
                    let price = item.price_1;
                    if (currentCustomer) {
                        const tier = currentCustomer.price_tier || 1;
                        price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
                    }
                    const stockClass = item.stock <= 0 ? 'zero' : '';
                    html += `<div class="search-result-item" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "&#39;")}, ${price})'>
                        <div>
                            <div class="sr-name">${item.name}</div>
                            <div class="sr-pn">${item.part_number} | ${item.brand || ''}</div>
                            <div class="sr-stock ${stockClass}">Stock: ${item.stock}</div>
                        </div>
                        <div class="sr-price">${fmt(price)}</div>
                    </div>`;
                });
                container.innerHTML = html;
            }

            container.classList.add('active');
            totals.classList.add('hidden');
        } catch (e) {
            console.error('Search error:', e);
        }
    }

    function addFromSearch(item, price) {
        addToCart({
            inventory_id: item.id,
            part_number: item.part_number,
            name: item.name,
            unit_price: price,
            cost: item.cost,
            tax_rate: item.tax_rate,
            stock: item.stock,
        });
        hideSearchResults();
        document.getElementById('itemSearch').value = '';
        document.getElementById('itemSearch').focus();
    }

    function hideSearchResults() {
        document.getElementById('searchResults').classList.remove('active');
        document.getElementById('totalsPanel').classList.remove('hidden');
    }

    // ─── Customer Search ─────────────────
    function setupCustomerSearch() {
        const input = document.getElementById('customerSearch');
        input.addEventListener('input', () => {
            clearTimeout(customerSearchTimeout);
            customerSearchTimeout = setTimeout(() => searchCustomers(input.value.trim()), 300);
        });
        input.addEventListener('keydown', (e) => {
            if (e.key === 'Escape') {
                input.value = '';
                document.getElementById('customerAutocomplete').style.display = 'none';
            }
        });
    }

    async function searchCustomers(q) {
        if (!q || q.length < 2) {
            document.getElementById('customerAutocomplete').style.display = 'none';
            return;
        }

        try {
            const data = await api(`/pos/api/customers?q=${encodeURIComponent(q)}&per_page=10`);
            const ac = document.getElementById('customerAutocomplete');

            if (data.data.length === 0) {
                ac.innerHTML = '<div class="ac-item" style="color:#999;">Sin resultados</div>';
            } else {
                let html = '';
                data.data.forEach(c => {
                    const tiers = { 1: 'Mostrador', 2: 'Taller', 3: 'Mayoreo' };
                    html += `<div class="ac-item" onclick='POS.selectCustomer(${JSON.stringify(c).replace(/'/g, "&#39;")})'>
                        <div>${c.name}</div>
                        <div class="ac-meta">${c.rfc || ''} | ${c.phone || ''} | ${tiers[c.price_tier] || 'P1'} | Credito: ${fmt(c.credit_balance)}/${fmt(c.credit_limit)}</div>
                    </div>`;
                });
                ac.innerHTML = html;
            }
            ac.style.display = 'block';
        } catch (e) {
            console.error('Customer search error:', e);
        }
    }

    async function selectCustomer(customer) {
        currentCustomer = customer;
        document.getElementById('customerAutocomplete').style.display = 'none';
        document.getElementById('customerSearchWrap').querySelector('input').style.display = 'none';

        const tiers = { 1: 'P1 Mostrador', 2: 'P2 Taller', 3: 'P3 Mayoreo' };
        document.getElementById('customerName').textContent = customer.name;
        document.getElementById('customerTier').textContent = tiers[customer.price_tier] || 'P1';
        document.getElementById('customerCredit').textContent =
            `Credito: ${fmt(customer.credit_balance)} / ${fmt(customer.credit_limit)}`;
        document.getElementById('customerSelected').style.display = '';

        // Show vehicle info
        if (customer.vehicle_info && customer.vehicle_info.length > 0) {
            const v = customer.vehicle_info[0];
            document.getElementById('vehicleInfo').textContent =
                `${v.make || ''} ${v.model || ''} ${v.year || ''} ${v.plates ? '(' + v.plates + ')' : ''}`;
            document.getElementById('vehicleBanner').classList.add('visible');
        }

        // Fetch full customer detail to get recent purchase info
        try {
            const detail = await api(`/pos/api/customers/${customer.id}`);
            if (detail.recent_purchases && detail.recent_purchases.length > 0) {
                const last = detail.recent_purchases[0];
                const daysAgo = Math.floor((Date.now() - new Date(last.created_at).getTime()) / 86400000);
                const daysText = daysAgo === 0 ? 'hoy' : daysAgo === 1 ? 'hace 1 dia' : `hace ${daysAgo} dias`;
                document.getElementById('lastPurchaseInfo').textContent =
                    `Ultima compra: ${fmt(last.total)} ${daysText}`;
                document.getElementById('vehicleBanner').classList.add('visible');
            }
        } catch (e) {
            console.warn('Could not fetch customer detail:', e);
        }

        // Re-apply prices based on customer tier
        cart.forEach(item => {
            // Fetch updated price for this customer tier (would need to re-query)
            // For now, prices stay as-is (they were set at add time)
        });

        renderCart();
    }

    function clearCustomer() {
        currentCustomer = null;
        document.getElementById('customerSelected').style.display = 'none';
        document.getElementById('customerSearchWrap').querySelector('input').style.display = '';
        document.getElementById('customerSearchWrap').querySelector('input').value = '';
        document.getElementById('vehicleBanner').classList.remove('visible');
        renderCart();
    }

    // ─── New Customer Modal ──────────────
    function showNewCustomerModal() {
        document.getElementById('newCustomerModal').classList.add('active');
        document.getElementById('ncName').focus();
    }

    function closeNewCustomerModal() {
        document.getElementById('newCustomerModal').classList.remove('active');
    }

    async function saveNewCustomer() {
        const name = document.getElementById('ncName').value.trim();
        if (!name) { alert('Nombre es requerido'); return; }

        const vehicle_info = [];
        const make = document.getElementById('ncVehMake').value.trim();
        if (make) {
            vehicle_info.push({
                make: make,
                model: document.getElementById('ncVehModel').value.trim(),
                year: document.getElementById('ncVehYear').value.trim(),
                plates: document.getElementById('ncVehPlates').value.trim(),
            });
        }

        const body = {
            name: name,
            rfc: document.getElementById('ncRfc').value.trim() || null,
            razon_social: document.getElementById('ncRazonSocial').value.trim() || null,
            regimen_fiscal: document.getElementById('ncRegimenFiscal').value || null,
            uso_cfdi: document.getElementById('ncUsoCfdi').value || 'G03',
            phone: document.getElementById('ncPhone').value.trim() || null,
            email: document.getElementById('ncEmail').value.trim() || null,
            price_tier: parseInt(document.getElementById('ncPriceTier').value) || 1,
            credit_limit: parseFloat(document.getElementById('ncCreditLimit').value) || 0,
            vehicle_info: vehicle_info.length > 0 ? vehicle_info : null,
        };

        try {
            const result = await api('/pos/api/customers', {
                method: 'POST',
                body: JSON.stringify(body),
            });

            // Select the new customer
            selectCustomer({
                id: result.id,
                name: body.name,
                rfc: body.rfc,
                phone: body.phone,
                price_tier: body.price_tier,
                credit_limit: body.credit_limit,
                credit_balance: 0,
                vehicle_info: body.vehicle_info,
            });

            closeNewCustomerModal();
        } catch (e) {
            alert('Error al crear cliente: ' + e.message);
        }
    }

    // ─── Payment ─────────────────────────
    function checkout() {
        if (cart.length === 0) { alert('Carrito vacio'); return; }
        if (!currentRegister) { alert('No hay caja abierta. Abra una caja primero.'); return; }

        paymentMethod = 'efectivo';
        const total = getTotal();

        document.getElementById('modalTotal').textContent = fmt(total);
        document.getElementById('cashReceived').value = '';
        document.getElementById('changeDisplay').textContent = 'Cambio: $0.00';
        document.getElementById('changeDisplay').className = 'change-display positive';
        document.getElementById('paymentRef').value = '';

        // Reset payment method buttons
        document.querySelectorAll('.pm-btn').forEach(b => b.classList.remove('active'));
        document.querySelector('.pm-btn[data-method="efectivo"]').classList.add('active');
        document.getElementById('cashPayment').style.display = '';
        document.getElementById('refPayment').style.display = 'none';
        document.getElementById('mixedPayment').style.display = 'none';

        document.getElementById('paymentModal').classList.add('active');
        setTimeout(() => document.getElementById('cashReceived').focus(), 100);
    }

    function selectPaymentMethod(method, btn) {
        paymentMethod = method;
        document.querySelectorAll('.pm-btn').forEach(b => b.classList.remove('active'));
        btn.classList.add('active');

        document.getElementById('cashPayment').style.display = method === 'efectivo' ? '' : 'none';
        document.getElementById('refPayment').style.display = (method === 'transferencia' || method === 'tarjeta') ? '' : 'none';
        document.getElementById('mixedPayment').style.display = method === 'mixto' ? '' : 'none';

        if (method === 'efectivo') document.getElementById('cashReceived').focus();
        if (method === 'transferencia' || method === 'tarjeta') document.getElementById('paymentRef').focus();
    }

    function updateChange() {
        const total = getTotal();
        const received = parseFloat(document.getElementById('cashReceived').value) || 0;
        const change = received - total;
        const el = document.getElementById('changeDisplay');
        el.textContent = `Cambio: ${fmt(Math.abs(change))}`;
        el.className = 'change-display ' + (change >= 0 ? 'positive' : 'negative');
    }

    function updateMixedTotal() {
        const total = getTotal();
        let sum = 0;
        document.querySelectorAll('.mixed-amount').forEach(input => {
            sum += parseFloat(input.value) || 0;
        });
        const remaining = total - sum;
        document.getElementById('mixedRemaining').textContent =
            remaining > 0 ? `Faltante: ${fmt(remaining)}` : `Cubierto (${fmt(sum)})`;
        document.getElementById('mixedRemaining').style.color = remaining > 0 ? '#c62828' : '#2e7d32';
    }

    function closePaymentModal() {
        document.getElementById('paymentModal').classList.remove('active');
    }

    async function confirmPayment() {
        const total = getTotal();
        let amountPaid = 0;
        let paymentDetails = [];
        let reference = '';

        if (paymentMethod === 'efectivo') {
            amountPaid = parseFloat(document.getElementById('cashReceived').value) || 0;
            if (amountPaid < total) { alert(`Monto insuficiente. Total: ${fmt(total)}`); return; }
        } else if (paymentMethod === 'transferencia' || paymentMethod === 'tarjeta') {
            amountPaid = total;
            reference = document.getElementById('paymentRef').value.trim();
        } else if (paymentMethod === 'mixto') {
            const rows = document.querySelectorAll('.mixed-row');
            rows.forEach(row => {
                const method = row.querySelector('select').value;
                const amount = parseFloat(row.querySelector('.mixed-amount').value) || 0;
                const ref = row.querySelectorAll('input')[1]?.value || '';
                if (amount > 0) {
                    paymentDetails.push({ method, amount, reference: ref });
                    amountPaid += amount;
                }
            });
            if (amountPaid < total) { alert(`Monto total insuficiente. Falta: ${fmt(total - amountPaid)}`); return; }
        }

        const saleData = {
            items: cart.map(item => ({
                inventory_id: item.inventory_id,
                quantity: item.quantity,
                unit_price: item.unit_price,
                discount_pct: item.discount_pct,
                tax_rate: item.tax_rate,
            })),
            customer_id: currentCustomer ? currentCustomer.id : null,
            payment_method: paymentMethod,
            sale_type: 'cash',
            register_id: currentRegister ? currentRegister.id : null,
            amount_paid: amountPaid,
            payment_details: paymentDetails,
            reference: reference,
        };

        document.getElementById('btnConfirmPayment').disabled = true;
        document.getElementById('btnConfirmPayment').textContent = 'Procesando...';

        try {
            const sale = await api('/pos/api/sales', {
                method: 'POST',
                body: JSON.stringify(saleData),
            });

            lastSaleId = sale.id;
            closePaymentModal();
            showTicket(sale);

            // Clear cart
            cart = [];
            selectedRow = -1;
            clearCustomer();
            renderCart();

        } catch (e) {
            alert('Error al procesar venta: ' + e.message);
        } finally {
            document.getElementById('btnConfirmPayment').disabled = false;
            document.getElementById('btnConfirmPayment').textContent = 'Confirmar Pago';
        }
    }

    // ─── Credit Sale ─────────────────────
    async function creditSale() {
        if (cart.length === 0) { alert('Carrito vacio'); return; }
        if (!currentCustomer) { alert('Seleccione un cliente para venta a credito'); return; }
        if (!currentRegister) { alert('No hay caja abierta.'); return; }

        const total = getTotal();
        const available = (currentCustomer.credit_limit || 0) - (currentCustomer.credit_balance || 0);

        if (currentCustomer.credit_limit > 0 && total > available) {
            if (!confirm(`Credito insuficiente. Disponible: ${fmt(available)}, Total: ${fmt(total)}. Continuar de todas formas?`)) {
                return;
            }
        }

        const saleData = {
            items: cart.map(item => ({
                inventory_id: item.inventory_id,
                quantity: item.quantity,
                unit_price: item.unit_price,
                discount_pct: item.discount_pct,
                tax_rate: item.tax_rate,
            })),
            customer_id: currentCustomer.id,
            payment_method: 'credito',
            sale_type: 'credit',
            register_id: currentRegister ? currentRegister.id : null,
            amount_paid: 0,
        };

        try {
            const sale = await api('/pos/api/sales', {
                method: 'POST',
                body: JSON.stringify(saleData),
            });

            lastSaleId = sale.id;
            showTicket(sale);
            cart = [];
            selectedRow = -1;
            clearCustomer();
            renderCart();
        } catch (e) {
            alert('Error: ' + e.message);
        }
    }

    // ─── Quotation ───────────────────────
    async function saveQuotation() {
        if (cart.length === 0) { alert('Carrito vacio'); return; }

        const body = {
            items: cart.map(item => ({
                inventory_id: item.inventory_id,
                quantity: item.quantity,
                unit_price: item.unit_price,
                discount_pct: item.discount_pct,
                tax_rate: item.tax_rate,
            })),
            customer_id: currentCustomer ? currentCustomer.id : null,
        };

        try {
            const result = await api('/pos/api/quotations', {
                method: 'POST',
                body: JSON.stringify(body),
            });
            alert(`Cotizacion #${result.id} guardada. Total: ${fmt(result.total)}\nValida hasta: ${result.valid_until}`);
        } catch (e) {
            alert('Error: ' + e.message);
        }
    }

    // ─── Layaway ─────────────────────────
    async function createLayaway() {
        if (cart.length === 0) { alert('Carrito vacio'); return; }
        if (!currentCustomer) { alert('Seleccione un cliente para apartado'); return; }

        const total = getTotal();
        const initialPayment = prompt(`Total: ${fmt(total)}\nIngrese monto del anticipo:`);
        if (!initialPayment) return;

        const amount = parseFloat(initialPayment);
        if (isNaN(amount) || amount <= 0) { alert('Monto invalido'); return; }
        if (amount > total) { alert('El anticipo no puede exceder el total'); return; }

        const body = {
            items: cart.map(item => ({
                inventory_id: item.inventory_id,
                quantity: item.quantity,
                unit_price: item.unit_price,
                discount_pct: item.discount_pct,
                tax_rate: item.tax_rate,
            })),
            customer_id: currentCustomer.id,
            initial_payment: amount,
            payment_method: 'efectivo',
            register_id: currentRegister ? currentRegister.id : null,
        };

        try {
            const result = await api('/pos/api/layaways', {
                method: 'POST',
                body: JSON.stringify(body),
            });
            alert(
                `Apartado #${result.id} creado.\n` +
                `Total: ${fmt(result.total)}\n` +
                `Anticipo: ${fmt(result.amount_paid)}\n` +
                `Restante: ${fmt(result.remaining)}\n` +
                `Vence: ${result.expires_at}`
            );
            cart = [];
            selectedRow = -1;
            clearCustomer();
            renderCart();
        } catch (e) {
            alert('Error: ' + e.message);
        }
    }

    // ─── Ticket ──────────────────────────
    function showTicket(sale) {
        const lines = [];
        lines.push('========================================');
        lines.push('              NEXUS POS');
        lines.push('========================================');
        lines.push(`Venta #${sale.id}`);
        lines.push(`Fecha: ${new Date(sale.created_at).toLocaleString('es-MX')}`);
        if (currentCustomer) {
            lines.push(`Cliente: ${currentCustomer.name}`);
            if (currentCustomer.rfc) lines.push(`RFC: ${currentCustomer.rfc}`);
        } else {
            lines.push('Cliente: Publico General');
        }
        lines.push('----------------------------------------');

        (sale.items || []).forEach(item => {
            lines.push(`${item.name}`);
            let line = `  ${item.quantity} x ${fmt(item.unit_price)}`;
            if (item.discount_pct > 0) line += ` (-${item.discount_pct}%)`;
            line += `  ${fmt(item.subtotal)}`;
            lines.push(line);
        });

        lines.push('----------------------------------------');
        lines.push(`Subtotal:      ${fmt(sale.subtotal).padStart(12)}`);
        if (sale.discount_total > 0) {
            lines.push(`Descuento:    -${fmt(sale.discount_total).padStart(12)}`);
        }
        lines.push(`IVA:           ${fmt(sale.tax_total).padStart(12)}`);
        lines.push('========================================');
        lines.push(`TOTAL:         ${fmt(sale.total).padStart(12)}`);
        lines.push('========================================');

        if (sale.payment_method === 'efectivo') {
            lines.push(`Efectivo:      ${fmt(sale.amount_paid).padStart(12)}`);
            lines.push(`Cambio:        ${fmt(sale.change_given).padStart(12)}`);
        } else {
            lines.push(`Pago: ${sale.payment_method}`);
        }

        lines.push('');
        lines.push('       Gracias por su compra!');
        lines.push('');

        document.getElementById('ticketPreview').textContent = lines.join('\n');
        document.getElementById('ticketModal').classList.add('active');
    }

    function closeTicketModal() {
        document.getElementById('ticketModal').classList.remove('active');
    }

    function printTicket() {
        window.print();
    }

    // ─── Last Sale ───────────────────────
    async function showLastSale() {
        if (!lastSaleId) { alert('No hay venta reciente'); return; }
        try {
            const sale = await api(`/pos/api/sales/${lastSaleId}`);
            showTicket(sale);
        } catch (e) {
            alert('Error: ' + e.message);
        }
    }

    // ─── Drawer ──────────────────────────
    function openDrawer() {
        // Cash drawer open command (ESC/POS compatible)
        // In a real implementation, this would send the command to the printer
        alert('Comando enviado al cajon de efectivo.');
    }

    // ─── Keyboard Shortcuts ──────────────
    function setupKeyboard() {
        document.addEventListener('keydown', (e) => {
            // Don't intercept when typing in inputs
            const tag = e.target.tagName;
            const inInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';

            switch (e.key) {
                case 'F1':
                    e.preventDefault();
                    document.getElementById('itemSearch').focus();
                    break;
                case 'F2':
                    e.preventDefault();
                    document.getElementById('customerSearch').focus();
                    document.getElementById('customerSearch').style.display = '';
                    document.getElementById('customerSelected').style.display = 'none';
                    break;
                case 'F3':
                    e.preventDefault();
                    checkout();
                    break;
                case 'F4':
                    e.preventDefault();
                    saveQuotation();
                    break;
                case 'F5':
                    e.preventDefault();
                    showLastSale();
                    break;
                case 'F6':
                    e.preventDefault();
                    openDrawer();
                    break;
                case 'Escape':
                    e.preventDefault();
                    if (document.getElementById('paymentModal').classList.contains('active')) {
                        closePaymentModal();
                    } else if (document.getElementById('newCustomerModal').classList.contains('active')) {
                        closeNewCustomerModal();
                    } else if (document.getElementById('ticketModal').classList.contains('active')) {
                        closeTicketModal();
                    } else {
                        hideSearchResults();
                    }
                    break;
                case 'Delete':
                    if (!inInput && selectedRow >= 0 && selectedRow < cart.length) {
                        e.preventDefault();
                        removeFromCart(selectedRow);
                    }
                    break;
                case '+':
                    if (!inInput && selectedRow >= 0 && selectedRow < cart.length) {
                        e.preventDefault();
                        cart[selectedRow].quantity++;
                        renderCart();
                    }
                    break;
                case '-':
                    if (!inInput && selectedRow >= 0 && selectedRow < cart.length) {
                        e.preventDefault();
                        if (cart[selectedRow].quantity > 1) {
                            cart[selectedRow].quantity--;
                            renderCart();
                        }
                    }
                    break;
                case '*':
                    if (!inInput && selectedRow >= 0 && selectedRow < cart.length) {
                        e.preventDefault();
                        const disc = prompt('Descuento %:', cart[selectedRow].discount_pct);
                        if (disc !== null) {
                            updateDiscount(selectedRow, disc);
                        }
                    }
                    break;
                case 'ArrowUp':
                    if (!inInput && cart.length > 0) {
                        e.preventDefault();
                        selectedRow = Math.max(0, selectedRow - 1);
                        renderCart();
                    }
                    break;
                case 'ArrowDown':
                    if (!inInput && cart.length > 0) {
                        e.preventDefault();
                        selectedRow = Math.min(cart.length - 1, selectedRow + 1);
                        renderCart();
                    }
                    break;
                case 'Enter':
                    if (e.target.id === 'cashReceived') {
                        e.preventDefault();
                        confirmPayment();
                    }
                    break;
            }
        });
    }

    // ─── Public API ──────────────────────
    init();

    return {
        addToCart, removeFromCart, selectRow,
        updateQty, updateDiscount,
        addFromSearch, hideSearchResults,
        selectCustomer, clearCustomer,
        showNewCustomerModal, closeNewCustomerModal, saveNewCustomer,
        checkout, confirmPayment, closePaymentModal,
        selectPaymentMethod, updateChange, updateMixedTotal,
        creditSale, saveQuotation, createLayaway,
        showLastSale, openDrawer,
        showTicket, closeTicketModal, printTicket,
    };
})();

Task 7: Customers frontend (pos/templates/customers.html + pos/static/js/customers.js)

Files:

  • Create: /home/Autopartes/pos/templates/customers.html
  • Create: /home/Autopartes/pos/static/js/customers.js

Customer management page with search, CRUD, credit status, vehicles, and account statement.

  • Step 1: Create customers.html
<!-- /home/Autopartes/pos/templates/customers.html -->
<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Clientes - Nexus POS</title>
    <link rel="stylesheet" href="/pos/static/css/common.css">
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: var(--font-body, 'Segoe UI', system-ui, sans-serif); background: var(--color-bg, #f0f2f5); color: var(--color-text, #1a1a2e); }

        .topbar { background: var(--color-primary, #1a1a2e); color: #fff; padding: 12px 24px; display: flex; align-items: center; justify-content: space-between; }
        .topbar h1 { font-size: 18px; font-weight: 600; }
        .topbar .nav-links a { color: #b0bec5; text-decoration: none; margin-left: 16px; font-size: 14px; }
        .topbar .nav-links a:hover { color: #fff; }

        .container { max-width: 1200px; margin: 0 auto; padding: 20px; }

        .toolbar { display: flex; gap: 12px; margin-bottom: 16px; align-items: center; }
        .toolbar input { flex: 1; padding: 10px 14px; border: 1px solid var(--color-border, #ddd); border-radius: var(--radius, 6px); font-size: 14px; }
        .toolbar .btn { padding: 10px 20px; border: none; border-radius: var(--radius, 6px); cursor: pointer; font-size: 14px; font-weight: 500; }
        .btn-primary { background: var(--color-primary, #1a1a2e); color: #fff; }
        .btn-secondary { background: #e0e0e0; color: #333; }

        .customers-table { width: 100%; border-collapse: collapse; background: #fff; border-radius: var(--radius, 6px); overflow: hidden; box-shadow: var(--shadow, 0 1px 3px rgba(0,0,0,0.1)); }
        .customers-table th { background: var(--color-surface, #f8f9fa); padding: 10px 12px; text-align: left; font-size: 12px; font-weight: 600; color: #666; border-bottom: 2px solid var(--color-border, #ddd); }
        .customers-table td { padding: 10px 12px; font-size: 13px; border-bottom: 1px solid #f0f0f0; }
        .customers-table tr { cursor: pointer; }
        .customers-table tr:hover { background: #f8f9fa; }

        .tier-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; }
        .tier-1 { background: #e3f2fd; color: #1565c0; }
        .tier-2 { background: #e8f5e9; color: #2e7d32; }
        .tier-3 { background: #fff3e0; color: #e65100; }

        .credit-bar { width: 80px; height: 6px; background: #e0e0e0; border-radius: 3px; display: inline-block; vertical-align: middle; margin-left: 6px; }
        .credit-fill { height: 100%; border-radius: 3px; background: #4caf50; }
        .credit-fill.warning { background: #ff9800; }
        .credit-fill.danger { background: #f44336; }

        .pagination { display: flex; justify-content: center; gap: 8px; margin-top: 16px; }
        .pagination .btn { padding: 6px 12px; font-size: 13px; }
        .pagination .btn.active { background: var(--color-primary, #1a1a2e); color: #fff; }

        /* Detail panel */
        .detail-panel { display: none; position: fixed; top: 0; right: 0; width: 480px; height: 100vh; background: #fff; box-shadow: -4px 0 20px rgba(0,0,0,0.15); z-index: 100; overflow-y: auto; }
        .detail-panel.active { display: block; }
        .detail-header { padding: 16px 20px; background: var(--color-primary, #1a1a2e); color: #fff; display: flex; justify-content: space-between; align-items: center; }
        .detail-header h2 { font-size: 16px; }
        .detail-header .btn-close { background: none; border: none; color: #fff; font-size: 24px; cursor: pointer; }
        .detail-body { padding: 20px; }
        .detail-section { margin-bottom: 20px; }
        .detail-section h3 { font-size: 14px; font-weight: 600; color: #666; margin-bottom: 8px; border-bottom: 1px solid #eee; padding-bottom: 4px; }
        .detail-field { display: flex; justify-content: space-between; padding: 4px 0; font-size: 13px; }
        .detail-field .label { color: #999; }

        .credit-summary { background: #f5f5f5; padding: 12px; border-radius: 8px; margin-bottom: 16px; }
        .credit-summary .big-number { font-size: 24px; font-weight: 700; }

        .purchases-list { max-height: 300px; overflow-y: auto; }
        .purchase-item { padding: 8px 0; border-bottom: 1px solid #f0f0f0; font-size: 13px; display: flex; justify-content: space-between; }

        .vehicle-card { background: #f5f5f5; padding: 10px 12px; border-radius: 8px; margin-bottom: 8px; font-size: 13px; }

        /* Modal */
        .modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 200; align-items: center; justify-content: center; }
        .modal-overlay.active { display: flex; }
        .modal { background: #fff; border-radius: 12px; padding: 24px; width: 550px; max-width: 95vw; max-height: 90vh; overflow-y: auto; }
        .modal h2 { margin-bottom: 16px; }
        .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
        .form-grid .full-width { grid-column: 1 / -1; }
        .form-field { display: flex; flex-direction: column; gap: 4px; }
        .form-field label { font-size: 12px; color: #666; font-weight: 500; }
        .form-field input, .form-field select { padding: 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; }
        .modal-actions { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; }
        .modal-actions .btn { padding: 10px 20px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }
    </style>
</head>
<body>
    <div class="topbar">
        <h1>Clientes</h1>
        <div class="nav-links">
            <a href="/pos/sale">POS</a>
            <a href="/pos/catalog">Catalogo</a>
            <a href="/pos/inventory">Inventario</a>
            <a href="/pos/customers">Clientes</a>
        </div>
    </div>

    <div class="container">
        <div class="toolbar">
            <input type="text" id="searchInput" placeholder="Buscar por nombre, RFC, telefono..." oninput="Customers.search()">
            <button class="btn btn-primary" onclick="Customers.showCreateModal()">+ Nuevo Cliente</button>
        </div>

        <table class="customers-table">
            <thead>
                <tr>
                    <th>Nombre</th>
                    <th>RFC</th>
                    <th>Telefono</th>
                    <th>Lista</th>
                    <th>Credito</th>
                    <th>Saldo</th>
                </tr>
            </thead>
            <tbody id="customersBody"></tbody>
        </table>

        <div class="pagination" id="pagination"></div>
    </div>

    <!-- Detail Panel (slides in from right) -->
    <div class="detail-panel" id="detailPanel">
        <div class="detail-header">
            <h2 id="detailName">Cliente</h2>
            <button class="btn-close" onclick="Customers.closeDetail()">&times;</button>
        </div>
        <div class="detail-body">
            <div class="detail-section">
                <div class="credit-summary">
                    <div>Credito disponible</div>
                    <div class="big-number" id="detailCreditAvailable">$0.00</div>
                    <div style="font-size: 12px; color: #666;">
                        Limite: <span id="detailCreditLimit">$0.00</span> |
                        Saldo: <span id="detailCreditBalance">$0.00</span>
                    </div>
                </div>
            </div>

            <div class="detail-section">
                <h3>Datos Fiscales</h3>
                <div id="detailFiscal"></div>
            </div>

            <div class="detail-section">
                <h3>Contacto</h3>
                <div id="detailContact"></div>
            </div>

            <div class="detail-section">
                <h3>Vehiculos</h3>
                <div id="detailVehicles"></div>
            </div>

            <div class="detail-section">
                <h3>Compras Recientes</h3>
                <div class="purchases-list" id="detailPurchases"></div>
            </div>

            <div style="display: flex; gap: 8px; margin-top: 16px;">
                <button class="btn btn-primary" onclick="Customers.editCurrent()">Editar</button>
                <button class="btn btn-secondary" onclick="Customers.showStatement()">Estado de Cuenta</button>
            </div>
        </div>
    </div>

    <!-- Create/Edit Modal -->
    <div class="modal-overlay" id="customerModal">
        <div class="modal">
            <h2 id="modalTitle">Nuevo Cliente</h2>
            <input type="hidden" id="editId">
            <div class="form-grid">
                <div class="form-field full-width"><label>Nombre *</label><input type="text" id="fName"></div>
                <div class="form-field"><label>RFC</label><input type="text" id="fRfc" maxlength="13"></div>
                <div class="form-field"><label>Razon Social</label><input type="text" id="fRazonSocial"></div>
                <div class="form-field"><label>Regimen Fiscal</label>
                    <select id="fRegimenFiscal">
                        <option value="">Seleccionar...</option>
                        <option value="601">601 - General de Ley PM</option>
                        <option value="603">603 - PM Fines No Lucrativos</option>
                        <option value="605">605 - Sueldos y Salarios</option>
                        <option value="606">606 - Arrendamiento</option>
                        <option value="612">612 - PF Actividad Empresarial</option>
                        <option value="616">616 - Sin Obligaciones Fiscales</option>
                        <option value="621">621 - Incorporacion Fiscal</option>
                        <option value="625">625 - RESICO</option>
                    </select>
                </div>
                <div class="form-field"><label>Uso CFDI</label>
                    <select id="fUsoCfdi">
                        <option value="G03">G03 - Gastos en general</option>
                        <option value="G01">G01 - Adquisicion de mercancias</option>
                        <option value="P01">P01 - Por definir</option>
                    </select>
                </div>
                <div class="form-field"><label>Codigo Postal</label><input type="text" id="fCp" maxlength="5"></div>
                <div class="form-field"><label>Telefono</label><input type="tel" id="fPhone"></div>
                <div class="form-field"><label>Email</label><input type="email" id="fEmail"></div>
                <div class="form-field full-width"><label>Direccion</label><input type="text" id="fAddress"></div>
                <div class="form-field"><label>Lista de precio</label>
                    <select id="fPriceTier">
                        <option value="1">1 - Mostrador</option>
                        <option value="2">2 - Taller</option>
                        <option value="3">3 - Mayoreo</option>
                    </select>
                </div>
                <div class="form-field"><label>Limite de credito</label><input type="number" id="fCreditLimit" value="0" min="0" step="100"></div>
            </div>
            <div class="modal-actions">
                <button class="btn btn-secondary" onclick="Customers.closeModal()">Cancelar</button>
                <button class="btn btn-primary" onclick="Customers.save()">Guardar</button>
            </div>
        </div>
    </div>

    <!-- Statement Modal -->
    <div class="modal-overlay" id="statementModal">
        <div class="modal" style="width: 650px;">
            <h2>Estado de Cuenta: <span id="statementName"></span></h2>
            <div id="statementContent" style="max-height: 500px; overflow-y: auto;"></div>
            <div class="modal-actions" style="margin-top: 16px;">
                <button class="btn btn-secondary" onclick="document.getElementById('statementModal').classList.remove('active')">Cerrar</button>
            </div>
        </div>
    </div>

    <script src="/pos/static/js/customers.js"></script>
</body>
</html>
  • Step 2: Create customers.js
// /home/Autopartes/pos/static/js/customers.js
/**
 * Customers management frontend.
 * Communicates with /pos/api/customers (customers_bp).
 */
const Customers = (() => {
    let token = localStorage.getItem('pos_token') || '';
    let currentPage = 1;
    let currentCustomer = null;
    let searchTimeout = null;

    const fmt = (n) => '$' + parseFloat(n || 0).toLocaleString('es-MX', {
        minimumFractionDigits: 2, maximumFractionDigits: 2
    });

    function headers() {
        return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
    }

    async function api(url, options = {}) {
        options.headers = headers();
        const res = await fetch(url, options);
        const data = await res.json();
        if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
        return data;
    }

    // ─── List ────────────────────────────
    async function loadCustomers(page, q) {
        page = page || currentPage;
        q = q !== undefined ? q : (document.getElementById('searchInput').value || '');

        try {
            const params = new URLSearchParams({ page, per_page: 50 });
            if (q) params.append('q', q);

            const data = await api(`/pos/api/customers?${params}`);
            renderTable(data.data);
            renderPagination(data.pagination);
        } catch (e) {
            console.error('Load customers failed:', e);
        }
    }

    function renderTable(customers) {
        const tbody = document.getElementById('customersBody');
        const tiers = { 1: ['Mostrador', 'tier-1'], 2: ['Taller', 'tier-2'], 3: ['Mayoreo', 'tier-3'] };

        let html = '';
        customers.forEach(c => {
            const [tierName, tierClass] = tiers[c.price_tier] || ['P1', 'tier-1'];
            const limit = c.credit_limit || 0;
            const balance = c.credit_balance || 0;
            const usagePct = limit > 0 ? Math.min(100, (balance / limit) * 100) : 0;
            const fillClass = usagePct > 90 ? 'danger' : usagePct > 70 ? 'warning' : '';

            html += `<tr onclick="Customers.showDetail(${c.id})">
                <td><strong>${c.name}</strong></td>
                <td>${c.rfc || '-'}</td>
                <td>${c.phone || '-'}</td>
                <td><span class="tier-badge ${tierClass}">${tierName}</span></td>
                <td>${fmt(limit)}
                    ${limit > 0 ? `<div class="credit-bar"><div class="credit-fill ${fillClass}" style="width:${usagePct}%"></div></div>` : ''}
                </td>
                <td>${balance > 0 ? fmt(balance) : '-'}</td>
            </tr>`;
        });

        if (customers.length === 0) {
            html = '<tr><td colspan="6" style="text-align:center;color:#999;padding:20px;">Sin resultados</td></tr>';
        }

        tbody.innerHTML = html;
    }

    function renderPagination(pag) {
        const container = document.getElementById('pagination');
        if (pag.total_pages <= 1) { container.innerHTML = ''; return; }

        let html = '';
        if (pag.page > 1) {
            html += `<button class="btn btn-secondary" onclick="Customers.goToPage(${pag.page - 1})">Anterior</button>`;
        }
        for (let i = 1; i <= Math.min(pag.total_pages, 10); i++) {
            html += `<button class="btn ${i === pag.page ? 'active' : 'btn-secondary'}" onclick="Customers.goToPage(${i})">${i}</button>`;
        }
        if (pag.page < pag.total_pages) {
            html += `<button class="btn btn-secondary" onclick="Customers.goToPage(${pag.page + 1})">Siguiente</button>`;
        }
        container.innerHTML = html;
    }

    function goToPage(page) {
        currentPage = page;
        loadCustomers(page);
    }

    function search() {
        clearTimeout(searchTimeout);
        searchTimeout = setTimeout(() => {
            currentPage = 1;
            loadCustomers(1);
        }, 300);
    }

    // ─── Detail ──────────────────────────
    async function showDetail(id) {
        try {
            const c = await api(`/pos/api/customers/${id}`);
            currentCustomer = c;

            document.getElementById('detailName').textContent = c.name;

            // Credit
            const available = (c.credit_limit || 0) - (c.credit_balance || 0);
            document.getElementById('detailCreditAvailable').textContent = fmt(available);
            document.getElementById('detailCreditLimit').textContent = fmt(c.credit_limit);
            document.getElementById('detailCreditBalance').textContent = fmt(c.credit_balance);

            // Fiscal
            let fiscalHtml = '';
            fiscalHtml += `<div class="detail-field"><span class="label">RFC</span><span>${c.rfc || '-'}</span></div>`;
            fiscalHtml += `<div class="detail-field"><span class="label">Razon Social</span><span>${c.razon_social || '-'}</span></div>`;
            fiscalHtml += `<div class="detail-field"><span class="label">Regimen</span><span>${c.regimen_fiscal || '-'}</span></div>`;
            fiscalHtml += `<div class="detail-field"><span class="label">Uso CFDI</span><span>${c.uso_cfdi || '-'}</span></div>`;
            fiscalHtml += `<div class="detail-field"><span class="label">CP</span><span>${c.cp || '-'}</span></div>`;
            document.getElementById('detailFiscal').innerHTML = fiscalHtml;

            // Contact
            let contactHtml = '';
            contactHtml += `<div class="detail-field"><span class="label">Telefono</span><span>${c.phone || '-'}</span></div>`;
            contactHtml += `<div class="detail-field"><span class="label">Email</span><span>${c.email || '-'}</span></div>`;
            contactHtml += `<div class="detail-field"><span class="label">Direccion</span><span>${c.address || '-'}</span></div>`;
            document.getElementById('detailContact').innerHTML = contactHtml;

            // Vehicles
            const vehicles = c.vehicle_info || [];
            if (vehicles.length === 0) {
                document.getElementById('detailVehicles').innerHTML = '<div style="color:#999;font-size:13px;">Sin vehiculos registrados</div>';
            } else {
                document.getElementById('detailVehicles').innerHTML = vehicles.map(v =>
                    `<div class="vehicle-card">
                        <strong>${v.make || ''} ${v.model || ''} ${v.year || ''}</strong>
                        ${v.plates ? `<span style="margin-left:8px;color:#666;">Placas: ${v.plates}</span>` : ''}
                        ${v.vin ? `<div style="font-size:11px;color:#999;">VIN: ${v.vin}</div>` : ''}
                    </div>`
                ).join('');
            }

            // Recent purchases
            const purchases = c.recent_purchases || [];
            if (purchases.length === 0) {
                document.getElementById('detailPurchases').innerHTML = '<div style="color:#999;font-size:13px;">Sin compras recientes</div>';
            } else {
                document.getElementById('detailPurchases').innerHTML = purchases.map(p =>
                    `<div class="purchase-item">
                        <span>Venta #${p.id} - ${new Date(p.created_at).toLocaleDateString('es-MX')}</span>
                        <span>${fmt(p.total)}</span>
                    </div>`
                ).join('');
            }

            document.getElementById('detailPanel').classList.add('active');
        } catch (e) {
            alert('Error: ' + e.message);
        }
    }

    function closeDetail() {
        document.getElementById('detailPanel').classList.remove('active');
        currentCustomer = null;
    }

    // ─── Create/Edit Modal ───────────────
    function showCreateModal() {
        document.getElementById('modalTitle').textContent = 'Nuevo Cliente';
        document.getElementById('editId').value = '';
        document.getElementById('fName').value = '';
        document.getElementById('fRfc').value = '';
        document.getElementById('fRazonSocial').value = '';
        document.getElementById('fRegimenFiscal').value = '';
        document.getElementById('fUsoCfdi').value = 'G03';
        document.getElementById('fCp').value = '';
        document.getElementById('fPhone').value = '';
        document.getElementById('fEmail').value = '';
        document.getElementById('fAddress').value = '';
        document.getElementById('fPriceTier').value = '1';
        document.getElementById('fCreditLimit').value = '0';
        document.getElementById('customerModal').classList.add('active');
        document.getElementById('fName').focus();
    }

    function editCurrent() {
        if (!currentCustomer) return;
        const c = currentCustomer;
        document.getElementById('modalTitle').textContent = 'Editar Cliente';
        document.getElementById('editId').value = c.id;
        document.getElementById('fName').value = c.name || '';
        document.getElementById('fRfc').value = c.rfc || '';
        document.getElementById('fRazonSocial').value = c.razon_social || '';
        document.getElementById('fRegimenFiscal').value = c.regimen_fiscal || '';
        document.getElementById('fUsoCfdi').value = c.uso_cfdi || 'G03';
        document.getElementById('fCp').value = c.cp || '';
        document.getElementById('fPhone').value = c.phone || '';
        document.getElementById('fEmail').value = c.email || '';
        document.getElementById('fAddress').value = c.address || '';
        document.getElementById('fPriceTier').value = c.price_tier || '1';
        document.getElementById('fCreditLimit').value = c.credit_limit || 0;
        document.getElementById('customerModal').classList.add('active');
    }

    function closeModal() {
        document.getElementById('customerModal').classList.remove('active');
    }

    async function save() {
        const name = document.getElementById('fName').value.trim();
        if (!name) { alert('Nombre es requerido'); return; }

        const body = {
            name: name,
            rfc: document.getElementById('fRfc').value.trim() || null,
            razon_social: document.getElementById('fRazonSocial').value.trim() || null,
            regimen_fiscal: document.getElementById('fRegimenFiscal').value || null,
            uso_cfdi: document.getElementById('fUsoCfdi').value || 'G03',
            cp: document.getElementById('fCp').value.trim() || null,
            phone: document.getElementById('fPhone').value.trim() || null,
            email: document.getElementById('fEmail').value.trim() || null,
            address: document.getElementById('fAddress').value.trim() || null,
            price_tier: parseInt(document.getElementById('fPriceTier').value) || 1,
            credit_limit: parseFloat(document.getElementById('fCreditLimit').value) || 0,
        };

        const editId = document.getElementById('editId').value;

        try {
            if (editId) {
                await api(`/pos/api/customers/${editId}`, {
                    method: 'PUT',
                    body: JSON.stringify(body),
                });
            } else {
                await api('/pos/api/customers', {
                    method: 'POST',
                    body: JSON.stringify(body),
                });
            }

            closeModal();
            loadCustomers();
            if (editId && currentCustomer) {
                showDetail(editId);
            }
        } catch (e) {
            alert('Error: ' + e.message);
        }
    }

    // ─── Statement ───────────────────────
    async function showStatement() {
        if (!currentCustomer) return;
        document.getElementById('statementName').textContent = currentCustomer.name;

        try {
            const data = await api(`/pos/api/customers/${currentCustomer.id}/statement`);

            let html = `<div style="margin-bottom:12px;font-size:14px;">
                <strong>Saldo actual: ${fmt(data.balance)}</strong> |
                Limite: ${fmt(data.customer.credit_limit)}
            </div>`;

            if (data.entries.length === 0) {
                html += '<div style="color:#999;padding:20px;text-align:center;">Sin movimientos</div>';
            } else {
                html += '<table style="width:100%;border-collapse:collapse;font-size:13px;">';
                html += '<tr style="background:#f5f5f5;"><th style="padding:8px;text-align:left;">Fecha</th><th style="text-align:left;">Concepto</th><th style="text-align:right;padding:8px;">Cargo</th><th style="text-align:right;padding:8px;">Abono</th><th style="text-align:right;padding:8px;">Saldo</th></tr>';

                data.entries.forEach(e => {
                    const dateStr = new Date(e.date).toLocaleDateString('es-MX');
                    html += `<tr style="border-bottom:1px solid #eee;">
                        <td style="padding:6px 8px;">${dateStr}</td>
                        <td>${e.description}</td>
                        <td style="text-align:right;padding:6px 8px;">${e.type === 'charge' ? fmt(e.amount) : ''}</td>
                        <td style="text-align:right;padding:6px 8px;">${e.type === 'payment' ? fmt(e.amount) : ''}</td>
                        <td style="text-align:right;padding:6px 8px;">${fmt(e.running_balance)}</td>
                    </tr>`;
                });
                html += '</table>';
            }

            document.getElementById('statementContent').innerHTML = html;
            document.getElementById('statementModal').classList.add('active');
        } catch (e) {
            alert('Error: ' + e.message);
        }
    }

    // ─── Init ────────────────────────────
    loadCustomers();

    return {
        search, goToPage, loadCustomers,
        showDetail, closeDetail,
        showCreateModal, editCurrent, closeModal, save,
        showStatement,
    };
})();

Task 8: Database migration for sale_payments and layaway_items

Files:

  • Create: /home/Autopartes/pos/migrations/v1.1_pos_tables.sql

The original schema (v1.0) includes sales, sale_items, quotations, quotation_items, layaways, layaway_payments, cash_registers, and cash_movements. This migration adds the sale_payments and layaway_items tables that were not in the original schema but are needed by the POS engine.

  • Step 1: Create migration file
-- /home/Autopartes/pos/migrations/v1.1_pos_tables.sql
-- POS Plan 3: Additional tables for sale payments and layaway items.
-- Run against each tenant DB.

-- Sale payments: tracks individual payment methods for a sale (especially mixed payments)
CREATE TABLE IF NOT EXISTS sale_payments (
    id              SERIAL PRIMARY KEY,
    sale_id         INTEGER REFERENCES sales(id),
    register_id     INTEGER REFERENCES cash_registers(id),
    method          VARCHAR(20) NOT NULL,           -- efectivo, transferencia, tarjeta
    amount          NUMERIC(12,2) NOT NULL,
    reference       VARCHAR(100),                    -- transaction ref for non-cash
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_sale_payments_sale ON sale_payments(sale_id);

-- Customer payments: tracks credit payments (abonos) against customer balance
CREATE TABLE IF NOT EXISTS customer_payments (
    id              SERIAL PRIMARY KEY,
    customer_id     INTEGER REFERENCES customers(id),
    amount          NUMERIC(12,2) NOT NULL,
    payment_method  VARCHAR(20) NOT NULL DEFAULT 'efectivo',
    reference       VARCHAR(100),
    employee_id     INTEGER REFERENCES employees(id),
    register_id     INTEGER REFERENCES cash_registers(id),
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_customer_payments_customer ON customer_payments(customer_id);

-- Layaway items: line items for a layaway (mirrors quotation_items structure)
CREATE TABLE IF NOT EXISTS layaway_items (
    id              SERIAL PRIMARY KEY,
    layaway_id      INTEGER REFERENCES layaways(id),
    inventory_id    INTEGER REFERENCES inventory(id),
    part_number     VARCHAR(100),
    name            VARCHAR(300),
    quantity        INTEGER NOT NULL,
    unit_price      NUMERIC(12,2) NOT NULL,
    discount_pct    NUMERIC(5,2) DEFAULT 0,
    tax_rate        NUMERIC(5,4) DEFAULT 0.16,
    subtotal        NUMERIC(12,2) NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_layaway_items_layaway ON layaway_items(layaway_id);

-- Additional indexes for POS query performance
CREATE INDEX IF NOT EXISTS idx_sales_register ON sales(register_id) WHERE status = 'completed';
CREATE INDEX IF NOT EXISTS idx_sales_customer ON sales(customer_id);
CREATE INDEX IF NOT EXISTS idx_sales_created ON sales(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_sales_branch_date ON sales(branch_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_quotations_customer ON quotations(customer_id);
CREATE INDEX IF NOT EXISTS idx_layaways_customer ON layaways(customer_id);
CREATE INDEX IF NOT EXISTS idx_cash_registers_employee ON cash_registers(employee_id) WHERE status = 'open';
  • Step 2: Apply migration

Run the migration against each tenant database. Example for the test tenant:

cd /home/Autopartes/pos
python3 -c "
from tenant_db import get_tenant_conn_by_dbname
conn = get_tenant_conn_by_dbname('tenant_inv_test')
cur = conn.cursor()
cur.execute(open('migrations/v1.1_pos_tables.sql').read())
conn.commit()
print('Migration v1.1 applied successfully')
"

For all tenants, iterate using the tenant registry:

cd /home/Autopartes/pos
python3 -c "
from tenant_db import get_all_tenant_dbnames, get_tenant_conn_by_dbname
sql = open('migrations/v1.1_pos_tables.sql').read()
for dbname in get_all_tenant_dbnames():
    conn = get_tenant_conn_by_dbname(dbname)
    cur = conn.cursor()
    cur.execute(sql)
    conn.commit()
    conn.close()
    print(f'Migration applied to {dbname}')
print('All tenants migrated')
"

Note: The layaway_items table must exist before creating any layaways. The migration must be applied before deploying POS Plan 3 code.


Task 9: Integration test

Files:

  • Create: /home/Autopartes/pos/tests/test_pos_integration.py

Full integration test covering: register open, customer create, sale processing, quotation conversion, layaway workflow, cancellation, and register closing.

  • Step 1: Create test file
# /home/Autopartes/pos/tests/test_pos_integration.py
"""Integration tests for POS Plan 3: Sales, Customers, Cash Register.

Covers the full lifecycle:
1. Open register
2. Create customer
3. Process sale (verify inventory deduction, customer credit)
4. Create quotation -> convert to sale
5. Create layaway -> add payments -> complete
6. Cancel a sale (verify inventory reversal, credit reversal)
7. Cut-X (verify read-only summary)
8. Cut-Z (verify register closes with correct amounts)

Prerequisites:
- Flask app running or test client configured
- A test tenant DB with at least one branch, one employee, and inventory items
"""

import json
import pytest
import sys
import os

# Add pos directory to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))

from app import create_app
from tenant_db import get_tenant_conn


@pytest.fixture
def app():
    app = create_app()
    app.config['TESTING'] = True
    return app


@pytest.fixture
def client(app):
    return app.test_client()


@pytest.fixture
def auth_token(client):
    """Login and get a valid JWT token. Adjust credentials for your test tenant."""
    res = client.post('/pos/api/auth/login', json={
        'pin': '1234',
        'device_id': 'test-device-001'
    })
    assert res.status_code == 200, f"Login failed: {res.get_json()}"
    return res.get_json()['token']


def headers(token):
    return {
        'Content-Type': 'application/json',
        'Authorization': f'Bearer {token}',
        'X-Device-Id': 'test-device-001'
    }


class TestPOSIntegration:
    """Full POS lifecycle integration test."""

    register_id = None
    customer_id = None
    sale_id = None
    quotation_id = None
    layaway_id = None
    inventory_id = None
    initial_stock = None

    def test_01_open_register(self, client, auth_token):
        """Open a cash register with an opening amount."""
        res = client.post('/pos/api/register/open', headers=headers(auth_token), json={
            'register_number': 99,
            'opening_amount': 1000.00
        })
        data = res.get_json()
        assert res.status_code == 201, f"Open register failed: {data}"
        assert data['register_number'] == 99
        assert data['opening_amount'] == 1000.00
        TestPOSIntegration.register_id = data['id']

    def test_02_verify_current_register(self, client, auth_token):
        """Verify the current register is returned."""
        res = client.get('/pos/api/register/current', headers=headers(auth_token))
        data = res.get_json()
        assert res.status_code == 200
        assert data['register'] is not None
        assert data['register']['id'] == TestPOSIntegration.register_id

    def test_03_create_customer(self, client, auth_token):
        """Create a test customer with credit limit."""
        res = client.post('/pos/api/customers', headers=headers(auth_token), json={
            'name': 'Test Customer POS',
            'rfc': 'XAXX010101000',
            'phone': '5551234567',
            'price_tier': 1,
            'credit_limit': 50000.00,
            'vehicle_info': [{'make': 'Nissan', 'model': 'Tsuru', 'year': '2017', 'plates': 'ABC-123'}]
        })
        data = res.get_json()
        assert res.status_code == 201, f"Create customer failed: {data}"
        TestPOSIntegration.customer_id = data['id']

    def test_04_search_customer(self, client, auth_token):
        """Search for the created customer."""
        res = client.get('/pos/api/customers?q=Test Customer POS', headers=headers(auth_token))
        data = res.get_json()
        assert res.status_code == 200
        assert len(data['data']) >= 1
        assert any(c['name'] == 'Test Customer POS' for c in data['data'])

    def test_05_get_inventory_item(self, client, auth_token):
        """Get an inventory item to use for sales. Uses the first active item."""
        res = client.get('/pos/api/inventory/items?per_page=1', headers=headers(auth_token))
        data = res.get_json()
        assert res.status_code == 200
        assert len(data['data']) >= 1, "Need at least one inventory item for testing"
        item = data['data'][0]
        TestPOSIntegration.inventory_id = item['id']
        TestPOSIntegration.initial_stock = item['stock']

    def test_06_process_sale(self, client, auth_token):
        """Process a cash sale and verify inventory deduction."""
        inv_id = TestPOSIntegration.inventory_id
        res = client.post('/pos/api/sales', headers=headers(auth_token), json={
            'items': [{
                'inventory_id': inv_id,
                'quantity': 2,
                'unit_price': 150.00,
                'discount_pct': 0,
                'tax_rate': 0.16
            }],
            'customer_id': TestPOSIntegration.customer_id,
            'payment_method': 'efectivo',
            'sale_type': 'cash',
            'register_id': TestPOSIntegration.register_id,
            'amount_paid': 400.00,
        })
        data = res.get_json()
        assert res.status_code == 201, f"Process sale failed: {data}"
        assert data['total'] == 348.00  # 150*2 * 1.16 = 348.00
        assert data['change_given'] == 52.00  # 400 - 348 = 52
        assert data['status'] == 'completed'
        assert len(data['items']) == 2 or len(data['items']) >= 1
        TestPOSIntegration.sale_id = data['id']

    def test_07_verify_stock_deducted(self, client, auth_token):
        """Verify inventory stock was deducted after the sale."""
        inv_id = TestPOSIntegration.inventory_id
        res = client.get(f'/pos/api/inventory/items/{inv_id}', headers=headers(auth_token))
        data = res.get_json()
        assert res.status_code == 200
        expected_stock = TestPOSIntegration.initial_stock - 2
        assert data['stock'] == expected_stock, f"Stock should be {expected_stock}, got {data['stock']}"

    def test_08_get_sale_detail_and_payments(self, client, auth_token):
        """Verify sale detail retrieval including sale_payments records."""
        sale_id = TestPOSIntegration.sale_id
        res = client.get(f'/pos/api/sales/{sale_id}', headers=headers(auth_token))
        data = res.get_json()
        assert res.status_code == 200
        assert data['id'] == sale_id
        assert 'items' in data
        assert 'payments' in data
        # Verify sale_payments were created
        assert len(data['payments']) >= 1, "sale_payments should have at least one record"
        assert data['payments'][0]['method'] == 'efectivo'
        assert data['payments'][0]['amount'] > 0

    def test_08b_mixed_payment_sale(self, client, auth_token):
        """Process a sale with mixed payment (cash + transfer)."""
        inv_id = TestPOSIntegration.inventory_id
        res = client.post('/pos/api/sales', headers=headers(auth_token), json={
            'items': [{
                'inventory_id': inv_id,
                'quantity': 1,
                'unit_price': 200.00,
                'discount_pct': 0,
                'tax_rate': 0.16
            }],
            'customer_id': None,
            'payment_method': 'mixto',
            'sale_type': 'cash',
            'register_id': TestPOSIntegration.register_id,
            'amount_paid': 232.00,
            'payment_details': [
                {'method': 'efectivo', 'amount': 132.00, 'reference': ''},
                {'method': 'transferencia', 'amount': 100.00, 'reference': 'TRF-001'}
            ],
        })
        data = res.get_json()
        assert res.status_code == 201, f"Mixed payment sale failed: {data}"
        assert data['total'] == 232.00  # 200 * 1.16
        assert data['payment_method'] == 'mixto'

        # Verify sale_payments has two records for mixed payment
        sale_detail = client.get(f'/pos/api/sales/{data["id"]}', headers=headers(auth_token))
        detail = sale_detail.get_json()
        assert len(detail['payments']) == 2, f"Expected 2 payment records, got {len(detail['payments'])}"

    def test_08c_credit_sale(self, client, auth_token):
        """Process a credit sale and verify customer balance updated."""
        inv_id = TestPOSIntegration.inventory_id
        cust_id = TestPOSIntegration.customer_id
        res = client.post('/pos/api/sales', headers=headers(auth_token), json={
            'items': [{
                'inventory_id': inv_id,
                'quantity': 1,
                'unit_price': 100.00,
                'discount_pct': 0,
                'tax_rate': 0.16
            }],
            'customer_id': cust_id,
            'payment_method': 'credito',
            'sale_type': 'credit',
            'register_id': TestPOSIntegration.register_id,
            'amount_paid': 0,
        })
        data = res.get_json()
        assert res.status_code == 201, f"Credit sale failed: {data}"
        assert data['sale_type'] == 'credit'
        assert data['total'] == 116.00  # 100 * 1.16

        # Verify customer credit balance increased
        cust_res = client.get(f'/pos/api/customers/{cust_id}', headers=headers(auth_token))
        cust_data = cust_res.get_json()
        assert cust_data['credit_balance'] >= 116.00, \
            f"Credit balance should include {116.00}, got {cust_data['credit_balance']}"

    def test_09_create_quotation(self, client, auth_token):
        """Create a quotation."""
        inv_id = TestPOSIntegration.inventory_id
        res = client.post('/pos/api/quotations', headers=headers(auth_token), json={
            'items': [{
                'inventory_id': inv_id,
                'quantity': 1,
                'unit_price': 200.00,
                'discount_pct': 5,
                'tax_rate': 0.16
            }],
            'customer_id': TestPOSIntegration.customer_id,
        })
        data = res.get_json()
        assert res.status_code == 201, f"Create quotation failed: {data}"
        TestPOSIntegration.quotation_id = data['id']

    def test_10_convert_quotation(self, client, auth_token):
        """Convert quotation to sale."""
        quot_id = TestPOSIntegration.quotation_id
        res = client.post(f'/pos/api/quotations/{quot_id}/convert', headers=headers(auth_token), json={
            'register_id': TestPOSIntegration.register_id,
            'payment_method': 'transferencia',
            'sale_type': 'cash',
            'amount_paid': 220.40,  # 200 * 0.95 * 1.16 = 220.40
        })
        data = res.get_json()
        assert res.status_code == 201, f"Convert quotation failed: {data}"
        assert data['status'] == 'completed'

    def test_11_create_layaway(self, client, auth_token):
        """Create a layaway with partial payment."""
        inv_id = TestPOSIntegration.inventory_id
        res = client.post('/pos/api/layaways', headers=headers(auth_token), json={
            'items': [{
                'inventory_id': inv_id,
                'quantity': 3,
                'unit_price': 100.00,
                'discount_pct': 0,
                'tax_rate': 0.16
            }],
            'customer_id': TestPOSIntegration.customer_id,
            'initial_payment': 100.00,
            'payment_method': 'efectivo',
            'register_id': TestPOSIntegration.register_id,
        })
        data = res.get_json()
        assert res.status_code == 201, f"Create layaway failed: {data}"
        assert data['total'] == 348.00  # 100*3*1.16
        assert data['amount_paid'] == 100.00
        assert data['remaining'] == 248.00
        TestPOSIntegration.layaway_id = data['id']

    def test_12_layaway_add_payment(self, client, auth_token):
        """Add a payment to the layaway."""
        layaway_id = TestPOSIntegration.layaway_id
        res = client.post(f'/pos/api/layaways/{layaway_id}/payment', headers=headers(auth_token), json={
            'amount': 248.00,
            'payment_method': 'efectivo',
            'register_id': TestPOSIntegration.register_id,
        })
        data = res.get_json()
        assert res.status_code == 200, f"Layaway payment failed: {data}"
        assert data['fully_paid'] is True
        assert data['remaining'] == 0

    def test_13_layaway_complete(self, client, auth_token):
        """Complete layaway (convert to sale)."""
        layaway_id = TestPOSIntegration.layaway_id
        res = client.post(f'/pos/api/layaways/{layaway_id}/complete', headers=headers(auth_token), json={
            'register_id': TestPOSIntegration.register_id,
        })
        data = res.get_json()
        assert res.status_code == 201, f"Layaway complete failed: {data}"
        assert data['status'] == 'completed'

    def test_14_cancel_sale(self, client, auth_token):
        """Cancel the first sale and verify inventory reversal."""
        sale_id = TestPOSIntegration.sale_id
        res = client.put(f'/pos/api/sales/{sale_id}/cancel', headers=headers(auth_token), json={
            'reason': 'Test cancellation — integration test'
        })
        data = res.get_json()
        assert res.status_code == 200, f"Cancel sale failed: {data}"
        assert data['status'] == 'cancelled'
        assert data['items_reversed'] >= 1

    def test_15_verify_stock_restored(self, client, auth_token):
        """Verify inventory stock was restored after cancellation."""
        inv_id = TestPOSIntegration.inventory_id
        res = client.get(f'/pos/api/inventory/items/{inv_id}', headers=headers(auth_token))
        data = res.get_json()
        assert res.status_code == 200
        # Stock should be initial - quotation_qty(1) - layaway_qty(3) since the first sale (2) was cancelled
        expected = TestPOSIntegration.initial_stock - 1 - 3
        assert data['stock'] == expected, f"Stock should be {expected}, got {data['stock']}"

    def test_16_cash_movement(self, client, auth_token):
        """Record a manual cash withdrawal."""
        res = client.post('/pos/api/register/movement', headers=headers(auth_token), json={
            'type': 'out',
            'amount': 200.00,
            'reason': 'Retiro para cambio'
        })
        data = res.get_json()
        assert res.status_code == 201, f"Cash movement failed: {data}"

    def test_17_cut_x(self, client, auth_token):
        """X-cut: read-only summary without closing."""
        res = client.get('/pos/api/register/cut-x', headers=headers(auth_token))
        data = res.get_json()
        assert res.status_code == 200
        assert data['type'] == 'X'
        assert data['status'] == 'open'
        assert 'expected_cash' in data
        assert 'sales_by_method' in data
        assert data['opening_amount'] == 1000.00

    def test_18_cut_z(self, client, auth_token):
        """Z-cut: close register with counted amount."""
        # First get expected from X-cut
        res_x = client.get('/pos/api/register/cut-x', headers=headers(auth_token))
        expected = res_x.get_json()['expected_cash']

        # Close with the expected amount (perfect close)
        res = client.post('/pos/api/register/cut-z', headers=headers(auth_token), json={
            'closing_amount': expected
        })
        data = res.get_json()
        assert res.status_code == 200
        assert data['type'] == 'Z'
        assert data['status'] == 'closed'
        assert data['difference'] == 0.0, f"Difference should be 0, got {data['difference']}"

    def test_19_register_history(self, client, auth_token):
        """Verify closed register appears in history."""
        res = client.get('/pos/api/register/history', headers=headers(auth_token))
        data = res.get_json()
        assert res.status_code == 200
        assert len(data['data']) >= 1
        # Find our register
        our_reg = [r for r in data['data'] if r['id'] == TestPOSIntegration.register_id]
        assert len(our_reg) == 1
        assert our_reg[0]['register_number'] == 99

    def test_20_customer_statement(self, client, auth_token):
        """Verify customer account statement."""
        cust_id = TestPOSIntegration.customer_id
        res = client.get(f'/pos/api/customers/{cust_id}/statement', headers=headers(auth_token))
        data = res.get_json()
        assert res.status_code == 200
        assert 'entries' in data
        assert 'balance' in data

Summary of Endpoints

Method Endpoint Blueprint Permission Description
POST /pos/api/sales pos_bp pos.sell Create sale
GET /pos/api/sales pos_bp pos.view List sales
GET /pos/api/sales/:id pos_bp pos.view Sale detail
PUT /pos/api/sales/:id/cancel pos_bp pos.sell Cancel sale
POST /pos/api/quotations pos_bp pos.sell Create quotation
GET /pos/api/quotations pos_bp pos.view List quotations
GET /pos/api/quotations/:id pos_bp pos.view Quotation detail
POST /pos/api/quotations/:id/convert pos_bp pos.sell Convert to sale
PUT /pos/api/quotations/:id/cancel pos_bp pos.sell Cancel quotation
POST /pos/api/layaways pos_bp pos.sell Create layaway
GET /pos/api/layaways pos_bp pos.view List layaways
GET /pos/api/layaways/:id pos_bp pos.view Layaway detail
POST /pos/api/layaways/:id/payment pos_bp pos.sell Add payment
POST /pos/api/layaways/:id/complete pos_bp pos.sell Complete layaway
PUT /pos/api/layaways/:id/cancel pos_bp pos.sell Cancel layaway
GET /pos/api/customers customers_bp customers.view Search/list customers
GET /pos/api/customers/:id customers_bp customers.view Customer detail
POST /pos/api/customers customers_bp customers.create Create customer
PUT /pos/api/customers/:id customers_bp customers.edit Update customer
GET /pos/api/customers/:id/statement customers_bp customers.view Account statement
GET /pos/api/customers/:id/vehicles customers_bp customers.view Vehicle history
POST /pos/api/customers/:id/payment customers_bp customers.edit Record credit payment
POST /pos/api/register/open cashregister_bp pos.sell Open register
GET /pos/api/register/current cashregister_bp pos.sell Current register
POST /pos/api/register/movement cashregister_bp pos.sell Cash in/out
GET /pos/api/register/cut-x cashregister_bp pos.sell Partial cut (X)
POST /pos/api/register/cut-z cashregister_bp pos.sell Final cut (Z)
GET /pos/api/register/history cashregister_bp pos.view Closed registers
GET /pos/api/register/daily-summary cashregister_bp pos.view Consolidated daily summary

Page Routes

Route Template Description
/pos/sale pos.html POS sale page
/pos/customers customers.html Customer management