Files
Autoparts-DB/pos/blueprints/warranty_bp.py
consultoria-as a386ca5b4a
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- 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
2026-10-12 00:57:31 +00:00

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 %} &middot; 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>
"""