From c66fb13c15b8584d9f57b9090f8a2ede80a0d5cb Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Tue, 31 Mar 2026 03:36:04 +0000 Subject: [PATCH] =?UTF-8?q?feat(pos):=20add=20POS=20blueprint=20=E2=80=94?= =?UTF-8?q?=20sales,=20quotations,=20layaways=20with=20stock=20reservation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- pos/blueprints/pos_bp.py | 1160 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 1160 insertions(+) create mode 100644 pos/blueprints/pos_bp.py diff --git a/pos/blueprints/pos_bp.py b/pos/blueprints/pos_bp.py new file mode 100644 index 0000000..2a81748 --- /dev/null +++ b/pos/blueprints/pos_bp.py @@ -0,0 +1,1160 @@ +# /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 + + +@pos_bp.route('/sales/last', methods=['GET']) +@require_auth('pos.view') +def get_last_sale(): + """Get the last sale for the current employee.""" + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + where_clauses = ["s.employee_id = %s"] + params = [g.employee_id] + + 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 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 1 + """, params) + + row = cur.fetchone() + if not row: + cur.close(); conn.close() + return jsonify({'error': 'No sales found'}), 404 + + sale = { + 'id': row[0], 'branch_id': row[1], 'customer_id': row[2], + 'employee_id': row[3], 'register_id': row[4], + 'sale_type': row[5], 'payment_method': row[6], + 'subtotal': float(row[7]) if row[7] else 0, + 'discount_total': float(row[8]) if row[8] else 0, + 'tax_total': float(row[9]) if row[9] else 0, + 'total': float(row[10]) if row[10] else 0, + 'amount_paid': float(row[11]) if row[11] else 0, + 'change_given': float(row[12]) if row[12] else 0, + 'status': row[13], 'created_at': str(row[14]), + 'employee_name': row[15], 'customer_name': row[16], + } + + # 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) + + +# ─── 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.' + })