feat: add automatic stock synchronization for orders

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>
This commit is contained in:
2026-01-01 18:05:44 +01:00
parent abeacbe25a
commit 871f52da80
4 changed files with 649 additions and 1 deletions

View File

@@ -0,0 +1,422 @@
# 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()

View File

@@ -31,6 +31,7 @@ from app.exceptions import (
ValidationException,
)
from app.services.order_item_exception_service import order_item_exception_service
from app.services.order_inventory_service import order_inventory_service
from app.services.subscription_service import (
subscription_service,
TierLimitExceededException,
@@ -963,6 +964,11 @@ class OrderService:
"""
Update order status and tracking information.
This method now includes automatic inventory management:
- processing: Reserves inventory for order items
- shipped: Fulfills (deducts) inventory
- cancelled: Releases reserved inventory
Args:
db: Database session
vendor_id: Vendor ID
@@ -975,9 +981,9 @@ class OrderService:
order = self.get_order(db, vendor_id, order_id)
now = datetime.now(UTC)
old_status = order.status
if order_update.status:
old_status = order.status
order.status = order_update.status
# Update timestamps based on status
@@ -990,6 +996,29 @@ class OrderService:
elif order_update.status == "cancelled" and not order.cancelled_at:
order.cancelled_at = now
# Handle inventory operations based on status change
try:
inventory_result = order_inventory_service.handle_status_change(
db=db,
vendor_id=vendor_id,
order_id=order_id,
old_status=old_status,
new_status=order_update.status,
)
if inventory_result:
logger.info(
f"Order {order.order_number} inventory update: "
f"{inventory_result.get('reserved_count', 0)} reserved, "
f"{inventory_result.get('fulfilled_count', 0)} fulfilled, "
f"{inventory_result.get('released_count', 0)} released"
)
except Exception as e:
# Log inventory errors but don't fail the status update
# Inventory can be adjusted manually if needed
logger.warning(
f"Order {order.order_number} inventory operation failed: {e}"
)
if order_update.tracking_number:
order.tracking_number = order_update.tracking_number

View File

@@ -0,0 +1,196 @@
# Stock Management Integration
**Created:** January 1, 2026
**Status:** Implemented
## Overview
This document describes the automatic inventory synchronization between orders and stock levels. When order status changes, inventory is automatically updated to maintain accurate stock counts.
## Architecture
### Services Involved
```
OrderService OrderInventoryService
│ │
├─ update_order_status() ──────────► handle_status_change()
│ │
│ ├─► reserve_for_order()
│ ├─► fulfill_order()
│ └─► release_order_reservation()
│ │
│ ▼
│ InventoryService
│ │
│ ├─► reserve_inventory()
│ ├─► fulfill_reservation()
│ └─► release_reservation()
```
### Key Files
| File | Purpose |
|------|---------|
| `app/services/order_inventory_service.py` | Orchestrates order-inventory operations |
| `app/services/order_service.py` | Calls inventory hooks on status change |
| `app/services/inventory_service.py` | Low-level inventory operations |
## Status Change Inventory Actions
| Status Transition | Inventory Action | Description |
|-------------------|------------------|-------------|
| Any → `processing` | Reserve | Reserves stock for order items |
| Any → `shipped` | Fulfill | Deducts from stock and releases reservation |
| Any → `cancelled` | Release | Returns reserved stock to available |
## Inventory Operations
### Reserve Inventory
When an order status changes to `processing`:
1. For each order item:
- Find inventory record with available quantity
- Increase `reserved_quantity` by item quantity
- Log the reservation
2. Placeholder products (unmatched Letzshop items) are skipped
### Fulfill Inventory
When an order status changes to `shipped`:
1. For each order item:
- Decrease `quantity` by item quantity (stock consumed)
- Decrease `reserved_quantity` accordingly
- Log the fulfillment
### Release Reservation
When an order is `cancelled`:
1. For each order item:
- Decrease `reserved_quantity` (stock becomes available again)
- Total `quantity` remains unchanged
- Log the release
## Error Handling
Inventory operations use **soft failure** - if inventory cannot be updated:
1. Warning is logged
2. Order status update continues
3. Inventory can be manually adjusted
This ensures orders are never blocked by inventory issues while providing visibility into any problems.
## Edge Cases
### Placeholder Products
Letzshop orders may contain unmatched GTINs that map to placeholder products. These are identified by:
- GTIN `0000000000000`
- Product linked to placeholder MarketplaceProduct
Inventory operations skip placeholder products since they have no real stock.
### Missing Inventory
If a product has no inventory record:
- Operation is skipped with `skip_missing=True`
- Item is logged in `skipped_items` list
- No error is raised
### Multi-Location Inventory
The service finds the first location with available stock:
```python
def _find_inventory_location(db, product_id, vendor_id):
return (
db.query(Inventory)
.filter(
Inventory.product_id == product_id,
Inventory.vendor_id == vendor_id,
Inventory.quantity > Inventory.reserved_quantity,
)
.first()
)
```
## Usage Example
### Automatic (Via Order Status Update)
```python
from app.services.order_service import order_service
from models.schema.order import OrderUpdate
# Update order status - inventory is handled automatically
order = order_service.update_order_status(
db=db,
vendor_id=vendor_id,
order_id=order_id,
order_update=OrderUpdate(status="processing")
)
# Inventory is now reserved for this order
```
### Direct (Manual Operations)
```python
from app.services.order_inventory_service import order_inventory_service
# Reserve inventory for an order
result = order_inventory_service.reserve_for_order(
db=db,
vendor_id=vendor_id,
order_id=order_id,
skip_missing=True
)
print(f"Reserved: {result['reserved_count']}, Skipped: {len(result['skipped_items'])}")
# Fulfill when shipped
result = order_inventory_service.fulfill_order(
db=db,
vendor_id=vendor_id,
order_id=order_id
)
# Release if cancelled
result = order_inventory_service.release_order_reservation(
db=db,
vendor_id=vendor_id,
order_id=order_id
)
```
## Inventory Model
```python
class Inventory:
quantity: int # Total stock
reserved_quantity: int # Reserved for pending orders
@property
def available_quantity(self):
return self.quantity - self.reserved_quantity
```
## Logging
All inventory operations are logged:
```
INFO: Reserved 2 units of product 123 for order ORD-1-20260101-ABC123
INFO: Order ORD-1-20260101-ABC123: reserved 3 items, skipped 1
INFO: Fulfilled 2 units of product 123 for order ORD-1-20260101-ABC123
WARNING: Order ORD-1-20260101-ABC123 inventory operation failed: No inventory found
```
## Future Enhancements
1. **Inventory Transaction Log** - Audit trail for all stock movements
2. **Multi-Location Selection** - Choose which location to draw from
3. **Backorder Support** - Handle orders when stock is insufficient
4. **Return Processing** - Increase stock when orders are returned

View File

@@ -167,6 +167,7 @@ nav:
- VAT Invoice Feature: implementation/vat-invoice-feature.md
- OMS Feature Plan: implementation/oms-feature-plan.md
- Vendor Frontend Parity: implementation/vendor-frontend-parity-plan.md
- Stock Management Integration: implementation/stock-management-integration.md
# --- Testing ---
- Testing: