FASE 4-5-6: Infraestructura, CRM, Service Orders, Notificaciones, Ahorro, Logistica, API Publica

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
This commit is contained in:
Nexus Dev
2026-04-27 05:23:30 +00:00
parent b70cb3042b
commit 9ff3dc4c8b
71 changed files with 10939 additions and 420 deletions

View File

@@ -9,10 +9,30 @@ Operations are append-only. No UPDATE, no DELETE on inventory_operations.
from flask import g
from services.audit import log_action
from services.redis_stock_cache import (
get_cached_stock, set_cached_stock, invalidate_stock
)
def _safe_g(attr, default=None):
"""Safely read flask.g attribute outside of app context."""
try:
return getattr(g, attr, default)
except RuntimeError:
return default
def get_stock(conn, inventory_id, branch_id=None):
"""Get current stock for an inventory item. Optionally filter by branch."""
"""Get current stock for an inventory item. Optionally filter by branch.
Uses Redis cache first, falls back to PostgreSQL SUM query.
"""
# Try Redis first
cached = get_cached_stock(inventory_id, branch_id)
if cached is not None:
return cached
# Fallback to PostgreSQL
cur = conn.cursor()
if branch_id:
cur.execute(
@@ -26,11 +46,18 @@ def get_stock(conn, inventory_id, branch_id=None):
)
stock = cur.fetchone()[0]
cur.close()
# Cache the result
set_cached_stock(inventory_id, stock, branch_id)
return stock
def get_stock_bulk(conn, branch_id=None):
"""Get stock for all items. Returns dict {inventory_id: stock_quantity}."""
"""Get stock for all items. Returns dict {inventory_id: stock_quantity}.
Uses PostgreSQL directly (bulk operation, Redis wouldn't help much here
unless we pre-populated all keys).
"""
cur = conn.cursor()
if branch_id:
cur.execute("""
@@ -46,6 +73,11 @@ def get_stock_bulk(conn, branch_id=None):
""")
stock_map = {r[0]: r[1] for r in cur.fetchall()}
cur.close()
# Populate Redis cache with results
for inv_id, qty in stock_map.items():
set_cached_stock(inv_id, qty, branch_id)
return stock_map
@@ -67,8 +99,8 @@ def record_operation(conn, inventory_id, branch_id, operation_type, quantity,
""", (
inventory_id, branch_id, operation_type, quantity,
reference_id, reference_type, cost_at_time,
getattr(g, 'employee_id', None),
getattr(g, 'device_id', None),
_safe_g('employee_id'),
_safe_g('device_id'),
notes
))
op_id = cur.fetchone()[0]
@@ -84,50 +116,72 @@ def record_purchase(conn, inventory_id, branch_id, quantity, unit_cost,
must use TOTAL stock across ALL branches when computing the weighted average.
Using branch-scoped stock would produce incorrect averages when the same item
exists in multiple branches.
Uses SELECT ... FOR UPDATE to prevent race conditions on concurrent purchases
of the same item.
"""
from decimal import Decimal, ROUND_HALF_UP
TWO = Decimal('0.01')
cur = conn.cursor()
cur.execute("SELECT cost FROM inventory WHERE id = %s", (inventory_id,))
current_cost = float(cur.fetchone()[0] or 0)
cur.execute("SELECT cost FROM inventory WHERE id = %s FOR UPDATE", (inventory_id,))
row = cur.fetchone()
current_cost = Decimal(str(row[0] or 0)) if row else Decimal('0')
# Use GLOBAL stock (all branches) because cost is a global field on the inventory item
current_stock = get_stock(conn, inventory_id, branch_id=None)
current_stock = Decimal(str(get_stock(conn, inventory_id, branch_id=None) or 0))
qty_dec = Decimal(str(quantity))
unit_cost_dec = Decimal(str(unit_cost))
# Weighted average cost
if current_stock + quantity > 0:
new_cost = ((current_cost * max(current_stock, 0)) + (unit_cost * quantity)) / (max(current_stock, 0) + quantity)
# Weighted average cost (Decimal arithmetic)
stock_plus_qty = current_stock + qty_dec
if stock_plus_qty > 0:
numerator = (current_cost * current_stock) + (unit_cost_dec * qty_dec)
new_cost = (numerator / stock_plus_qty).quantize(TWO, rounding=ROUND_HALF_UP)
else:
new_cost = unit_cost
new_cost = unit_cost_dec
# Update cost on inventory item
cur.execute("UPDATE inventory SET cost = %s WHERE id = %s", (round(new_cost, 2), inventory_id))
cur.execute("UPDATE inventory SET cost = %s WHERE id = %s", (float(new_cost), inventory_id))
cur.close()
ref_note = f"Compra: {quantity} uds @ ${unit_cost:.2f}"
ref_note = f"Compra: {quantity} uds @ ${float(unit_cost_dec):.2f}"
if supplier_invoice:
ref_note += f" | Factura: {supplier_invoice}"
if notes:
ref_note += f" | {notes}"
return record_operation(
result = record_operation(
conn, inventory_id, branch_id, 'PURCHASE', quantity,
cost_at_time=unit_cost, notes=ref_note
cost_at_time=float(unit_cost_dec), notes=ref_note
)
invalidate_stock(inventory_id, branch_id)
invalidate_stock(inventory_id, None)
return result
def record_sale(conn, inventory_id, branch_id, quantity, sale_id=None, cost_at_time=None):
def record_sale(conn, inventory_id, branch_id, quantity, sale_id=None, cost_at_time=None, remaining_stock=None):
"""Record a sale (negative quantity).
NOT exposed via HTTP endpoint — called directly by the POS blueprint (Plan 3)
NOT exposed via HTTP endpoint — called directly by the POS blueprint
which imports inventory_engine as part of the full sale transaction.
Args:
remaining_stock: optional pre-calculated stock to avoid redundant SUM query.
If None, stock will be calculated internally.
"""
op_id = record_operation(
conn, inventory_id, branch_id, 'SALE', -abs(quantity),
reference_id=sale_id, reference_type='sale', cost_at_time=cost_at_time
)
# Invalidate cache immediately
invalidate_stock(inventory_id, branch_id)
invalidate_stock(inventory_id, None)
# Check if stock hit zero — push to owner (best-effort)
try:
remaining = get_stock(conn, inventory_id, branch_id)
remaining = remaining_stock if remaining_stock is not None else get_stock(conn, inventory_id, branch_id)
if remaining <= 0:
cur = conn.cursor()
cur.execute("SELECT part_number, name FROM inventory WHERE id = %s", (inventory_id,))
@@ -149,10 +203,13 @@ def record_sale(conn, inventory_id, branch_id, quantity, sale_id=None, cost_at_t
def record_return(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
"""Record a customer return (positive quantity)."""
return record_operation(
result = record_operation(
conn, inventory_id, branch_id, 'RETURN', abs(quantity),
reference_id=sale_id, reference_type='return', notes=notes
)
invalidate_stock(inventory_id, branch_id)
invalidate_stock(inventory_id, None)
return result
def record_adjustment(conn, inventory_id, branch_id, quantity, reason):
@@ -164,10 +221,13 @@ def record_adjustment(conn, inventory_id, branch_id, quantity, reason):
old_value={'stock': get_stock(conn, inventory_id, branch_id)},
new_value={'adjustment': quantity, 'reason': reason})
return record_operation(
result = record_operation(
conn, inventory_id, branch_id, 'ADJUST', quantity,
notes=f"Ajuste: {reason}"
)
invalidate_stock(inventory_id, branch_id)
invalidate_stock(inventory_id, None)
return result
def record_transfer(conn, inventory_id, from_branch_id, to_branch_id, quantity, notes=None):
@@ -180,15 +240,21 @@ def record_transfer(conn, inventory_id, from_branch_id, to_branch_id, quantity,
conn, inventory_id, to_branch_id, 'TRANSFER', abs(quantity),
notes=f"Transferencia desde sucursal {from_branch_id}" + (f" | {notes}" if notes else "")
)
invalidate_stock(inventory_id, from_branch_id)
invalidate_stock(inventory_id, to_branch_id)
invalidate_stock(inventory_id, None)
return out_id, in_id
def record_initial(conn, inventory_id, branch_id, quantity, cost=None):
"""Record initial stock load."""
return record_operation(
result = record_operation(
conn, inventory_id, branch_id, 'INITIAL', quantity,
cost_at_time=cost, notes="Carga inicial de inventario"
)
invalidate_stock(inventory_id, branch_id)
invalidate_stock(inventory_id, None)
return result
def get_alerts(conn, branch_id=None):