Files
orion/app/modules/orders/services/order_service.py
Samir Boulahtit de83875d0a refactor: migrate modules from re-exports to canonical implementations
Move actual code implementations into module directories:
- orders: 5 services, 4 models, order/invoice schemas
- inventory: 3 services, 2 models, 30+ schemas
- customers: 3 services, 2 models, customer schemas
- messaging: 3 services, 2 models, message/notification schemas
- monitoring: background_tasks_service
- marketplace: 5+ services including letzshop submodule
- dev_tools: code_quality_service, test_runner_service
- billing: billing_service
- contracts: definition.py

Legacy files in app/services/, models/database/, models/schema/
now re-export from canonical module locations for backwards
compatibility. Architecture validator passes with 0 errors.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 21:28:56 +01:00

1326 lines
46 KiB
Python

# app/modules/orders/services/order_service.py
"""
Unified order service for all sales channels.
This service handles:
- Order creation (direct and marketplace)
- Order status management
- Order retrieval and filtering
- Customer creation for marketplace imports
- Order item management
All orders use snapshotted customer and address data.
All monetary calculations use integer cents internally for precision.
See docs/architecture/money-handling.md for details.
"""
import logging
import random
import string
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.exceptions import (
CustomerNotFoundException,
InsufficientInventoryException,
OrderNotFoundException,
ValidationException,
)
from app.modules.customers.models.customer import Customer
from app.modules.orders.models.order import Order, OrderItem
from app.modules.orders.schemas.order import (
AddressSnapshot,
CustomerSnapshot,
OrderCreate,
OrderItemCreate,
OrderUpdate,
)
from app.services.subscription_service import (
subscription_service,
TierLimitExceededException,
)
from app.utils.money import Money, cents_to_euros, euros_to_cents
from app.utils.vat import (
VATResult,
calculate_vat_amount,
determine_vat_regime,
)
from models.database.marketplace_product import MarketplaceProduct
from models.database.marketplace_product_translation import MarketplaceProductTranslation
from models.database.product import Product
from models.database.vendor import Vendor
# Placeholder product constants
PLACEHOLDER_GTIN = "0000000000000"
PLACEHOLDER_MARKETPLACE_ID = "PLACEHOLDER"
logger = logging.getLogger(__name__)
class OrderService:
"""Unified service for order operations across all channels."""
# =========================================================================
# Order Number Generation
# =========================================================================
def _generate_order_number(self, db: Session, vendor_id: int) -> str:
"""
Generate unique order number.
Format: ORD-{VENDOR_ID}-{TIMESTAMP}-{RANDOM}
Example: ORD-1-20250110-A1B2C3
"""
timestamp = datetime.now(UTC).strftime("%Y%m%d")
random_suffix = "".join(
random.choices(string.ascii_uppercase + string.digits, k=6)
)
order_number = f"ORD-{vendor_id}-{timestamp}-{random_suffix}"
# Ensure uniqueness
while db.query(Order).filter(Order.order_number == order_number).first():
random_suffix = "".join(
random.choices(string.ascii_uppercase + string.digits, k=6)
)
order_number = f"ORD-{vendor_id}-{timestamp}-{random_suffix}"
return order_number
# =========================================================================
# Tax Calculation
# =========================================================================
def _calculate_tax_for_order(
self,
db: Session,
vendor_id: int,
subtotal_cents: int,
billing_country_iso: str,
buyer_vat_number: str | None = None,
) -> VATResult:
"""
Calculate tax amount for an order based on billing destination.
Uses the shared VAT utility to determine the correct VAT regime
and rate, consistent with invoice VAT calculation.
"""
from app.modules.orders.models.invoice import VendorInvoiceSettings
# Get vendor invoice settings for seller country and OSS status
settings = (
db.query(VendorInvoiceSettings)
.filter(VendorInvoiceSettings.vendor_id == vendor_id)
.first()
)
# Default to Luxembourg if no settings exist
seller_country = settings.company_country if settings else "LU"
seller_oss_registered = settings.is_oss_registered if settings else False
# Determine VAT regime using shared utility
return determine_vat_regime(
seller_country=seller_country,
buyer_country=billing_country_iso or "LU",
buyer_vat_number=buyer_vat_number,
seller_oss_registered=seller_oss_registered,
)
# =========================================================================
# Placeholder Product Management
# =========================================================================
def _get_or_create_placeholder_product(
self,
db: Session,
vendor_id: int,
) -> Product:
"""
Get or create the vendor's placeholder product for unmatched items.
"""
# Check for existing placeholder product for this vendor
placeholder = (
db.query(Product)
.filter(
and_(
Product.vendor_id == vendor_id,
Product.gtin == PLACEHOLDER_GTIN,
)
)
.first()
)
if placeholder:
return placeholder
# Get or create placeholder marketplace product (shared)
mp = (
db.query(MarketplaceProduct)
.filter(
MarketplaceProduct.marketplace_product_id == PLACEHOLDER_MARKETPLACE_ID
)
.first()
)
if not mp:
mp = MarketplaceProduct(
marketplace_product_id=PLACEHOLDER_MARKETPLACE_ID,
marketplace="internal",
vendor_name="system",
product_type_enum="physical",
is_active=False,
)
db.add(mp)
db.flush()
# Add translation for placeholder
translation = MarketplaceProductTranslation(
marketplace_product_id=mp.id,
language="en",
title="Unmatched Product (Pending Resolution)",
description=(
"This is a placeholder for products not found during order import. "
"Please resolve the exception to assign the correct product."
),
)
db.add(translation)
db.flush()
logger.info(f"Created placeholder MarketplaceProduct {mp.id}")
# Create vendor-specific placeholder product
placeholder = Product(
vendor_id=vendor_id,
marketplace_product_id=mp.id,
gtin=PLACEHOLDER_GTIN,
gtin_type="placeholder",
is_active=False,
)
db.add(placeholder)
db.flush()
logger.info(f"Created placeholder product {placeholder.id} for vendor {vendor_id}")
return placeholder
# =========================================================================
# Customer Management
# =========================================================================
def find_or_create_customer(
self,
db: Session,
vendor_id: int,
email: str,
first_name: str,
last_name: str,
phone: str | None = None,
is_active: bool = False,
) -> Customer:
"""
Find existing customer by email or create new one.
"""
# Look for existing customer by email within vendor scope
customer = (
db.query(Customer)
.filter(
and_(
Customer.vendor_id == vendor_id,
Customer.email == email,
)
)
.first()
)
if customer:
return customer
# Generate a unique customer number
timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S")
random_suffix = "".join(random.choices(string.digits, k=4))
customer_number = f"CUST-{vendor_id}-{timestamp}-{random_suffix}"
# Create new customer
customer = Customer(
vendor_id=vendor_id,
email=email,
first_name=first_name,
last_name=last_name,
phone=phone,
customer_number=customer_number,
hashed_password="",
is_active=is_active,
)
db.add(customer)
db.flush()
logger.info(
f"Created {'active' if is_active else 'inactive'} customer "
f"{customer.id} for vendor {vendor_id}: {email}"
)
return customer
# =========================================================================
# Order Creation
# =========================================================================
def create_order(
self,
db: Session,
vendor_id: int,
order_data: OrderCreate,
) -> Order:
"""
Create a new direct order.
"""
# Check tier limit before creating order
subscription_service.check_order_limit(db, vendor_id)
try:
# Get or create customer
if order_data.customer_id:
customer = (
db.query(Customer)
.filter(
and_(
Customer.id == order_data.customer_id,
Customer.vendor_id == vendor_id,
)
)
.first()
)
if not customer:
raise CustomerNotFoundException(str(order_data.customer_id))
else:
# Create customer from snapshot
customer = self.find_or_create_customer(
db=db,
vendor_id=vendor_id,
email=order_data.customer.email,
first_name=order_data.customer.first_name,
last_name=order_data.customer.last_name,
phone=order_data.customer.phone,
is_active=True,
)
# Calculate order totals and validate products
subtotal_cents = 0
order_items_data = []
for item_data in order_data.items:
product = (
db.query(Product)
.filter(
and_(
Product.id == item_data.product_id,
Product.vendor_id == vendor_id,
Product.is_active == True,
)
)
.first()
)
if not product:
raise ValidationException(
f"Product {item_data.product_id} not found"
)
# Check inventory
if product.available_inventory < item_data.quantity:
raise InsufficientInventoryException(
product_id=product.id,
requested=item_data.quantity,
available=product.available_inventory,
)
# Get price in cents
unit_price_cents = (
product.sale_price_cents
or product.price_cents
)
if not unit_price_cents:
raise ValidationException(f"Product {product.id} has no price")
# Calculate line total in cents
line_total_cents = Money.calculate_line_total(
unit_price_cents, item_data.quantity
)
subtotal_cents += line_total_cents
order_items_data.append(
{
"product_id": product.id,
"product_name": product.marketplace_product.get_title("en")
if product.marketplace_product
else str(product.id),
"product_sku": product.vendor_sku,
"gtin": product.gtin,
"gtin_type": product.gtin_type,
"quantity": item_data.quantity,
"unit_price_cents": unit_price_cents,
"total_price_cents": line_total_cents,
}
)
# Use billing address or shipping address for VAT
billing = order_data.billing_address or order_data.shipping_address
# Calculate VAT using vendor settings
vat_result = self._calculate_tax_for_order(
db=db,
vendor_id=vendor_id,
subtotal_cents=subtotal_cents,
billing_country_iso=billing.country_iso,
buyer_vat_number=getattr(billing, 'vat_number', None),
)
# Calculate amounts in cents
tax_amount_cents = calculate_vat_amount(subtotal_cents, vat_result.rate)
shipping_amount_cents = 599 if subtotal_cents < 5000 else 0
discount_amount_cents = 0
total_amount_cents = Money.calculate_order_total(
subtotal_cents, tax_amount_cents, shipping_amount_cents, discount_amount_cents
)
# Generate order number
order_number = self._generate_order_number(db, vendor_id)
# Create order with snapshots
order = Order(
vendor_id=vendor_id,
customer_id=customer.id,
order_number=order_number,
channel="direct",
status="pending",
# Financials
subtotal_cents=subtotal_cents,
tax_amount_cents=tax_amount_cents,
shipping_amount_cents=shipping_amount_cents,
discount_amount_cents=discount_amount_cents,
total_amount_cents=total_amount_cents,
currency="EUR",
# VAT information
vat_regime=vat_result.regime.value,
vat_rate=vat_result.rate,
vat_rate_label=vat_result.label,
vat_destination_country=vat_result.destination_country,
# Customer snapshot
customer_first_name=order_data.customer.first_name,
customer_last_name=order_data.customer.last_name,
customer_email=order_data.customer.email,
customer_phone=order_data.customer.phone,
customer_locale=order_data.customer.locale,
# Shipping address snapshot
ship_first_name=order_data.shipping_address.first_name,
ship_last_name=order_data.shipping_address.last_name,
ship_company=order_data.shipping_address.company,
ship_address_line_1=order_data.shipping_address.address_line_1,
ship_address_line_2=order_data.shipping_address.address_line_2,
ship_city=order_data.shipping_address.city,
ship_postal_code=order_data.shipping_address.postal_code,
ship_country_iso=order_data.shipping_address.country_iso,
# Billing address snapshot
bill_first_name=billing.first_name,
bill_last_name=billing.last_name,
bill_company=billing.company,
bill_address_line_1=billing.address_line_1,
bill_address_line_2=billing.address_line_2,
bill_city=billing.city,
bill_postal_code=billing.postal_code,
bill_country_iso=billing.country_iso,
# Other
shipping_method=order_data.shipping_method,
customer_notes=order_data.customer_notes,
order_date=datetime.now(UTC),
)
db.add(order)
db.flush()
# Create order items
for item_data in order_items_data:
order_item = OrderItem(order_id=order.id, **item_data)
db.add(order_item)
db.flush()
db.refresh(order)
# Increment order count for subscription tracking
subscription_service.increment_order_count(db, vendor_id)
logger.info(
f"Order {order.order_number} created for vendor {vendor_id}, "
f"total: EUR {cents_to_euros(total_amount_cents):.2f}"
)
return order
except (
ValidationException,
InsufficientInventoryException,
CustomerNotFoundException,
TierLimitExceededException,
):
raise
except Exception as e:
logger.error(f"Error creating order: {str(e)}")
raise ValidationException(f"Failed to create order: {str(e)}")
def create_letzshop_order(
self,
db: Session,
vendor_id: int,
shipment_data: dict[str, Any],
skip_limit_check: bool = False,
) -> Order:
"""
Create an order from Letzshop shipment data.
"""
from app.modules.orders.services.order_item_exception_service import (
order_item_exception_service,
)
# Check tier limit before creating order
if not skip_limit_check:
can_create, message = subscription_service.can_create_order(db, vendor_id)
if not can_create:
raise TierLimitExceededException(
message=message or "Order limit exceeded",
limit_type="orders",
current=0,
limit=0,
)
order_data = shipment_data.get("order", {})
# Generate order number using Letzshop order number
letzshop_order_number = order_data.get("number", "")
order_number = f"LS-{vendor_id}-{letzshop_order_number}"
# Check if order already exists
existing = (
db.query(Order)
.filter(Order.order_number == order_number)
.first()
)
if existing:
updated = False
# Update tracking if available and not already set
tracking_data = shipment_data.get("tracking") or {}
new_tracking = tracking_data.get("code") or tracking_data.get("number")
if new_tracking and not existing.tracking_number:
existing.tracking_number = new_tracking
tracking_provider = tracking_data.get("provider")
if not tracking_provider and tracking_data.get("carrier"):
carrier = tracking_data.get("carrier", {})
tracking_provider = carrier.get("code") or carrier.get("name")
existing.tracking_provider = tracking_provider
updated = True
logger.info(
f"Updated tracking for order {order_number}: "
f"{tracking_provider} {new_tracking}"
)
# Update shipment number if not already set
shipment_number = shipment_data.get("number")
if shipment_number and not existing.shipment_number:
existing.shipment_number = shipment_number
updated = True
# Update carrier if not already set
shipment_data_obj = shipment_data.get("data") or {}
if shipment_data_obj and not existing.shipping_carrier:
carrier_name = shipment_data_obj.get("__typename", "").lower()
if carrier_name:
existing.shipping_carrier = carrier_name
updated = True
if updated:
existing.updated_at = datetime.now(UTC)
return existing
# Parse inventory units
inventory_units = shipment_data.get("inventoryUnits", [])
if isinstance(inventory_units, dict):
inventory_units = inventory_units.get("nodes", [])
# Collect all GTINs and check for items without GTINs
gtins = set()
has_items_without_gtin = False
for unit in inventory_units:
variant = unit.get("variant", {}) or {}
trade_id = variant.get("tradeId", {}) or {}
gtin = trade_id.get("number")
if gtin:
gtins.add(gtin)
else:
has_items_without_gtin = True
# Batch query all products by GTIN
products_by_gtin: dict[str, Product] = {}
if gtins:
products = (
db.query(Product)
.filter(
and_(
Product.vendor_id == vendor_id,
Product.gtin.in_(gtins),
)
)
.all()
)
products_by_gtin = {p.gtin: p for p in products if p.gtin}
# Identify missing GTINs
missing_gtins = gtins - set(products_by_gtin.keys())
placeholder = None
if missing_gtins or has_items_without_gtin:
placeholder = self._get_or_create_placeholder_product(db, vendor_id)
if missing_gtins:
logger.warning(
f"Order {order_number}: {len(missing_gtins)} product(s) not found. "
f"GTINs: {missing_gtins}. Using placeholder and creating exceptions."
)
if has_items_without_gtin:
logger.warning(
f"Order {order_number}: Some items have no GTIN. "
f"Using placeholder and creating exceptions."
)
# Parse address data
ship_address = order_data.get("shipAddress", {}) or {}
bill_address = order_data.get("billAddress", {}) or {}
ship_country = ship_address.get("country", {}) or {}
bill_country = bill_address.get("country", {}) or {}
# Extract customer info
customer_email = order_data.get("email", "")
ship_first_name = ship_address.get("firstName", "") or ""
ship_last_name = ship_address.get("lastName", "") or ""
# Parse order date
order_date = datetime.now(UTC)
completed_at_str = order_data.get("completedAt")
if completed_at_str:
try:
if completed_at_str.endswith("Z"):
completed_at_str = completed_at_str[:-1] + "+00:00"
order_date = datetime.fromisoformat(completed_at_str)
except (ValueError, TypeError):
pass
# Parse total amount
total_str = order_data.get("total", "0")
try:
total_euros = float(str(total_str).split()[0])
total_amount_cents = euros_to_cents(total_euros)
except (ValueError, IndexError):
total_amount_cents = 0
# Map Letzshop state to status
letzshop_state = shipment_data.get("state", "unconfirmed")
status_mapping = {
"unconfirmed": "pending",
"confirmed": "processing",
"declined": "cancelled",
}
status = status_mapping.get(letzshop_state, "pending")
# Parse tracking info if available
tracking_data = shipment_data.get("tracking") or {}
tracking_number = tracking_data.get("code") or tracking_data.get("number")
tracking_provider = tracking_data.get("provider")
if not tracking_provider and tracking_data.get("carrier"):
carrier = tracking_data.get("carrier", {})
tracking_provider = carrier.get("code") or carrier.get("name")
# Parse shipment number and carrier
shipment_number = shipment_data.get("number")
shipping_carrier = None
shipment_data_obj = shipment_data.get("data") or {}
if shipment_data_obj:
shipping_carrier = shipment_data_obj.get("__typename", "").lower()
# Find or create customer (inactive)
customer = self.find_or_create_customer(
db=db,
vendor_id=vendor_id,
email=customer_email,
first_name=ship_first_name,
last_name=ship_last_name,
is_active=False,
)
# Create order
order = Order(
vendor_id=vendor_id,
customer_id=customer.id,
order_number=order_number,
channel="letzshop",
external_order_id=order_data.get("id"),
external_shipment_id=shipment_data.get("id"),
external_order_number=letzshop_order_number,
external_data=shipment_data,
status=status,
total_amount_cents=total_amount_cents,
currency="EUR",
customer_first_name=ship_first_name,
customer_last_name=ship_last_name,
customer_email=customer_email,
customer_locale=order_data.get("locale"),
ship_first_name=ship_first_name,
ship_last_name=ship_last_name,
ship_company=ship_address.get("company"),
ship_address_line_1=ship_address.get("streetName", "") or "",
ship_address_line_2=ship_address.get("streetNumber"),
ship_city=ship_address.get("city", "") or "",
ship_postal_code=ship_address.get("postalCode", "") or "",
ship_country_iso=ship_country.get("iso", "") or "",
bill_first_name=bill_address.get("firstName", "") or ship_first_name,
bill_last_name=bill_address.get("lastName", "") or ship_last_name,
bill_company=bill_address.get("company"),
bill_address_line_1=bill_address.get("streetName", "") or "",
bill_address_line_2=bill_address.get("streetNumber"),
bill_city=bill_address.get("city", "") or "",
bill_postal_code=bill_address.get("postalCode", "") or "",
bill_country_iso=bill_country.get("iso", "") or "",
order_date=order_date,
confirmed_at=datetime.now(UTC) if status == "processing" else None,
cancelled_at=datetime.now(UTC) if status == "cancelled" else None,
tracking_number=tracking_number,
tracking_provider=tracking_provider,
shipment_number=shipment_number,
shipping_carrier=shipping_carrier,
)
db.add(order)
db.flush()
# Create order items from inventory units
exceptions_created = 0
for unit in inventory_units:
variant = unit.get("variant", {}) or {}
product_info = variant.get("product", {}) or {}
trade_id = variant.get("tradeId", {}) or {}
product_name_dict = product_info.get("name", {}) or {}
gtin = trade_id.get("number")
gtin_type = trade_id.get("parser")
# Get product from map, or use placeholder if not found
product = products_by_gtin.get(gtin)
needs_product_match = False
if not product:
product = placeholder
needs_product_match = True
# Get product name from marketplace data
product_name = (
product_name_dict.get("en")
or product_name_dict.get("fr")
or product_name_dict.get("de")
or str(product_name_dict)
)
# Get price
unit_price_cents = 0
price_str = variant.get("price", "0")
try:
price_euros = float(str(price_str).split()[0])
unit_price_cents = euros_to_cents(price_euros)
except (ValueError, IndexError):
pass
item_state = unit.get("state")
order_item = OrderItem(
order_id=order.id,
product_id=product.id if product else None,
product_name=product_name,
product_sku=variant.get("sku"),
gtin=gtin,
gtin_type=gtin_type,
quantity=1,
unit_price_cents=unit_price_cents,
total_price_cents=unit_price_cents,
external_item_id=unit.get("id"),
external_variant_id=variant.get("id"),
item_state=item_state,
needs_product_match=needs_product_match,
)
db.add(order_item)
db.flush()
# Create exception record for unmatched items
if needs_product_match:
order_item_exception_service.create_exception(
db=db,
order_item=order_item,
vendor_id=vendor_id,
original_gtin=gtin,
original_product_name=product_name,
original_sku=variant.get("sku"),
exception_type="product_not_found",
)
exceptions_created += 1
db.flush()
db.refresh(order)
if exceptions_created > 0:
logger.info(
f"Created {exceptions_created} order item exception(s) for "
f"order {order.order_number}"
)
# Increment order count for subscription tracking
subscription_service.increment_order_count(db, vendor_id)
logger.info(
f"Letzshop order {order.order_number} created for vendor {vendor_id}, "
f"status: {status}, items: {len(inventory_units)}"
)
return order
# =========================================================================
# Order Retrieval
# =========================================================================
def get_order(self, db: Session, vendor_id: int, order_id: int) -> Order:
"""Get order by ID within vendor scope."""
order = (
db.query(Order)
.filter(and_(Order.id == order_id, Order.vendor_id == vendor_id))
.first()
)
if not order:
raise OrderNotFoundException(str(order_id))
return order
def get_order_by_external_shipment_id(
self,
db: Session,
vendor_id: int,
shipment_id: str,
) -> Order | None:
"""Get order by external shipment ID (for Letzshop)."""
return (
db.query(Order)
.filter(
and_(
Order.vendor_id == vendor_id,
Order.external_shipment_id == shipment_id,
)
)
.first()
)
def get_vendor_orders(
self,
db: Session,
vendor_id: int,
skip: int = 0,
limit: int = 50,
status: str | None = None,
channel: str | None = None,
search: str | None = None,
customer_id: int | None = None,
) -> tuple[list[Order], int]:
"""Get orders for vendor with filtering."""
query = db.query(Order).filter(Order.vendor_id == vendor_id)
if status:
query = query.filter(Order.status == status)
if channel:
query = query.filter(Order.channel == channel)
if customer_id:
query = query.filter(Order.customer_id == customer_id)
if search:
search_term = f"%{search}%"
query = query.filter(
or_(
Order.order_number.ilike(search_term),
Order.external_order_number.ilike(search_term),
Order.customer_email.ilike(search_term),
Order.customer_first_name.ilike(search_term),
Order.customer_last_name.ilike(search_term),
)
)
# Order by most recent first
query = query.order_by(Order.order_date.desc())
total = query.count()
orders = query.offset(skip).limit(limit).all()
return orders, total
def get_customer_orders(
self,
db: Session,
vendor_id: int,
customer_id: int,
skip: int = 0,
limit: int = 50,
) -> tuple[list[Order], int]:
"""Get orders for a specific customer."""
return self.get_vendor_orders(
db=db,
vendor_id=vendor_id,
skip=skip,
limit=limit,
customer_id=customer_id,
)
def get_order_stats(self, db: Session, vendor_id: int) -> dict[str, int]:
"""Get order counts by status for a vendor."""
status_counts = (
db.query(Order.status, func.count(Order.id).label("count"))
.filter(Order.vendor_id == vendor_id)
.group_by(Order.status)
.all()
)
stats = {
"pending": 0,
"processing": 0,
"partially_shipped": 0,
"shipped": 0,
"delivered": 0,
"cancelled": 0,
"refunded": 0,
"total": 0,
}
for status, count in status_counts:
if status in stats:
stats[status] = count
stats["total"] += count
# Also count by channel
channel_counts = (
db.query(Order.channel, func.count(Order.id).label("count"))
.filter(Order.vendor_id == vendor_id)
.group_by(Order.channel)
.all()
)
for channel, count in channel_counts:
stats[f"{channel}_orders"] = count
return stats
# =========================================================================
# Order Updates
# =========================================================================
def update_order_status(
self,
db: Session,
vendor_id: int,
order_id: int,
order_update: OrderUpdate,
) -> Order:
"""Update order status and tracking information."""
from app.modules.orders.services.order_inventory_service import (
order_inventory_service,
)
order = self.get_order(db, vendor_id, order_id)
now = datetime.now(UTC)
old_status = order.status
if order_update.status:
order.status = order_update.status
# Update timestamps based on status
if order_update.status == "processing" and not order.confirmed_at:
order.confirmed_at = now
elif order_update.status == "shipped" and not order.shipped_at:
order.shipped_at = now
elif order_update.status == "delivered" and not order.delivered_at:
order.delivered_at = now
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:
logger.warning(
f"Order {order.order_number} inventory operation failed: {e}"
)
if order_update.tracking_number:
order.tracking_number = order_update.tracking_number
if order_update.tracking_provider:
order.tracking_provider = order_update.tracking_provider
if order_update.internal_notes:
order.internal_notes = order_update.internal_notes
order.updated_at = now
db.flush()
db.refresh(order)
logger.info(f"Order {order.order_number} updated: status={order.status}")
return order
def set_order_tracking(
self,
db: Session,
vendor_id: int,
order_id: int,
tracking_number: str,
tracking_provider: str,
) -> Order:
"""Set tracking information and mark as shipped."""
order = self.get_order(db, vendor_id, order_id)
now = datetime.now(UTC)
order.tracking_number = tracking_number
order.tracking_provider = tracking_provider
order.status = "shipped"
order.shipped_at = now
order.updated_at = now
db.flush()
db.refresh(order)
logger.info(
f"Order {order.order_number} shipped: "
f"{tracking_provider} {tracking_number}"
)
return order
def update_item_state(
self,
db: Session,
vendor_id: int,
order_id: int,
item_id: int,
state: str,
) -> OrderItem:
"""Update the state of an order item (for marketplace confirmation)."""
order = self.get_order(db, vendor_id, order_id)
item = (
db.query(OrderItem)
.filter(
and_(
OrderItem.id == item_id,
OrderItem.order_id == order.id,
)
)
.first()
)
if not item:
raise ValidationException(f"Order item {item_id} not found")
item.item_state = state
item.updated_at = datetime.now(UTC)
# Check if all items are processed
all_items = db.query(OrderItem).filter(OrderItem.order_id == order.id).all()
all_confirmed = all(
i.item_state in ("confirmed_available", "confirmed_unavailable")
for i in all_items
)
if all_confirmed:
has_available = any(
i.item_state == "confirmed_available" for i in all_items
)
all_unavailable = all(
i.item_state == "confirmed_unavailable" for i in all_items
)
now = datetime.now(UTC)
if all_unavailable:
order.status = "cancelled"
order.cancelled_at = now
elif has_available:
order.status = "processing"
order.confirmed_at = now
order.updated_at = now
db.flush()
db.refresh(item)
return item
# =========================================================================
# Admin Methods (cross-vendor)
# =========================================================================
def get_all_orders_admin(
self,
db: Session,
skip: int = 0,
limit: int = 50,
vendor_id: int | None = None,
status: str | None = None,
channel: str | None = None,
search: str | None = None,
) -> tuple[list[dict], int]:
"""Get orders across all vendors for admin."""
query = db.query(Order).join(Vendor)
if vendor_id:
query = query.filter(Order.vendor_id == vendor_id)
if status:
query = query.filter(Order.status == status)
if channel:
query = query.filter(Order.channel == channel)
if search:
search_term = f"%{search}%"
query = query.filter(
or_(
Order.order_number.ilike(search_term),
Order.external_order_number.ilike(search_term),
Order.customer_email.ilike(search_term),
Order.customer_first_name.ilike(search_term),
Order.customer_last_name.ilike(search_term),
)
)
query = query.order_by(Order.order_date.desc())
total = query.count()
orders = query.offset(skip).limit(limit).all()
result = []
for order in orders:
item_count = len(order.items) if order.items else 0
result.append(
{
"id": order.id,
"vendor_id": order.vendor_id,
"vendor_name": order.vendor.name if order.vendor else None,
"vendor_code": order.vendor.vendor_code if order.vendor else None,
"customer_id": order.customer_id,
"customer_full_name": order.customer_full_name,
"customer_email": order.customer_email,
"order_number": order.order_number,
"channel": order.channel,
"status": order.status,
"external_order_number": order.external_order_number,
"external_shipment_id": order.external_shipment_id,
"subtotal": order.subtotal,
"tax_amount": order.tax_amount,
"shipping_amount": order.shipping_amount,
"discount_amount": order.discount_amount,
"total_amount": order.total_amount,
"currency": order.currency,
"ship_country_iso": order.ship_country_iso,
"tracking_number": order.tracking_number,
"tracking_provider": order.tracking_provider,
"item_count": item_count,
"order_date": order.order_date,
"confirmed_at": order.confirmed_at,
"shipped_at": order.shipped_at,
"delivered_at": order.delivered_at,
"cancelled_at": order.cancelled_at,
"created_at": order.created_at,
"updated_at": order.updated_at,
}
)
return result, total
def get_order_stats_admin(self, db: Session) -> dict:
"""Get platform-wide order statistics."""
# Get status counts
status_counts = (
db.query(Order.status, func.count(Order.id))
.group_by(Order.status)
.all()
)
stats = {
"total_orders": 0,
"pending_orders": 0,
"processing_orders": 0,
"partially_shipped_orders": 0,
"shipped_orders": 0,
"delivered_orders": 0,
"cancelled_orders": 0,
"refunded_orders": 0,
"total_revenue": 0.0,
"direct_orders": 0,
"letzshop_orders": 0,
"vendors_with_orders": 0,
}
for status, count in status_counts:
stats["total_orders"] += count
key = f"{status}_orders"
if key in stats:
stats[key] = count
# Get channel counts
channel_counts = (
db.query(Order.channel, func.count(Order.id))
.group_by(Order.channel)
.all()
)
for channel, count in channel_counts:
key = f"{channel}_orders"
if key in stats:
stats[key] = count
# Get total revenue
revenue_cents = (
db.query(func.sum(Order.total_amount_cents))
.filter(Order.status == "delivered")
.scalar()
)
stats["total_revenue"] = cents_to_euros(revenue_cents) if revenue_cents else 0.0
# Count vendors with orders
vendors_count = (
db.query(func.count(func.distinct(Order.vendor_id))).scalar() or 0
)
stats["vendors_with_orders"] = vendors_count
return stats
def get_order_by_id_admin(self, db: Session, order_id: int) -> Order:
"""Get order by ID without vendor scope (admin only)."""
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise OrderNotFoundException(str(order_id))
return order
def get_vendors_with_orders_admin(self, db: Session) -> list[dict]:
"""Get list of vendors that have orders (admin only)."""
results = (
db.query(
Vendor.id,
Vendor.name,
Vendor.vendor_code,
func.count(Order.id).label("order_count"),
)
.join(Order, Order.vendor_id == Vendor.id)
.group_by(Vendor.id, Vendor.name, Vendor.vendor_code)
.order_by(func.count(Order.id).desc())
.all()
)
return [
{
"id": row.id,
"name": row.name,
"vendor_code": row.vendor_code,
"order_count": row.order_count,
}
for row in results
]
def mark_as_shipped_admin(
self,
db: Session,
order_id: int,
tracking_number: str | None = None,
tracking_url: str | None = None,
shipping_carrier: str | None = None,
) -> Order:
"""Mark an order as shipped with optional tracking info (admin only)."""
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise OrderNotFoundException(str(order_id))
order.status = "shipped"
order.shipped_at = datetime.now(UTC)
order.updated_at = datetime.now(UTC)
if tracking_number:
order.tracking_number = tracking_number
if tracking_url:
order.tracking_url = tracking_url
if shipping_carrier:
order.shipping_carrier = shipping_carrier
logger.info(
f"Order {order.order_number} marked as shipped. "
f"Tracking: {tracking_number or 'N/A'}, Carrier: {shipping_carrier or 'N/A'}"
)
return order
def get_shipping_label_info_admin(
self,
db: Session,
order_id: int,
) -> dict[str, Any]:
"""Get shipping label information for an order (admin only)."""
from app.services.admin_settings_service import admin_settings_service # noqa: MOD-004
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise OrderNotFoundException(str(order_id))
label_url = None
carrier = order.shipping_carrier
# Generate label URL based on carrier
if order.shipment_number and carrier:
setting_key = f"carrier_{carrier}_label_url"
prefix = admin_settings_service.get_setting_value(db, setting_key)
if prefix:
label_url = prefix + order.shipment_number
return {
"shipment_number": order.shipment_number,
"shipping_carrier": carrier,
"label_url": label_url,
"tracking_number": order.tracking_number,
"tracking_url": order.tracking_url,
}
# Create service instance
order_service = OrderService()