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

@@ -7,12 +7,14 @@ to override any field. The override pattern works as follows:
This allows vendors to customize pricing, images, descriptions etc. while
still being able to "reset to source" by setting values back to NULL.
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,
Float,
ForeignKey,
Index,
Integer,
@@ -23,6 +25,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
@@ -32,6 +35,8 @@ class Product(Base, TimestampMixin):
Each vendor can have their own version of a marketplace product with
custom pricing, images, and other overrides. Fields set to NULL
inherit their value from the linked marketplace_product.
Price fields use integer cents for precision (€19.99 = 1999 cents).
"""
__tablename__ = "products"
@@ -52,9 +57,9 @@ class Product(Base, TimestampMixin):
gtin_type = Column(String(20)) # Format: gtin13, gtin14, gtin12, gtin8, isbn13, isbn10
# === OVERRIDABLE FIELDS (NULL = inherit from marketplace_product) ===
# Pricing
price = Column(Float)
sale_price = Column(Float)
# Pricing - stored as integer cents (€19.99 = 1999)
price_cents = Column(Integer) # Price in cents
sale_price_cents = Column(Integer) # Sale price in cents
currency = Column(String(3))
# Product Info
@@ -73,8 +78,8 @@ class Product(Base, TimestampMixin):
# === SUPPLIER TRACKING ===
supplier = Column(String(50)) # 'codeswholesale', 'internal', etc.
supplier_product_id = Column(String) # Supplier's product reference
supplier_cost = Column(Float) # What we pay the supplier
margin_percent = Column(Float) # Markup percentage
supplier_cost_cents = Column(Integer) # What we pay the supplier (in cents)
margin_percent_x100 = Column(Integer) # Markup percentage * 100 (e.g., 25.5% = 2550)
# === VENDOR-SPECIFIC (No inheritance) ===
is_featured = Column(Boolean, default=False)
@@ -115,8 +120,8 @@ class Product(Base, TimestampMixin):
# === OVERRIDABLE FIELDS LIST ===
OVERRIDABLE_FIELDS = [
"price",
"sale_price",
"price_cents",
"sale_price_cents",
"currency",
"brand",
"condition",
@@ -133,23 +138,85 @@ class Product(Base, TimestampMixin):
f"vendor_sku='{self.vendor_sku}')>"
)
# === PRICE PROPERTIES (Euro convenience accessors) ===
@property
def price(self) -> float | None:
"""Get price in euros (for API/display)."""
if self.price_cents is not None:
return cents_to_euros(self.price_cents)
return None
@price.setter
def price(self, value: float | None):
"""Set price from euros."""
self.price_cents = euros_to_cents(value) if value is not None else None
@property
def sale_price(self) -> float | None:
"""Get sale price in euros (for API/display)."""
if self.sale_price_cents is not None:
return cents_to_euros(self.sale_price_cents)
return None
@sale_price.setter
def sale_price(self, value: float | None):
"""Set sale price from euros."""
self.sale_price_cents = euros_to_cents(value) if value is not None else None
@property
def supplier_cost(self) -> float | None:
"""Get supplier cost in euros."""
if self.supplier_cost_cents is not None:
return cents_to_euros(self.supplier_cost_cents)
return None
@supplier_cost.setter
def supplier_cost(self, value: float | None):
"""Set supplier cost from euros."""
self.supplier_cost_cents = euros_to_cents(value) if value is not None else None
@property
def margin_percent(self) -> float | None:
"""Get margin percent (e.g., 25.5)."""
if self.margin_percent_x100 is not None:
return self.margin_percent_x100 / 100.0
return None
@margin_percent.setter
def margin_percent(self, value: float | None):
"""Set margin percent."""
self.margin_percent_x100 = int(value * 100) if value is not None else None
# === EFFECTIVE PROPERTIES (Override Pattern) ===
@property
def effective_price(self) -> float | None:
"""Get price (vendor override or marketplace fallback)."""
if self.price is not None:
return self.price
def effective_price_cents(self) -> int | None:
"""Get price in cents (vendor override or marketplace fallback)."""
if self.price_cents is not None:
return self.price_cents
mp = self.marketplace_product
return mp.price_numeric if mp else None
return mp.price_cents if mp else None
@property
def effective_price(self) -> float | None:
"""Get price in euros (vendor override or marketplace fallback)."""
cents = self.effective_price_cents
return cents_to_euros(cents) if cents is not None else None
@property
def effective_sale_price_cents(self) -> int | None:
"""Get sale price in cents (vendor override or marketplace fallback)."""
if self.sale_price_cents is not None:
return self.sale_price_cents
mp = self.marketplace_product
return mp.sale_price_cents if mp else None
@property
def effective_sale_price(self) -> float | None:
"""Get sale price (vendor override or marketplace fallback)."""
if self.sale_price is not None:
return self.sale_price
mp = self.marketplace_product
return mp.sale_price_numeric if mp else None
"""Get sale price in euros (vendor override or marketplace fallback)."""
cents = self.effective_sale_price_cents
return cents_to_euros(cents) if cents is not None else None
@property
def effective_currency(self) -> str:
@@ -260,12 +327,14 @@ class Product(Base, TimestampMixin):
return {
# Price
"price": self.effective_price,
"price_overridden": self.price is not None,
"price_source": mp.price_numeric if mp else None,
"price_cents": self.effective_price_cents,
"price_overridden": self.price_cents is not None,
"price_source": cents_to_euros(mp.price_cents) if mp and mp.price_cents else None,
# Sale Price
"sale_price": self.effective_sale_price,
"sale_price_overridden": self.sale_price is not None,
"sale_price_source": mp.sale_price_numeric if mp else None,
"sale_price_cents": self.effective_sale_price_cents,
"sale_price_overridden": self.sale_price_cents is not None,
"sale_price_source": cents_to_euros(mp.sale_price_cents) if mp and mp.sale_price_cents else None,
# Currency
"currency": self.effective_currency,
"currency_overridden": self.currency is not None,