diff --git a/models/database/__init__.py b/models/database/__init__.py index 6f7467fb..b290d6d7 100644 --- a/models/database/__init__.py +++ b/models/database/__init__.py @@ -21,7 +21,7 @@ from .customer import Customer, CustomerAddress from .inventory import Inventory from .letzshop import ( LetzshopFulfillmentQueue, - LetzshopOrder, + LetzshopHistoricalImportJob, LetzshopSyncLog, VendorLetzshopCredentials, ) @@ -91,7 +91,7 @@ __all__ = [ "OrderItem", # Letzshop Integration "VendorLetzshopCredentials", - "LetzshopOrder", "LetzshopFulfillmentQueue", "LetzshopSyncLog", + "LetzshopHistoricalImportJob", ] diff --git a/models/database/letzshop.py b/models/database/letzshop.py index 8222c5f4..03e33632 100644 --- a/models/database/letzshop.py +++ b/models/database/letzshop.py @@ -4,9 +4,12 @@ Database models for Letzshop marketplace integration. Provides models for: - VendorLetzshopCredentials: Per-vendor API key storage (encrypted) -- LetzshopOrder: External order tracking and mapping - LetzshopFulfillmentQueue: Outbound operation queue with retry - LetzshopSyncLog: Audit trail for sync operations +- LetzshopHistoricalImportJob: Progress tracking for historical imports + +Note: Orders are now stored in the unified `orders` table with channel='letzshop'. +The LetzshopOrder model has been removed in favor of the unified Order model. """ from sqlalchemy import ( @@ -61,99 +64,24 @@ class VendorLetzshopCredentials(Base, TimestampMixin): return f"" -class LetzshopOrder(Base, TimestampMixin): - """ - Letzshop order tracking and mapping. - - Stores imported orders from Letzshop with mapping to local Order model. - """ - - __tablename__ = "letzshop_orders" - - id = Column(Integer, primary_key=True, index=True) - vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True) - - # Letzshop identifiers - letzshop_order_id = Column(String(100), nullable=False, index=True) - letzshop_shipment_id = Column(String(100), nullable=True, index=True) - letzshop_order_number = Column(String(100), nullable=True) - - # Local order mapping (if imported to local system) - local_order_id = Column(Integer, ForeignKey("orders.id"), nullable=True) - - # Order state from Letzshop - letzshop_state = Column(String(50), nullable=True) # unconfirmed, confirmed, etc. - - # Customer info from Letzshop - customer_email = Column(String(255), nullable=True) - customer_name = Column(String(255), nullable=True) - - # Order totals from Letzshop - total_amount = Column( - String(50), nullable=True - ) # Store as string to preserve format - currency = Column(String(10), default="EUR") - - # Customer preferences (for invoicing) - customer_locale = Column(String(10), nullable=True) # en, fr, de - - # Shipping/billing country - shipping_country_iso = Column(String(5), nullable=True) # LU, DE, FR, etc. - billing_country_iso = Column(String(5), nullable=True) - - # Raw data storage (for debugging/auditing) - raw_order_data = Column(JSON, nullable=True) - - # Inventory units (from Letzshop) - inventory_units = Column(JSON, nullable=True) # List of inventory unit IDs - - # Sync status - sync_status = Column( - String(50), default="pending" - ) # pending, imported, confirmed, rejected, shipped - last_synced_at = Column(DateTime(timezone=True), nullable=True) - sync_error = Column(Text, nullable=True) - - # Fulfillment status - confirmed_at = Column(DateTime(timezone=True), nullable=True) - rejected_at = Column(DateTime(timezone=True), nullable=True) - tracking_set_at = Column(DateTime(timezone=True), nullable=True) - tracking_number = Column(String(100), nullable=True) - tracking_carrier = Column(String(100), nullable=True) - - # Relationships - vendor = relationship("Vendor") - local_order = relationship("Order") - - __table_args__ = ( - Index("idx_letzshop_order_vendor", "vendor_id", "letzshop_order_id"), - Index("idx_letzshop_order_state", "vendor_id", "letzshop_state"), - Index("idx_letzshop_order_sync", "vendor_id", "sync_status"), - ) - - def __repr__(self): - return f"" - - class LetzshopFulfillmentQueue(Base, TimestampMixin): """ Queue for outbound fulfillment operations to Letzshop. Supports retry logic for failed operations. + References the unified orders table. """ __tablename__ = "letzshop_fulfillment_queue" id = Column(Integer, primary_key=True, index=True) vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True) - letzshop_order_id = Column( - Integer, ForeignKey("letzshop_orders.id"), nullable=False - ) + order_id = Column(Integer, ForeignKey("orders.id"), nullable=False, index=True) # Operation type operation = Column( String(50), nullable=False - ) # confirm, reject, set_tracking, return + ) # confirm_item, decline_item, set_tracking # Operation payload payload = Column(JSON, nullable=False) @@ -174,15 +102,16 @@ class LetzshopFulfillmentQueue(Base, TimestampMixin): # Relationships vendor = relationship("Vendor") - letzshop_order = relationship("LetzshopOrder") + order = relationship("Order") __table_args__ = ( Index("idx_fulfillment_queue_status", "status", "vendor_id"), Index("idx_fulfillment_queue_retry", "status", "next_retry_at"), + Index("idx_fulfillment_queue_order", "order_id"), ) def __repr__(self): - return f"" + return f"" class LetzshopSyncLog(Base, TimestampMixin): @@ -228,3 +157,60 @@ class LetzshopSyncLog(Base, TimestampMixin): def __repr__(self): return f"" + + +class LetzshopHistoricalImportJob(Base, TimestampMixin): + """ + Track progress of historical order imports from Letzshop. + + Enables real-time progress tracking via polling for long-running imports. + """ + + __tablename__ = "letzshop_historical_import_jobs" + + id = Column(Integer, primary_key=True, index=True) + vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + + # Status: pending | fetching | processing | completed | failed + status = Column(String(50), default="pending", nullable=False) + + # Current phase: "confirmed" | "declined" + current_phase = Column(String(20), nullable=True) + + # Fetch progress + current_page = Column(Integer, default=0) + total_pages = Column(Integer, nullable=True) # null = unknown yet + shipments_fetched = Column(Integer, default=0) + + # Processing progress + orders_processed = Column(Integer, default=0) + orders_imported = Column(Integer, default=0) + orders_updated = Column(Integer, default=0) + orders_skipped = Column(Integer, default=0) + + # EAN matching stats + products_matched = Column(Integer, default=0) + products_not_found = Column(Integer, default=0) + + # Phase-specific stats (stored as JSON for combining confirmed + declined) + confirmed_stats = Column(JSON, nullable=True) + declined_stats = Column(JSON, nullable=True) + + # Error handling + error_message = Column(Text, nullable=True) + + # Timing + started_at = Column(DateTime(timezone=True), nullable=True) + completed_at = Column(DateTime(timezone=True), nullable=True) + + # Relationships + vendor = relationship("Vendor") + user = relationship("User") + + __table_args__ = ( + Index("idx_historical_import_vendor", "vendor_id", "status"), + ) + + def __repr__(self): + return f"" diff --git a/models/database/order.py b/models/database/order.py index 33ec3fa7..efe4dfa6 100644 --- a/models/database/order.py +++ b/models/database/order.py @@ -1,4 +1,17 @@ # 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, @@ -6,6 +19,7 @@ from sqlalchemy import ( DateTime, Float, ForeignKey, + Index, Integer, String, Text, @@ -18,7 +32,12 @@ from models.database.base import TimestampMixin class Order(Base, TimestampMixin): - """Customer orders.""" + """ + Unified order model for all sales channels. + + Stores orders from direct sales and marketplaces (Letzshop, etc.) + with snapshotted customer and address data. + """ __tablename__ = "orders" @@ -27,69 +46,130 @@ class Order(Base, TimestampMixin): customer_id = Column( Integer, ForeignKey("customers.id"), nullable=False, index=True ) + order_number = Column(String(100), nullable=False, unique=True, index=True) - order_number = Column(String, nullable=False, unique=True, index=True) - - # Order channel/source + # === Channel/Source === channel = Column( - String(50), default="direct", index=True - ) # direct, letzshop, amazon, etc. + 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 - ) # External order reference - external_channel_data = Column(JSON, nullable=True) # Channel-specific metadata + ) # 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 - # Order status - status = Column(String, nullable=False, default="pending", index=True) - # pending, processing, shipped, delivered, cancelled, refunded + # === 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) - # Financial - subtotal = Column(Float, nullable=False) - tax_amount = Column(Float, default=0.0) - shipping_amount = Column(Float, default=0.0) - discount_amount = Column(Float, default=0.0) + # === 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, default="EUR") + currency = Column(String(10), default="EUR") - # Addresses (stored as IDs) - shipping_address_id = Column( - Integer, ForeignKey("customer_addresses.id"), nullable=False - ) - billing_address_id = Column( - Integer, ForeignKey("customer_addresses.id"), nullable=False - ) + # === 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 - shipping_method = Column(String, nullable=True) - tracking_number = Column(String, nullable=True) + # === 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) - # Notes + # === 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 - paid_at = Column(DateTime, nullable=True) - shipped_at = Column(DateTime, nullable=True) - delivered_at = Column(DateTime, nullable=True) - cancelled_at = Column(DateTime, 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 + # === Relationships === vendor = relationship("Vendor") customer = relationship("Customer", back_populates="orders") items = relationship( "OrderItem", back_populates="order", cascade="all, delete-orphan" ) - shipping_address = relationship( - "CustomerAddress", foreign_keys=[shipping_address_id] + + # 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"), ) - billing_address = relationship("CustomerAddress", foreign_keys=[billing_address_id]) def __repr__(self): - return f"" + 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.""" + """ + Individual items in an order. + + Stores product snapshot at time of order plus external references + for marketplace items. + """ __tablename__ = "order_items" @@ -97,21 +177,48 @@ class OrderItem(Base, TimestampMixin): order_id = Column(Integer, ForeignKey("orders.id"), nullable=False, index=True) product_id = Column(Integer, ForeignKey("products.id"), nullable=False) - # Product details at time of order (snapshot) - product_name = Column(String, nullable=False) - product_sku = Column(String, nullable=True) + # === 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) - # Inventory tracking + # === 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) - # Relationships + # === Relationships === order = relationship("Order", back_populates="items") product = relationship("Product") def __repr__(self): - return f"" + 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"