Implements order-inventory integration that automatically manages stock when order status changes: - processing: reserves inventory for order items - shipped: fulfills (deducts) from stock - cancelled: releases reserved inventory Creates OrderInventoryService to orchestrate operations between OrderService and InventoryService. Placeholder products (unmatched Letzshop items) are skipped. Inventory errors are logged but don't block order status updates. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
423 lines
14 KiB
Python
423 lines
14 KiB
Python
# 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()
|