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:
422
app/services/order_inventory_service.py
Normal file
422
app/services/order_inventory_service.py
Normal 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()
|
||||||
@@ -31,6 +31,7 @@ from app.exceptions import (
|
|||||||
ValidationException,
|
ValidationException,
|
||||||
)
|
)
|
||||||
from app.services.order_item_exception_service import order_item_exception_service
|
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 (
|
from app.services.subscription_service import (
|
||||||
subscription_service,
|
subscription_service,
|
||||||
TierLimitExceededException,
|
TierLimitExceededException,
|
||||||
@@ -963,6 +964,11 @@ class OrderService:
|
|||||||
"""
|
"""
|
||||||
Update order status and tracking information.
|
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:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
vendor_id: Vendor ID
|
vendor_id: Vendor ID
|
||||||
@@ -975,9 +981,9 @@ class OrderService:
|
|||||||
order = self.get_order(db, vendor_id, order_id)
|
order = self.get_order(db, vendor_id, order_id)
|
||||||
|
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
|
old_status = order.status
|
||||||
|
|
||||||
if order_update.status:
|
if order_update.status:
|
||||||
old_status = order.status
|
|
||||||
order.status = order_update.status
|
order.status = order_update.status
|
||||||
|
|
||||||
# Update timestamps based on status
|
# Update timestamps based on status
|
||||||
@@ -990,6 +996,29 @@ class OrderService:
|
|||||||
elif order_update.status == "cancelled" and not order.cancelled_at:
|
elif order_update.status == "cancelled" and not order.cancelled_at:
|
||||||
order.cancelled_at = now
|
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:
|
if order_update.tracking_number:
|
||||||
order.tracking_number = order_update.tracking_number
|
order.tracking_number = order_update.tracking_number
|
||||||
|
|
||||||
|
|||||||
196
docs/implementation/stock-management-integration.md
Normal file
196
docs/implementation/stock-management-integration.md
Normal 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
|
||||||
@@ -167,6 +167,7 @@ nav:
|
|||||||
- VAT Invoice Feature: implementation/vat-invoice-feature.md
|
- VAT Invoice Feature: implementation/vat-invoice-feature.md
|
||||||
- OMS Feature Plan: implementation/oms-feature-plan.md
|
- OMS Feature Plan: implementation/oms-feature-plan.md
|
||||||
- Vendor Frontend Parity: implementation/vendor-frontend-parity-plan.md
|
- Vendor Frontend Parity: implementation/vendor-frontend-parity-plan.md
|
||||||
|
- Stock Management Integration: implementation/stock-management-integration.md
|
||||||
|
|
||||||
# --- Testing ---
|
# --- Testing ---
|
||||||
- Testing:
|
- Testing:
|
||||||
|
|||||||
Reference in New Issue
Block a user