Files
orion/models/database/order.py
Samir Boulahtit a19c84ea4e feat: integer cents money handling, order page fixes, and vendor filter persistence
Money Handling Architecture:
- Store all monetary values as integer cents (€105.91 = 10591)
- Add app/utils/money.py with Money class and conversion helpers
- Add static/shared/js/money.js for frontend formatting
- Update all database models to use _cents columns (Product, Order, etc.)
- Update CSV processor to convert prices to cents on import
- Add Alembic migration for Float to Integer conversion
- Create .architecture-rules/money.yaml with 7 validation rules
- Add docs/architecture/money-handling.md documentation

Order Details Page Fixes:
- Fix customer name showing 'undefined undefined' - use flat field names
- Fix vendor info empty - add vendor_name/vendor_code to OrderDetailResponse
- Fix shipping address using wrong nested object structure
- Enrich order detail API response with vendor info

Vendor Filter Persistence Fixes:
- Fix orders.js: restoreSavedVendor now sets selectedVendor and filters
- Fix orders.js: init() only loads orders if no saved vendor to restore
- Fix marketplace-letzshop.js: restoreSavedVendor calls selectVendor()
- Fix marketplace-letzshop.js: clearVendorSelection clears TomSelect dropdown
- Align vendor selector placeholder text between pages

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 20:33:48 +01:00

342 lines
12 KiB
Python

# 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,
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")
# === 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"
)
# 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"<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"
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)
# === 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