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