# 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.exc import SQLAlchemyError from sqlalchemy.orm import Session from app.modules.billing.exceptions import TierLimitExceededException from app.modules.billing.services.subscription_service import subscription_service from app.modules.catalog.models import Product from app.modules.customers.exceptions import CustomerNotFoundException from app.modules.customers.models.customer import Customer from app.modules.inventory.exceptions import InsufficientInventoryException from app.modules.marketplace.models import ( # IMPORT-002 MarketplaceProduct, MarketplaceProductTranslation, ) from app.modules.orders.exceptions import ( OrderNotFoundException, OrderValidationException, ) from app.modules.orders.models.order import Order, OrderItem from app.modules.orders.schemas.order import ( OrderCreate, OrderUpdate, ) from app.modules.tenancy.models import Store from app.utils.money import Money, cents_to_euros, euros_to_cents from app.utils.vat import ( VATResult, calculate_vat_amount, determine_vat_regime, ) # 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, store_id: int) -> str: """ Generate unique order number. Format: ORD-{STORE_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-{store_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-{store_id}-{timestamp}-{random_suffix}" return order_number # ========================================================================= # Tax Calculation # ========================================================================= def _calculate_tax_for_order( self, db: Session, store_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 StoreInvoiceSettings # Get store invoice settings for seller country and OSS status settings = ( db.query(StoreInvoiceSettings) .filter(StoreInvoiceSettings.store_id == store_id) .first() ) # Default to Luxembourg if no settings exist seller_country = settings.merchant_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, store_id: int, ) -> Product: """ Get or create the store's placeholder product for unmatched items. """ # Check for existing placeholder product for this store placeholder = ( db.query(Product) .filter( and_( Product.store_id == store_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", store_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 store-specific placeholder product placeholder = Product( store_id=store_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 store {store_id}") return placeholder # ========================================================================= # Customer Management # ========================================================================= def find_or_create_customer( self, db: Session, store_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 store scope customer = ( db.query(Customer) .filter( and_( Customer.store_id == store_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-{store_id}-{timestamp}-{random_suffix}" # Create new customer customer = Customer( store_id=store_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 store {store_id}: {email}" ) return customer # ========================================================================= # Order Creation # ========================================================================= def create_order( self, db: Session, store_id: int, order_data: OrderCreate, ) -> Order: """ Create a new direct order. """ # Check tier limit before creating order subscription_service.check_order_limit(db, store_id) try: # Get or create customer if order_data.customer_id: customer = ( db.query(Customer) .filter( and_( Customer.id == order_data.customer_id, Customer.store_id == store_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, store_id=store_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.store_id == store_id, Product.is_active == True, ) ) .first() ) if not product: raise OrderValidationException( f"Product {item_data.product_id} not found" ) # Check inventory if product.available_inventory < item_data.quantity: raise InsufficientInventoryException( gtin=getattr(product, "gtin", str(product.id)), location="default", 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 OrderValidationException(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.store_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 store settings vat_result = self._calculate_tax_for_order( db=db, store_id=store_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, store_id) # Create order with snapshots order = Order( store_id=store_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 order_items = [ OrderItem(order_id=order.id, **item_data) for item_data in order_items_data ] db.add_all(order_items) db.flush() db.refresh(order) # Increment order count for subscription tracking subscription_service.increment_order_count(db, store_id) logger.info( f"Order {order.order_number} created for store {store_id}, " f"total: EUR {cents_to_euros(total_amount_cents):.2f}" ) return order except ( OrderValidationException, InsufficientInventoryException, CustomerNotFoundException, TierLimitExceededException, ): raise except SQLAlchemyError as e: logger.error(f"Error creating order: {str(e)}") raise OrderValidationException(f"Failed to create order: {str(e)}") def create_letzshop_order( self, db: Session, store_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, store_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-{store_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.store_id == store_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, store_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, store_id=store_id, email=customer_email, first_name=ship_first_name, last_name=ship_last_name, is_active=False, ) # Create order order = Order( store_id=store_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) # noqa: PERF006 db.flush() # Create exception record for unmatched items if needs_product_match: order_item_exception_service.create_exception( db=db, order_item=order_item, store_id=store_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, store_id) logger.info( f"Letzshop order {order.order_number} created for store {store_id}, " f"status: {status}, items: {len(inventory_units)}" ) return order # ========================================================================= # Order Retrieval # ========================================================================= def get_order(self, db: Session, store_id: int, order_id: int) -> Order: """Get order by ID within store scope.""" order = ( db.query(Order) .filter(and_(Order.id == order_id, Order.store_id == store_id)) .first() ) if not order: raise OrderNotFoundException(str(order_id)) return order def get_order_by_external_shipment_id( self, db: Session, store_id: int, shipment_id: str, ) -> Order | None: """Get order by external shipment ID (for Letzshop).""" return ( db.query(Order) .filter( and_( Order.store_id == store_id, Order.external_shipment_id == shipment_id, ) ) .first() ) def get_store_orders( self, db: Session, store_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 store with filtering.""" query = db.query(Order).filter(Order.store_id == store_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, store_id: int, customer_id: int, skip: int = 0, limit: int = 50, ) -> tuple[list[Order], int]: """Get orders for a specific customer.""" return self.get_store_orders( db=db, store_id=store_id, skip=skip, limit=limit, customer_id=customer_id, ) def get_order_stats(self, db: Session, store_id: int) -> dict[str, int]: """Get order counts by status for a store.""" status_counts = ( db.query(Order.status, func.count(Order.id).label("count")) .filter(Order.store_id == store_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.store_id == store_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, store_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, store_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, store_id=store_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: # noqa: EXC003 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, store_id: int, order_id: int, tracking_number: str, tracking_provider: str, ) -> Order: """Set tracking information and mark as shipped.""" order = self.get_order(db, store_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, store_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, store_id, order_id) item = ( db.query(OrderItem) .filter( and_( OrderItem.id == item_id, OrderItem.order_id == order.id, ) ) .first() ) if not item: raise OrderValidationException(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-store) # ========================================================================= def get_all_orders_admin( self, db: Session, skip: int = 0, limit: int = 50, store_id: int | None = None, status: str | None = None, channel: str | None = None, search: str | None = None, ) -> tuple[list[dict], int]: """Get orders across all stores for admin.""" query = db.query(Order).join(Store) if store_id: query = query.filter(Order.store_id == store_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, "store_id": order.store_id, "store_name": order.store.name if order.store else None, "store_code": order.store.store_code if order.store 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, "stores_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 stores with orders stores_count = ( db.query(func.count(func.distinct(Order.store_id))).scalar() or 0 ) stats["stores_with_orders"] = stores_count return stats def get_order_by_id_admin(self, db: Session, order_id: int) -> Order: """Get order by ID without store 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_stores_with_orders_admin(self, db: Session) -> list[dict]: """Get list of stores that have orders (admin only).""" results = ( db.query( Store.id, Store.name, Store.store_code, func.count(Order.id).label("order_count"), ) .join(Order, Order.store_id == Store.id) .group_by(Store.id, Store.name, Store.store_code) .order_by(func.count(Order.id).desc()) .all() ) return [ { "id": row.id, "name": row.name, "store_code": row.store_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, # 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()