# 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 Money values are stored as integer cents (e.g., €105.91 = 10591). See docs/architecture/money-handling.md for details. """ from sqlalchemy import ( Boolean, Column, DateTime, ForeignKey, Index, Integer, Numeric, 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 app.utils.money import cents_to_euros, euros_to_cents 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. All monetary amounts are stored as integer cents for precision. """ __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 (stored as integer cents) === subtotal_cents = Column(Integer, nullable=True) # May not be available from marketplace tax_amount_cents = Column(Integer, nullable=True) shipping_amount_cents = Column(Integer, nullable=True) discount_amount_cents = Column(Integer, nullable=True) total_amount_cents = Column(Integer, nullable=False) currency = Column(String(10), default="EUR") # === VAT Information === # VAT regime: domestic, oss, reverse_charge, origin, exempt vat_regime = Column(String(20), nullable=True) # VAT rate as percentage (e.g., 17.00 for 17%) vat_rate = Column(Numeric(5, 2), nullable=True) # Human-readable VAT label (e.g., "Luxembourg VAT 17%") vat_rate_label = Column(String(100), nullable=True) # Destination country for cross-border sales (ISO code) vat_destination_country = Column(String(2), nullable=True) # === 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) tracking_url = Column(String(500), nullable=True) # Full tracking URL shipment_number = Column(String(100), nullable=True) # Carrier shipment number (e.g., H74683403433) shipping_carrier = Column(String(50), nullable=True) # Carrier code (greco, colissimo, etc.) # === 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" ) invoices = relationship( "Invoice", 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"" # === PRICE PROPERTIES (Euro convenience accessors) === @property def subtotal(self) -> float | None: """Get subtotal in euros.""" if self.subtotal_cents is not None: return cents_to_euros(self.subtotal_cents) return None @subtotal.setter def subtotal(self, value: float | None): """Set subtotal from euros.""" self.subtotal_cents = euros_to_cents(value) if value is not None else None @property def tax_amount(self) -> float | None: """Get tax amount in euros.""" if self.tax_amount_cents is not None: return cents_to_euros(self.tax_amount_cents) return None @tax_amount.setter def tax_amount(self, value: float | None): """Set tax amount from euros.""" self.tax_amount_cents = euros_to_cents(value) if value is not None else None @property def shipping_amount(self) -> float | None: """Get shipping amount in euros.""" if self.shipping_amount_cents is not None: return cents_to_euros(self.shipping_amount_cents) return None @shipping_amount.setter def shipping_amount(self, value: float | None): """Set shipping amount from euros.""" self.shipping_amount_cents = euros_to_cents(value) if value is not None else None @property def discount_amount(self) -> float | None: """Get discount amount in euros.""" if self.discount_amount_cents is not None: return cents_to_euros(self.discount_amount_cents) return None @discount_amount.setter def discount_amount(self, value: float | None): """Set discount amount from euros.""" self.discount_amount_cents = euros_to_cents(value) if value is not None else None @property def total_amount(self) -> float: """Get total amount in euros.""" return cents_to_euros(self.total_amount_cents) @total_amount.setter def total_amount(self, value: float): """Set total amount from euros.""" self.total_amount_cents = euros_to_cents(value) # === NAME PROPERTIES === @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" @property def is_fully_shipped(self) -> bool: """Check if all items are fully shipped.""" if not self.items: return False return all(item.is_fully_shipped for item in self.items) @property def is_partially_shipped(self) -> bool: """Check if some items are shipped but not all.""" if not self.items: return False has_shipped = any(item.shipped_quantity > 0 for item in self.items) all_shipped = all(item.is_fully_shipped for item in self.items) return has_shipped and not all_shipped @property def shipped_item_count(self) -> int: """Count of fully shipped items.""" return sum(1 for item in self.items if item.is_fully_shipped) @property def total_shipped_units(self) -> int: """Total quantity shipped across all items.""" return sum(item.shipped_quantity for item in self.items) @property def total_ordered_units(self) -> int: """Total quantity ordered across all items.""" return sum(item.quantity for item in self.items) class OrderItem(Base, TimestampMixin): """ Individual items in an order. Stores product snapshot at time of order plus external references for marketplace items. All monetary amounts are stored as integer cents for precision. """ __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 (stored as integer cents) === quantity = Column(Integer, nullable=False) unit_price_cents = Column(Integer, nullable=False) total_price_cents = Column(Integer, 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) # === Shipment Tracking === shipped_quantity = Column(Integer, default=0, nullable=False) # Units shipped so far # === 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"" # === PRICE PROPERTIES (Euro convenience accessors) === @property def unit_price(self) -> float: """Get unit price in euros.""" return cents_to_euros(self.unit_price_cents) @unit_price.setter def unit_price(self, value: float): """Set unit price from euros.""" self.unit_price_cents = euros_to_cents(value) @property def total_price(self) -> float: """Get total price in euros.""" return cents_to_euros(self.total_price_cents) @total_price.setter def total_price(self, value: float): """Set total price from euros.""" self.total_price_cents = euros_to_cents(value) # === STATUS PROPERTIES === @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 # === SHIPMENT PROPERTIES === @property def remaining_quantity(self) -> int: """Quantity not yet shipped.""" return max(0, self.quantity - self.shipped_quantity) @property def is_fully_shipped(self) -> bool: """Check if all units have been shipped.""" return self.shipped_quantity >= self.quantity @property def is_partially_shipped(self) -> bool: """Check if some but not all units have been shipped.""" return 0 < self.shipped_quantity < self.quantity