FASE 4: - Redis cache de stock con fallback graceful - Multi-moneda (MXN/USD) con contabilidad en MXN - Proveedores y ordenes de compra completo - Meilisearch 1.5M+ partes indexadas - Metabase KPIs con dashboard auto-generado FASE 5: - CRM mejorado: activities, tags, loyalty program, analytics - Imagenes de partes: upload, resize, thumbnails WebP - Ordenes de servicio Kanban: received->diagnosis->repair->ready->delivered - Garantias/RMA, alertas de reorden, multi-sucursal - Stubs BNPL (APLAZO) y ERP Sync (Aspel/Contpaqi) FASE 6: - Notificaciones automaticas: push/WhatsApp/email/in-app - Reportes de ahorro vs retail_price - Logistica + tracking: DHL, FedEx, Estafeta, 99min, Uber - API Publica: API keys, rate limiting, catalog search Migraciones: v1.9-v3.0 Tests: 93/93 pasando Backup: nexus_backup_20260427_045859.tar.gz
274 lines
9.2 KiB
Python
274 lines
9.2 KiB
Python
"""Warranty / RMA engine (Mejora #10).
|
|
|
|
Registers warranties at sale time and manages the claim lifecycle.
|
|
|
|
Tables:
|
|
warranties — one row per warranted item sold
|
|
warranty_claims — one row per claim filed
|
|
"""
|
|
|
|
from datetime import date, timedelta
|
|
from services.audit import log_action
|
|
|
|
|
|
def register_warranty(conn, sale_id, sale_item_id, inventory_id,
|
|
customer_id, warranty_months, supplier_id=None,
|
|
part_number=None, name=None, notes=None):
|
|
"""Register a warranty for a sold item.
|
|
|
|
Args:
|
|
warranty_months: int (e.g., 12, 24, 36)
|
|
Returns:
|
|
int: warranty_id
|
|
"""
|
|
if not warranty_months or warranty_months <= 0:
|
|
raise ValueError("warranty_months must be a positive integer")
|
|
|
|
start = date.today()
|
|
end = start + timedelta(days=30 * warranty_months)
|
|
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
INSERT INTO warranties
|
|
(sale_id, sale_item_id, inventory_id, customer_id, supplier_id,
|
|
part_number, name, warranty_months, start_date, end_date, notes)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
RETURNING id
|
|
""", (
|
|
sale_id, sale_item_id, inventory_id, customer_id, supplier_id,
|
|
part_number, name, warranty_months, start, end, notes
|
|
))
|
|
w_id = cur.fetchone()[0]
|
|
cur.close()
|
|
|
|
log_action(conn, 'WARRANTY_REGISTER', 'warranty', w_id,
|
|
new_value={'months': warranty_months, 'end_date': str(end)})
|
|
return w_id
|
|
|
|
|
|
def create_claim(conn, warranty_id, reason, employee_id=None, notes=None):
|
|
"""File a warranty claim.
|
|
|
|
Args:
|
|
warranty_id: int
|
|
reason: str (min 10 chars)
|
|
Returns:
|
|
int: claim_id
|
|
"""
|
|
if not reason or len(reason.strip()) < 10:
|
|
raise ValueError("Claim reason must be at least 10 characters")
|
|
|
|
cur = conn.cursor()
|
|
# Verify warranty exists and is active
|
|
cur.execute("SELECT status FROM warranties WHERE id = %s", (warranty_id,))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
raise ValueError("Warranty not found")
|
|
if row[0] != 'active':
|
|
raise ValueError(f"Cannot claim a warranty with status '{row[0]}'")
|
|
|
|
cur.execute("""
|
|
INSERT INTO warranty_claims
|
|
(warranty_id, reason, employee_id, notes)
|
|
VALUES (%s, %s, %s, %s)
|
|
RETURNING id
|
|
""", (warranty_id, reason, employee_id, notes))
|
|
claim_id = cur.fetchone()[0]
|
|
cur.close()
|
|
|
|
log_action(conn, 'WARRANTY_CLAIM', 'warranty_claim', claim_id,
|
|
new_value={'warranty_id': warranty_id, 'reason': reason})
|
|
return claim_id
|
|
|
|
|
|
def resolve_claim(conn, claim_id, resolution, diagnosis=None,
|
|
replacement_inventory_id=None, refund_amount=None,
|
|
labor_cost=None, supplier_rma_number=None, notes=None):
|
|
"""Resolve a warranty claim.
|
|
|
|
Args:
|
|
resolution: 'approved'|'rejected'|'repaired'|'replaced'|'refunded'
|
|
"""
|
|
if resolution not in ('approved', 'rejected', 'repaired', 'replaced', 'refunded'):
|
|
raise ValueError(f"Invalid resolution: {resolution}")
|
|
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
UPDATE warranty_claims
|
|
SET resolution = %s, diagnosis = COALESCE(%s, diagnosis),
|
|
replacement_inventory_id = COALESCE(%s, replacement_inventory_id),
|
|
refund_amount = COALESCE(%s, refund_amount),
|
|
labor_cost = COALESCE(%s, labor_cost),
|
|
supplier_rma_number = COALESCE(%s, supplier_rma_number),
|
|
notes = COALESCE(notes || ' | ', '') || %s,
|
|
status = 'resolved', resolved_at = NOW()
|
|
WHERE id = %s AND status != 'closed'
|
|
""", (
|
|
resolution, diagnosis, replacement_inventory_id,
|
|
refund_amount, labor_cost, supplier_rma_number,
|
|
notes or 'Resuelto', claim_id
|
|
))
|
|
updated = cur.rowcount > 0
|
|
|
|
# If replaced or refunded, mark warranty as claimed
|
|
if updated and resolution in ('replaced', 'refunded', 'approved'):
|
|
cur.execute("""
|
|
UPDATE warranties SET status = 'claimed'
|
|
WHERE id = (SELECT warranty_id FROM warranty_claims WHERE id = %s)
|
|
""", (claim_id,))
|
|
|
|
cur.close()
|
|
return updated
|
|
|
|
|
|
def close_claim(conn, claim_id):
|
|
"""Close a resolved claim (final status)."""
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
UPDATE warranty_claims
|
|
SET status = 'closed'
|
|
WHERE id = %s AND status = 'resolved'
|
|
""", (claim_id,))
|
|
updated = cur.rowcount > 0
|
|
cur.close()
|
|
return updated
|
|
|
|
|
|
def get_warranty(conn, warranty_id):
|
|
"""Get warranty detail."""
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT w.id, w.sale_id, w.sale_item_id, w.inventory_id, w.customer_id,
|
|
w.supplier_id, w.part_number, w.name, w.warranty_months,
|
|
w.start_date, w.end_date, w.status, w.notes, w.created_at,
|
|
c.name as customer_name, s.name as supplier_name
|
|
FROM warranties w
|
|
LEFT JOIN customers c ON w.customer_id = c.id
|
|
LEFT JOIN suppliers s ON w.supplier_id = s.id
|
|
WHERE w.id = %s
|
|
""", (warranty_id,))
|
|
row = cur.fetchone()
|
|
cur.close()
|
|
if not row:
|
|
return None
|
|
return {
|
|
'id': row[0], 'sale_id': row[1], 'sale_item_id': row[2],
|
|
'inventory_id': row[3], 'customer_id': row[4], 'supplier_id': row[5],
|
|
'part_number': row[6], 'name': row[7], 'warranty_months': row[8],
|
|
'start_date': str(row[9]), 'end_date': str(row[10]),
|
|
'status': row[11], 'notes': row[12], 'created_at': str(row[13]),
|
|
'customer_name': row[14], 'supplier_name': row[15],
|
|
}
|
|
|
|
|
|
def list_warranties(conn, customer_id=None, status=None, limit=50, offset=0):
|
|
"""List warranties."""
|
|
cur = conn.cursor()
|
|
filters = []
|
|
params = []
|
|
if customer_id:
|
|
filters.append("w.customer_id = %s")
|
|
params.append(customer_id)
|
|
if status:
|
|
filters.append("w.status = %s")
|
|
params.append(status)
|
|
where = "WHERE " + " AND ".join(filters) if filters else ""
|
|
|
|
cur.execute(f"""
|
|
SELECT w.id, w.part_number, w.name, w.warranty_months,
|
|
w.start_date, w.end_date, w.status,
|
|
c.name as customer_name
|
|
FROM warranties w
|
|
LEFT JOIN customers c ON w.customer_id = c.id
|
|
{where}
|
|
ORDER BY w.end_date ASC
|
|
LIMIT %s OFFSET %s
|
|
""", params + [limit, offset])
|
|
rows = cur.fetchall()
|
|
cur.close()
|
|
return [{
|
|
'id': r[0], 'part_number': r[1], 'name': r[2],
|
|
'warranty_months': r[3], 'start_date': str(r[4]),
|
|
'end_date': str(r[5]), 'status': r[6],
|
|
'customer_name': r[7],
|
|
} for r in rows]
|
|
|
|
|
|
def get_claim(conn, claim_id):
|
|
"""Get claim detail."""
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT wc.id, wc.warranty_id, wc.claim_date, wc.reason, wc.diagnosis,
|
|
wc.resolution, wc.replacement_inventory_id, wc.refund_amount,
|
|
wc.labor_cost, wc.status, wc.supplier_rma_number, wc.notes,
|
|
wc.created_at, wc.resolved_at,
|
|
w.part_number, w.name, w.customer_id, c.name as customer_name
|
|
FROM warranty_claims wc
|
|
JOIN warranties w ON wc.warranty_id = w.id
|
|
LEFT JOIN customers c ON w.customer_id = c.id
|
|
WHERE wc.id = %s
|
|
""", (claim_id,))
|
|
row = cur.fetchone()
|
|
cur.close()
|
|
if not row:
|
|
return None
|
|
return {
|
|
'id': row[0], 'warranty_id': row[1], 'claim_date': str(row[2]),
|
|
'reason': row[3], 'diagnosis': row[4], 'resolution': row[5],
|
|
'replacement_inventory_id': row[6], 'refund_amount': float(row[7]) if row[7] else None,
|
|
'labor_cost': float(row[8]) if row[8] else None,
|
|
'status': row[9], 'supplier_rma_number': row[10], 'notes': row[11],
|
|
'created_at': str(row[12]), 'resolved_at': str(row[13]) if row[13] else None,
|
|
'part_number': row[14], 'name': row[15],
|
|
'customer_id': row[16], 'customer_name': row[17],
|
|
}
|
|
|
|
|
|
def list_claims(conn, status=None, warranty_id=None, limit=50, offset=0):
|
|
"""List warranty claims."""
|
|
cur = conn.cursor()
|
|
filters = []
|
|
params = []
|
|
if status:
|
|
filters.append("wc.status = %s")
|
|
params.append(status)
|
|
if warranty_id:
|
|
filters.append("wc.warranty_id = %s")
|
|
params.append(warranty_id)
|
|
where = "WHERE " + " AND ".join(filters) if filters else ""
|
|
|
|
cur.execute(f"""
|
|
SELECT wc.id, wc.claim_date, wc.reason, wc.resolution, wc.status,
|
|
w.part_number, w.name, c.name as customer_name
|
|
FROM warranty_claims wc
|
|
JOIN warranties w ON wc.warranty_id = w.id
|
|
LEFT JOIN customers c ON w.customer_id = c.id
|
|
{where}
|
|
ORDER BY wc.created_at DESC
|
|
LIMIT %s OFFSET %s
|
|
""", params + [limit, offset])
|
|
rows = cur.fetchall()
|
|
cur.close()
|
|
return [{
|
|
'id': r[0], 'claim_date': str(r[1]), 'reason': r[2],
|
|
'resolution': r[3], 'status': r[4],
|
|
'part_number': r[5], 'name': r[6], 'customer_name': r[7],
|
|
} for r in rows]
|
|
|
|
|
|
def expire_warranties(conn):
|
|
"""Batch-update warranties whose end_date has passed to 'expired'.
|
|
|
|
Should be run periodically (e.g., daily via cron).
|
|
Returns number of warranties expired.
|
|
"""
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
UPDATE warranties
|
|
SET status = 'expired'
|
|
WHERE status = 'active' AND end_date < CURRENT_DATE
|
|
""")
|
|
count = cur.rowcount
|
|
cur.close()
|
|
return count
|