From 871f52da807ccbde01de26afac2d8ccba4d5ecad Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Thu, 1 Jan 2026 18:05:44 +0100 Subject: [PATCH] feat: add automatic stock synchronization for orders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/services/order_inventory_service.py | 422 ++++++++++++++++++ app/services/order_service.py | 31 +- .../stock-management-integration.md | 196 ++++++++ mkdocs.yml | 1 + 4 files changed, 649 insertions(+), 1 deletion(-) create mode 100644 app/services/order_inventory_service.py create mode 100644 docs/implementation/stock-management-integration.md diff --git a/app/services/order_inventory_service.py b/app/services/order_inventory_service.py new file mode 100644 index 00000000..b42d7598 --- /dev/null +++ b/app/services/order_inventory_service.py @@ -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() diff --git a/app/services/order_service.py b/app/services/order_service.py index bb90c53f..1ed3d158 100644 --- a/app/services/order_service.py +++ b/app/services/order_service.py @@ -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 diff --git a/docs/implementation/stock-management-integration.md b/docs/implementation/stock-management-integration.md new file mode 100644 index 00000000..d7fed0e4 --- /dev/null +++ b/docs/implementation/stock-management-integration.md @@ -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 diff --git a/mkdocs.yml b/mkdocs.yml index 3b3cbd5b..2e3a9c4e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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: