From 5550fe7bb0439c9fab34dbd72e504c85af3ea9b4 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Tue, 31 Mar 2026 03:34:32 +0000 Subject: [PATCH] =?UTF-8?q?feat(pos):=20add=20customers=20blueprint=20?= =?UTF-8?q?=E2=80=94=20CRUD,=20credit,=20vehicles,=20statements?= 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/customers_bp.py | 465 +++++++++++++++++++++++++++++++++ 1 file changed, 465 insertions(+) create mode 100644 pos/blueprints/customers_bp.py diff --git a/pos/blueprints/customers_bp.py b/pos/blueprints/customers_bp.py new file mode 100644 index 0000000..73a277f --- /dev/null +++ b/pos/blueprints/customers_bp.py @@ -0,0 +1,465 @@ +# /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