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

@@ -10,6 +10,9 @@ This service handles:
- Order item management
All orders use snapshotted customer and address data.
All monetary calculations use integer cents internally for precision.
See docs/architecture/money-handling.md for details.
"""
import logging
@@ -28,6 +31,7 @@ from app.exceptions import (
ValidationException,
)
from app.services.order_item_exception_service import order_item_exception_service
from app.utils.money import Money, cents_to_euros, euros_to_cents
from models.database.customer import Customer
from models.database.marketplace_product import MarketplaceProduct
from models.database.marketplace_product_translation import MarketplaceProductTranslation
@@ -296,7 +300,8 @@ class OrderService:
)
# Calculate order totals and validate products
subtotal = 0.0
# All calculations use integer cents for precision
subtotal_cents = 0
order_items_data = []
for item_data in order_data.items:
@@ -325,34 +330,42 @@ class OrderService:
available=product.available_inventory,
)
# Calculate item total
unit_price = product.sale_price if product.sale_price else product.price
if not unit_price:
# Get price in cents (prefer sale price, then regular price)
unit_price_cents = (
product.effective_sale_price_cents
or product.effective_price_cents
)
if not unit_price_cents:
raise ValidationException(f"Product {product.id} has no price")
item_total = unit_price * item_data.quantity
subtotal += item_total
# Calculate line total in cents
line_total_cents = Money.calculate_line_total(
unit_price_cents, item_data.quantity
)
subtotal_cents += line_total_cents
order_items_data.append(
{
"product_id": product.id,
"product_name": product.marketplace_product.title
"product_name": product.marketplace_product.get_title("en")
if product.marketplace_product
else product.product_id,
"product_sku": product.product_id,
else str(product.id),
"product_sku": product.vendor_sku,
"gtin": product.gtin,
"gtin_type": product.gtin_type,
"quantity": item_data.quantity,
"unit_price": unit_price,
"total_price": item_total,
"unit_price_cents": unit_price_cents,
"total_price_cents": line_total_cents,
}
)
# Calculate totals
tax_amount = 0.0 # TODO: Implement tax calculation
shipping_amount = 5.99 if subtotal < 50 else 0.0
discount_amount = 0.0
total_amount = subtotal + tax_amount + shipping_amount - discount_amount
# Calculate totals in cents
tax_amount_cents = 0 # TODO: Implement tax calculation
shipping_amount_cents = 599 if subtotal_cents < 5000 else 0 # €5.99 / €50
discount_amount_cents = 0
total_amount_cents = Money.calculate_order_total(
subtotal_cents, tax_amount_cents, shipping_amount_cents, discount_amount_cents
)
# Use billing address or shipping address
billing = order_data.billing_address or order_data.shipping_address
@@ -367,12 +380,12 @@ class OrderService:
order_number=order_number,
channel="direct",
status="pending",
# Financials
subtotal=subtotal,
tax_amount=tax_amount,
shipping_amount=shipping_amount,
discount_amount=discount_amount,
total_amount=total_amount,
# Financials (in cents)
subtotal_cents=subtotal_cents,
tax_amount_cents=tax_amount_cents,
shipping_amount_cents=shipping_amount_cents,
discount_amount_cents=discount_amount_cents,
total_amount_cents=total_amount_cents,
currency="EUR",
# Customer snapshot
customer_first_name=order_data.customer.first_name,
@@ -417,7 +430,7 @@ class OrderService:
logger.info(
f"Order {order.order_number} created for vendor {vendor_id}, "
f"total: EUR {total_amount:.2f}"
f"total: EUR {cents_to_euros(total_amount_cents):.2f}"
)
return order
@@ -469,6 +482,41 @@ class OrderService:
.first()
)
if existing:
updated = False
# Update tracking if available and not already set
tracking_data = shipment_data.get("tracking") or {}
new_tracking = tracking_data.get("code") or tracking_data.get("number")
if new_tracking and not existing.tracking_number:
existing.tracking_number = new_tracking
tracking_provider = tracking_data.get("provider")
if not tracking_provider and tracking_data.get("carrier"):
carrier = tracking_data.get("carrier", {})
tracking_provider = carrier.get("code") or carrier.get("name")
existing.tracking_provider = tracking_provider
updated = True
logger.info(
f"Updated tracking for order {order_number}: "
f"{tracking_provider} {new_tracking}"
)
# Update shipment number if not already set
shipment_number = shipment_data.get("number")
if shipment_number and not existing.shipment_number:
existing.shipment_number = shipment_number
updated = True
# Update carrier if not already set
shipment_data_obj = shipment_data.get("data") or {}
if shipment_data_obj and not existing.shipping_carrier:
carrier_name = shipment_data_obj.get("__typename", "").lower()
if carrier_name:
existing.shipping_carrier = carrier_name
updated = True
if updated:
existing.updated_at = datetime.now(UTC)
return existing
# =====================================================================
@@ -546,13 +594,14 @@ class OrderService:
except (ValueError, TypeError):
pass
# Parse total amount
# Parse total amount (convert to cents)
total_str = order_data.get("total", "0")
try:
# Handle format like "99.99 EUR"
total_amount = float(str(total_str).split()[0])
total_euros = float(str(total_str).split()[0])
total_amount_cents = euros_to_cents(total_euros)
except (ValueError, IndexError):
total_amount = 0.0
total_amount_cents = 0
# Map Letzshop state to status
letzshop_state = shipment_data.get("state", "unconfirmed")
@@ -563,6 +612,23 @@ class OrderService:
}
status = status_mapping.get(letzshop_state, "pending")
# Parse tracking info if available
tracking_data = shipment_data.get("tracking") or {}
tracking_number = tracking_data.get("code") or tracking_data.get("number")
tracking_provider = tracking_data.get("provider")
# Handle carrier object format: tracking { carrier { name code } }
if not tracking_provider and tracking_data.get("carrier"):
carrier = tracking_data.get("carrier", {})
tracking_provider = carrier.get("code") or carrier.get("name")
# Parse shipment number and carrier
shipment_number = shipment_data.get("number") # e.g., H74683403433
shipping_carrier = None
shipment_data_obj = shipment_data.get("data") or {}
if shipment_data_obj:
# Carrier is determined by __typename (Greco, Colissimo, XpressLogistics)
shipping_carrier = shipment_data_obj.get("__typename", "").lower()
# =====================================================================
# PHASE 2: All validation passed - now create database records
# =====================================================================
@@ -590,8 +656,8 @@ class OrderService:
external_data=shipment_data,
# Status
status=status,
# Financials
total_amount=total_amount,
# Financials (in cents)
total_amount_cents=total_amount_cents,
currency="EUR",
# Customer snapshot
customer_first_name=ship_first_name,
@@ -620,6 +686,12 @@ class OrderService:
order_date=order_date,
confirmed_at=datetime.now(UTC) if status == "processing" else None,
cancelled_at=datetime.now(UTC) if status == "cancelled" else None,
# Tracking (if available from Letzshop)
tracking_number=tracking_number,
tracking_provider=tracking_provider,
# Shipment info
shipment_number=shipment_number,
shipping_carrier=shipping_carrier,
)
db.add(order)
@@ -653,11 +725,12 @@ class OrderService:
or str(product_name_dict)
)
# Get price
unit_price = 0.0
# Get price (convert to cents)
unit_price_cents = 0
price_str = variant.get("price", "0")
try:
unit_price = float(str(price_str).split()[0])
price_euros = float(str(price_str).split()[0])
unit_price_cents = euros_to_cents(price_euros)
except (ValueError, IndexError):
pass
@@ -672,8 +745,8 @@ class OrderService:
gtin=gtin,
gtin_type=gtin_type,
quantity=1, # Letzshop uses individual inventory units
unit_price=unit_price,
total_price=unit_price,
unit_price_cents=unit_price_cents,
total_price_cents=unit_price_cents, # qty=1 so same as unit
external_item_id=unit.get("id"),
external_variant_id=variant.get("id"),
item_state=item_state,
@@ -1147,13 +1220,13 @@ class OrderService:
if key in stats:
stats[key] = count
# Get total revenue (from delivered orders)
revenue = (
db.query(func.sum(Order.total_amount))
# Get total revenue (from delivered orders) - convert cents to euros
revenue_cents = (
db.query(func.sum(Order.total_amount_cents))
.filter(Order.status == "delivered")
.scalar()
)
stats["total_revenue"] = float(revenue) if revenue else 0.0
stats["total_revenue"] = cents_to_euros(revenue_cents) if revenue_cents else 0.0
# Count vendors with orders
vendors_count = (
@@ -1200,6 +1273,88 @@ class OrderService:
for row in results
]
def mark_as_shipped_admin(
self,
db: Session,
order_id: int,
tracking_number: str | None = None,
tracking_url: str | None = None,
shipping_carrier: str | None = None,
) -> Order:
"""
Mark an order as shipped with optional tracking info (admin only).
Args:
db: Database session
order_id: Order ID
tracking_number: Optional tracking number
tracking_url: Optional full tracking URL
shipping_carrier: Optional carrier code (greco, colissimo, etc.)
Returns:
Updated order
"""
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise OrderNotFoundException(str(order_id))
order.status = "shipped"
order.shipped_at = datetime.now(UTC)
order.updated_at = datetime.now(UTC)
if tracking_number:
order.tracking_number = tracking_number
if tracking_url:
order.tracking_url = tracking_url
if shipping_carrier:
order.shipping_carrier = shipping_carrier
logger.info(
f"Order {order.order_number} marked as shipped. "
f"Tracking: {tracking_number or 'N/A'}, Carrier: {shipping_carrier or 'N/A'}"
)
return order
def get_shipping_label_info_admin(
self,
db: Session,
order_id: int,
) -> dict[str, Any]:
"""
Get shipping label information for an order (admin only).
Returns shipment number, carrier, and generated label URL
based on carrier settings.
"""
from app.services.admin_settings_service import admin_settings_service
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise OrderNotFoundException(str(order_id))
label_url = None
carrier = order.shipping_carrier
# Generate label URL based on carrier
if order.shipment_number and carrier:
# Get carrier label URL prefix from settings
setting_key = f"carrier_{carrier}_label_url"
prefix = admin_settings_service.get_setting_value(db, setting_key)
if prefix:
label_url = prefix + order.shipment_number
return {
"shipment_number": order.shipment_number,
"shipping_carrier": carrier,
"label_url": label_url,
"tracking_number": order.tracking_number,
"tracking_url": order.tracking_url,
}
# Create service instance
order_service = OrderService()