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,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
- 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:
|
||||
|
||||
Reference in New Issue
Block a user