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:
@@ -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)],
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
364
app/utils/money.py
Normal 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",
|
||||
]
|
||||
Reference in New Issue
Block a user