- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos - F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado - F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment - F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings) - F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant - Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas - Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant) - Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant - Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
322 lines
12 KiB
Python
322 lines
12 KiB
Python
"""Warranty / RMA blueprint.
|
|
|
|
Endpoints (all under /pos/api):
|
|
GET/POST /warranties
|
|
GET /warranties/<id>
|
|
GET /warranties/<id>/print (HTML)
|
|
GET /customers/<id>/warranties
|
|
POST /warranty-claims
|
|
GET /warranty-claims
|
|
GET /warranty-claims/<id>
|
|
PUT /warranty-claims/<id>/resolve
|
|
PUT /warranty-claims/<id>/close
|
|
"""
|
|
|
|
from flask import Blueprint, request, jsonify, g, render_template_string
|
|
from middleware import require_auth
|
|
from tenant_db import get_tenant_conn
|
|
from services.warranty_engine import (
|
|
register_warranty, create_claim, resolve_claim, close_claim,
|
|
get_warranty, list_warranties, get_claim, list_claims, expire_warranties,
|
|
)
|
|
|
|
warranty_bp = Blueprint('warranty', __name__, url_prefix='/pos/api')
|
|
|
|
|
|
# ── WARRANTIES ─────────────────────────────────────────────────────────────
|
|
|
|
@warranty_bp.route('/warranties', methods=['GET'])
|
|
@require_auth('inventory.view')
|
|
def get_warranties():
|
|
"""List warranties."""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
status = request.args.get('status')
|
|
customer_id = request.args.get('customer_id', type=int)
|
|
limit = request.args.get('limit', 50, type=int)
|
|
offset = request.args.get('offset', 0, type=int)
|
|
data = list_warranties(conn, customer_id=customer_id, status=status, limit=limit, offset=offset)
|
|
conn.close()
|
|
return jsonify({'data': data})
|
|
|
|
|
|
@warranty_bp.route('/warranties', methods=['POST'])
|
|
@require_auth('pos.sell')
|
|
def post_warranty():
|
|
"""Register a warranty (usually called at sale time)."""
|
|
data = request.get_json() or {}
|
|
required = ['sale_id', 'sale_item_id', 'inventory_id', 'customer_id']
|
|
# warranty_months is required unless a day-based coverage is given (used parts)
|
|
if 'warranty_months' not in data and 'warranty_days' not in data:
|
|
required.append('warranty_months')
|
|
missing = [f for f in required if f not in data]
|
|
if missing:
|
|
return jsonify({'error': f'Missing fields: {missing}'}), 400
|
|
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
try:
|
|
w_id = register_warranty(
|
|
conn,
|
|
sale_id=data['sale_id'],
|
|
sale_item_id=data['sale_item_id'],
|
|
inventory_id=data['inventory_id'],
|
|
customer_id=data['customer_id'],
|
|
warranty_months=int(data.get('warranty_months') or 0),
|
|
supplier_id=data.get('supplier_id'),
|
|
part_number=data.get('part_number'),
|
|
name=data.get('name'),
|
|
notes=data.get('notes'),
|
|
warranty_days=int(data['warranty_days']) if data.get('warranty_days') else None,
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return jsonify({'id': w_id, 'message': 'Warranty registered'}), 201
|
|
except ValueError as e:
|
|
conn.rollback()
|
|
conn.close()
|
|
return jsonify({'error': str(e)}), 400
|
|
except Exception as e:
|
|
conn.rollback()
|
|
conn.close()
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@warranty_bp.route('/warranties/<int:warranty_id>', methods=['GET'])
|
|
@require_auth('inventory.view')
|
|
def get_warranty_detail(warranty_id):
|
|
"""Get warranty by ID."""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
w = get_warranty(conn, warranty_id)
|
|
conn.close()
|
|
if not w:
|
|
return jsonify({'error': 'Warranty not found'}), 404
|
|
return jsonify(w)
|
|
|
|
|
|
@warranty_bp.route('/warranties/<int:warranty_id>/print', methods=['GET'])
|
|
@require_auth('inventory.view')
|
|
def print_warranty(warranty_id):
|
|
"""Printable warranty receipt (HTML).
|
|
|
|
Serves both month-based and Yonke day-based coverage so a used-part buyer
|
|
gets a physical voucher with the coverage window.
|
|
"""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
try:
|
|
w = get_warranty(conn, warranty_id)
|
|
if not w:
|
|
return jsonify({'error': 'Warranty not found'}), 404
|
|
business = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': '', 'phone': ''}
|
|
try:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT 'name' AS k, value FROM tenant_config
|
|
WHERE key IN ('tenant_nombre', 'receipt_store_name')
|
|
UNION ALL SELECT 'rfc', value FROM tenant_config
|
|
WHERE key IN ('tenant_rfc', 'receipt_rfc')
|
|
UNION ALL SELECT 'address', value FROM tenant_config
|
|
WHERE key IN ('tenant_direccion', 'receipt_address')
|
|
UNION ALL SELECT 'phone', value FROM tenant_config
|
|
WHERE key IN ('tenant_telefono', 'receipt_phone')
|
|
""")
|
|
for k, v in cur.fetchall():
|
|
if v:
|
|
business[k] = v
|
|
cur.close()
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
conn.close()
|
|
|
|
coverage = (f"{w['warranty_days']} días" if w.get('warranty_days')
|
|
else f"{w.get('warranty_months') or 0} meses")
|
|
html = render_template_string(
|
|
WARRANTY_PRINT_TEMPLATE, w=w, business=business, coverage=coverage
|
|
)
|
|
return html, 200, {'Content-Type': 'text/html; charset=utf-8'}
|
|
|
|
|
|
@warranty_bp.route('/customers/<int:customer_id>/warranties', methods=['GET'])
|
|
@require_auth('inventory.view')
|
|
def get_customer_warranties(customer_id):
|
|
"""List warranties for a customer."""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
data = list_warranties(conn, customer_id=customer_id)
|
|
conn.close()
|
|
return jsonify({'data': data})
|
|
|
|
|
|
# ── WARRANTY CLAIMS ────────────────────────────────────────────────────────
|
|
|
|
@warranty_bp.route('/warranty-claims', methods=['POST'])
|
|
@require_auth('inventory.edit')
|
|
def post_claim():
|
|
"""File a warranty claim."""
|
|
data = request.get_json() or {}
|
|
if not data.get('warranty_id') or not data.get('reason'):
|
|
return jsonify({'error': 'warranty_id and reason are required'}), 400
|
|
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
try:
|
|
claim_id = create_claim(
|
|
conn,
|
|
warranty_id=data['warranty_id'],
|
|
reason=data['reason'],
|
|
employee_id=g.employee_id,
|
|
notes=data.get('notes')
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return jsonify({'id': claim_id, 'message': 'Claim filed'}), 201
|
|
except ValueError as e:
|
|
conn.rollback()
|
|
conn.close()
|
|
return jsonify({'error': str(e)}), 400
|
|
except Exception as e:
|
|
conn.rollback()
|
|
conn.close()
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@warranty_bp.route('/warranty-claims', methods=['GET'])
|
|
@require_auth('inventory.view')
|
|
def get_claims():
|
|
"""List warranty claims."""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
status = request.args.get('status')
|
|
warranty_id = request.args.get('warranty_id', type=int)
|
|
limit = request.args.get('limit', 50, type=int)
|
|
offset = request.args.get('offset', 0, type=int)
|
|
data = list_claims(conn, status=status, warranty_id=warranty_id, limit=limit, offset=offset)
|
|
conn.close()
|
|
return jsonify({'data': data})
|
|
|
|
|
|
@warranty_bp.route('/warranty-claims/<int:claim_id>', methods=['GET'])
|
|
@require_auth('inventory.view')
|
|
def get_claim_detail(claim_id):
|
|
"""Get claim by ID."""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
c = get_claim(conn, claim_id)
|
|
conn.close()
|
|
if not c:
|
|
return jsonify({'error': 'Claim not found'}), 404
|
|
return jsonify(c)
|
|
|
|
|
|
@warranty_bp.route('/warranty-claims/<int:claim_id>/resolve', methods=['PUT'])
|
|
@require_auth('inventory.edit')
|
|
def put_resolve_claim(claim_id):
|
|
"""Resolve a claim."""
|
|
data = request.get_json() or {}
|
|
resolution = data.get('resolution')
|
|
if not resolution:
|
|
return jsonify({'error': 'resolution is required'}), 400
|
|
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
try:
|
|
ok = resolve_claim(
|
|
conn, claim_id, resolution,
|
|
diagnosis=data.get('diagnosis'),
|
|
replacement_inventory_id=data.get('replacement_inventory_id'),
|
|
refund_amount=data.get('refund_amount'),
|
|
labor_cost=data.get('labor_cost'),
|
|
supplier_rma_number=data.get('supplier_rma_number'),
|
|
notes=data.get('notes')
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
if not ok:
|
|
return jsonify({'error': 'Claim not found or already closed'}), 400
|
|
return jsonify({'message': 'Claim resolved'})
|
|
except ValueError as e:
|
|
conn.rollback()
|
|
conn.close()
|
|
return jsonify({'error': str(e)}), 400
|
|
except Exception as e:
|
|
conn.rollback()
|
|
conn.close()
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@warranty_bp.route('/warranty-claims/<int:claim_id>/close', methods=['PUT'])
|
|
@require_auth('inventory.edit')
|
|
def put_close_claim(claim_id):
|
|
"""Close a resolved claim."""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
try:
|
|
ok = close_claim(conn, claim_id)
|
|
conn.commit()
|
|
conn.close()
|
|
if not ok:
|
|
return jsonify({'error': 'Claim not found or not resolved'}), 400
|
|
return jsonify({'message': 'Claim closed'})
|
|
except Exception as e:
|
|
conn.rollback()
|
|
conn.close()
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
# ── Printable warranty receipt ─────────────────────────────────────────────
|
|
|
|
WARRANTY_PRINT_TEMPLATE = """<!DOCTYPE html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Garantia #{{ w.id }}</title>
|
|
<style>
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f3f4f6;color:#111;padding:16px;line-height:1.5}
|
|
.card{max-width:640px;margin:0 auto;background:#fff;border-radius:12px;box-shadow:0 4px 20px rgba(0,0,0,.08);overflow:hidden}
|
|
.header{background:linear-gradient(135deg,#1f2937,#374151);color:#fff;padding:24px;text-align:center}
|
|
.header h1{font-size:20px;font-weight:700;margin-bottom:4px}
|
|
.header p{font-size:13px;opacity:.85}
|
|
.body{padding:24px}
|
|
.meta{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:20px;font-size:13px;color:#4b5563}
|
|
.meta div{background:#f9fafb;padding:10px 12px;border-radius:8px}
|
|
.meta strong{color:#111;display:block;font-size:11px;text-transform:uppercase;letter-spacing:.4px;margin-bottom:2px}
|
|
.badge{display:inline-block;padding:4px 10px;border-radius:999px;font-size:11px;font-weight:700;text-transform:uppercase}
|
|
.badge-active{background:#d1fae5;color:#065f46}
|
|
.badge-expired,.badge-claimed,.badge-void{background:#fee2e2;color:#991b1b}
|
|
.terms{font-size:12px;color:#6b7280;border-top:1px solid #e5e7eb;padding-top:14px}
|
|
.footer{text-align:center;padding:14px;font-size:12px;color:#9ca3af}
|
|
.actions{padding:0 24px 20px;text-align:center}
|
|
.btn{display:inline-block;padding:12px 22px;border-radius:10px;border:none;background:#1f2937;color:#fff;font-size:14px;font-weight:700;cursor:pointer}
|
|
@media print{body{background:#fff;padding:0}.card{box-shadow:none}.actions{display:none}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<div class="header">
|
|
<h1>Comprobante de Garantia</h1>
|
|
<p>{{ business.name }}{% if business.rfc %} · RFC {{ business.rfc }}{% endif %}</p>
|
|
</div>
|
|
<div class="body">
|
|
<div class="meta">
|
|
<div><strong>Folio</strong>G-{{ w.id }}</div>
|
|
<div><strong>Estado</strong><span class="badge badge-{{ w.status }}">{{ w.status }}</span></div>
|
|
<div><strong>Cliente</strong>{{ w.customer_name or 'Publico general' }}</div>
|
|
<div><strong>Venta</strong>#{{ w.sale_id or '—' }}</div>
|
|
<div><strong>Pieza</strong>{{ w.name or '—' }}</div>
|
|
<div><strong>No. parte</strong>{{ w.part_number or '—' }}</div>
|
|
<div><strong>Inicio</strong>{{ w.start_date }}</div>
|
|
<div><strong>Vence</strong>{{ w.end_date }}</div>
|
|
<div><strong>Cobertura</strong>{{ coverage }}</div>
|
|
</div>
|
|
<div class="terms">
|
|
<p>Esta garantia ampara defectos de funcionamiento de la pieza por {{ coverage }} a partir de
|
|
la fecha de compra. Presente este comprobante junto con la pieza para cualquier reclamacion.</p>
|
|
{% if w.notes %}<p>Notas: {{ w.notes }}</p>{% endif %}
|
|
{% if business.address %}<p>{{ business.address }}</p>{% endif %}
|
|
{% if business.phone %}<p>Tel: {{ business.phone }}</p>{% endif %}
|
|
</div>
|
|
</div>
|
|
<div class="actions">
|
|
<button class="btn" onclick="window.print()">Imprimir</button>
|
|
</div>
|
|
<div class="footer">{{ business.name }} — Comprobante generado el {{ w.created_at[:10] }}</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|