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:
2025-12-20 20:33:48 +01:00
parent 7f0d32c18d
commit a19c84ea4e
56 changed files with 6155 additions and 447 deletions

View File

@@ -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)."""