diff --git a/docs/plans/2026-03-27-pos-plan-3-pos-cashregister.md b/docs/plans/2026-03-27-pos-plan-3-pos-cashregister.md new file mode 100644 index 0000000..8fb19d0 --- /dev/null +++ b/docs/plans/2026-03-27-pos-plan-3-pos-cashregister.md @@ -0,0 +1,5210 @@ +# 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** + +```python +# /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** + +```python +# /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('/', 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('/', 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('//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('//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('//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** + +```python +# /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/', 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//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/', 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//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//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/', 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//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//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//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** + +```python +# /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** + +```python +# /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/') + 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** + +```html + + + + + + + Punto de Venta - Nexus POS + + + + + +
+
+ Cargando... + +
+
+ Caja: -- +
+
F1=Buscar F2=Cliente F3=Cobrar F4=Cotizacion F5=Ult.Venta
+
+ + +
+ +
+ +
+ Cliente: +
+ + +
+ + +
+ + +
+ Vehiculo: + + +
+ + + + + +
+
+
Carrito vacio
Busca productos o presiona F1
+
+ + + + + + + + + + + + + + + + +
+
+ + +
+ +
+ + +
+
+
Subtotal:$0.00
+ +
IVA (16%):$0.00
+
TOTAL:$0.00
+
+
+ + + % +
+
+ + +
+ + + + + + +
+
+
+ + +
+ F1Buscar + F2Cliente + F3Cobrar + F4Cotizacion + F5Ult.Venta + F6Cajon + +/-Cantidad + *Descuento + EnterAgregar + DelEliminar +
+ + + + + + + + + + + + + +``` + +- [ ] **Step 2: Create pos.js** + +```javascript +// /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 = + `Caja #${data.register.register_number}`; + document.getElementById('registerInfo').classList.remove('no-register'); + } else { + document.getElementById('registerInfo').innerHTML = + 'Sin caja abierta'; + 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 ? `${fmt(item.unit_cost)}` : ''; + 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 = `${marginPct}%`; + } + + html += ` + ${i + 1} + +
${item.name}
+
${item.part_number} | Stock: ${item.stock}
+ + + ${fmt(item.unit_price)} + % + ${fmt(lineSubtotal)} + ${costHtml} + ${marginHtml} + + `; + }); + 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 = '
Sin resultados
'; + } 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 += `
+
+
${item.name}
+
${item.part_number} | ${item.brand || ''}
+
Stock: ${item.stock}
+
+
${fmt(price)}
+
`; + }); + 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 = '
Sin resultados
'; + } else { + let html = ''; + data.data.forEach(c => { + const tiers = { 1: 'Mostrador', 2: 'Taller', 3: 'Mayoreo' }; + html += `
+
${c.name}
+
${c.rfc || ''} | ${c.phone || ''} | ${tiers[c.price_tier] || 'P1'} | Credito: ${fmt(c.credit_balance)}/${fmt(c.credit_limit)}
+
`; + }); + 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** + +```html + + + + + + + Clientes - Nexus POS + + + + +
+

Clientes

+ +
+ +
+
+ + +
+ + + + + + + + + + + + + +
NombreRFCTelefonoListaCreditoSaldo
+ + +
+ + +
+
+

Cliente

+ +
+
+
+
+
Credito disponible
+
$0.00
+
+ Limite: $0.00 | + Saldo: $0.00 +
+
+
+ +
+

Datos Fiscales

+
+
+ +
+

Contacto

+
+
+ +
+

Vehiculos

+
+
+ +
+

Compras Recientes

+
+
+ +
+ + +
+
+
+ + + + + + + + + + +``` + +- [ ] **Step 2: Create customers.js** + +```javascript +// /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 += ` + ${c.name} + ${c.rfc || '-'} + ${c.phone || '-'} + ${tierName} + ${fmt(limit)} + ${limit > 0 ? `
` : ''} + + ${balance > 0 ? fmt(balance) : '-'} + `; + }); + + if (customers.length === 0) { + html = 'Sin resultados'; + } + + 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 += ``; + } + for (let i = 1; i <= Math.min(pag.total_pages, 10); i++) { + html += ``; + } + if (pag.page < pag.total_pages) { + html += ``; + } + 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 += `
RFC${c.rfc || '-'}
`; + fiscalHtml += `
Razon Social${c.razon_social || '-'}
`; + fiscalHtml += `
Regimen${c.regimen_fiscal || '-'}
`; + fiscalHtml += `
Uso CFDI${c.uso_cfdi || '-'}
`; + fiscalHtml += `
CP${c.cp || '-'}
`; + document.getElementById('detailFiscal').innerHTML = fiscalHtml; + + // Contact + let contactHtml = ''; + contactHtml += `
Telefono${c.phone || '-'}
`; + contactHtml += `
Email${c.email || '-'}
`; + contactHtml += `
Direccion${c.address || '-'}
`; + document.getElementById('detailContact').innerHTML = contactHtml; + + // Vehicles + const vehicles = c.vehicle_info || []; + if (vehicles.length === 0) { + document.getElementById('detailVehicles').innerHTML = '
Sin vehiculos registrados
'; + } else { + document.getElementById('detailVehicles').innerHTML = vehicles.map(v => + `
+ ${v.make || ''} ${v.model || ''} ${v.year || ''} + ${v.plates ? `Placas: ${v.plates}` : ''} + ${v.vin ? `
VIN: ${v.vin}
` : ''} +
` + ).join(''); + } + + // Recent purchases + const purchases = c.recent_purchases || []; + if (purchases.length === 0) { + document.getElementById('detailPurchases').innerHTML = '
Sin compras recientes
'; + } else { + document.getElementById('detailPurchases').innerHTML = purchases.map(p => + `
+ Venta #${p.id} - ${new Date(p.created_at).toLocaleDateString('es-MX')} + ${fmt(p.total)} +
` + ).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 = `
+ Saldo actual: ${fmt(data.balance)} | + Limite: ${fmt(data.customer.credit_limit)} +
`; + + if (data.entries.length === 0) { + html += '
Sin movimientos
'; + } else { + html += ''; + html += ''; + + data.entries.forEach(e => { + const dateStr = new Date(e.date).toLocaleDateString('es-MX'); + html += ` + + + + + + `; + }); + html += '
FechaConceptoCargoAbonoSaldo
${dateStr}${e.description}${e.type === 'charge' ? fmt(e.amount) : ''}${e.type === 'payment' ? fmt(e.amount) : ''}${fmt(e.running_balance)}
'; + } + + 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** + +```sql +-- /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: + +```bash +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: + +```bash +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** + +```python +# /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 |