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>
This commit is contained in:
@@ -11,13 +11,15 @@ Design principles:
|
||||
- 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,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
@@ -32,6 +34,7 @@ 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
|
||||
|
||||
|
||||
@@ -41,6 +44,8 @@ class Order(Base, TimestampMixin):
|
||||
|
||||
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"
|
||||
@@ -76,12 +81,12 @@ class Order(Base, TimestampMixin):
|
||||
# refunded: order refunded
|
||||
status = Column(String(50), nullable=False, default="pending", index=True)
|
||||
|
||||
# === 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)
|
||||
# === 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) ===
|
||||
@@ -115,6 +120,9 @@ class Order(Base, TimestampMixin):
|
||||
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)
|
||||
@@ -146,6 +154,68 @@ class Order(Base, TimestampMixin):
|
||||
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."""
|
||||
@@ -173,6 +243,8 @@ class OrderItem(Base, TimestampMixin):
|
||||
|
||||
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"
|
||||
@@ -187,10 +259,10 @@ class OrderItem(Base, TimestampMixin):
|
||||
gtin = Column(String(50), nullable=True) # EAN/UPC/ISBN etc.
|
||||
gtin_type = Column(String(20), nullable=True) # ean13, upc, isbn, etc.
|
||||
|
||||
# === Pricing ===
|
||||
# === Pricing (stored as integer cents) ===
|
||||
quantity = Column(Integer, nullable=False)
|
||||
unit_price = Column(Float, nullable=False)
|
||||
total_price = Column(Float, 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
|
||||
@@ -222,6 +294,30 @@ class OrderItem(Base, TimestampMixin):
|
||||
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)."""
|
||||
|
||||
Reference in New Issue
Block a user