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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user