# app/services/order_inventory_service.py """ Order-Inventory Integration Service. This service orchestrates inventory operations for orders: - Reserve inventory when orders are confirmed - Fulfill (deduct) inventory when orders are shipped - Release reservations when orders are cancelled This is the critical link between the order and inventory systems that ensures stock accuracy. """ import logging from sqlalchemy.orm import Session from app.exceptions import ( InsufficientInventoryException, InventoryNotFoundException, OrderNotFoundException, ValidationException, ) from app.services.inventory_service import inventory_service from models.database.inventory import Inventory from models.database.order import Order, OrderItem from models.schema.inventory import InventoryReserve logger = logging.getLogger(__name__) # Default location for inventory operations DEFAULT_LOCATION = "DEFAULT" class OrderInventoryService: """ Orchestrate order and inventory operations together. This service ensures that: 1. When orders are confirmed, inventory is reserved 2. When orders are shipped, inventory is fulfilled (deducted) 3. When orders are cancelled, reservations are released Note: Letzshop orders with unmatched products (placeholder) skip inventory operations for those items. """ def get_order_with_items( self, db: Session, vendor_id: int, order_id: int ) -> Order: """Get order with items or raise OrderNotFoundException.""" order = ( db.query(Order) .filter(Order.id == order_id, Order.vendor_id == vendor_id) .first() ) if not order: raise OrderNotFoundException(f"Order {order_id} not found") return order def _find_inventory_location( self, db: Session, product_id: int, vendor_id: int ) -> str | None: """ Find the location with available inventory for a product. Returns the first location with available quantity, or None if no inventory exists. """ inventory = ( db.query(Inventory) .filter( Inventory.product_id == product_id, Inventory.vendor_id == vendor_id, Inventory.quantity > Inventory.reserved_quantity, ) .first() ) return inventory.location if inventory else None def _is_placeholder_product(self, order_item: OrderItem) -> bool: """Check if the order item uses a placeholder product.""" if not order_item.product: return True # Check if it's the placeholder product (GTIN 0000000000000) return order_item.product.gtin == "0000000000000" def reserve_for_order( self, db: Session, vendor_id: int, order_id: int, skip_missing: bool = True, ) -> dict: """ Reserve inventory for all items in an order. Args: db: Database session vendor_id: Vendor ID order_id: Order ID skip_missing: If True, skip items without inventory instead of failing Returns: Dict with reserved count and any skipped items Raises: InsufficientInventoryException: If skip_missing=False and inventory unavailable """ order = self.get_order_with_items(db, vendor_id, order_id) reserved_count = 0 skipped_items = [] for item in order.items: # Skip placeholder products if self._is_placeholder_product(item): skipped_items.append({ "item_id": item.id, "reason": "placeholder_product", }) continue # Find inventory location location = self._find_inventory_location(db, item.product_id, vendor_id) if not location: if skip_missing: skipped_items.append({ "item_id": item.id, "product_id": item.product_id, "reason": "no_inventory", }) continue else: raise InventoryNotFoundException( f"No inventory found for product {item.product_id}" ) try: reserve_data = InventoryReserve( product_id=item.product_id, location=location, quantity=item.quantity, ) inventory_service.reserve_inventory(db, vendor_id, reserve_data) reserved_count += 1 logger.info( f"Reserved {item.quantity} units of product {item.product_id} " f"for order {order.order_number}" ) except InsufficientInventoryException: if skip_missing: skipped_items.append({ "item_id": item.id, "product_id": item.product_id, "reason": "insufficient_inventory", }) else: raise logger.info( f"Order {order.order_number}: reserved {reserved_count} items, " f"skipped {len(skipped_items)}" ) return { "order_id": order_id, "order_number": order.order_number, "reserved_count": reserved_count, "skipped_items": skipped_items, } def fulfill_order( self, db: Session, vendor_id: int, order_id: int, skip_missing: bool = True, ) -> dict: """ Fulfill (deduct) inventory when an order is shipped. This decreases both the total quantity and reserved quantity, effectively consuming the reserved stock. Args: db: Database session vendor_id: Vendor ID order_id: Order ID skip_missing: If True, skip items without inventory Returns: Dict with fulfilled count and any skipped items """ order = self.get_order_with_items(db, vendor_id, order_id) fulfilled_count = 0 skipped_items = [] for item in order.items: # Skip placeholder products if self._is_placeholder_product(item): skipped_items.append({ "item_id": item.id, "reason": "placeholder_product", }) continue # Find inventory location location = self._find_inventory_location(db, item.product_id, vendor_id) # Also check for inventory with reserved quantity if not location: inventory = ( db.query(Inventory) .filter( Inventory.product_id == item.product_id, Inventory.vendor_id == vendor_id, ) .first() ) if inventory: location = inventory.location if not location: if skip_missing: skipped_items.append({ "item_id": item.id, "product_id": item.product_id, "reason": "no_inventory", }) continue else: raise InventoryNotFoundException( f"No inventory found for product {item.product_id}" ) try: reserve_data = InventoryReserve( product_id=item.product_id, location=location, quantity=item.quantity, ) inventory_service.fulfill_reservation(db, vendor_id, reserve_data) fulfilled_count += 1 logger.info( f"Fulfilled {item.quantity} units of product {item.product_id} " f"for order {order.order_number}" ) except (InsufficientInventoryException, InventoryNotFoundException) as e: if skip_missing: skipped_items.append({ "item_id": item.id, "product_id": item.product_id, "reason": str(e), }) else: raise logger.info( f"Order {order.order_number}: fulfilled {fulfilled_count} items, " f"skipped {len(skipped_items)}" ) return { "order_id": order_id, "order_number": order.order_number, "fulfilled_count": fulfilled_count, "skipped_items": skipped_items, } def release_order_reservation( self, db: Session, vendor_id: int, order_id: int, skip_missing: bool = True, ) -> dict: """ Release reserved inventory when an order is cancelled. This decreases the reserved quantity, making the stock available again. Args: db: Database session vendor_id: Vendor ID order_id: Order ID skip_missing: If True, skip items without inventory Returns: Dict with released count and any skipped items """ order = self.get_order_with_items(db, vendor_id, order_id) released_count = 0 skipped_items = [] for item in order.items: # Skip placeholder products if self._is_placeholder_product(item): skipped_items.append({ "item_id": item.id, "reason": "placeholder_product", }) continue # Find inventory - look for any inventory for this product inventory = ( db.query(Inventory) .filter( Inventory.product_id == item.product_id, Inventory.vendor_id == vendor_id, ) .first() ) if not inventory: if skip_missing: skipped_items.append({ "item_id": item.id, "product_id": item.product_id, "reason": "no_inventory", }) continue else: raise InventoryNotFoundException( f"No inventory found for product {item.product_id}" ) try: reserve_data = InventoryReserve( product_id=item.product_id, location=inventory.location, quantity=item.quantity, ) inventory_service.release_reservation(db, vendor_id, reserve_data) released_count += 1 logger.info( f"Released {item.quantity} units of product {item.product_id} " f"for cancelled order {order.order_number}" ) except Exception as e: if skip_missing: skipped_items.append({ "item_id": item.id, "product_id": item.product_id, "reason": str(e), }) else: raise logger.info( f"Order {order.order_number}: released {released_count} items, " f"skipped {len(skipped_items)}" ) return { "order_id": order_id, "order_number": order.order_number, "released_count": released_count, "skipped_items": skipped_items, } def handle_status_change( self, db: Session, vendor_id: int, order_id: int, old_status: str | None, new_status: str, ) -> dict | None: """ Handle inventory operations based on order status changes. Status transitions that trigger inventory operations: - Any → processing: Reserve inventory (if not already reserved) - processing → shipped: Fulfill inventory (deduct from stock) - Any → cancelled: Release reservations Args: db: Database session vendor_id: Vendor ID order_id: Order ID old_status: Previous status (can be None for new orders) new_status: New status Returns: Result of inventory operation, or None if no operation needed """ # Skip if status didn't change if old_status == new_status: return None result = None # Transitioning to processing - reserve inventory if new_status == "processing": result = self.reserve_for_order(db, vendor_id, order_id, skip_missing=True) logger.info(f"Order {order_id} confirmed: inventory reserved") # Transitioning to shipped - fulfill inventory elif new_status == "shipped": result = self.fulfill_order(db, vendor_id, order_id, skip_missing=True) logger.info(f"Order {order_id} shipped: inventory fulfilled") # Transitioning to cancelled - release reservations elif new_status == "cancelled": # Only release if there was a previous status (order was in progress) if old_status and old_status not in ("cancelled", "refunded"): result = self.release_order_reservation( db, vendor_id, order_id, skip_missing=True ) logger.info(f"Order {order_id} cancelled: reservations released") return result # Create service instance order_inventory_service = OrderInventoryService()