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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user