feat(pos): add customers blueprint — CRUD, credit, vehicles, statements
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
465
pos/blueprints/customers_bp.py
Normal file
465
pos/blueprints/customers_bp.py
Normal file
@@ -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('/<int:customer_id>', 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('/<int:customer_id>', 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('/<int:customer_id>/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('/<int:customer_id>/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('/<int:customer_id>/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
|
||||
Reference in New Issue
Block a user