Adds SoftDeleteMixin (deleted_at + deleted_by_id) with automatic query
filtering via do_orm_execute event. Soft-deleted records are invisible
by default; bypass with execution_options={"include_deleted": True}.
Models: User, Merchant, Store, StoreUser, Customer, Order, Product,
LoyaltyProgram, LoyaltyCard.
Infrastructure:
- SoftDeleteMixin in models/database/base.py
- Auto query filter registered on SessionLocal and test sessions
- soft_delete(), restore(), soft_delete_cascade() in app/core/soft_delete.py
- Alembic migration adding columns to 9 tables
- Partial unique indexes on users.email/username, stores.store_code/subdomain
Service changes:
- admin_service: delete_user, delete_store → soft_delete/soft_delete_cascade
- merchant_service: delete_merchant → soft_delete_cascade (stores→children)
- store_team_service: remove_team_member → soft_delete (fixes is_active bug)
- product_service: delete_product → soft_delete
- program_service: delete_program → soft_delete_cascade
Admin API:
- include_deleted/only_deleted query params on admin list endpoints
- PUT restore endpoints for users, merchants, stores
Tests: 9 unit tests for soft-delete infrastructure.
Docs: docs/backend/soft-delete.md + follow-up proposals.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
408 lines
15 KiB
Python
408 lines
15 KiB
Python
# app/modules/orders/models/order.py
|
|
"""
|
|
Unified Order model for all sales channels.
|
|
|
|
Supports:
|
|
- Direct orders (from store'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 typing import TYPE_CHECKING
|
|
|
|
from sqlalchemy import (
|
|
Boolean,
|
|
Column,
|
|
DateTime,
|
|
ForeignKey,
|
|
Index,
|
|
Integer,
|
|
Numeric,
|
|
String,
|
|
Text,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
pass
|
|
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 SoftDeleteMixin, TimestampMixin
|
|
|
|
|
|
class Order(Base, TimestampMixin, SoftDeleteMixin):
|
|
"""
|
|
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)
|
|
store_id = Column(Integer, ForeignKey("stores.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 ===
|
|
store = relationship("Store")
|
|
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_store_status", "store_id", "status"),
|
|
Index("idx_order_store_channel", "store_id", "channel"),
|
|
Index("idx_order_store_date", "store_id", "order_date"),
|
|
)
|
|
|
|
def __repr__(self):
|
|
return f"<Order(id={self.id}, order_number='{self.order_number}', channel='{self.channel}', status='{self.status}')>"
|
|
|
|
# === 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"<OrderItem(id={self.id}, order_id={self.order_id}, product_id={self.product_id}, gtin='{self.gtin}')>"
|
|
|
|
# === 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
|