- Add address and cp to list_customers() backend response - Show razon_social as subtitle in customer table rows - Add razon_social and cp fields to detail panel - Update customers.html detail panel layout
468 lines
16 KiB
Python
468 lines
16 KiB
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.address, c.cp,
|
|
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], 'address': r[6], 'cp': r[7],
|
|
'price_tier': r[8],
|
|
'credit_limit': float(r[9]) if r[9] else 0,
|
|
'credit_balance': float(r[10]) if r[10] else 0,
|
|
'vehicle_info': r[11],
|
|
'branch_id': r[12],
|
|
})
|
|
|
|
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, 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('/<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',
|
|
'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('/<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
|