Files
Autoparts-DB/pos/services/inventory_engine.py
consultoria-as ff45905b49 feat(whatsapp): QWEN primary AI backend, Hermes fallback, conversation history, vehicle persistence, demo prompts
- Add QWEN (qwen3.6) as primary AI backend with short system prompt
- Hermes remains as fallback with 45s timeout
- Increase QWEN timeout to 35s, max_tokens to 4000
- Add conversation history loading from whatsapp_messages (last 4 msgs)
- Persist detected vehicle in whatsapp_sessions table
- Add 'limpiar chat' / 'nuevo chat' / 'reset' commands to clear history
- Fix CSS conflict: rename whatsapp chat-panel classes to wa-chat-panel
- Fix JS ID conflicts with chat.js widget (waChatPanel, waChatMessages, etc.)
- Improve no-stock response: conversational with alternatives
- Split search_query by | for multi-part lookups
- Add DEMO_PROMPTS.md and DEMO_PROMPTS_V2.md
2026-05-06 20:27:14 +00:00

334 lines
12 KiB
Python

# /home/Autopartes/pos/services/inventory_engine.py
"""Inventory operations engine. All stock mutations go through here.
Stock is NEVER stored as a field — it is always computed as:
SUM(inventory_operations.quantity) WHERE inventory_id = X AND branch_id = Y
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.
Uses Redis cache first, then inventory_stock_summary, falls back to
PostgreSQL SUM query.
"""
# Try Redis first
cached = get_cached_stock(inventory_id, branch_id)
if cached is not None:
return cached
# Use inventory_stock_summary (O(1) lookup)
cur = conn.cursor()
if branch_id:
cur.execute(
"SELECT stock FROM inventory_stock_summary WHERE inventory_id = %s AND branch_id = %s",
(inventory_id, branch_id)
)
else:
cur.execute(
"SELECT stock FROM inventory_stock_summary WHERE inventory_id = %s",
(inventory_id,)
)
row = cur.fetchone()
if row is not None:
cur.close()
set_cached_stock(inventory_id, row[0], branch_id)
return row[0]
# Fallback to PostgreSQL SUM (legacy, should not reach here if trigger works)
if branch_id:
cur.execute(
"SELECT COALESCE(SUM(quantity), 0) FROM inventory_operations WHERE inventory_id = %s AND branch_id = %s",
(inventory_id, branch_id)
)
else:
cur.execute(
"SELECT COALESCE(SUM(quantity), 0) FROM inventory_operations WHERE inventory_id = %s",
(inventory_id,)
)
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}.
Uses inventory_stock_summary for O(1) bulk lookup.
"""
cur = conn.cursor()
if branch_id:
cur.execute("""
SELECT inventory_id, stock
FROM inventory_stock_summary WHERE branch_id = %s
""", (branch_id,))
else:
cur.execute("""
SELECT inventory_id, stock FROM inventory_stock_summary
""")
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
def record_operation(conn, inventory_id, branch_id, operation_type, quantity,
reference_id=None, reference_type=None, cost_at_time=None,
notes=None, employee_id=None):
"""Record a single inventory operation. Does NOT commit — caller controls transaction.
Args:
quantity: positive for entries (PURCHASE, RETURN, INITIAL), negative for exits (SALE)
operation_type: SALE, PURCHASE, RETURN, ADJUST, TRANSFER, INITIAL, QUOTE_RESERVE, QUOTE_RELEASE
"""
cur = conn.cursor()
cur.execute("""
INSERT INTO inventory_operations
(inventory_id, branch_id, operation_type, quantity, reference_id,
reference_type, cost_at_time, employee_id, device_id, notes)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (
inventory_id, branch_id, operation_type, quantity,
reference_id, reference_type, cost_at_time,
employee_id if employee_id is not None else _safe_g('employee_id'),
_safe_g('device_id'),
notes
))
op_id = cur.fetchone()[0]
cur.close()
return op_id
def record_purchase(conn, inventory_id, branch_id, quantity, unit_cost,
supplier_invoice=None, notes=None):
"""Record a purchase entry. Updates weighted average cost on the inventory item.
IMPORTANT: Cost is stored globally on the inventory item (not per-branch), so we
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 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 = 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 (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_dec
# Update cost on inventory item
cur.execute("UPDATE inventory SET cost = %s WHERE id = %s", (float(new_cost), inventory_id))
cur.close()
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}"
result = record_operation(
conn, inventory_id, branch_id, 'PURCHASE', quantity,
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, remaining_stock=None):
"""Record a sale (negative quantity).
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 = 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,))
inv_row = cur.fetchone()
cur.close()
if inv_row:
from services.push_service import notify_owner
notify_owner(
conn,
'Stock en Cero',
f'{inv_row[1] or inv_row[0]} se quedo sin existencias',
'/pos'
)
except Exception:
pass # Push failures never block sales
return op_id
def record_return(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
"""Record a customer return (positive quantity)."""
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):
"""Record a manual stock adjustment. Reason is mandatory."""
if not reason or len(reason.strip()) < 3:
raise ValueError("Adjustment reason is mandatory (min 3 characters)")
log_action(conn, 'STOCK_ADJUST', 'inventory', inventory_id,
old_value={'stock': get_stock(conn, inventory_id, branch_id)},
new_value={'adjustment': quantity, 'reason': reason})
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):
"""Transfer stock between branches. Creates two operations (out + in)."""
out_id = record_operation(
conn, inventory_id, from_branch_id, 'TRANSFER', -abs(quantity),
notes=f"Transferencia a sucursal {to_branch_id}" + (f" | {notes}" if notes else "")
)
in_id = record_operation(
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."""
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):
"""Get stock alerts: zero stock, below minimum, above maximum."""
stock_map = get_stock_bulk(conn, branch_id)
cur = conn.cursor()
where = "WHERE i.is_active = true"
params = []
if branch_id:
where += " AND i.branch_id = %s"
params.append(branch_id)
cur.execute(f"""
SELECT i.id, i.part_number, i.name, i.min_stock, i.max_stock, i.branch_id
FROM inventory i {where}
""", params)
alerts = []
for row in cur.fetchall():
inv_id, part_num, name, min_s, max_s, br_id = row
stock = stock_map.get(inv_id, 0)
if stock <= 0:
alerts.append({'type': 'zero', 'severity': 'critical', 'inventory_id': inv_id,
'part_number': part_num, 'name': name, 'stock': stock, 'branch_id': br_id})
elif min_s and stock < min_s:
alerts.append({'type': 'low', 'severity': 'warning', 'inventory_id': inv_id,
'part_number': part_num, 'name': name, 'stock': stock,
'min_stock': min_s, 'branch_id': br_id})
elif max_s and stock > max_s:
alerts.append({'type': 'over', 'severity': 'info', 'inventory_id': inv_id,
'part_number': part_num, 'name': name, 'stock': stock,
'max_stock': max_s, 'branch_id': br_id})
cur.close()
return alerts
def get_movement_history(conn, inventory_id, limit=50):
"""Get operation history for a specific item."""
cur = conn.cursor()
cur.execute("""
SELECT io.id, io.operation_type, io.quantity, io.cost_at_time,
io.notes, io.created_at, e.name as employee_name, io.branch_id
FROM inventory_operations io
LEFT JOIN employees e ON io.employee_id = e.id
WHERE io.inventory_id = %s
ORDER BY io.created_at DESC
LIMIT %s
""", (inventory_id, limit))
history = []
for r in cur.fetchall():
history.append({
'id': r[0], 'type': r[1], 'quantity': r[2],
'cost': float(r[3]) if r[3] else None,
'notes': r[4], 'date': str(r[5]),
'employee': r[6], 'branch_id': r[7]
})
cur.close()
return history