This commit completes the migration to a fully module-driven architecture: ## Models Migration - Moved all domain models from models/database/ to their respective modules: - tenancy: User, Admin, Vendor, Company, Platform, VendorDomain, etc. - cms: MediaFile, VendorTheme - messaging: Email, VendorEmailSettings, VendorEmailTemplate - core: AdminMenuConfig - models/database/ now only contains Base and TimestampMixin (infrastructure) ## Schemas Migration - Moved all domain schemas from models/schema/ to their respective modules: - tenancy: company, vendor, admin, team, vendor_domain - cms: media, image, vendor_theme - messaging: email - models/schema/ now only contains base.py and auth.py (infrastructure) ## Routes Migration - Moved admin routes from app/api/v1/admin/ to modules: - menu_config.py -> core module - modules.py -> tenancy module - module_config.py -> tenancy module - app/api/v1/admin/ now only aggregates auto-discovered module routes ## Menu System - Implemented module-driven menu system with MenuDiscoveryService - Extended FrontendType enum: PLATFORM, ADMIN, VENDOR, STOREFRONT - Added MenuItemDefinition and MenuSectionDefinition dataclasses - Each module now defines its own menu items in definition.py - MenuService integrates with MenuDiscoveryService for template rendering ## Documentation - Updated docs/architecture/models-structure.md - Updated docs/architecture/menu-management.md - Updated architecture validation rules for new exceptions ## Architecture Validation - Updated MOD-019 rule to allow base.py in models/schema/ - Created core module exceptions.py and schemas/ directory - All validation errors resolved (only warnings remain) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1323 lines
46 KiB
Python
1323 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 ValidationException
|
|
from app.modules.customers.exceptions import CustomerNotFoundException
|
|
from app.modules.inventory.exceptions import InsufficientInventoryException
|
|
from app.modules.orders.exceptions import OrderNotFoundException
|
|
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.modules.billing.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 app.modules.marketplace.models import MarketplaceProduct, MarketplaceProductTranslation
|
|
from app.modules.catalog.models import Product
|
|
from app.modules.tenancy.models 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.modules.core.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()
|