feat: implement unified order schema with customer/address snapshots

- Update Order model with customer/address snapshot fields
- Add external marketplace references (external_order_id, external_shipment_id)
- Add tracking_provider field for shipping carriers
- Add order_date, confirmed_at timestamps
- Update OrderItem with gtin/gtin_type, external_item_id, item_state
- Remove LetzshopOrder model (orders now go to unified table)
- Update LetzshopFulfillmentQueue to reference orders.id

Design decision: Single orders table for all channels with snapshotted
data preserved at order time for historical accuracy.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-19 21:17:38 +01:00
parent 575b00760b
commit 6f3a07c7d7
3 changed files with 221 additions and 128 deletions

View File

@@ -21,7 +21,7 @@ from .customer import Customer, CustomerAddress
from .inventory import Inventory from .inventory import Inventory
from .letzshop import ( from .letzshop import (
LetzshopFulfillmentQueue, LetzshopFulfillmentQueue,
LetzshopOrder, LetzshopHistoricalImportJob,
LetzshopSyncLog, LetzshopSyncLog,
VendorLetzshopCredentials, VendorLetzshopCredentials,
) )
@@ -91,7 +91,7 @@ __all__ = [
"OrderItem", "OrderItem",
# Letzshop Integration # Letzshop Integration
"VendorLetzshopCredentials", "VendorLetzshopCredentials",
"LetzshopOrder",
"LetzshopFulfillmentQueue", "LetzshopFulfillmentQueue",
"LetzshopSyncLog", "LetzshopSyncLog",
"LetzshopHistoricalImportJob",
] ]

View File

@@ -4,9 +4,12 @@ Database models for Letzshop marketplace integration.
Provides models for: Provides models for:
- VendorLetzshopCredentials: Per-vendor API key storage (encrypted) - VendorLetzshopCredentials: Per-vendor API key storage (encrypted)
- LetzshopOrder: External order tracking and mapping
- LetzshopFulfillmentQueue: Outbound operation queue with retry - LetzshopFulfillmentQueue: Outbound operation queue with retry
- LetzshopSyncLog: Audit trail for sync operations - 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 ( from sqlalchemy import (
@@ -61,99 +64,24 @@ class VendorLetzshopCredentials(Base, TimestampMixin):
return f"<VendorLetzshopCredentials(vendor_id={self.vendor_id}, auto_sync={self.auto_sync_enabled})>" return f"<VendorLetzshopCredentials(vendor_id={self.vendor_id}, auto_sync={self.auto_sync_enabled})>"
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"<LetzshopOrder(id={self.id}, letzshop_id='{self.letzshop_order_id}', state='{self.letzshop_state}')>"
class LetzshopFulfillmentQueue(Base, TimestampMixin): class LetzshopFulfillmentQueue(Base, TimestampMixin):
""" """
Queue for outbound fulfillment operations to Letzshop. Queue for outbound fulfillment operations to Letzshop.
Supports retry logic for failed operations. Supports retry logic for failed operations.
References the unified orders table.
""" """
__tablename__ = "letzshop_fulfillment_queue" __tablename__ = "letzshop_fulfillment_queue"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True) vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
letzshop_order_id = Column( order_id = Column(Integer, ForeignKey("orders.id"), nullable=False, index=True)
Integer, ForeignKey("letzshop_orders.id"), nullable=False
)
# Operation type # Operation type
operation = Column( operation = Column(
String(50), nullable=False String(50), nullable=False
) # confirm, reject, set_tracking, return ) # confirm_item, decline_item, set_tracking
# Operation payload # Operation payload
payload = Column(JSON, nullable=False) payload = Column(JSON, nullable=False)
@@ -174,15 +102,16 @@ class LetzshopFulfillmentQueue(Base, TimestampMixin):
# Relationships # Relationships
vendor = relationship("Vendor") vendor = relationship("Vendor")
letzshop_order = relationship("LetzshopOrder") order = relationship("Order")
__table_args__ = ( __table_args__ = (
Index("idx_fulfillment_queue_status", "status", "vendor_id"), Index("idx_fulfillment_queue_status", "status", "vendor_id"),
Index("idx_fulfillment_queue_retry", "status", "next_retry_at"), Index("idx_fulfillment_queue_retry", "status", "next_retry_at"),
Index("idx_fulfillment_queue_order", "order_id"),
) )
def __repr__(self): def __repr__(self):
return f"<LetzshopFulfillmentQueue(id={self.id}, operation='{self.operation}', status='{self.status}')>" return f"<LetzshopFulfillmentQueue(id={self.id}, order_id={self.order_id}, operation='{self.operation}', status='{self.status}')>"
class LetzshopSyncLog(Base, TimestampMixin): class LetzshopSyncLog(Base, TimestampMixin):
@@ -228,3 +157,60 @@ class LetzshopSyncLog(Base, TimestampMixin):
def __repr__(self): def __repr__(self):
return f"<LetzshopSyncLog(id={self.id}, type='{self.operation_type}', status='{self.status}')>" return f"<LetzshopSyncLog(id={self.id}, type='{self.operation_type}', status='{self.status}')>"
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"<LetzshopHistoricalImportJob(id={self.id}, status='{self.status}', phase='{self.current_phase}')>"

View File

@@ -1,4 +1,17 @@
# models/database/order.py # 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 ( from sqlalchemy import (
Boolean, Boolean,
@@ -6,6 +19,7 @@ from sqlalchemy import (
DateTime, DateTime,
Float, Float,
ForeignKey, ForeignKey,
Index,
Integer, Integer,
String, String,
Text, Text,
@@ -18,7 +32,12 @@ from models.database.base import TimestampMixin
class Order(Base, 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" __tablename__ = "orders"
@@ -27,69 +46,130 @@ class Order(Base, TimestampMixin):
customer_id = Column( customer_id = Column(
Integer, ForeignKey("customers.id"), nullable=False, index=True 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) # === Channel/Source ===
# Order channel/source
channel = Column( channel = Column(
String(50), default="direct", index=True String(50), default="direct", nullable=False, index=True
) # direct, letzshop, amazon, etc. ) # direct, letzshop
# External references (for marketplace orders)
external_order_id = Column( external_order_id = Column(
String(100), nullable=True, index=True String(100), nullable=True, index=True
) # External order reference ) # Marketplace order ID
external_channel_data = Column(JSON, nullable=True) # Channel-specific metadata 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 ===
status = Column(String, nullable=False, default="pending", index=True) # pending: awaiting confirmation
# pending, processing, shipped, delivered, cancelled, refunded # 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 # === Financials ===
subtotal = Column(Float, nullable=False) subtotal = Column(Float, nullable=True) # May not be available from marketplace
tax_amount = Column(Float, default=0.0) tax_amount = Column(Float, nullable=True)
shipping_amount = Column(Float, default=0.0) shipping_amount = Column(Float, nullable=True)
discount_amount = Column(Float, default=0.0) discount_amount = Column(Float, nullable=True)
total_amount = Column(Float, nullable=False) total_amount = Column(Float, nullable=False)
currency = Column(String, default="EUR") currency = Column(String(10), default="EUR")
# Addresses (stored as IDs) # === Customer Snapshot (preserved at order time) ===
shipping_address_id = Column( customer_first_name = Column(String(100), nullable=False)
Integer, ForeignKey("customer_addresses.id"), nullable=False customer_last_name = Column(String(100), nullable=False)
) customer_email = Column(String(255), nullable=False)
billing_address_id = Column( customer_phone = Column(String(50), nullable=True)
Integer, ForeignKey("customer_addresses.id"), nullable=False customer_locale = Column(String(10), nullable=True) # en, fr, de, lb
)
# Shipping # === Shipping Address Snapshot ===
shipping_method = Column(String, nullable=True) ship_first_name = Column(String(100), nullable=False)
tracking_number = Column(String, nullable=True) 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) customer_notes = Column(Text, nullable=True)
internal_notes = Column(Text, nullable=True) internal_notes = Column(Text, nullable=True)
# Timestamps # === Timestamps ===
paid_at = Column(DateTime, nullable=True) order_date = Column(
shipped_at = Column(DateTime, nullable=True) DateTime(timezone=True), nullable=False
delivered_at = Column(DateTime, nullable=True) ) # When customer placed order
cancelled_at = Column(DateTime, nullable=True) 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") vendor = relationship("Vendor")
customer = relationship("Customer", back_populates="orders") customer = relationship("Customer", back_populates="orders")
items = relationship( items = relationship(
"OrderItem", back_populates="order", cascade="all, delete-orphan" "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): def __repr__(self):
return f"<Order(id={self.id}, order_number='{self.order_number}', status='{self.status}')>" return f"<Order(id={self.id}, order_number='{self.order_number}', channel='{self.channel}', status='{self.status}')>"
@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): 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" __tablename__ = "order_items"
@@ -97,21 +177,48 @@ class OrderItem(Base, TimestampMixin):
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False, index=True) order_id = Column(Integer, ForeignKey("orders.id"), nullable=False, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False) product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
# Product details at time of order (snapshot) # === Product Snapshot (preserved at order time) ===
product_name = Column(String, nullable=False) product_name = Column(String(255), nullable=False)
product_sku = Column(String, nullable=True) 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) quantity = Column(Integer, nullable=False)
unit_price = Column(Float, nullable=False) unit_price = Column(Float, nullable=False)
total_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_reserved = Column(Boolean, default=False)
inventory_fulfilled = Column(Boolean, default=False) inventory_fulfilled = Column(Boolean, default=False)
# Relationships # === Relationships ===
order = relationship("Order", back_populates="items") order = relationship("Order", back_populates="items")
product = relationship("Product") product = relationship("Product")
def __repr__(self): def __repr__(self):
return f"<OrderItem(id={self.id}, order_id={self.order_id}, product_id={self.product_id})>" return f"<OrderItem(id={self.id}, order_id={self.order_id}, product_id={self.product_id}, gtin='{self.gtin}')>"
@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"