# /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 def get_stock(conn, inventory_id, branch_id=None): """Get current stock for an inventory item. Optionally filter by branch.""" cur = conn.cursor() 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() return stock def get_stock_bulk(conn, branch_id=None): """Get stock for all items. Returns dict {inventory_id: stock_quantity}.""" cur = conn.cursor() if branch_id: cur.execute(""" SELECT inventory_id, COALESCE(SUM(quantity), 0) FROM inventory_operations WHERE branch_id = %s GROUP BY inventory_id """, (branch_id,)) else: cur.execute(""" SELECT inventory_id, COALESCE(SUM(quantity), 0) FROM inventory_operations GROUP BY inventory_id """) stock_map = {r[0]: r[1] for r in cur.fetchall()} cur.close() 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): """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 """ 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, getattr(g, 'employee_id', None), getattr(g, 'device_id', None), 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. """ cur = conn.cursor() cur.execute("SELECT cost FROM inventory WHERE id = %s", (inventory_id,)) current_cost = float(cur.fetchone()[0] or 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) # Weighted average cost if current_stock + quantity > 0: new_cost = ((current_cost * max(current_stock, 0)) + (unit_cost * quantity)) / (max(current_stock, 0) + quantity) else: new_cost = unit_cost # Update cost on inventory item cur.execute("UPDATE inventory SET cost = %s WHERE id = %s", (round(new_cost, 2), inventory_id)) cur.close() ref_note = f"Compra: {quantity} uds @ ${unit_cost:.2f}" if supplier_invoice: ref_note += f" | Factura: {supplier_invoice}" if notes: ref_note += f" | {notes}" return record_operation( conn, inventory_id, branch_id, 'PURCHASE', quantity, cost_at_time=unit_cost, notes=ref_note ) def record_sale(conn, inventory_id, branch_id, quantity, sale_id=None, cost_at_time=None): """Record a sale (negative quantity). NOT exposed via HTTP endpoint — called directly by the POS blueprint (Plan 3) which imports inventory_engine as part of the full sale transaction. """ return record_operation( conn, inventory_id, branch_id, 'SALE', -abs(quantity), reference_id=sale_id, reference_type='sale', cost_at_time=cost_at_time ) def record_return(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None): """Record a customer return (positive quantity).""" return record_operation( conn, inventory_id, branch_id, 'RETURN', abs(quantity), reference_id=sale_id, reference_type='return', notes=notes ) 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}) return record_operation( conn, inventory_id, branch_id, 'ADJUST', quantity, notes=f"Ajuste: {reason}" ) 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 "") ) return out_id, in_id def record_initial(conn, inventory_id, branch_id, quantity, cost=None): """Record initial stock load.""" return record_operation( conn, inventory_id, branch_id, 'INITIAL', quantity, cost_at_time=cost, notes="Carga inicial de inventario" ) 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