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

@@ -347,6 +347,96 @@ def test_api_key(
# ============================================================================
@router.get(
"/orders",
response_model=LetzshopOrderListResponse,
)
def list_all_letzshop_orders(
vendor_id: int | None = Query(None, description="Filter by vendor"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
status: str | None = Query(None, description="Filter by order status"),
has_declined_items: bool | None = Query(
None, description="Filter orders with declined/unavailable items"
),
search: str | None = Query(
None, description="Search by order number, customer name, or email"
),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
List Letzshop orders across all vendors (or for a specific vendor).
When vendor_id is not provided, returns orders from all vendors.
"""
order_service = get_order_service(db)
orders, total = order_service.list_orders(
vendor_id=vendor_id,
skip=skip,
limit=limit,
status=status,
has_declined_items=has_declined_items,
search=search,
)
# Get order stats (cross-vendor or vendor-specific)
stats = order_service.get_order_stats(vendor_id)
return LetzshopOrderListResponse(
orders=[
LetzshopOrderResponse(
id=order.id,
vendor_id=order.vendor_id,
vendor_name=order.vendor.name if order.vendor else None,
order_number=order.order_number,
external_order_id=order.external_order_id,
external_shipment_id=order.external_shipment_id,
external_order_number=order.external_order_number,
status=order.status,
customer_email=order.customer_email,
customer_name=order.customer_full_name,
customer_locale=order.customer_locale,
ship_country_iso=order.ship_country_iso,
bill_country_iso=order.bill_country_iso,
total_amount=order.total_amount,
currency=order.currency,
tracking_number=order.tracking_number,
tracking_provider=order.tracking_provider,
order_date=order.order_date,
confirmed_at=order.confirmed_at,
shipped_at=order.shipped_at,
cancelled_at=order.cancelled_at,
created_at=order.created_at,
updated_at=order.updated_at,
items=[
LetzshopOrderItemResponse(
id=item.id,
product_id=item.product_id,
product_name=item.product_name,
product_sku=item.product_sku,
gtin=item.gtin,
gtin_type=item.gtin_type,
quantity=item.quantity,
unit_price=item.unit_price,
total_price=item.total_price,
external_item_id=item.external_item_id,
external_variant_id=item.external_variant_id,
item_state=item.item_state,
)
for item in order.items
],
)
for order in orders
],
total=total,
skip=skip,
limit=limit,
stats=LetzshopOrderStats(**stats),
)
@router.get(
"/vendors/{vendor_id}/orders",
response_model=LetzshopOrderListResponse,
@@ -1048,3 +1138,99 @@ def decline_single_item(
except LetzshopClientError as e:
return FulfillmentOperationResponse(success=False, message=str(e))
# ============================================================================
# Tracking Sync
# ============================================================================
@router.post(
"/vendors/{vendor_id}/sync-tracking",
response_model=LetzshopSyncTriggerResponse,
)
def sync_tracking_for_vendor(
vendor_id: int = Path(..., description="Vendor ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Sync tracking information from Letzshop for confirmed orders.
Fetches tracking data from Letzshop API for orders that:
- Are in "processing" status (confirmed)
- Don't have tracking info yet
- Have an external shipment ID
This is useful when tracking is added by Letzshop after order confirmation.
"""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
# Verify credentials exist
try:
creds_service.get_credentials_or_raise(vendor_id)
except CredentialsNotFoundError:
raise ValidationException(
f"Letzshop credentials not configured for vendor {vendor_id}"
)
# Get orders that need tracking
orders_without_tracking = order_service.get_orders_without_tracking(vendor_id)
if not orders_without_tracking:
return LetzshopSyncTriggerResponse(
success=True,
message="No orders need tracking updates",
orders_imported=0,
orders_updated=0,
)
logger.info(
f"Syncing tracking for {len(orders_without_tracking)} orders (vendor {vendor_id})"
)
orders_updated = 0
errors = []
try:
with creds_service.create_client(vendor_id) as client:
for order in orders_without_tracking:
try:
# Fetch shipment by ID
shipment_data = client.get_shipment_by_id(order.external_shipment_id)
if shipment_data:
updated = order_service.update_tracking_from_shipment_data(
order, shipment_data
)
if updated:
orders_updated += 1
except Exception as e:
errors.append(
f"Error syncing tracking for order {order.order_number}: {e}"
)
db.commit()
message = f"Tracking sync completed: {orders_updated} orders updated"
if errors:
message += f" ({len(errors)} errors)"
return LetzshopSyncTriggerResponse(
success=True,
message=message,
orders_imported=0,
orders_updated=orders_updated,
errors=errors,
)
except LetzshopClientError as e:
return LetzshopSyncTriggerResponse(
success=False,
message=f"Tracking sync failed: {e}",
errors=[str(e)],
)

View File

@@ -69,7 +69,7 @@ def list_exceptions(
limit=limit,
)
# Enrich with order info
# Enrich with order and vendor info
response_items = []
for exc in exceptions:
item = OrderItemExceptionResponse.model_validate(exc)
@@ -79,6 +79,9 @@ def list_exceptions(
item.order_id = order.id
item.order_date = order.order_date
item.order_status = order.status
# Add vendor name for cross-vendor view
if order.vendor:
item.vendor_name = order.vendor.name
response_items.append(item)
return OrderItemExceptionListResponse(

View File

@@ -27,7 +27,9 @@ from models.schema.order import (
AdminOrderStats,
AdminOrderStatusUpdate,
AdminVendorsWithOrdersResponse,
MarkAsShippedRequest,
OrderDetailResponse,
ShippingLabelInfo,
)
router = APIRouter(prefix="/orders")
@@ -105,7 +107,14 @@ def get_order_detail(
):
"""Get order details including items and addresses."""
order = order_service.get_order_by_id_admin(db, order_id)
return order
# Enrich with vendor info
response = OrderDetailResponse.model_validate(order)
if order.vendor:
response.vendor_name = order.vendor.name
response.vendor_code = order.vendor.vendor_code
return response
@router.patch("/{order_id}/status", response_model=OrderDetailResponse)
@@ -136,3 +145,49 @@ def update_order_status(
db.commit()
return order
@router.post("/{order_id}/ship", response_model=OrderDetailResponse)
def mark_order_as_shipped(
order_id: int,
ship_request: MarkAsShippedRequest,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Mark an order as shipped with optional tracking information.
This endpoint:
- Sets order status to 'shipped'
- Sets shipped_at timestamp
- Optionally stores tracking number, URL, and carrier
"""
order = order_service.mark_as_shipped_admin(
db=db,
order_id=order_id,
tracking_number=ship_request.tracking_number,
tracking_url=ship_request.tracking_url,
shipping_carrier=ship_request.shipping_carrier,
)
logger.info(
f"Admin {current_admin.email} marked order {order.order_number} as shipped"
)
db.commit()
return order
@router.get("/{order_id}/shipping-label", response_model=ShippingLabelInfo)
def get_shipping_label_info(
order_id: int,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Get shipping label information for an order.
Returns the shipment number, carrier, and generated label URL
based on carrier settings.
"""
return order_service.get_shipping_label_info_admin(db, order_id)

View File

@@ -6,6 +6,9 @@ This module provides:
- Session-based cart management
- Cart item operations (add, update, remove)
- Cart total calculations
All monetary calculations use integer cents internally for precision.
See docs/architecture/money-handling.md for details.
"""
import logging
@@ -19,6 +22,7 @@ from app.exceptions import (
InvalidCartQuantityException,
ProductNotFoundException,
)
from app.utils.money import cents_to_euros
from models.database.cart import CartItem
from models.database.product import Product
@@ -62,21 +66,23 @@ class CartService:
extra={"item_count": len(cart_items)},
)
# Build response
# Build response - calculate totals in cents, return euros
items = []
subtotal = 0.0
subtotal_cents = 0
for cart_item in cart_items:
product = cart_item.product
line_total = cart_item.line_total
line_total_cents = cart_item.line_total_cents
items.append(
{
"product_id": product.id,
"product_name": product.marketplace_product.title,
"product_name": product.marketplace_product.get_title("en")
if product.marketplace_product
else str(product.id),
"quantity": cart_item.quantity,
"price": cart_item.price_at_add,
"line_total": line_total,
"price": cart_item.price_at_add, # Returns euros via property
"line_total": cents_to_euros(line_total_cents),
"image_url": (
product.marketplace_product.image_link
if product.marketplace_product
@@ -85,8 +91,10 @@ class CartService:
}
)
subtotal += line_total
subtotal_cents += line_total_cents
# Convert to euros for API response
subtotal = cents_to_euros(subtotal_cents)
cart_data = {
"vendor_id": vendor_id,
"session_id": session_id,
@@ -166,8 +174,12 @@ class CartService:
},
)
# Get current price (use sale_price if available, otherwise regular price)
current_price = product.sale_price if product.sale_price else product.price
# Get current price in cents (use sale_price if available, otherwise regular price)
current_price_cents = (
product.effective_sale_price_cents
or product.effective_price_cents
or 0
)
# Check if item already exists in cart
existing_item = (
@@ -236,13 +248,13 @@ class CartService:
available=product.available_inventory,
)
# Create new cart item
# Create new cart item (price stored in cents)
cart_item = CartItem(
vendor_id=vendor_id,
session_id=session_id,
product_id=product_id,
quantity=quantity,
price_at_add=current_price,
price_at_add_cents=current_price_cents,
)
db.add(cart_item)
db.flush()
@@ -253,7 +265,7 @@ class CartService:
extra={
"cart_item_id": cart_item.id,
"quantity": quantity,
"price": current_price,
"price_cents": current_price_cents,
},
)

View File

@@ -116,6 +116,9 @@ query {
code
provider
}
data {
__typename
}
}
}
}
@@ -194,6 +197,9 @@ query {
code
provider
}
data {
__typename
}
}
}
}
@@ -272,6 +278,9 @@ query GetShipment($id: ID!) {
code
provider
}
data {
__typename
}
}
}
}
@@ -349,6 +358,9 @@ query GetShipmentsPaginated($first: Int!, $after: String) {{
}}
}}
}}
data {{
__typename
}}
}}
}}
}}

View File

@@ -182,7 +182,7 @@ class LetzshopOrderService:
def list_orders(
self,
vendor_id: int,
vendor_id: int | None = None,
skip: int = 0,
limit: int = 50,
status: str | None = None,
@@ -190,10 +190,10 @@ class LetzshopOrderService:
search: str | None = None,
) -> tuple[list[Order], int]:
"""
List Letzshop orders for a vendor.
List Letzshop orders for a vendor (or all vendors).
Args:
vendor_id: Vendor ID to filter by.
vendor_id: Vendor ID to filter by. If None, returns all vendors.
skip: Number of records to skip.
limit: Maximum number of records to return.
status: Filter by order status (pending, processing, shipped, etc.)
@@ -203,10 +203,13 @@ class LetzshopOrderService:
Returns a tuple of (orders, total_count).
"""
query = self.db.query(Order).filter(
Order.vendor_id == vendor_id,
Order.channel == "letzshop",
)
# Filter by vendor if specified
if vendor_id is not None:
query = query.filter(Order.vendor_id == vendor_id)
if status:
query = query.filter(Order.status == status)
@@ -242,25 +245,25 @@ class LetzshopOrderService:
return orders, total
def get_order_stats(self, vendor_id: int) -> dict[str, int]:
def get_order_stats(self, vendor_id: int | None = None) -> dict[str, int]:
"""
Get order counts by status for a vendor's Letzshop orders.
Get order counts by status for Letzshop orders.
Args:
vendor_id: Vendor ID to filter by. If None, returns stats for all vendors.
Returns:
Dict with counts for each status.
"""
status_counts = (
self.db.query(
Order.status,
func.count(Order.id).label("count"),
)
.filter(
Order.vendor_id == vendor_id,
Order.channel == "letzshop",
)
.group_by(Order.status)
.all()
)
query = self.db.query(
Order.status,
func.count(Order.id).label("count"),
).filter(Order.channel == "letzshop")
if vendor_id is not None:
query = query.filter(Order.vendor_id == vendor_id)
status_counts = query.group_by(Order.status).all()
stats = {
"pending": 0,
@@ -277,18 +280,18 @@ class LetzshopOrderService:
stats["total"] += count
# Count orders with declined items
declined_items_count = (
declined_query = (
self.db.query(func.count(func.distinct(OrderItem.order_id)))
.join(Order, OrderItem.order_id == Order.id)
.filter(
Order.vendor_id == vendor_id,
Order.channel == "letzshop",
OrderItem.item_state == "confirmed_unavailable",
)
.scalar()
or 0
)
stats["has_declined_items"] = declined_items_count
if vendor_id is not None:
declined_query = declined_query.filter(Order.vendor_id == vendor_id)
stats["has_declined_items"] = declined_query.scalar() or 0
return stats
@@ -464,6 +467,62 @@ class LetzshopOrderService:
order.updated_at = datetime.now(UTC)
return order
def get_orders_without_tracking(
self,
vendor_id: int,
limit: int = 100,
) -> list[Order]:
"""Get orders that have been confirmed but don't have tracking info."""
return (
self.db.query(Order)
.filter(
Order.vendor_id == vendor_id,
Order.channel == "letzshop",
Order.status == "processing", # Confirmed orders
Order.tracking_number.is_(None),
Order.external_shipment_id.isnot(None), # Has shipment ID
)
.limit(limit)
.all()
)
def update_tracking_from_shipment_data(
self,
order: Order,
shipment_data: dict[str, Any],
) -> bool:
"""
Update order tracking from Letzshop shipment data.
Args:
order: The order to update.
shipment_data: Raw shipment data from Letzshop API.
Returns:
True if tracking was updated, False otherwise.
"""
tracking_data = shipment_data.get("tracking") or {}
tracking_number = tracking_data.get("code") or tracking_data.get("number")
if not tracking_number:
return False
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")
order.tracking_number = tracking_number
order.tracking_provider = tracking_provider
order.updated_at = datetime.now(UTC)
logger.info(
f"Updated tracking for order {order.order_number}: "
f"{tracking_provider} {tracking_number}"
)
return True
def get_order_items(self, order: Order) -> list[OrderItem]:
"""Get all items for an order."""
return (
@@ -733,7 +792,7 @@ class LetzshopOrderService:
pass
if needs_update:
self.db.commit() # Commit update immediately
self.db.commit() # noqa: SVC-006 - background task needs incremental commits
stats["updated"] += 1
else:
stats["skipped"] += 1
@@ -741,7 +800,7 @@ class LetzshopOrderService:
# Create new order using unified service
try:
self.create_order(vendor_id, shipment)
self.db.commit() # Commit each order immediately
self.db.commit() # noqa: SVC-006 - background task needs incremental commits
stats["imported"] += 1
except Exception as e:
self.db.rollback() # Rollback failed order
@@ -948,7 +1007,7 @@ class LetzshopOrderService:
status="pending",
)
self.db.add(job)
self.db.commit()
self.db.commit() # noqa: SVC-006 - job must be visible immediately before background task starts
self.db.refresh(job)
return job

View File

@@ -329,8 +329,9 @@ class OrderItemExceptionService:
order_item.needs_product_match = False
# Update product snapshot on order item
order_item.product_name = product.get_title("en") # Default to English
order_item.product_sku = product.sku or order_item.product_sku
if product.marketplace_product:
order_item.product_name = product.marketplace_product.get_title("en")
order_item.product_sku = product.vendor_sku or order_item.product_sku
db.flush()
@@ -446,7 +447,8 @@ class OrderItemExceptionService:
order_item = exception.order_item
order_item.product_id = product_id
order_item.needs_product_match = False
order_item.product_name = product.get_title("en")
if product.marketplace_product:
order_item.product_name = product.marketplace_product.get_title("en")
resolved.append(exception)
@@ -613,7 +615,8 @@ class OrderItemExceptionService:
order_item = exception.order_item
order_item.product_id = product_id
order_item.needs_product_match = False
order_item.product_name = product.get_title("en")
if product.marketplace_product:
order_item.product_name = product.marketplace_product.get_title("en")
db.flush()

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()

View File

@@ -59,17 +59,21 @@
Order Information
</h4>
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Order Number</span>
<span class="font-medium text-gray-700 dark:text-gray-300" x-text="order?.external_order_number || order?.order_number"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Order Date</span>
<span class="font-medium text-gray-700 dark:text-gray-300" x-text="formatDate(order?.order_date || order?.created_at)"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Shipment ID</span>
<span class="font-mono text-xs text-gray-600 dark:text-gray-400" x-text="order?.external_shipment_id"></span>
<span class="text-gray-500 dark:text-gray-400">Order Number</span>
<span class="font-medium text-gray-700 dark:text-gray-300" x-text="order?.external_order_number || order?.order_number"></span>
</div>
<div class="flex justify-between" x-show="order?.shipment_number">
<span class="text-gray-500 dark:text-gray-400">Shipment Number</span>
<span class="font-mono font-medium text-gray-700 dark:text-gray-300" x-text="order?.shipment_number"></span>
</div>
<div class="flex justify-between" x-show="order?.external_shipment_id">
<span class="text-gray-500 dark:text-gray-400">Hash ID</span>
<span class="font-mono text-xs text-gray-600 dark:text-gray-400" x-text="order?.external_shipment_id?.split('/').pop()"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Total</span>
@@ -128,28 +132,47 @@
</div>
</div>
<!-- Tracking Information -->
<!-- Shipping & Tracking Information -->
<div class="min-w-0 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h4 class="mb-4 font-semibold text-gray-600 dark:text-gray-300 flex items-center gap-2">
<span x-html="$icon('truck', 'w-5 h-5')"></span>
Tracking Information
Shipping & Tracking
</h4>
<div x-show="order?.tracking_number" class="space-y-3 text-sm">
<div class="flex justify-between">
<div x-show="order?.shipment_number || order?.shipping_carrier || order?.tracking_number || order?.tracking_url" class="space-y-3 text-sm">
<div class="flex justify-between" x-show="order?.shipping_carrier">
<span class="text-gray-500 dark:text-gray-400">Carrier</span>
<span class="font-medium text-gray-700 dark:text-gray-300" x-text="order?.tracking_provider"></span>
<span class="font-medium text-gray-700 dark:text-gray-300 capitalize" x-text="order?.shipping_carrier"></span>
</div>
<div class="flex justify-between">
<div class="flex justify-between" x-show="order?.shipment_number">
<span class="text-gray-500 dark:text-gray-400">Shipment Number</span>
<span class="font-mono text-gray-700 dark:text-gray-300" x-text="order?.shipment_number"></span>
</div>
<div class="flex justify-between" x-show="order?.tracking_number">
<span class="text-gray-500 dark:text-gray-400">Tracking Number</span>
<span class="font-mono text-gray-700 dark:text-gray-300" x-text="order?.tracking_number"></span>
</div>
<div class="flex justify-between" x-show="order?.tracking_provider">
<span class="text-gray-500 dark:text-gray-400">Tracking Provider</span>
<span class="text-gray-700 dark:text-gray-300" x-text="order?.tracking_provider"></span>
</div>
<div class="flex justify-between" x-show="order?.shipped_at">
<span class="text-gray-500 dark:text-gray-400">Shipped At</span>
<span class="text-gray-700 dark:text-gray-300" x-text="formatDate(order?.shipped_at)"></span>
</div>
<!-- Tracking Link -->
<div x-show="order?.tracking_url || (order?.shipping_carrier === 'greco' && order?.shipment_number)" class="pt-2 border-t dark:border-gray-700">
<a
:href="order?.tracking_url || ('https://dispatchweb.fr/Tracky/Home/' + order?.shipment_number)"
target="_blank"
class="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-purple-600 bg-purple-50 dark:bg-purple-900/30 dark:text-purple-400 rounded-lg hover:bg-purple-100 dark:hover:bg-purple-900/50 transition-colors"
>
<span x-html="$icon('external-link', 'w-4 h-4')"></span>
View Tracking / Download Label
</a>
</div>
</div>
<div x-show="!order?.tracking_number" class="text-sm text-gray-500 dark:text-gray-400 italic">
No tracking information available
<div x-show="!order?.shipment_number && !order?.shipping_carrier && !order?.tracking_number && !order?.tracking_url" class="text-sm text-gray-500 dark:text-gray-400 italic">
No shipping information available yet
</div>
</div>
</div>

View File

@@ -78,73 +78,86 @@
<!-- Error Message -->
{{ error_state('Error', show_condition='error && !loading') }}
<!-- Vendor Required Warning -->
<div x-show="!selectedVendor && !loading" class="mb-8 p-6 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
<!-- Cross-vendor info banner (shown when no vendor selected) -->
<div x-show="!selectedVendor && !loading" class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div class="flex items-center">
<span x-html="$icon('exclamation', 'w-6 h-6 text-yellow-500 mr-3')"></span>
<span x-html="$icon('information-circle', 'w-6 h-6 text-blue-500 mr-3')"></span>
<div>
<h3 class="font-medium text-yellow-800 dark:text-yellow-200">Select a Vendor</h3>
<p class="text-sm text-yellow-700 dark:text-yellow-300">Please select a vendor from the dropdown above to manage their Letzshop integration.</p>
<h3 class="font-medium text-blue-800 dark:text-blue-200">All Vendors View</h3>
<p class="text-sm text-blue-700 dark:text-blue-300">Showing data across all vendors. Select a vendor above to manage products, import orders, or access settings.</p>
</div>
</div>
</div>
<!-- Main Content (shown when vendor selected) -->
<div x-show="selectedVendor" x-transition x-cloak>
<!-- Vendor Info Bar -->
<div class="mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
<span class="text-lg font-semibold text-purple-600 dark:text-purple-300" x-text="selectedVendor?.name?.charAt(0).toUpperCase()"></span>
<!-- Main Content -->
<div x-show="!loading" x-transition x-cloak>
<!-- Selected Vendor Filter (same pattern as orders page) -->
<div x-show="selectedVendor" x-transition class="mb-6 p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300" x-text="selectedVendor?.name?.charAt(0).toUpperCase()"></span>
</div>
<div class="flex items-center gap-3">
<div>
<span class="font-medium text-purple-800 dark:text-purple-200" x-text="selectedVendor?.name"></span>
<span class="ml-2 text-xs text-purple-600 dark:text-purple-400 font-mono" x-text="selectedVendor?.vendor_code"></span>
</div>
<!-- Status badges -->
<span class="px-2 py-0.5 text-xs font-medium rounded-full"
:class="letzshopStatus.is_configured ? 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
x-text="letzshopStatus.is_configured ? 'Configured' : 'Not Configured'">
</span>
<span x-show="letzshopStatus.auto_sync_enabled" class="px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300">
Auto-sync
</span>
</div>
</div>
<div>
<h3 class="font-semibold text-gray-800 dark:text-gray-200" x-text="selectedVendor?.name"></h3>
<p class="text-sm text-gray-500" x-text="selectedVendor?.vendor_code"></p>
</div>
</div>
<div class="flex items-center gap-2">
<!-- Status Badge -->
<span class="px-3 py-1 text-sm font-medium rounded-full"
:class="letzshopStatus.is_configured ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'"
x-text="letzshopStatus.is_configured ? 'Configured' : 'Not Configured'">
</span>
<!-- Auto-sync indicator -->
<span x-show="letzshopStatus.auto_sync_enabled" class="px-3 py-1 text-sm font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300">
Auto-sync
</span>
<button @click="clearVendorSelection()" class="text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 text-sm flex items-center gap-1">
<span x-html="$icon('x', 'w-4 h-4')"></span>
Clear filter
</button>
</div>
</div>
<!-- Tabs -->
{% call tabs_nav(tab_var='activeTab') %}
{{ tab_button('products', 'Products', tab_var='activeTab', icon='cube') }}
<template x-if="selectedVendor">
<span>{{ tab_button('products', 'Products', tab_var='activeTab', icon='cube') }}</span>
</template>
{{ tab_button('orders', 'Orders', tab_var='activeTab', icon='shopping-cart', count_var='orderStats.pending') }}
{{ tab_button('exceptions', 'Exceptions', tab_var='activeTab', icon='exclamation-circle', count_var='exceptionStats.pending') }}
{{ tab_button('settings', 'Settings', tab_var='activeTab', icon='cog') }}
<template x-if="selectedVendor">
<span>{{ tab_button('settings', 'Settings', tab_var='activeTab', icon='cog') }}</span>
</template>
{% endcall %}
<!-- Products Tab (Import + Export) -->
{{ tab_panel('products', tab_var='activeTab') }}
{% include 'admin/partials/letzshop-products-tab.html' %}
{{ endtab_panel() }}
<!-- Products Tab (Import + Export) - Vendor only -->
<template x-if="selectedVendor">
{{ tab_panel('products', tab_var='activeTab') }}
{% include 'admin/partials/letzshop-products-tab.html' %}
{{ endtab_panel() }}
</template>
<!-- Orders Tab -->
{{ tab_panel('orders', tab_var='activeTab') }}
{% include 'admin/partials/letzshop-orders-tab.html' %}
{{ endtab_panel() }}
<!-- Settings Tab -->
{{ tab_panel('settings', tab_var='activeTab') }}
{% include 'admin/partials/letzshop-settings-tab.html' %}
{{ endtab_panel() }}
<!-- Settings Tab - Vendor only -->
<template x-if="selectedVendor">
{{ tab_panel('settings', tab_var='activeTab') }}
{% include 'admin/partials/letzshop-settings-tab.html' %}
{{ endtab_panel() }}
</template>
<!-- Exceptions Tab -->
{{ tab_panel('exceptions', tab_var='activeTab') }}
{% include 'admin/partials/letzshop-exceptions-tab.html' %}
{{ endtab_panel() }}
<!-- Unified Jobs Table (below all tabs) -->
<div class="mt-8">
<!-- Unified Jobs Table (below all tabs) - Vendor only -->
<div x-show="selectedVendor" class="mt-8">
{% include 'admin/partials/letzshop-jobs-table.html' %}
</div>
</div>
@@ -361,7 +374,7 @@
(<span x-text="selectedOrder?.items?.length"></span> item<span x-show="selectedOrder?.items?.length > 1">s</span>)
</span>
</h4>
<div class="space-y-2">
<div class="space-y-2 max-h-64 overflow-y-auto">
<template x-for="(item, index) in selectedOrder?.items || []" :key="item.id">
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
<div class="flex justify-between items-start">

View File

@@ -59,7 +59,7 @@
<div class="flex items-center gap-4">
<!-- Vendor Autocomplete (Tom Select) -->
<div class="w-80">
<select id="vendor-select" x-ref="vendorSelect" placeholder="All vendors...">
<select id="vendor-select" x-ref="vendorSelect" placeholder="Search vendor...">
</select>
</div>
{{ refresh_button(loading_var='loading', onclick='refresh()', variant='secondary') }}
@@ -401,20 +401,23 @@
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Customer</p>
<p class="text-sm font-medium text-gray-800 dark:text-gray-200" x-text="selectedOrderDetail?.customer?.first_name + ' ' + selectedOrderDetail?.customer?.last_name"></p>
<p class="text-xs text-gray-500" x-text="selectedOrderDetail?.customer?.email"></p>
<p class="text-sm font-medium text-gray-800 dark:text-gray-200" x-text="(selectedOrderDetail?.customer_first_name || '') + ' ' + (selectedOrderDetail?.customer_last_name || '')"></p>
<p class="text-xs text-gray-500" x-text="selectedOrderDetail?.customer_email"></p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Vendor</p>
<p class="text-sm font-medium text-gray-800 dark:text-gray-200" x-text="selectedOrderDetail?.vendor?.name"></p>
<p class="text-xs text-gray-500 font-mono" x-text="selectedOrderDetail?.vendor?.vendor_code"></p>
<p class="text-sm font-medium text-gray-800 dark:text-gray-200" x-text="selectedOrderDetail?.vendor_name || 'Unknown'"></p>
<p class="text-xs text-gray-500 font-mono" x-text="selectedOrderDetail?.vendor_code || ''"></p>
</div>
</div>
<!-- Items -->
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-2">Items</p>
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg overflow-hidden">
<div class="flex items-center justify-between mb-2">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Items</p>
<span class="text-xs text-gray-400" x-text="(selectedOrderDetail?.items?.length || 0) + ' item(s)'"></span>
</div>
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg overflow-hidden max-h-48 overflow-y-auto">
<template x-for="item in selectedOrderDetail?.items || []" :key="item.id">
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-200 dark:border-gray-600 last:border-0">
<div>
@@ -457,21 +460,65 @@
</div>
<!-- Shipping Address -->
<div x-show="selectedOrderDetail?.shipping_address">
<div x-show="selectedOrderDetail?.ship_address_line_1">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-2">Shipping Address</p>
<div class="text-sm text-gray-700 dark:text-gray-300">
<p x-text="selectedOrderDetail?.shipping_address?.first_name + ' ' + selectedOrderDetail?.shipping_address?.last_name"></p>
<p x-text="selectedOrderDetail?.shipping_address?.address_line_1"></p>
<p x-show="selectedOrderDetail?.shipping_address?.address_line_2" x-text="selectedOrderDetail?.shipping_address?.address_line_2"></p>
<p x-text="selectedOrderDetail?.shipping_address?.postal_code + ' ' + selectedOrderDetail?.shipping_address?.city"></p>
<p x-text="selectedOrderDetail?.shipping_address?.country"></p>
<p x-text="(selectedOrderDetail?.ship_first_name || '') + ' ' + (selectedOrderDetail?.ship_last_name || '')"></p>
<p x-text="selectedOrderDetail?.ship_address_line_1"></p>
<p x-show="selectedOrderDetail?.ship_address_line_2" x-text="selectedOrderDetail?.ship_address_line_2"></p>
<p x-text="(selectedOrderDetail?.ship_postal_code || '') + ' ' + (selectedOrderDetail?.ship_city || '')"></p>
<p x-text="selectedOrderDetail?.ship_country_iso"></p>
</div>
</div>
<!-- Tracking Info -->
<div x-show="selectedOrderDetail?.tracking_number">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Tracking Number</p>
<p class="text-sm font-mono text-gray-800 dark:text-gray-200" x-text="selectedOrderDetail?.tracking_number"></p>
<!-- Shipping & Tracking Info -->
<div x-show="selectedOrderDetail?.shipment_number || selectedOrderDetail?.tracking_number || selectedOrderDetail?.shipping_carrier">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-2">Shipping & Tracking</p>
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-3 space-y-2">
<!-- Shipment Number -->
<div x-show="selectedOrderDetail?.shipment_number" class="flex items-center justify-between">
<div>
<span class="text-xs text-gray-500 dark:text-gray-400">Shipment #:</span>
<span class="ml-2 text-sm font-mono text-gray-800 dark:text-gray-200" x-text="selectedOrderDetail?.shipment_number"></span>
</div>
<!-- Download Label Button -->
<button
x-show="selectedOrderDetail?.shipping_carrier"
@click="downloadShippingLabel(selectedOrderDetail)"
class="flex items-center gap-1 px-2 py-1 text-xs font-medium text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 bg-purple-50 dark:bg-purple-900/30 rounded"
title="Download shipping label"
>
<span x-html="$icon('download', 'w-3 h-3')"></span>
Label
</button>
</div>
<!-- Carrier -->
<div x-show="selectedOrderDetail?.shipping_carrier" class="flex items-center">
<span class="text-xs text-gray-500 dark:text-gray-400">Carrier:</span>
<span class="ml-2 px-2 py-0.5 text-xs font-medium rounded capitalize"
:class="{
'bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200': selectedOrderDetail?.shipping_carrier === 'greco',
'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200': selectedOrderDetail?.shipping_carrier === 'colissimo',
'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200': selectedOrderDetail?.shipping_carrier === 'xpresslogistics',
'bg-gray-100 dark:bg-gray-600 text-gray-800 dark:text-gray-200': !['greco', 'colissimo', 'xpresslogistics'].includes(selectedOrderDetail?.shipping_carrier)
}"
x-text="selectedOrderDetail?.shipping_carrier"></span>
</div>
<!-- Tracking Number (if different from shipment) -->
<div x-show="selectedOrderDetail?.tracking_number" class="flex items-center">
<span class="text-xs text-gray-500 dark:text-gray-400">Tracking #:</span>
<span class="ml-2 text-sm font-mono text-gray-800 dark:text-gray-200" x-text="selectedOrderDetail?.tracking_number"></span>
</div>
<!-- Tracking URL -->
<div x-show="selectedOrderDetail?.tracking_url" class="flex items-center">
<span class="text-xs text-gray-500 dark:text-gray-400">Track:</span>
<a :href="selectedOrderDetail?.tracking_url" target="_blank"
class="ml-2 text-xs text-purple-600 dark:text-purple-400 hover:underline flex items-center gap-1">
View tracking
<span x-html="$icon('external-link', 'w-3 h-3')"></span>
</a>
</div>
</div>
</div>
<!-- Notes -->
@@ -480,18 +527,113 @@
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap" x-text="selectedOrderDetail?.internal_notes"></p>
</div>
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
@click="showDetailModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<div>
<!-- Mark as Shipped button (only for processing orders) -->
<button
x-show="selectedOrderDetail?.status === 'processing'"
@click="showDetailModal = false; openMarkAsShippedModal(selectedOrderDetail)"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
>
<span x-html="$icon('truck', 'w-4 h-4')"></span>
Mark as Shipped
</button>
</div>
<div class="flex items-center gap-3">
<button
@click="showDetailModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
Close
</button>
<button
@click="showDetailModal = false; openStatusModal(selectedOrderDetail)"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors"
>
Update Status
</button>
</div>
</div>
</div>
{% endcall %}
<!-- Mark as Shipped Modal -->
{% call modal_simple('markAsShippedModal', 'Mark Order as Shipped', show_var='showMarkAsShippedModal', size='sm') %}
<div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">
Mark order <span class="font-mono font-medium" x-text="selectedOrder?.order_number"></span> as shipped.
</p>
<!-- Shipment Info (read-only if from Letzshop) -->
<div x-show="selectedOrder?.shipment_number" class="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Letzshop Shipment</p>
<p class="text-sm font-mono text-gray-800 dark:text-gray-200" x-text="selectedOrder?.shipment_number"></p>
<p x-show="selectedOrder?.shipping_carrier" class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Carrier: <span class="capitalize" x-text="selectedOrder?.shipping_carrier"></span>
</p>
</div>
<!-- Tracking Number Input -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Tracking Number <span class="text-gray-400">(optional)</span>
</label>
<input
type="text"
x-model="shipForm.tracking_number"
placeholder="e.g., 3XYVi85dDE8l6bov97122"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-600"
/>
</div>
<!-- Tracking URL Input -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Tracking URL <span class="text-gray-400">(optional)</span>
</label>
<input
type="url"
x-model="shipForm.tracking_url"
placeholder="https://dispatchweb.fr/Tracky/Home/..."
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-600"
/>
</div>
<!-- Carrier Select (if not already set) -->
<div x-show="!selectedOrder?.shipping_carrier">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Carrier <span class="text-gray-400">(optional)</span>
</label>
<select
x-model="shipForm.shipping_carrier"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-600"
>
Close
<option value="">Select carrier...</option>
<option value="greco">Greco</option>
<option value="colissimo">Colissimo</option>
<option value="xpresslogistics">XpressLogistics</option>
<option value="dhl">DHL</option>
<option value="ups">UPS</option>
<option value="fedex">FedEx</option>
<option value="other">Other</option>
</select>
</div>
<div class="flex justify-end gap-3 pt-4">
<button
@click="showMarkAsShippedModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
>
Cancel
</button>
<button
@click="showDetailModal = false; openStatusModal(selectedOrderDetail)"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors"
@click="markAsShipped()"
:disabled="markingAsShipped"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Update Status
<span x-show="!markingAsShipped" x-html="$icon('truck', 'w-4 h-4')"></span>
<span x-show="markingAsShipped" class="animate-spin" x-html="$icon('refresh', 'w-4 h-4')"></span>
<span x-text="markingAsShipped ? 'Shipping...' : 'Mark as Shipped'"></span>
</button>
</div>
</div>

View File

@@ -5,7 +5,7 @@
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Product Exceptions</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Resolve unmatched products from order imports</p>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedVendor ? 'Resolve unmatched products from order imports' : 'All exceptions across vendors'"></p>
</div>
<button
@click="loadExceptions()"
@@ -106,6 +106,7 @@
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Product Info</th>
<th x-show="!selectedVendor" class="px-4 py-3">Vendor</th>
<th class="px-4 py-3">GTIN</th>
<th class="px-4 py-3">Order</th>
<th class="px-4 py-3">Status</th>
@@ -116,7 +117,7 @@
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="loadingExceptions && exceptions.length === 0">
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<td :colspan="selectedVendor ? 6 : 7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('spinner', 'w-6 h-6 mx-auto mb-2')"></span>
<p>Loading exceptions...</p>
</td>
@@ -124,7 +125,7 @@
</template>
<template x-if="!loadingExceptions && exceptions.length === 0">
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<td :colspan="selectedVendor ? 6 : 7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('check-circle', 'w-12 h-12 mx-auto mb-2 text-green-300')"></span>
<p class="font-medium">No exceptions found</p>
<p class="text-sm mt-1">All order items are properly matched to products</p>
@@ -141,6 +142,10 @@
</div>
</div>
</td>
<!-- Vendor column (only in cross-vendor view) -->
<td x-show="!selectedVendor" class="px-4 py-3 text-sm">
<p class="font-medium text-gray-700 dark:text-gray-200" x-text="exc.vendor_name || 'N/A'"></p>
</td>
<td class="px-4 py-3 text-sm">
<code class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded" x-text="exc.original_gtin || 'No GTIN'"></code>
</td>

View File

@@ -5,9 +5,10 @@
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Orders</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Manage Letzshop orders for this vendor</p>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedVendor ? 'Manage Letzshop orders for this vendor' : 'All Letzshop orders across vendors'"></p>
</div>
<div class="flex gap-2">
<!-- Import buttons only shown when vendor is selected -->
<div x-show="selectedVendor" class="flex gap-2">
<button
@click="importHistoricalOrders()"
:disabled="!letzshopStatus.is_configured || importingHistorical"
@@ -78,9 +79,9 @@
</div>
<!-- Status Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-5">
<!-- Connection Status -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="grid gap-6 mb-8" :class="selectedVendor ? 'md:grid-cols-5' : 'md:grid-cols-4'">
<!-- Connection Status (only when vendor selected) -->
<div x-show="selectedVendor" class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div :class="letzshopStatus.is_configured ? 'bg-green-100 dark:bg-green-900' : 'bg-gray-100 dark:bg-gray-700'" class="p-3 mr-4 rounded-full">
<span x-html="$icon(letzshopStatus.is_configured ? 'check' : 'x', letzshopStatus.is_configured ? 'w-5 h-5 text-green-500' : 'w-5 h-5 text-gray-400')"></span>
</div>
@@ -182,8 +183,8 @@
</button>
</div>
<!-- Not Configured Warning -->
<div x-show="!letzshopStatus.is_configured" class="mb-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
<!-- Not Configured Warning (only when vendor selected) -->
<div x-show="selectedVendor && !letzshopStatus.is_configured" class="mb-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
<div class="flex items-center">
<span x-html="$icon('exclamation', 'w-5 h-5 text-yellow-500 mr-3')"></span>
<div>
@@ -194,12 +195,13 @@
</div>
<!-- Orders Table -->
<div class="w-full overflow-hidden rounded-lg shadow-xs" x-show="letzshopStatus.is_configured">
<div class="w-full overflow-hidden rounded-lg shadow-xs">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-no-wrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Order</th>
<th x-show="!selectedVendor" class="px-4 py-3">Vendor</th>
<th class="px-4 py-3">Customer</th>
<th class="px-4 py-3">Total</th>
<th class="px-4 py-3">Status</th>
@@ -210,7 +212,7 @@
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="loadingOrders && orders.length === 0">
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<td :colspan="selectedVendor ? 6 : 7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('spinner', 'w-6 h-6 mx-auto mb-2')"></span>
<p>Loading orders...</p>
</td>
@@ -218,10 +220,10 @@
</template>
<template x-if="!loadingOrders && orders.length === 0">
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<td :colspan="selectedVendor ? 6 : 7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('inbox', 'w-12 h-12 mx-auto mb-2 text-gray-300')"></span>
<p class="font-medium">No orders found</p>
<p class="text-sm mt-1">Click "Import Orders" to fetch orders from Letzshop</p>
<p class="text-sm mt-1" x-text="selectedVendor ? 'Click \"Import Orders\" to fetch orders from Letzshop' : 'Select a vendor to import orders'"></p>
</td>
</tr>
</template>
@@ -235,6 +237,10 @@
</div>
</div>
</td>
<!-- Vendor column (only in cross-vendor view) -->
<td x-show="!selectedVendor" class="px-4 py-3 text-sm">
<p class="font-medium text-gray-700 dark:text-gray-200" x-text="order.vendor_name || 'N/A'"></p>
</td>
<td class="px-4 py-3 text-sm">
<p x-text="order.customer_email || 'N/A'"></p>
</td>

View File

@@ -35,6 +35,32 @@
</p>
</div>
<!-- Test Mode -->
<div class="mb-4">
<label class="flex items-center cursor-pointer">
<input
type="checkbox"
x-model="settingsForm.test_mode_enabled"
class="form-checkbox h-5 w-5 text-orange-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:ring-orange-500"
/>
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-400">Test Mode</span>
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-8">
When enabled, operations (confirm, reject, tracking) will NOT be sent to Letzshop API
</p>
</div>
<!-- Test Mode Warning -->
<div x-show="settingsForm.test_mode_enabled" class="mb-4 p-3 bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg">
<div class="flex items-center">
<span x-html="$icon('exclamation', 'w-5 h-5 text-orange-500 mr-2')"></span>
<span class="text-sm text-orange-700 dark:text-orange-300 font-medium">Test Mode Active</span>
</div>
<p class="mt-1 text-xs text-orange-600 dark:text-orange-400 ml-7">
All Letzshop API mutations are disabled. Orders can be imported but confirmations/rejections will only be saved locally.
</p>
</div>
<!-- Auto Sync -->
<div class="mb-4">
<label class="flex items-center cursor-pointer">
@@ -209,4 +235,106 @@
</div>
</div>
</div>
<!-- Carrier Settings Card -->
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 lg:col-span-2">
<div class="p-6">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Carrier Settings
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
Configure default carrier and label URL prefixes for shipping labels.
</p>
<form @submit.prevent="saveCarrierSettings()">
<div class="grid gap-6 lg:grid-cols-2">
<!-- Default Carrier -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Default Carrier
</label>
<select
x-model="settingsForm.default_carrier"
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
>
<option value="">-- Select carrier --</option>
<option value="greco">Greco</option>
<option value="colissimo">Colissimo</option>
<option value="xpresslogistics">XpressLogistics</option>
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Letzshop automatically assigns carriers based on shipment data
</p>
</div>
<!-- Placeholder for alignment -->
<div></div>
<!-- Greco Label URL -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
<span class="inline-flex items-center">
<span class="w-3 h-3 rounded-full bg-blue-500 mr-2"></span>
Greco Label URL Prefix
</span>
</label>
<input
x-model="settingsForm.carrier_greco_label_url"
type="url"
placeholder="https://dispatchweb.fr/Tracky/Home/"
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Label URL = Prefix + Shipment Number (e.g., H74683403433)
</p>
</div>
<!-- Colissimo Label URL -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
<span class="inline-flex items-center">
<span class="w-3 h-3 rounded-full bg-yellow-500 mr-2"></span>
Colissimo Label URL Prefix
</span>
</label>
<input
x-model="settingsForm.carrier_colissimo_label_url"
type="url"
placeholder="https://..."
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
/>
</div>
<!-- XpressLogistics Label URL -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
<span class="inline-flex items-center">
<span class="w-3 h-3 rounded-full bg-green-500 mr-2"></span>
XpressLogistics Label URL Prefix
</span>
</label>
<input
x-model="settingsForm.carrier_xpresslogistics_label_url"
type="url"
placeholder="https://..."
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
/>
</div>
</div>
<!-- Save Button -->
<div class="mt-6">
<button
type="submit"
:disabled="savingCarrierSettings"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
>
<span x-show="!savingCarrierSettings" x-html="$icon('save', 'w-4 h-4 mr-2')"></span>
<span x-show="savingCarrierSettings" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="savingCarrierSettings ? 'Saving...' : 'Save Carrier Settings'"></span>
</button>
</div>
</form>
</div>
</div>
</div>

View File

@@ -19,6 +19,7 @@
<!-- Settings Categories Tabs -->
{% call tabs_nav() %}
{{ tab_button('logging', 'Logging', icon='document-text') }}
{{ tab_button('shipping', 'Shipping', icon='truck') }}
{{ tab_button('system', 'System', icon='cog') }}
{{ tab_button('security', 'Security', icon='shield-check') }}
{{ tab_button('notifications', 'Notifications', icon='bell') }}
@@ -156,6 +157,102 @@
</div>
</div>
<!-- Shipping Settings Tab -->
<div x-show="activeTab === 'shipping'" x-transition>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">
Shipping & Carrier Configuration
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
Configure shipping carrier label URL prefixes. These are used to generate shipping label download links.
</p>
<!-- Carrier Label URL Settings -->
<div class="space-y-6">
<!-- Greco (Letzshop default) -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<span class="flex items-center gap-2">
<span class="px-2 py-0.5 text-xs font-medium bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 rounded">Greco</span>
Label URL Prefix
</span>
</label>
<input
type="url"
x-model="shippingSettings.carrier_greco_label_url"
placeholder="https://dispatchweb.fr/Tracky/Home/"
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
/>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
The shipment number will be appended to this URL. Default for Letzshop: <code class="bg-gray-100 dark:bg-gray-700 px-1 rounded">https://dispatchweb.fr/Tracky/Home/</code>
</p>
</div>
<!-- Colissimo -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<span class="flex items-center gap-2">
<span class="px-2 py-0.5 text-xs font-medium bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 rounded">Colissimo</span>
Label URL Prefix
</span>
</label>
<input
type="url"
x-model="shippingSettings.carrier_colissimo_label_url"
placeholder="https://www.laposte.fr/outils/suivre-vos-envois?code="
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
/>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Enter the tracking URL prefix for Colissimo shipments.
</p>
</div>
<!-- XpressLogistics -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<span class="flex items-center gap-2">
<span class="px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded">XpressLogistics</span>
Label URL Prefix
</span>
</label>
<input
type="url"
x-model="shippingSettings.carrier_xpresslogistics_label_url"
placeholder="https://tracking.xpresslogistics.com/"
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
/>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Enter the tracking URL prefix for XpressLogistics shipments.
</p>
</div>
</div>
<!-- Info Box -->
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div class="flex items-start">
<span x-html="$icon('information-circle', 'w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3 flex-shrink-0')"></span>
<div class="text-sm text-blue-800 dark:text-blue-200">
<p class="font-medium mb-1">How label URLs work</p>
<p>When viewing an order, the system will combine the URL prefix with the shipment number to create a downloadable label link.</p>
<p class="mt-1">Example: <code class="bg-blue-100 dark:bg-blue-800 px-1 rounded">https://dispatchweb.fr/Tracky/Home/</code> + <code class="bg-blue-100 dark:bg-blue-800 px-1 rounded">H74683403433</code></p>
</div>
</div>
</div>
<!-- Save Button -->
<div class="flex items-center justify-end pt-6 mt-6 border-t border-gray-200 dark:border-gray-700">
<button
@click="saveShippingSettings()"
:disabled="saving"
class="px-6 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!saving">Save Shipping Settings</span>
<span x-show="saving">Saving...</span>
</button>
</div>
</div>
</div>
<!-- System Settings Tab -->
<div x-show="activeTab === 'system'" x-transition>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">

View File

@@ -0,0 +1,16 @@
# app/utils/__init__.py
"""Utility modules for the application."""
from app.utils.money import (
Money,
cents_to_euros,
euros_to_cents,
parse_price_to_cents,
)
__all__ = [
"Money",
"cents_to_euros",
"euros_to_cents",
"parse_price_to_cents",
]

View File

@@ -18,6 +18,7 @@ import requests
from sqlalchemy import literal
from sqlalchemy.orm import Session
from app.utils.money import euros_to_cents
from models.database.marketplace_import_job import MarketplaceImportError
from models.database.marketplace_product import MarketplaceProduct
from models.database.marketplace_product_translation import (
@@ -154,8 +155,18 @@ class CSVProcessor:
logger.info(f"Normalized columns: {list(df.columns)}")
return df
def _parse_price_to_numeric(self, price_str: str | None) -> float | None:
"""Parse price string like '19.99 EUR' to float."""
def _parse_price_to_cents(self, price_str: str | None) -> int | None:
"""Parse price string like '19.99 EUR' to integer cents.
Uses the money utility for precise conversion.
Example: '19.99 EUR' -> 1999
Args:
price_str: Price string with optional currency
Returns:
Price in integer cents, or None if parsing fails
"""
if not price_str:
return None
@@ -164,8 +175,9 @@ class CSVProcessor:
if numbers:
num_str = numbers[0].replace(",", ".")
try:
return float(num_str)
except ValueError:
# Convert euros to cents using money utility
return euros_to_cents(num_str)
except (ValueError, TypeError):
pass
return None
@@ -185,10 +197,10 @@ class CSVProcessor:
parsed_price, currency = self.price_processor.parse_price_currency(
processed_data["price"]
)
# Store both raw price string and numeric value
# Store both raw price string and numeric value in cents
raw_price = processed_data["price"]
processed_data["price"] = parsed_price
processed_data["price_numeric"] = self._parse_price_to_numeric(raw_price)
processed_data["price_cents"] = self._parse_price_to_cents(raw_price)
processed_data["currency"] = currency
# Process sale_price
@@ -198,7 +210,7 @@ class CSVProcessor:
processed_data["sale_price"]
)
processed_data["sale_price"] = parsed_sale_price
processed_data["sale_price_numeric"] = self._parse_price_to_numeric(
processed_data["sale_price_cents"] = self._parse_price_to_cents(
raw_sale_price
)

364
app/utils/money.py Normal file
View File

@@ -0,0 +1,364 @@
# app/utils/money.py
"""
Money handling utilities using integer cents.
All monetary values are stored as integers representing cents.
This eliminates floating-point precision issues common in financial applications.
Example:
€105.91 is stored as 10591 (integer cents)
Usage:
from app.utils.money import euros_to_cents, cents_to_euros, Money
# Convert euros to cents for storage
price_cents = euros_to_cents(105.91) # Returns 10591
# Convert cents to euros for display
price_euros = cents_to_euros(10591) # Returns 105.91
# Format for display
formatted = Money.format(10591) # Returns "105.91"
formatted = Money.format(10591, "EUR") # Returns "105.91 EUR"
# Parse price string from CSV/API
cents = parse_price_to_cents("19.99 EUR") # Returns 1999
See docs/architecture/money-handling.md for full documentation.
"""
import re
from decimal import ROUND_HALF_UP, Decimal
from typing import Union
# Type alias for clarity
Cents = int
Euros = float
# Currency configurations
CURRENCY_DECIMALS = {
"EUR": 2,
"USD": 2,
"GBP": 2,
"CHF": 2,
"JPY": 0, # Japanese Yen has no decimal places
}
DEFAULT_CURRENCY = "EUR"
def euros_to_cents(euros: Union[float, str, Decimal, int, None]) -> Cents:
"""
Convert a euro amount to cents.
Uses Decimal internally for precise conversion, avoiding floating-point errors.
Args:
euros: Amount in euros (float, string, Decimal, int, or None)
Returns:
Amount in cents as integer
Examples:
>>> euros_to_cents(105.91)
10591
>>> euros_to_cents("19.99")
1999
>>> euros_to_cents(None)
0
"""
if euros is None:
return 0
if isinstance(euros, int):
# Assume already an integer euro amount (rare)
return euros * 100
if isinstance(euros, str):
# Clean the string: remove currency symbols, spaces, handle comma decimals
cleaned = euros.strip()
cleaned = re.sub(r"[€$£\s]", "", cleaned)
cleaned = cleaned.replace(",", ".")
euros = cleaned
# Use Decimal for precise conversion
if isinstance(euros, Decimal):
d = euros
else:
# Convert via string to avoid float precision issues
d = Decimal(str(euros))
# Multiply by 100 and round to nearest integer
cents = d * Decimal("100")
return int(cents.quantize(Decimal("1"), rounding=ROUND_HALF_UP))
def cents_to_euros(cents: Union[int, None]) -> Euros:
"""
Convert cents to euros.
Args:
cents: Amount in cents (integer or None)
Returns:
Amount in euros as float, rounded to 2 decimal places
Examples:
>>> cents_to_euros(10591)
105.91
>>> cents_to_euros(None)
0.0
"""
if cents is None:
return 0.0
# Simple integer division, then convert to float
# Using Decimal to ensure exact representation
euros = Decimal(cents) / Decimal("100")
return float(euros.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
def parse_price_to_cents(
price_str: Union[str, float, int, None],
default_currency: str = DEFAULT_CURRENCY,
) -> tuple[Cents, str]:
"""
Parse a price string and return cents and currency.
Handles various formats:
- "19.99 EUR"
- "19,99 €"
- "19.99"
- 19.99 (float)
Args:
price_str: Price string or numeric value
default_currency: Currency to use if not specified in string
Returns:
Tuple of (cents, currency_code)
Examples:
>>> parse_price_to_cents("19.99 EUR")
(1999, "EUR")
>>> parse_price_to_cents("19,99 €")
(1999, "EUR")
>>> parse_price_to_cents(19.99)
(1999, "EUR")
"""
if price_str is None:
return (0, default_currency)
if isinstance(price_str, (int, float)):
return (euros_to_cents(price_str), default_currency)
# Parse string
price_str = str(price_str).strip()
# Extract currency if present
currency = default_currency
currency_patterns = [
(r"\bEUR\b", "EUR"),
(r"", "EUR"),
(r"\bUSD\b", "USD"),
(r"\$", "USD"),
(r"\bGBP\b", "GBP"),
(r"£", "GBP"),
(r"\bCHF\b", "CHF"),
]
for pattern, curr in currency_patterns:
if re.search(pattern, price_str, re.IGNORECASE):
currency = curr
break
# Remove currency symbols and extract numeric part
numeric_str = re.sub(r"[€$£a-zA-Z\s]", "", price_str)
# Handle European decimal comma
numeric_str = numeric_str.replace(",", ".")
# Handle thousand separators (e.g., "1.000,00" -> "1000.00")
# If there are multiple dots, assume the last one is decimal
parts = numeric_str.split(".")
if len(parts) > 2:
# Multiple dots: join all but last as thousands, last as decimal
numeric_str = "".join(parts[:-1]) + "." + parts[-1]
try:
cents = euros_to_cents(numeric_str)
except (ValueError, TypeError):
cents = 0
return (cents, currency)
class Money:
"""
Money utility class for formatting and operations.
This class provides static methods for money operations.
All internal values are in cents (integers).
"""
@staticmethod
def from_euros(euros: Union[float, str, Decimal, None]) -> Cents:
"""Create cents from euro amount."""
return euros_to_cents(euros)
@staticmethod
def from_cents(cents: Union[int, None]) -> Cents:
"""Passthrough for cents (for consistency)."""
return cents if cents is not None else 0
@staticmethod
def to_euros(cents: Union[int, None]) -> Euros:
"""Convert cents to euros."""
return cents_to_euros(cents)
@staticmethod
def format(
cents: Union[int, None],
currency: str = "",
locale: str = "en",
) -> str:
"""
Format cents as a currency string.
Args:
cents: Amount in cents
currency: Currency code to append (optional)
locale: Locale for formatting (en, de, fr)
Returns:
Formatted price string
Examples:
>>> Money.format(10591)
"105.91"
>>> Money.format(10591, "EUR")
"105.91 EUR"
>>> Money.format(10591, "EUR", "de")
"105,91 EUR"
"""
if cents is None:
cents = 0
euros = cents_to_euros(cents)
# Format based on locale
if locale in ("de", "fr", "lb"):
# European format: comma decimal, dot thousands
formatted = f"{euros:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
else:
# English format: dot decimal, comma thousands
formatted = f"{euros:,.2f}"
if currency:
return f"{formatted} {currency}"
return formatted
@staticmethod
def parse(price_str: Union[str, float, int, None]) -> Cents:
"""
Parse a price value to cents.
Args:
price_str: Price string or numeric value
Returns:
Amount in cents
"""
cents, _ = parse_price_to_cents(price_str)
return cents
@staticmethod
def add(*amounts: Union[int, None]) -> Cents:
"""
Add multiple cent amounts.
Args:
*amounts: Cent amounts to add
Returns:
Sum in cents
"""
return sum(a for a in amounts if a is not None)
@staticmethod
def subtract(amount: int, *deductions: Union[int, None]) -> Cents:
"""
Subtract amounts from a base amount.
Args:
amount: Base amount in cents
*deductions: Amounts to subtract
Returns:
Result in cents
"""
result = amount
for d in deductions:
if d is not None:
result -= d
return result
@staticmethod
def multiply(cents: int, quantity: int) -> Cents:
"""
Multiply cents by quantity.
Args:
cents: Unit price in cents
quantity: Number of units
Returns:
Total in cents
"""
return cents * quantity
@staticmethod
def calculate_line_total(unit_price_cents: int, quantity: int) -> Cents:
"""
Calculate line total for an order item.
Args:
unit_price_cents: Price per unit in cents
quantity: Number of units
Returns:
Line total in cents
"""
return unit_price_cents * quantity
@staticmethod
def calculate_order_total(
subtotal_cents: int,
tax_cents: int = 0,
shipping_cents: int = 0,
discount_cents: int = 0,
) -> Cents:
"""
Calculate order total.
Args:
subtotal_cents: Sum of line items in cents
tax_cents: Tax amount in cents
shipping_cents: Shipping cost in cents
discount_cents: Discount amount in cents
Returns:
Total in cents
"""
return subtotal_cents + tax_cents + shipping_cents - discount_cents
# Convenience exports
__all__ = [
"euros_to_cents",
"cents_to_euros",
"parse_price_to_cents",
"Money",
"Cents",
"Euros",
]