# models/database/order.py """ Unified Order model for all sales channels. Supports: - Direct orders (from vendor's own storefront) - Marketplace orders (Letzshop, etc.) Design principles: - Customer/address data is snapshotted at order time (preserves history) - customer_id FK links to Customer record (may be inactive for marketplace imports) - channel field distinguishes order source - external_* fields store marketplace-specific references """ from sqlalchemy import ( Boolean, Column, DateTime, Float, ForeignKey, Index, Integer, String, Text, ) from typing import TYPE_CHECKING if TYPE_CHECKING: from models.database.order_item_exception import OrderItemException from sqlalchemy.dialects.sqlite import JSON from sqlalchemy.orm import relationship from app.core.database import Base from models.database.base import TimestampMixin class Order(Base, TimestampMixin): """ Unified order model for all sales channels. Stores orders from direct sales and marketplaces (Letzshop, etc.) with snapshotted customer and address data. """ __tablename__ = "orders" id = Column(Integer, primary_key=True, index=True) vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True) customer_id = Column( Integer, ForeignKey("customers.id"), nullable=False, index=True ) order_number = Column(String(100), nullable=False, unique=True, index=True) # === Channel/Source === channel = Column( String(50), default="direct", nullable=False, index=True ) # direct, letzshop # External references (for marketplace orders) external_order_id = Column( String(100), nullable=True, index=True ) # Marketplace order ID external_shipment_id = Column( String(100), nullable=True, index=True ) # Marketplace shipment ID external_order_number = Column(String(100), nullable=True) # Marketplace order # external_data = Column(JSON, nullable=True) # Raw marketplace data for debugging # === Status === # pending: awaiting confirmation # processing: confirmed, being prepared # shipped: shipped with tracking # delivered: delivered to customer # cancelled: order cancelled/declined # refunded: order refunded status = Column(String(50), nullable=False, default="pending", index=True) # === Financials === subtotal = Column(Float, nullable=True) # May not be available from marketplace tax_amount = Column(Float, nullable=True) shipping_amount = Column(Float, nullable=True) discount_amount = Column(Float, nullable=True) total_amount = Column(Float, nullable=False) currency = Column(String(10), default="EUR") # === Customer Snapshot (preserved at order time) === customer_first_name = Column(String(100), nullable=False) customer_last_name = Column(String(100), nullable=False) customer_email = Column(String(255), nullable=False) customer_phone = Column(String(50), nullable=True) customer_locale = Column(String(10), nullable=True) # en, fr, de, lb # === Shipping Address Snapshot === ship_first_name = Column(String(100), nullable=False) ship_last_name = Column(String(100), nullable=False) ship_company = Column(String(200), nullable=True) ship_address_line_1 = Column(String(255), nullable=False) ship_address_line_2 = Column(String(255), nullable=True) ship_city = Column(String(100), nullable=False) ship_postal_code = Column(String(20), nullable=False) ship_country_iso = Column(String(5), nullable=False) # === Billing Address Snapshot === bill_first_name = Column(String(100), nullable=False) bill_last_name = Column(String(100), nullable=False) bill_company = Column(String(200), nullable=True) bill_address_line_1 = Column(String(255), nullable=False) bill_address_line_2 = Column(String(255), nullable=True) bill_city = Column(String(100), nullable=False) bill_postal_code = Column(String(20), nullable=False) bill_country_iso = Column(String(5), nullable=False) # === Tracking === shipping_method = Column(String(100), nullable=True) tracking_number = Column(String(100), nullable=True) tracking_provider = Column(String(100), nullable=True) # === Notes === customer_notes = Column(Text, nullable=True) internal_notes = Column(Text, nullable=True) # === Timestamps === order_date = Column( DateTime(timezone=True), nullable=False ) # When customer placed order confirmed_at = Column(DateTime(timezone=True), nullable=True) shipped_at = Column(DateTime(timezone=True), nullable=True) delivered_at = Column(DateTime(timezone=True), nullable=True) cancelled_at = Column(DateTime(timezone=True), nullable=True) # === Relationships === vendor = relationship("Vendor") customer = relationship("Customer", back_populates="orders") items = relationship( "OrderItem", back_populates="order", cascade="all, delete-orphan" ) # Composite indexes for common queries __table_args__ = ( Index("idx_order_vendor_status", "vendor_id", "status"), Index("idx_order_vendor_channel", "vendor_id", "channel"), Index("idx_order_vendor_date", "vendor_id", "order_date"), ) def __repr__(self): return f"" @property def customer_full_name(self) -> str: """Customer full name from snapshot.""" return f"{self.customer_first_name} {self.customer_last_name}".strip() @property def ship_full_name(self) -> str: """Shipping address full name.""" return f"{self.ship_first_name} {self.ship_last_name}".strip() @property def bill_full_name(self) -> str: """Billing address full name.""" return f"{self.bill_first_name} {self.bill_last_name}".strip() @property def is_marketplace_order(self) -> bool: """Check if this is a marketplace order.""" return self.channel != "direct" class OrderItem(Base, TimestampMixin): """ Individual items in an order. Stores product snapshot at time of order plus external references for marketplace items. """ __tablename__ = "order_items" id = Column(Integer, primary_key=True, index=True) order_id = Column(Integer, ForeignKey("orders.id"), nullable=False, index=True) product_id = Column(Integer, ForeignKey("products.id"), nullable=False) # === Product Snapshot (preserved at order time) === product_name = Column(String(255), nullable=False) product_sku = Column(String(100), nullable=True) gtin = Column(String(50), nullable=True) # EAN/UPC/ISBN etc. gtin_type = Column(String(20), nullable=True) # ean13, upc, isbn, etc. # === Pricing === quantity = Column(Integer, nullable=False) unit_price = Column(Float, nullable=False) total_price = Column(Float, nullable=False) # === External References (for marketplace items) === external_item_id = Column(String(100), nullable=True) # e.g., Letzshop inventory unit ID external_variant_id = Column(String(100), nullable=True) # e.g., Letzshop variant ID # === Item State (for marketplace confirmation flow) === # confirmed_available: item confirmed and available # confirmed_unavailable: item confirmed but not available (declined) item_state = Column(String(50), nullable=True) # === Inventory Tracking === inventory_reserved = Column(Boolean, default=False) inventory_fulfilled = Column(Boolean, default=False) # === Exception Tracking === # True if product was not found by GTIN during import (linked to placeholder) needs_product_match = Column(Boolean, default=False, index=True) # === Relationships === order = relationship("Order", back_populates="items") product = relationship("Product") exception = relationship( "OrderItemException", back_populates="order_item", uselist=False, cascade="all, delete-orphan", ) def __repr__(self): return f"" @property def is_confirmed(self) -> bool: """Check if item has been confirmed (available or unavailable).""" return self.item_state in ("confirmed_available", "confirmed_unavailable") @property def is_available(self) -> bool: """Check if item is confirmed as available.""" return self.item_state == "confirmed_available" @property def is_declined(self) -> bool: """Check if item was declined (unavailable).""" return self.item_state == "confirmed_unavailable" @property def has_unresolved_exception(self) -> bool: """Check if item has an unresolved exception blocking confirmation.""" if not self.exception: return False return self.exception.blocks_confirmation