diff --git a/app/services/order_service.py b/app/services/order_service.py index 05988ae5..95bab5b5 100644 --- a/app/services/order_service.py +++ b/app/services/order_service.py @@ -1,19 +1,24 @@ # app/services/order_service.py """ -Order service for order management. +Unified order service for all sales channels. -This module provides: -- Order creation from cart +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. """ import logging import random import string from datetime import UTC, datetime +from typing import Any -from sqlalchemy import and_ +from sqlalchemy import and_, func, or_ from sqlalchemy.orm import Session from app.exceptions import ( @@ -22,16 +27,27 @@ from app.exceptions import ( OrderNotFoundException, ValidationException, ) -from models.database.customer import Customer, CustomerAddress +from models.database.customer import Customer from models.database.order import Order, OrderItem from models.database.product import Product -from models.schema.order import OrderAddressCreate, OrderCreate, OrderUpdate +from models.database.vendor import Vendor +from models.schema.order import ( + AddressSnapshot, + CustomerSnapshot, + OrderCreate, + OrderItemCreate, + OrderUpdate, +) logger = logging.getLogger(__name__) class OrderService: - """Service for order operations.""" + """Unified service for order operations across all channels.""" + + # ========================================================================= + # Order Number Generation + # ========================================================================= def _generate_order_number(self, db: Session, vendor_id: int) -> str: """ @@ -55,38 +71,91 @@ class OrderService: return order_number - def _create_customer_address( + # ========================================================================= + # Customer Management + # ========================================================================= + + def find_or_create_customer( self, db: Session, vendor_id: int, - customer_id: int, - address_data: OrderAddressCreate, - address_type: str, - ) -> CustomerAddress: - """Create a customer address for order.""" - address = CustomerAddress( - vendor_id=vendor_id, - customer_id=customer_id, - address_type=address_type, - first_name=address_data.first_name, - last_name=address_data.last_name, - company=address_data.company, - address_line_1=address_data.address_line_1, - address_line_2=address_data.address_line_2, - city=address_data.city, - postal_code=address_data.postal_code, - country=address_data.country, - is_default=False, + 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. + + For marketplace imports, customers are created as inactive until + they register on the storefront. + + Args: + db: Database session + vendor_id: Vendor ID + email: Customer email + first_name: Customer first name + last_name: Customer last name + phone: Customer phone (optional) + is_active: Whether customer is active (default: False for imports) + + Returns: + Customer record (existing or newly created) + """ + # Look for existing customer by email within vendor scope + customer = ( + db.query(Customer) + .filter( + and_( + Customer.vendor_id == vendor_id, + Customer.email == email, + ) + ) + .first() ) - db.add(address) - db.flush() # Get ID without committing - return address + + 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="", # No password for imported customers + 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 + self, + db: Session, + vendor_id: int, + order_data: OrderCreate, ) -> Order: """ - Create a new order. + Create a new direct order. Args: db: Database session @@ -101,53 +170,37 @@ class OrderService: InsufficientInventoryException: If not enough inventory """ try: - # Validate customer exists if provided - customer_id = order_data.customer_id - if customer_id: + # Get or create customer + if order_data.customer_id: customer = ( db.query(Customer) .filter( and_( - Customer.id == customer_id, Customer.vendor_id == vendor_id + Customer.id == order_data.customer_id, + Customer.vendor_id == vendor_id, ) ) .first() ) - if not customer: - raise CustomerNotFoundException(str(customer_id)) + raise CustomerNotFoundException(str(order_data.customer_id)) else: - # Guest checkout - create guest customer - # TODO: Implement guest customer creation - raise ValidationException("Guest checkout not yet implemented") - - # Create shipping address - shipping_address = self._create_customer_address( - db=db, - vendor_id=vendor_id, - customer_id=customer_id, - address_data=order_data.shipping_address, - address_type="shipping", - ) - - # Create billing address (use shipping if not provided) - if order_data.billing_address: - billing_address = self._create_customer_address( + # Create customer from snapshot + customer = self.find_or_create_customer( db=db, vendor_id=vendor_id, - customer_id=customer_id, - address_data=order_data.billing_address, - address_type="billing", + 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, # Direct orders = active customers ) - else: - billing_address = shipping_address - # Calculate order totals + # Calculate order totals and validate products subtotal = 0.0 order_items_data = [] for item_data in order_data.items: - # Get product product = ( db.query(Product) .filter( @@ -184,43 +237,76 @@ class OrderService: order_items_data.append( { "product_id": product.id, - "product_name": product.marketplace_product.title, + "product_name": product.marketplace_product.title + if product.marketplace_product + else product.product_id, "product_sku": product.product_id, + "gtin": product.gtin, + "gtin_type": product.gtin_type, "quantity": item_data.quantity, "unit_price": unit_price, "total_price": item_total, } ) - # Calculate tax and shipping (simple implementation) + # Calculate totals tax_amount = 0.0 # TODO: Implement tax calculation - shipping_amount = 5.99 if subtotal < 50 else 0.0 # Free shipping over €50 - discount_amount = 0.0 # TODO: Implement discounts + shipping_amount = 5.99 if subtotal < 50 else 0.0 + discount_amount = 0.0 total_amount = subtotal + tax_amount + shipping_amount - discount_amount + # Use billing address or shipping address + billing = order_data.billing_address or order_data.shipping_address + # Generate order number order_number = self._generate_order_number(db, vendor_id) - # Create order + # Create order with snapshots order = Order( vendor_id=vendor_id, - customer_id=customer_id, + customer_id=customer.id, order_number=order_number, + channel="direct", status="pending", + # Financials subtotal=subtotal, tax_amount=tax_amount, shipping_amount=shipping_amount, discount_amount=discount_amount, total_amount=total_amount, currency="EUR", - shipping_address_id=shipping_address.id, - billing_address_id=billing_address.id, + # 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() # Get order ID + db.flush() # Create order items for item_data in order_items_data: @@ -232,7 +318,7 @@ class OrderService: logger.info( f"Order {order.order_number} created for vendor {vendor_id}, " - f"total: €{total_amount:.2f}" + f"total: EUR {total_amount:.2f}" ) return order @@ -247,8 +333,226 @@ class OrderService: 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], + ) -> Order: + """ + Create an order from Letzshop shipment data. + + Args: + db: Database session + vendor_id: Vendor ID + shipment_data: Raw shipment data from Letzshop API + + Returns: + Created Order object + + Raises: + ValidationException: If product not found by GTIN + """ + order_data = shipment_data.get("order", {}) + 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 "" + + # 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, + ) + + # 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: + # Handle format like "99.99 EUR" + total_amount = float(str(total_str).split()[0]) + except (ValueError, IndexError): + total_amount = 0.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") + + # 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: + return existing + + # Create order + order = Order( + vendor_id=vendor_id, + customer_id=customer.id, + order_number=order_number, + channel="letzshop", + # External references + 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=status, + # Financials + total_amount=total_amount, + currency="EUR", + # Customer snapshot + customer_first_name=ship_first_name, + customer_last_name=ship_last_name, + customer_email=customer_email, + customer_locale=order_data.get("locale"), + # Shipping address snapshot + 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 "", + # Billing address snapshot + 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 "", + # Dates + order_date=order_date, + confirmed_at=datetime.now(UTC) if status == "processing" else None, + cancelled_at=datetime.now(UTC) if status == "cancelled" else None, + ) + + db.add(order) + db.flush() + + # Create order items from inventory units + inventory_units = shipment_data.get("inventoryUnits", []) + if isinstance(inventory_units, dict): + inventory_units = inventory_units.get("nodes", []) + + 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") + + # Find product by GTIN + product = None + if gtin: + product = ( + db.query(Product) + .filter( + and_( + Product.vendor_id == vendor_id, + Product.gtin == gtin, + ) + ) + .first() + ) + + if not product: + # This should be an error per the design requirements + logger.error( + f"Product not found for GTIN {gtin} in vendor {vendor_id}. " + f"Order: {order_number}" + ) + raise ValidationException( + f"Product not found for GTIN {gtin}. " + f"Please ensure the product catalog is in sync." + ) + + # Get product name + 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 = 0.0 + price_str = variant.get("price", "0") + try: + unit_price = float(str(price_str).split()[0]) + except (ValueError, IndexError): + pass + + # Map item state + item_state = unit.get("state") # unconfirmed, confirmed_available, etc. + + order_item = OrderItem( + order_id=order.id, + product_id=product.id, + product_name=product_name, + product_sku=variant.get("sku"), + gtin=gtin, + gtin_type=gtin_type, + quantity=1, # Letzshop uses individual inventory units + unit_price=unit_price, + total_price=unit_price, + external_item_id=unit.get("id"), + external_variant_id=variant.get("id"), + item_state=item_state, + ) + db.add(order_item) + + db.flush() + db.refresh(order) + + 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.""" + """Get order by ID within vendor scope.""" order = ( db.query(Order) .filter(and_(Order.id == order_id, Order.vendor_id == vendor_id)) @@ -260,14 +564,33 @@ class OrderService: 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 = 100, + limit: int = 50, status: str | None = None, - customer_id: int | None = None, + channel: str | None = None, + search: str | None = None, ) -> tuple[list[Order], int]: """ Get orders for vendor with filtering. @@ -278,7 +601,8 @@ class OrderService: skip: Pagination offset limit: Pagination limit status: Filter by status - customer_id: Filter by customer + channel: Filter by channel (direct, letzshop) + search: Search by order number, customer name, or email Returns: Tuple of (orders, total_count) @@ -288,32 +612,81 @@ class OrderService: if status: query = query.filter(Order.status == status) - if customer_id: - query = query.filter(Order.customer_id == customer_id) + 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), + ) + ) # Order by most recent first - query = query.order_by(Order.created_at.desc()) + 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( + def get_order_stats(self, db: Session, vendor_id: int) -> dict[str, int]: + """ + Get order counts by status for a vendor. + + Returns: + Dict with counts for each status. + """ + 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, + "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, - customer_id: int, - skip: int = 0, - limit: int = 100, - ) -> 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 update_order_status( - self, db: Session, vendor_id: int, order_id: int, order_update: OrderUpdate + order_id: int, + order_update: OrderUpdate, ) -> Order: """ Update order status and tracking information. @@ -327,44 +700,150 @@ class OrderService: Returns: Updated Order object """ - try: - order = self.get_order(db, vendor_id, order_id) + order = self.get_order(db, vendor_id, order_id) - # Update status with timestamps - if order_update.status: - order.status = order_update.status + now = datetime.now(UTC) - # Update timestamp based on status - now = datetime.now(UTC) - if 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 + if order_update.status: + old_status = order.status + order.status = order_update.status - # Update tracking number - if order_update.tracking_number: - order.tracking_number = order_update.tracking_number + # 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 - # Update internal notes - if order_update.internal_notes: - order.internal_notes = order_update.internal_notes + if order_update.tracking_number: + order.tracking_number = order_update.tracking_number - order.updated_at = datetime.now(UTC) - db.flush() - db.refresh(order) + if order_update.tracking_provider: + order.tracking_provider = order_update.tracking_provider - logger.info(f"Order {order.order_number} updated: status={order.status}") + if order_update.internal_notes: + order.internal_notes = order_update.internal_notes - return order + order.updated_at = now + db.flush() + db.refresh(order) - except OrderNotFoundException: - raise - except Exception as e: - logger.error(f"Error updating order: {str(e)}") - raise ValidationException(f"Failed to update order: {str(e)}") + 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. + + Args: + db: Database session + vendor_id: Vendor ID + order_id: Order ID + tracking_number: Tracking number + tracking_provider: Shipping provider + + Returns: + Updated Order object + """ + 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). + + Args: + db: Database session + vendor_id: Vendor ID + order_id: Order ID + item_id: Item ID + state: New state (confirmed_available, confirmed_unavailable) + + Returns: + Updated OrderItem object + """ + 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) @@ -395,10 +874,7 @@ class OrderService: Returns: Tuple of (orders with vendor info, total_count) """ - from models.database.customer import Customer - from models.database.vendor import Vendor - - query = db.query(Order).join(Vendor).join(Customer) + query = db.query(Order).join(Vendor) if vendor_id: query = query.filter(Order.vendor_id == vendor_id) @@ -412,30 +888,23 @@ class OrderService: if search: search_term = f"%{search}%" query = query.filter( - (Order.order_number.ilike(search_term)) - | (Customer.email.ilike(search_term)) - | (Customer.first_name.ilike(search_term)) - | (Customer.last_name.ilike(search_term)) + 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.created_at.desc()) + query = query.order_by(Order.order_date.desc()) total = query.count() orders = query.offset(skip).limit(limit).all() - # Build response with vendor/customer info result = [] for order in orders: item_count = len(order.items) if order.items else 0 - customer_name = None - customer_email = None - - if order.customer: - customer_name = ( - f"{order.customer.first_name} {order.customer.last_name}".strip() - ) - customer_email = order.customer.email result.append( { @@ -444,26 +913,30 @@ class OrderService: "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_name": customer_name, - "customer_email": customer_email, + "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, - "shipping_method": order.shipping_method, + "ship_country_iso": order.ship_country_iso, "tracking_number": order.tracking_number, + "tracking_provider": order.tracking_provider, "item_count": item_count, - "created_at": order.created_at, - "updated_at": order.updated_at, - "paid_at": order.paid_at, + "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, } ) @@ -471,13 +944,11 @@ class OrderService: def get_order_stats_admin(self, db: Session) -> dict: """Get platform-wide order statistics.""" - from sqlalchemy import func - - from models.database.vendor import Vendor - # Get status counts status_counts = ( - db.query(Order.status, func.count(Order.id)).group_by(Order.status).all() + db.query(Order.status, func.count(Order.id)) + .group_by(Order.status) + .all() ) stats = { @@ -489,6 +960,8 @@ class OrderService: "cancelled_orders": 0, "refunded_orders": 0, "total_revenue": 0.0, + "direct_orders": 0, + "letzshop_orders": 0, "vendors_with_orders": 0, } @@ -498,7 +971,19 @@ class OrderService: if key in stats: stats[key] = count - # Get total revenue (from delivered orders only) + # 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 (from delivered orders) revenue = ( db.query(func.sum(Order.total_amount)) .filter(Order.status == "delivered") @@ -514,35 +999,6 @@ class OrderService: return stats - def get_vendors_with_orders_admin(self, db: Session) -> list[dict]: - """Get list of vendors that have orders.""" - from sqlalchemy import func - - from models.database.vendor import Vendor - - results = ( - db.query( - Vendor.id, - Vendor.name, - Vendor.vendor_code, - func.count(Order.id).label("order_count"), - ) - .join(Order) - .group_by(Vendor.id, Vendor.name, Vendor.vendor_code) - .order_by(Vendor.name) - .all() - ) - - return [ - { - "id": r.id, - "name": r.name, - "vendor_code": r.vendor_code, - "order_count": r.order_count, - } - for r in results - ] - 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() @@ -552,65 +1008,6 @@ class OrderService: return order - def update_order_status_admin( - self, - db: Session, - order_id: int, - status: str, - tracking_number: str | None = None, - reason: str | None = None, - ) -> Order: - """ - Update order status as admin. - - Args: - db: Database session - order_id: Order ID - status: New status - tracking_number: Optional tracking number - reason: Optional reason for change - - Returns: - Updated Order object - """ - order = self.get_order_by_id_admin(db, order_id) - - # Update status with timestamps - old_status = order.status - order.status = status - - now = datetime.now(UTC) - if status == "shipped" and not order.shipped_at: - order.shipped_at = now - elif status == "delivered" and not order.delivered_at: - order.delivered_at = now - elif status == "cancelled" and not order.cancelled_at: - order.cancelled_at = now - - # Update tracking number - if tracking_number: - order.tracking_number = tracking_number - - # Add reason to internal notes if provided - if reason: - note = f"[{now.isoformat()}] Status changed from {old_status} to {status}: {reason}" - if order.internal_notes: - order.internal_notes = f"{order.internal_notes}\n{note}" - else: - order.internal_notes = note - - order.updated_at = now - db.flush() - db.refresh(order) - - logger.info( - f"Admin updated order {order.order_number}: " - f"{old_status} -> {status}" - f"{f' (reason: {reason})' if reason else ''}" - ) - - return order - # Create service instance order_service = OrderService()