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