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