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:
@@ -1,9 +1,12 @@
|
||||
# models/database/cart.py
|
||||
"""Cart item database model."""
|
||||
"""Cart item database model.
|
||||
|
||||
Money values are stored as integer cents (e.g., €105.91 = 10591).
|
||||
See docs/architecture/money-handling.md for details.
|
||||
"""
|
||||
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
@@ -13,6 +16,7 @@ from sqlalchemy import (
|
||||
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
|
||||
|
||||
|
||||
@@ -22,6 +26,8 @@ class CartItem(Base, TimestampMixin):
|
||||
|
||||
Stores cart items per session, vendor, and product.
|
||||
Sessions are identified by a session_id string (from browser cookies).
|
||||
|
||||
Price is stored as integer cents for precision.
|
||||
"""
|
||||
|
||||
__tablename__ = "cart_items"
|
||||
@@ -33,7 +39,7 @@ class CartItem(Base, TimestampMixin):
|
||||
|
||||
# Cart details
|
||||
quantity = Column(Integer, nullable=False, default=1)
|
||||
price_at_add = Column(Float, nullable=False) # Store price when added to cart
|
||||
price_at_add_cents = Column(Integer, nullable=False) # Price in cents when added
|
||||
|
||||
# Relationships
|
||||
vendor = relationship("Vendor")
|
||||
@@ -49,7 +55,24 @@ class CartItem(Base, TimestampMixin):
|
||||
def __repr__(self):
|
||||
return f"<CartItem(id={self.id}, session='{self.session_id}', product_id={self.product_id}, qty={self.quantity})>"
|
||||
|
||||
# === PRICE PROPERTIES (Euro convenience accessors) ===
|
||||
|
||||
@property
|
||||
def price_at_add(self) -> float:
|
||||
"""Get price at add in euros."""
|
||||
return cents_to_euros(self.price_at_add_cents)
|
||||
|
||||
@price_at_add.setter
|
||||
def price_at_add(self, value: float):
|
||||
"""Set price at add from euros."""
|
||||
self.price_at_add_cents = euros_to_cents(value)
|
||||
|
||||
@property
|
||||
def line_total_cents(self) -> int:
|
||||
"""Calculate line total in cents."""
|
||||
return self.price_at_add_cents * self.quantity
|
||||
|
||||
@property
|
||||
def line_total(self) -> float:
|
||||
"""Calculate line total."""
|
||||
return self.price_at_add * self.quantity
|
||||
"""Calculate line total in euros."""
|
||||
return cents_to_euros(self.line_total_cents)
|
||||
|
||||
@@ -52,6 +52,19 @@ class VendorLetzshopCredentials(Base, TimestampMixin):
|
||||
auto_sync_enabled = Column(Boolean, default=False)
|
||||
sync_interval_minutes = Column(Integer, default=15)
|
||||
|
||||
# Test mode (disables API mutations when enabled)
|
||||
test_mode_enabled = Column(Boolean, default=False)
|
||||
|
||||
# Default carrier settings
|
||||
default_carrier = Column(String(50), nullable=True) # greco, colissimo, xpresslogistics
|
||||
|
||||
# Carrier label URL prefixes
|
||||
carrier_greco_label_url = Column(
|
||||
String(500), default="https://dispatchweb.fr/Tracky/Home/"
|
||||
)
|
||||
carrier_colissimo_label_url = Column(String(500), nullable=True)
|
||||
carrier_xpresslogistics_label_url = Column(String(500), nullable=True)
|
||||
|
||||
# Last sync status
|
||||
last_sync_at = Column(DateTime(timezone=True), nullable=True)
|
||||
last_sync_status = Column(String(50), nullable=True) # success, failed, partial
|
||||
|
||||
@@ -6,6 +6,10 @@ Amazon, eBay, CodesWholesale, etc.) in a universal format. It supports:
|
||||
- Multi-language translations (via MarketplaceProductTranslation)
|
||||
- Flexible attributes for marketplace-specific data
|
||||
- Google Shopping fields for Letzshop compatibility
|
||||
|
||||
Money values are stored as integer cents (e.g., €105.91 = 10591).
|
||||
Weight is stored as integer grams (e.g., 1.5kg = 1500g).
|
||||
See docs/architecture/money-handling.md for details.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
@@ -13,7 +17,6 @@ from enum import Enum
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
Float,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
@@ -22,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
|
||||
|
||||
|
||||
@@ -49,6 +53,9 @@ class MarketplaceProduct(Base, TimestampMixin):
|
||||
|
||||
This table stores normalized product information from all marketplace sources.
|
||||
Localized content (title, description) is stored in MarketplaceProductTranslation.
|
||||
|
||||
Price fields use integer cents for precision (€19.99 = 1999 cents).
|
||||
Weight uses integer grams (1.5kg = 1500 grams).
|
||||
"""
|
||||
|
||||
__tablename__ = "marketplace_products"
|
||||
@@ -86,11 +93,11 @@ class MarketplaceProduct(Base, TimestampMixin):
|
||||
category_path = Column(String) # Normalized category hierarchy
|
||||
condition = Column(String)
|
||||
|
||||
# === PRICING ===
|
||||
# === PRICING (stored as integer cents) ===
|
||||
price = Column(String) # Raw price string "19.99 EUR" (kept for reference)
|
||||
price_numeric = Column(Float) # Parsed numeric price
|
||||
price_cents = Column(Integer) # Parsed numeric price in cents
|
||||
sale_price = Column(String) # Raw sale price string
|
||||
sale_price_numeric = Column(Float) # Parsed numeric sale price
|
||||
sale_price_cents = Column(Integer) # Parsed numeric sale price in cents
|
||||
currency = Column(String(3), default="EUR")
|
||||
|
||||
# === MEDIA ===
|
||||
@@ -102,8 +109,8 @@ class MarketplaceProduct(Base, TimestampMixin):
|
||||
attributes = Column(JSON) # {color, size, material, etc.}
|
||||
|
||||
# === PHYSICAL PRODUCT FIELDS ===
|
||||
weight = Column(Float) # In kg
|
||||
weight_unit = Column(String(10), default="kg")
|
||||
weight_grams = Column(Integer) # Weight in grams (1.5kg = 1500)
|
||||
weight_unit = Column(String(10), default="kg") # Display unit
|
||||
dimensions = Column(JSON) # {length, width, height, unit}
|
||||
|
||||
# === GOOGLE SHOPPING FIELDS (Preserved for Letzshop) ===
|
||||
@@ -159,6 +166,44 @@ class MarketplaceProduct(Base, TimestampMixin):
|
||||
f"vendor='{self.vendor_name}')>"
|
||||
)
|
||||
|
||||
# === PRICE PROPERTIES (Euro convenience accessors) ===
|
||||
|
||||
@property
|
||||
def price_numeric(self) -> float | None:
|
||||
"""Get price in euros (for API/display). Legacy name for compatibility."""
|
||||
if self.price_cents is not None:
|
||||
return cents_to_euros(self.price_cents)
|
||||
return None
|
||||
|
||||
@price_numeric.setter
|
||||
def price_numeric(self, value: float | None):
|
||||
"""Set price from euros. Legacy name for compatibility."""
|
||||
self.price_cents = euros_to_cents(value) if value is not None else None
|
||||
|
||||
@property
|
||||
def sale_price_numeric(self) -> float | None:
|
||||
"""Get sale price in euros (for API/display). Legacy name for compatibility."""
|
||||
if self.sale_price_cents is not None:
|
||||
return cents_to_euros(self.sale_price_cents)
|
||||
return None
|
||||
|
||||
@sale_price_numeric.setter
|
||||
def sale_price_numeric(self, value: float | None):
|
||||
"""Set sale price from euros. Legacy name for compatibility."""
|
||||
self.sale_price_cents = euros_to_cents(value) if value is not None else None
|
||||
|
||||
@property
|
||||
def weight(self) -> float | None:
|
||||
"""Get weight in kg (for API/display)."""
|
||||
if self.weight_grams is not None:
|
||||
return self.weight_grams / 1000.0
|
||||
return None
|
||||
|
||||
@weight.setter
|
||||
def weight(self, value: float | None):
|
||||
"""Set weight from kg."""
|
||||
self.weight_grams = int(value * 1000) if value is not None else None
|
||||
|
||||
# === HELPER PROPERTIES ===
|
||||
|
||||
@property
|
||||
@@ -228,12 +273,12 @@ class MarketplaceProduct(Base, TimestampMixin):
|
||||
|
||||
@property
|
||||
def effective_price(self) -> float | None:
|
||||
"""Get the effective numeric price."""
|
||||
"""Get the effective numeric price in euros."""
|
||||
return self.price_numeric
|
||||
|
||||
@property
|
||||
def effective_sale_price(self) -> float | None:
|
||||
"""Get the effective numeric sale price."""
|
||||
"""Get the effective numeric sale price in euros."""
|
||||
return self.sale_price_numeric
|
||||
|
||||
@property
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -31,6 +31,21 @@ class LetzshopCredentialsCreate(BaseModel):
|
||||
sync_interval_minutes: int = Field(
|
||||
15, ge=5, le=1440, description="Sync interval in minutes (5-1440)"
|
||||
)
|
||||
test_mode_enabled: bool = Field(
|
||||
False, description="Test mode - disables API mutations"
|
||||
)
|
||||
default_carrier: str | None = Field(
|
||||
None, description="Default carrier (greco, colissimo, xpresslogistics)"
|
||||
)
|
||||
carrier_greco_label_url: str | None = Field(
|
||||
"https://dispatchweb.fr/Tracky/Home/", description="Greco label URL prefix"
|
||||
)
|
||||
carrier_colissimo_label_url: str | None = Field(
|
||||
None, description="Colissimo label URL prefix"
|
||||
)
|
||||
carrier_xpresslogistics_label_url: str | None = Field(
|
||||
None, description="XpressLogistics label URL prefix"
|
||||
)
|
||||
|
||||
|
||||
class LetzshopCredentialsUpdate(BaseModel):
|
||||
@@ -40,6 +55,11 @@ class LetzshopCredentialsUpdate(BaseModel):
|
||||
api_endpoint: str | None = None
|
||||
auto_sync_enabled: bool | None = None
|
||||
sync_interval_minutes: int | None = Field(None, ge=5, le=1440)
|
||||
test_mode_enabled: bool | None = None
|
||||
default_carrier: str | None = None
|
||||
carrier_greco_label_url: str | None = None
|
||||
carrier_colissimo_label_url: str | None = None
|
||||
carrier_xpresslogistics_label_url: str | None = None
|
||||
|
||||
|
||||
class LetzshopCredentialsResponse(BaseModel):
|
||||
@@ -53,6 +73,11 @@ class LetzshopCredentialsResponse(BaseModel):
|
||||
api_endpoint: str
|
||||
auto_sync_enabled: bool
|
||||
sync_interval_minutes: int
|
||||
test_mode_enabled: bool = False
|
||||
default_carrier: str | None = None
|
||||
carrier_greco_label_url: str | None = None
|
||||
carrier_colissimo_label_url: str | None = None
|
||||
carrier_xpresslogistics_label_url: str | None = None
|
||||
last_sync_at: datetime | None
|
||||
last_sync_status: str | None
|
||||
last_sync_error: str | None
|
||||
@@ -101,6 +126,7 @@ class LetzshopOrderResponse(BaseModel):
|
||||
|
||||
id: int
|
||||
vendor_id: int
|
||||
vendor_name: str | None = None # For cross-vendor views
|
||||
order_number: str
|
||||
|
||||
# External references
|
||||
|
||||
@@ -270,6 +270,9 @@ class OrderResponse(BaseModel):
|
||||
shipping_method: str | None
|
||||
tracking_number: str | None
|
||||
tracking_provider: str | None
|
||||
tracking_url: str | None = None
|
||||
shipment_number: str | None = None
|
||||
shipping_carrier: str | None = None
|
||||
|
||||
# Notes
|
||||
customer_notes: str | None
|
||||
@@ -302,6 +305,10 @@ class OrderDetailResponse(OrderResponse):
|
||||
|
||||
items: list[OrderItemResponse] = []
|
||||
|
||||
# Vendor info (enriched by API)
|
||||
vendor_name: str | None = None
|
||||
vendor_code: str | None = None
|
||||
|
||||
|
||||
class OrderListResponse(BaseModel):
|
||||
"""Schema for paginated order list."""
|
||||
@@ -345,6 +352,9 @@ class OrderListItem(BaseModel):
|
||||
# Tracking
|
||||
tracking_number: str | None
|
||||
tracking_provider: str | None
|
||||
tracking_url: str | None = None
|
||||
shipment_number: str | None = None
|
||||
shipping_carrier: str | None = None
|
||||
|
||||
# Item count
|
||||
item_count: int = 0
|
||||
@@ -394,6 +404,9 @@ class AdminOrderItem(BaseModel):
|
||||
ship_country_iso: str
|
||||
tracking_number: str | None
|
||||
tracking_provider: str | None
|
||||
tracking_url: str | None = None
|
||||
shipment_number: str | None = None
|
||||
shipping_carrier: str | None = None
|
||||
|
||||
# Item count
|
||||
item_count: int = 0
|
||||
@@ -534,3 +547,26 @@ class LetzshopOrderConfirmRequest(BaseModel):
|
||||
"""Schema for confirming/declining order items."""
|
||||
|
||||
items: list[LetzshopOrderConfirmItem]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Mark as Shipped Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class MarkAsShippedRequest(BaseModel):
|
||||
"""Schema for marking an order as shipped with tracking info."""
|
||||
|
||||
tracking_number: str | None = Field(None, max_length=100)
|
||||
tracking_url: str | None = Field(None, max_length=500)
|
||||
shipping_carrier: str | None = Field(None, max_length=50)
|
||||
|
||||
|
||||
class ShippingLabelInfo(BaseModel):
|
||||
"""Shipping label information for an order."""
|
||||
|
||||
shipment_number: str | None = None
|
||||
shipping_carrier: str | None = None
|
||||
label_url: str | None = None
|
||||
tracking_number: str | None = None
|
||||
tracking_url: str | None = None
|
||||
|
||||
@@ -23,6 +23,7 @@ class OrderItemExceptionResponse(BaseModel):
|
||||
id: int
|
||||
order_item_id: int
|
||||
vendor_id: int
|
||||
vendor_name: str | None = None # For cross-vendor views
|
||||
|
||||
# Original data from marketplace
|
||||
original_gtin: str | None
|
||||
|
||||
Reference in New Issue
Block a user