Files
Autoparts-DB/pos/blueprints/customers_bp.py
consultoria-as a236187f3a feat: MercadoLibre integration + inventory bulk publish + WhatsApp bridge fixes
- Add MercadoLibre OAuth, listings, orders, webhooks and category search
- New marketplace_external_bp.py, meli_service.py, marketplace_external_service.py
- New marketplace_external.html/js with ML management UI
- Inventory: bulk publish to ML with category autocomplete, listing type and shipping selectors
- Inventory: new .btn--meli styles, select/label CSS fixes
- WhatsApp bridge: rate limiting, 440/515/408 error handling, stale watchdog
- DB migration v3.4_meli_integration.sql for marketplace_listings, orders, sync_queue
- Add Celery tasks for ML sync and webhook processing
- Sidebar: MercadoLibre navigation link
2026-05-26 04:24:07 +00:00

466 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.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, 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