# 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
Nombre RFC Telefono Lista Credito Saldo

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 |