feat: add partial shipment support (Phase 3)
- Add shipped_quantity field to OrderItem for tracking partial fulfillment
- Add partially_shipped order status for orders with partial shipments
- Add fulfill_item method for shipping individual items with quantities
- Add get_shipment_status method for detailed shipment tracking
- Add vendor API endpoints for partial shipment operations:
- GET /orders/{id}/shipment-status - Get item-level shipment status
- POST /orders/{id}/items/{item_id}/ship - Ship specific item quantity
- Automatic status updates: partially_shipped when some items shipped,
shipped when all items fully shipped
- Migration to add shipped_quantity column with upgrade for existing data
- Update documentation with partial shipment usage examples
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
|||||||
|
# alembic/versions/p4d5e6f7a8b9_add_shipped_quantity_to_order_items.py
|
||||||
|
"""Add shipped_quantity to order_items for partial shipments.
|
||||||
|
|
||||||
|
Revision ID: p4d5e6f7a8b9
|
||||||
|
Revises: o3c4d5e6f7a8
|
||||||
|
Create Date: 2026-01-01 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'p4d5e6f7a8b9'
|
||||||
|
down_revision: Union[str, None] = 'o3c4d5e6f7a8'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add shipped_quantity column to order_items
|
||||||
|
op.add_column(
|
||||||
|
'order_items',
|
||||||
|
sa.Column('shipped_quantity', sa.Integer(), nullable=False, server_default='0')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set shipped_quantity = quantity for already fulfilled items
|
||||||
|
# This handles existing data where inventory_fulfilled is True
|
||||||
|
op.execute("""
|
||||||
|
UPDATE order_items
|
||||||
|
SET shipped_quantity = quantity
|
||||||
|
WHERE inventory_fulfilled = 1
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('order_items', 'shipped_quantity')
|
||||||
153
app/api/v1/vendor/orders.py
vendored
153
app/api/v1/vendor/orders.py
vendored
@@ -9,10 +9,12 @@ The get_current_vendor_api dependency guarantees token_vendor_id is present.
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_vendor_api
|
from app.api.deps import get_current_vendor_api
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
from app.services.order_inventory_service import order_inventory_service
|
||||||
from app.services.order_service import order_service
|
from app.services.order_service import order_service
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
from models.schema.order import (
|
from models.schema.order import (
|
||||||
@@ -114,3 +116,154 @@ def update_order_status(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return OrderResponse.model_validate(order)
|
return OrderResponse.model_validate(order)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Partial Shipment Endpoints
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class ShipItemRequest(BaseModel):
|
||||||
|
"""Request to ship specific quantity of an order item."""
|
||||||
|
|
||||||
|
quantity: int | None = Field(
|
||||||
|
None, ge=1, description="Quantity to ship (default: remaining quantity)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ShipItemResponse(BaseModel):
|
||||||
|
"""Response from shipping an item."""
|
||||||
|
|
||||||
|
order_id: int
|
||||||
|
item_id: int
|
||||||
|
fulfilled_quantity: int
|
||||||
|
shipped_quantity: int | None = None
|
||||||
|
remaining_quantity: int | None = None
|
||||||
|
is_fully_shipped: bool | None = None
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ShipmentStatusItemResponse(BaseModel):
|
||||||
|
"""Item-level shipment status."""
|
||||||
|
|
||||||
|
item_id: int
|
||||||
|
product_id: int
|
||||||
|
product_name: str
|
||||||
|
quantity: int
|
||||||
|
shipped_quantity: int
|
||||||
|
remaining_quantity: int
|
||||||
|
is_fully_shipped: bool
|
||||||
|
is_partially_shipped: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ShipmentStatusResponse(BaseModel):
|
||||||
|
"""Order shipment status response."""
|
||||||
|
|
||||||
|
order_id: int
|
||||||
|
order_number: str
|
||||||
|
order_status: str
|
||||||
|
is_fully_shipped: bool
|
||||||
|
is_partially_shipped: bool
|
||||||
|
shipped_item_count: int
|
||||||
|
total_item_count: int
|
||||||
|
total_shipped_units: int
|
||||||
|
total_ordered_units: int
|
||||||
|
items: list[ShipmentStatusItemResponse]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{order_id}/shipment-status", response_model=ShipmentStatusResponse)
|
||||||
|
def get_shipment_status(
|
||||||
|
order_id: int,
|
||||||
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get detailed shipment status for an order.
|
||||||
|
|
||||||
|
Returns item-level shipment status showing what has been shipped
|
||||||
|
and what remains. Useful for partial shipment tracking.
|
||||||
|
|
||||||
|
Requires Authorization header (API endpoint).
|
||||||
|
"""
|
||||||
|
result = order_inventory_service.get_shipment_status(
|
||||||
|
db=db,
|
||||||
|
vendor_id=current_user.token_vendor_id,
|
||||||
|
order_id=order_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ShipmentStatusResponse(
|
||||||
|
order_id=result["order_id"],
|
||||||
|
order_number=result["order_number"],
|
||||||
|
order_status=result["order_status"],
|
||||||
|
is_fully_shipped=result["is_fully_shipped"],
|
||||||
|
is_partially_shipped=result["is_partially_shipped"],
|
||||||
|
shipped_item_count=result["shipped_item_count"],
|
||||||
|
total_item_count=result["total_item_count"],
|
||||||
|
total_shipped_units=result["total_shipped_units"],
|
||||||
|
total_ordered_units=result["total_ordered_units"],
|
||||||
|
items=[ShipmentStatusItemResponse(**item) for item in result["items"]],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{order_id}/items/{item_id}/ship", response_model=ShipItemResponse)
|
||||||
|
def ship_order_item(
|
||||||
|
order_id: int,
|
||||||
|
item_id: int,
|
||||||
|
request: ShipItemRequest | None = None,
|
||||||
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Ship a specific order item (supports partial shipment).
|
||||||
|
|
||||||
|
Fulfills inventory and updates the item's shipped quantity.
|
||||||
|
If quantity is not specified, ships the remaining quantity.
|
||||||
|
|
||||||
|
Example use cases:
|
||||||
|
- Ship all of an item: POST /orders/{id}/items/{item_id}/ship
|
||||||
|
- Ship partial: POST /orders/{id}/items/{item_id}/ship with {"quantity": 2}
|
||||||
|
|
||||||
|
Requires Authorization header (API endpoint).
|
||||||
|
"""
|
||||||
|
quantity = request.quantity if request else None
|
||||||
|
|
||||||
|
result = order_inventory_service.fulfill_item(
|
||||||
|
db=db,
|
||||||
|
vendor_id=current_user.token_vendor_id,
|
||||||
|
order_id=order_id,
|
||||||
|
item_id=item_id,
|
||||||
|
quantity=quantity,
|
||||||
|
skip_missing=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update order status based on shipment state
|
||||||
|
order = order_service.get_order(db, current_user.token_vendor_id, order_id)
|
||||||
|
|
||||||
|
if order.is_fully_shipped and order.status != "shipped":
|
||||||
|
order_service.update_order_status(
|
||||||
|
db=db,
|
||||||
|
vendor_id=current_user.token_vendor_id,
|
||||||
|
order_id=order_id,
|
||||||
|
order_update=OrderUpdate(status="shipped"),
|
||||||
|
)
|
||||||
|
logger.info(f"Order {order.order_number} fully shipped")
|
||||||
|
elif order.is_partially_shipped and order.status not in (
|
||||||
|
"partially_shipped",
|
||||||
|
"shipped",
|
||||||
|
):
|
||||||
|
order_service.update_order_status(
|
||||||
|
db=db,
|
||||||
|
vendor_id=current_user.token_vendor_id,
|
||||||
|
order_id=order_id,
|
||||||
|
order_update=OrderUpdate(status="partially_shipped"),
|
||||||
|
)
|
||||||
|
logger.info(f"Order {order.order_number} partially shipped")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Shipped item {item_id} of order {order_id}: "
|
||||||
|
f"{result.get('fulfilled_quantity', 0)} units"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ShipItemResponse(**result)
|
||||||
|
|||||||
@@ -261,6 +261,10 @@ class OrderInventoryService:
|
|||||||
skipped_items = []
|
skipped_items = []
|
||||||
|
|
||||||
for item in order.items:
|
for item in order.items:
|
||||||
|
# Skip already fully shipped items
|
||||||
|
if item.is_fully_shipped:
|
||||||
|
continue
|
||||||
|
|
||||||
# Skip placeholder products
|
# Skip placeholder products
|
||||||
if self._is_placeholder_product(item):
|
if self._is_placeholder_product(item):
|
||||||
skipped_items.append({
|
skipped_items.append({
|
||||||
@@ -269,6 +273,9 @@ class OrderInventoryService:
|
|||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Only fulfill remaining quantity
|
||||||
|
quantity_to_fulfill = item.remaining_quantity
|
||||||
|
|
||||||
# Find inventory location
|
# Find inventory location
|
||||||
location = self._find_inventory_location(db, item.product_id, vendor_id)
|
location = self._find_inventory_location(db, item.product_id, vendor_id)
|
||||||
|
|
||||||
@@ -302,13 +309,17 @@ class OrderInventoryService:
|
|||||||
reserve_data = InventoryReserve(
|
reserve_data = InventoryReserve(
|
||||||
product_id=item.product_id,
|
product_id=item.product_id,
|
||||||
location=location,
|
location=location,
|
||||||
quantity=item.quantity,
|
quantity=quantity_to_fulfill,
|
||||||
)
|
)
|
||||||
updated_inventory = inventory_service.fulfill_reservation(
|
updated_inventory = inventory_service.fulfill_reservation(
|
||||||
db, vendor_id, reserve_data
|
db, vendor_id, reserve_data
|
||||||
)
|
)
|
||||||
fulfilled_count += 1
|
fulfilled_count += 1
|
||||||
|
|
||||||
|
# Update item shipped quantity
|
||||||
|
item.shipped_quantity = item.quantity
|
||||||
|
item.inventory_fulfilled = True
|
||||||
|
|
||||||
# Log transaction for audit trail
|
# Log transaction for audit trail
|
||||||
self._log_transaction(
|
self._log_transaction(
|
||||||
db=db,
|
db=db,
|
||||||
@@ -316,13 +327,13 @@ class OrderInventoryService:
|
|||||||
product_id=item.product_id,
|
product_id=item.product_id,
|
||||||
inventory=updated_inventory,
|
inventory=updated_inventory,
|
||||||
transaction_type=TransactionType.FULFILL,
|
transaction_type=TransactionType.FULFILL,
|
||||||
quantity_change=-item.quantity, # Negative because stock is consumed
|
quantity_change=-quantity_to_fulfill, # Negative because stock is consumed
|
||||||
order=order,
|
order=order,
|
||||||
reason=f"Fulfilled for order {order.order_number}",
|
reason=f"Fulfilled for order {order.order_number}",
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Fulfilled {item.quantity} units of product {item.product_id} "
|
f"Fulfilled {quantity_to_fulfill} units of product {item.product_id} "
|
||||||
f"for order {order.order_number}"
|
f"for order {order.order_number}"
|
||||||
)
|
)
|
||||||
except (InsufficientInventoryException, InventoryNotFoundException) as e:
|
except (InsufficientInventoryException, InventoryNotFoundException) as e:
|
||||||
@@ -347,6 +358,163 @@ class OrderInventoryService:
|
|||||||
"skipped_items": skipped_items,
|
"skipped_items": skipped_items,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def fulfill_item(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
vendor_id: int,
|
||||||
|
order_id: int,
|
||||||
|
item_id: int,
|
||||||
|
quantity: int | None = None,
|
||||||
|
skip_missing: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Fulfill (deduct) inventory for a specific order item.
|
||||||
|
|
||||||
|
Supports partial fulfillment - ship some units now, rest later.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
vendor_id: Vendor ID
|
||||||
|
order_id: Order ID
|
||||||
|
item_id: Order item ID
|
||||||
|
quantity: Quantity to ship (defaults to remaining quantity)
|
||||||
|
skip_missing: If True, skip if inventory not found
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with fulfillment result
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationException: If quantity exceeds remaining
|
||||||
|
"""
|
||||||
|
order = self.get_order_with_items(db, vendor_id, order_id)
|
||||||
|
|
||||||
|
# Find the item
|
||||||
|
item = None
|
||||||
|
for order_item in order.items:
|
||||||
|
if order_item.id == item_id:
|
||||||
|
item = order_item
|
||||||
|
break
|
||||||
|
|
||||||
|
if not item:
|
||||||
|
raise ValidationException(f"Item {item_id} not found in order {order_id}")
|
||||||
|
|
||||||
|
# Check if already fully shipped
|
||||||
|
if item.is_fully_shipped:
|
||||||
|
return {
|
||||||
|
"order_id": order_id,
|
||||||
|
"item_id": item_id,
|
||||||
|
"fulfilled_quantity": 0,
|
||||||
|
"message": "Item already fully shipped",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default to remaining quantity
|
||||||
|
quantity_to_fulfill = quantity or item.remaining_quantity
|
||||||
|
|
||||||
|
# Validate quantity
|
||||||
|
if quantity_to_fulfill > item.remaining_quantity:
|
||||||
|
raise ValidationException(
|
||||||
|
f"Cannot ship {quantity_to_fulfill} units - only {item.remaining_quantity} remaining"
|
||||||
|
)
|
||||||
|
|
||||||
|
if quantity_to_fulfill <= 0:
|
||||||
|
return {
|
||||||
|
"order_id": order_id,
|
||||||
|
"item_id": item_id,
|
||||||
|
"fulfilled_quantity": 0,
|
||||||
|
"message": "Nothing to fulfill",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Skip placeholder products
|
||||||
|
if self._is_placeholder_product(item):
|
||||||
|
return {
|
||||||
|
"order_id": order_id,
|
||||||
|
"item_id": item_id,
|
||||||
|
"fulfilled_quantity": 0,
|
||||||
|
"message": "Placeholder product - skipped",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find inventory location
|
||||||
|
location = self._find_inventory_location(db, item.product_id, vendor_id)
|
||||||
|
|
||||||
|
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:
|
||||||
|
return {
|
||||||
|
"order_id": order_id,
|
||||||
|
"item_id": item_id,
|
||||||
|
"fulfilled_quantity": 0,
|
||||||
|
"message": "No inventory found",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
raise InventoryNotFoundException(
|
||||||
|
f"No inventory found for product {item.product_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
reserve_data = InventoryReserve(
|
||||||
|
product_id=item.product_id,
|
||||||
|
location=location,
|
||||||
|
quantity=quantity_to_fulfill,
|
||||||
|
)
|
||||||
|
updated_inventory = inventory_service.fulfill_reservation(
|
||||||
|
db, vendor_id, reserve_data
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update item shipped quantity
|
||||||
|
item.shipped_quantity += quantity_to_fulfill
|
||||||
|
|
||||||
|
# Mark as fulfilled only if fully shipped
|
||||||
|
if item.is_fully_shipped:
|
||||||
|
item.inventory_fulfilled = True
|
||||||
|
|
||||||
|
# Log transaction
|
||||||
|
self._log_transaction(
|
||||||
|
db=db,
|
||||||
|
vendor_id=vendor_id,
|
||||||
|
product_id=item.product_id,
|
||||||
|
inventory=updated_inventory,
|
||||||
|
transaction_type=TransactionType.FULFILL,
|
||||||
|
quantity_change=-quantity_to_fulfill,
|
||||||
|
order=order,
|
||||||
|
reason=f"Partial shipment for order {order.order_number}",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Fulfilled {quantity_to_fulfill} of {item.quantity} units "
|
||||||
|
f"for item {item_id} in order {order.order_number}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"order_id": order_id,
|
||||||
|
"item_id": item_id,
|
||||||
|
"fulfilled_quantity": quantity_to_fulfill,
|
||||||
|
"shipped_quantity": item.shipped_quantity,
|
||||||
|
"remaining_quantity": item.remaining_quantity,
|
||||||
|
"is_fully_shipped": item.is_fully_shipped,
|
||||||
|
}
|
||||||
|
|
||||||
|
except (InsufficientInventoryException, InventoryNotFoundException) as e:
|
||||||
|
if skip_missing:
|
||||||
|
return {
|
||||||
|
"order_id": order_id,
|
||||||
|
"item_id": item_id,
|
||||||
|
"fulfilled_quantity": 0,
|
||||||
|
"message": str(e),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
def release_order_reservation(
|
def release_order_reservation(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
@@ -468,6 +636,7 @@ class OrderInventoryService:
|
|||||||
Status transitions that trigger inventory operations:
|
Status transitions that trigger inventory operations:
|
||||||
- Any → processing: Reserve inventory (if not already reserved)
|
- Any → processing: Reserve inventory (if not already reserved)
|
||||||
- processing → shipped: Fulfill inventory (deduct from stock)
|
- processing → shipped: Fulfill inventory (deduct from stock)
|
||||||
|
- processing → partially_shipped: Partial fulfillment already done via fulfill_item
|
||||||
- Any → cancelled: Release reservations
|
- Any → cancelled: Release reservations
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -491,11 +660,18 @@ class OrderInventoryService:
|
|||||||
result = self.reserve_for_order(db, vendor_id, order_id, skip_missing=True)
|
result = self.reserve_for_order(db, vendor_id, order_id, skip_missing=True)
|
||||||
logger.info(f"Order {order_id} confirmed: inventory reserved")
|
logger.info(f"Order {order_id} confirmed: inventory reserved")
|
||||||
|
|
||||||
# Transitioning to shipped - fulfill inventory
|
# Transitioning to shipped - fulfill remaining inventory
|
||||||
elif new_status == "shipped":
|
elif new_status == "shipped":
|
||||||
result = self.fulfill_order(db, vendor_id, order_id, skip_missing=True)
|
result = self.fulfill_order(db, vendor_id, order_id, skip_missing=True)
|
||||||
logger.info(f"Order {order_id} shipped: inventory fulfilled")
|
logger.info(f"Order {order_id} shipped: inventory fulfilled")
|
||||||
|
|
||||||
|
# partially_shipped - no automatic fulfillment (handled via fulfill_item)
|
||||||
|
elif new_status == "partially_shipped":
|
||||||
|
logger.info(
|
||||||
|
f"Order {order_id} partially shipped: use fulfill_item for item-level fulfillment"
|
||||||
|
)
|
||||||
|
result = {"order_id": order_id, "status": "partially_shipped"}
|
||||||
|
|
||||||
# Transitioning to cancelled - release reservations
|
# Transitioning to cancelled - release reservations
|
||||||
elif new_status == "cancelled":
|
elif new_status == "cancelled":
|
||||||
# Only release if there was a previous status (order was in progress)
|
# Only release if there was a previous status (order was in progress)
|
||||||
@@ -507,6 +683,53 @@ class OrderInventoryService:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def get_shipment_status(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
vendor_id: int,
|
||||||
|
order_id: int,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Get detailed shipment status for an order.
|
||||||
|
|
||||||
|
Returns item-level shipment status for partial shipment tracking.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
vendor_id: Vendor ID
|
||||||
|
order_id: Order ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with shipment status details
|
||||||
|
"""
|
||||||
|
order = self.get_order_with_items(db, vendor_id, order_id)
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for item in order.items:
|
||||||
|
items.append({
|
||||||
|
"item_id": item.id,
|
||||||
|
"product_id": item.product_id,
|
||||||
|
"product_name": item.product_name,
|
||||||
|
"quantity": item.quantity,
|
||||||
|
"shipped_quantity": item.shipped_quantity,
|
||||||
|
"remaining_quantity": item.remaining_quantity,
|
||||||
|
"is_fully_shipped": item.is_fully_shipped,
|
||||||
|
"is_partially_shipped": item.is_partially_shipped,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"order_id": order_id,
|
||||||
|
"order_number": order.order_number,
|
||||||
|
"order_status": order.status,
|
||||||
|
"is_fully_shipped": order.is_fully_shipped,
|
||||||
|
"is_partially_shipped": order.is_partially_shipped,
|
||||||
|
"shipped_item_count": order.shipped_item_count,
|
||||||
|
"total_item_count": len(order.items),
|
||||||
|
"total_shipped_units": order.total_shipped_units,
|
||||||
|
"total_ordered_units": order.total_ordered_units,
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Create service instance
|
# Create service instance
|
||||||
order_inventory_service = OrderInventoryService()
|
order_inventory_service = OrderInventoryService()
|
||||||
|
|||||||
@@ -925,6 +925,7 @@ class OrderService:
|
|||||||
stats = {
|
stats = {
|
||||||
"pending": 0,
|
"pending": 0,
|
||||||
"processing": 0,
|
"processing": 0,
|
||||||
|
"partially_shipped": 0,
|
||||||
"shipped": 0,
|
"shipped": 0,
|
||||||
"delivered": 0,
|
"delivered": 0,
|
||||||
"cancelled": 0,
|
"cancelled": 0,
|
||||||
@@ -989,6 +990,9 @@ class OrderService:
|
|||||||
# Update timestamps based on status
|
# Update timestamps based on status
|
||||||
if order_update.status == "processing" and not order.confirmed_at:
|
if order_update.status == "processing" and not order.confirmed_at:
|
||||||
order.confirmed_at = now
|
order.confirmed_at = now
|
||||||
|
elif order_update.status == "partially_shipped":
|
||||||
|
# partially_shipped doesn't set shipped_at yet
|
||||||
|
pass
|
||||||
elif order_update.status == "shipped" and not order.shipped_at:
|
elif order_update.status == "shipped" and not order.shipped_at:
|
||||||
order.shipped_at = now
|
order.shipped_at = now
|
||||||
elif order_update.status == "delivered" and not order.delivered_at:
|
elif order_update.status == "delivered" and not order.delivered_at:
|
||||||
@@ -1256,6 +1260,7 @@ class OrderService:
|
|||||||
"total_orders": 0,
|
"total_orders": 0,
|
||||||
"pending_orders": 0,
|
"pending_orders": 0,
|
||||||
"processing_orders": 0,
|
"processing_orders": 0,
|
||||||
|
"partially_shipped_orders": 0,
|
||||||
"shipped_orders": 0,
|
"shipped_orders": 0,
|
||||||
"delivered_orders": 0,
|
"delivered_orders": 0,
|
||||||
"cancelled_orders": 0,
|
"cancelled_orders": 0,
|
||||||
|
|||||||
@@ -242,9 +242,130 @@ recent = db.query(InventoryTransaction).filter(
|
|||||||
).order_by(InventoryTransaction.created_at.desc()).limit(10).all()
|
).order_by(InventoryTransaction.created_at.desc()).limit(10).all()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Partial Shipments (Phase 3)
|
||||||
|
|
||||||
|
Orders can be partially shipped, allowing vendors to ship items as they become available.
|
||||||
|
|
||||||
|
### Status Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
pending → processing → partially_shipped → shipped → delivered
|
||||||
|
↘ ↗
|
||||||
|
→ shipped (if all items shipped at once)
|
||||||
|
```
|
||||||
|
|
||||||
|
### OrderItem Tracking
|
||||||
|
|
||||||
|
Each order item has a `shipped_quantity` field:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class OrderItem:
|
||||||
|
quantity: int # Total ordered
|
||||||
|
shipped_quantity: int # Units shipped so far
|
||||||
|
|
||||||
|
@property
|
||||||
|
def remaining_quantity(self):
|
||||||
|
return self.quantity - self.shipped_quantity
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_fully_shipped(self):
|
||||||
|
return self.shipped_quantity >= self.quantity
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
#### Get Shipment Status
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/vendor/orders/{order_id}/shipment-status
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns item-level shipment status:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"order_id": 123,
|
||||||
|
"order_number": "ORD-1-20260101-ABC123",
|
||||||
|
"order_status": "partially_shipped",
|
||||||
|
"is_fully_shipped": false,
|
||||||
|
"is_partially_shipped": true,
|
||||||
|
"shipped_item_count": 1,
|
||||||
|
"total_item_count": 3,
|
||||||
|
"total_shipped_units": 2,
|
||||||
|
"total_ordered_units": 5,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"item_id": 1,
|
||||||
|
"product_name": "Widget A",
|
||||||
|
"quantity": 2,
|
||||||
|
"shipped_quantity": 2,
|
||||||
|
"remaining_quantity": 0,
|
||||||
|
"is_fully_shipped": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item_id": 2,
|
||||||
|
"product_name": "Widget B",
|
||||||
|
"quantity": 3,
|
||||||
|
"shipped_quantity": 0,
|
||||||
|
"remaining_quantity": 3,
|
||||||
|
"is_fully_shipped": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Ship Individual Item
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/vendor/orders/{order_id}/items/{item_id}/ship
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"quantity": 2 // Optional - defaults to remaining quantity
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"order_id": 123,
|
||||||
|
"item_id": 1,
|
||||||
|
"fulfilled_quantity": 2,
|
||||||
|
"shipped_quantity": 2,
|
||||||
|
"remaining_quantity": 0,
|
||||||
|
"is_fully_shipped": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Automatic Status Updates
|
||||||
|
|
||||||
|
When shipping items:
|
||||||
|
1. If some items are shipped → status becomes `partially_shipped`
|
||||||
|
2. If all items are fully shipped → status becomes `shipped`
|
||||||
|
|
||||||
|
### Service Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.services.order_inventory_service import order_inventory_service
|
||||||
|
|
||||||
|
# Ship partial quantity of an item
|
||||||
|
result = order_inventory_service.fulfill_item(
|
||||||
|
db=db,
|
||||||
|
vendor_id=vendor_id,
|
||||||
|
order_id=order_id,
|
||||||
|
item_id=item_id,
|
||||||
|
quantity=2, # Ship 2 units
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get shipment status
|
||||||
|
status = order_inventory_service.get_shipment_status(
|
||||||
|
db=db,
|
||||||
|
vendor_id=vendor_id,
|
||||||
|
order_id=order_id,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
## Future Enhancements
|
## Future Enhancements
|
||||||
|
|
||||||
1. **Multi-Location Selection** - Choose which location to draw from
|
1. **Multi-Location Selection** - Choose which location to draw from
|
||||||
2. **Backorder Support** - Handle orders when stock is insufficient
|
2. **Backorder Support** - Handle orders when stock is insufficient
|
||||||
3. **Return Processing** - Increase stock when orders are returned
|
3. **Return Processing** - Increase stock when orders are returned
|
||||||
4. **Transaction API** - Endpoint to view inventory history
|
|
||||||
|
|||||||
@@ -239,6 +239,37 @@ class Order(Base, TimestampMixin):
|
|||||||
"""Check if this is a marketplace order."""
|
"""Check if this is a marketplace order."""
|
||||||
return self.channel != "direct"
|
return self.channel != "direct"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_fully_shipped(self) -> bool:
|
||||||
|
"""Check if all items are fully shipped."""
|
||||||
|
if not self.items:
|
||||||
|
return False
|
||||||
|
return all(item.is_fully_shipped for item in self.items)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_partially_shipped(self) -> bool:
|
||||||
|
"""Check if some items are shipped but not all."""
|
||||||
|
if not self.items:
|
||||||
|
return False
|
||||||
|
has_shipped = any(item.shipped_quantity > 0 for item in self.items)
|
||||||
|
all_shipped = all(item.is_fully_shipped for item in self.items)
|
||||||
|
return has_shipped and not all_shipped
|
||||||
|
|
||||||
|
@property
|
||||||
|
def shipped_item_count(self) -> int:
|
||||||
|
"""Count of fully shipped items."""
|
||||||
|
return sum(1 for item in self.items if item.is_fully_shipped)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_shipped_units(self) -> int:
|
||||||
|
"""Total quantity shipped across all items."""
|
||||||
|
return sum(item.shipped_quantity for item in self.items)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_ordered_units(self) -> int:
|
||||||
|
"""Total quantity ordered across all items."""
|
||||||
|
return sum(item.quantity for item in self.items)
|
||||||
|
|
||||||
|
|
||||||
class OrderItem(Base, TimestampMixin):
|
class OrderItem(Base, TimestampMixin):
|
||||||
"""
|
"""
|
||||||
@@ -280,6 +311,9 @@ class OrderItem(Base, TimestampMixin):
|
|||||||
inventory_reserved = Column(Boolean, default=False)
|
inventory_reserved = Column(Boolean, default=False)
|
||||||
inventory_fulfilled = Column(Boolean, default=False)
|
inventory_fulfilled = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
# === Shipment Tracking ===
|
||||||
|
shipped_quantity = Column(Integer, default=0, nullable=False) # Units shipped so far
|
||||||
|
|
||||||
# === Exception Tracking ===
|
# === Exception Tracking ===
|
||||||
# True if product was not found by GTIN during import (linked to placeholder)
|
# True if product was not found by GTIN during import (linked to placeholder)
|
||||||
needs_product_match = Column(Boolean, default=False, index=True)
|
needs_product_match = Column(Boolean, default=False, index=True)
|
||||||
@@ -342,3 +376,20 @@ class OrderItem(Base, TimestampMixin):
|
|||||||
if not self.exception:
|
if not self.exception:
|
||||||
return False
|
return False
|
||||||
return self.exception.blocks_confirmation
|
return self.exception.blocks_confirmation
|
||||||
|
|
||||||
|
# === SHIPMENT PROPERTIES ===
|
||||||
|
|
||||||
|
@property
|
||||||
|
def remaining_quantity(self) -> int:
|
||||||
|
"""Quantity not yet shipped."""
|
||||||
|
return max(0, self.quantity - self.shipped_quantity)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_fully_shipped(self) -> bool:
|
||||||
|
"""Check if all units have been shipped."""
|
||||||
|
return self.shipped_quantity >= self.quantity
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_partially_shipped(self) -> bool:
|
||||||
|
"""Check if some but not all units have been shipped."""
|
||||||
|
return 0 < self.shipped_quantity < self.quantity
|
||||||
|
|||||||
Reference in New Issue
Block a user