feat: integer cents money handling, order page fixes, and vendor filter persistence

Money Handling Architecture:
- Store all monetary values as integer cents (€105.91 = 10591)
- Add app/utils/money.py with Money class and conversion helpers
- Add static/shared/js/money.js for frontend formatting
- Update all database models to use _cents columns (Product, Order, etc.)
- Update CSV processor to convert prices to cents on import
- Add Alembic migration for Float to Integer conversion
- Create .architecture-rules/money.yaml with 7 validation rules
- Add docs/architecture/money-handling.md documentation

Order Details Page Fixes:
- Fix customer name showing 'undefined undefined' - use flat field names
- Fix vendor info empty - add vendor_name/vendor_code to OrderDetailResponse
- Fix shipping address using wrong nested object structure
- Enrich order detail API response with vendor info

Vendor Filter Persistence Fixes:
- Fix orders.js: restoreSavedVendor now sets selectedVendor and filters
- Fix orders.js: init() only loads orders if no saved vendor to restore
- Fix marketplace-letzshop.js: restoreSavedVendor calls selectVendor()
- Fix marketplace-letzshop.js: clearVendorSelection clears TomSelect dropdown
- Align vendor selector placeholder text between pages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-20 20:33:48 +01:00
parent 7f0d32c18d
commit a19c84ea4e
56 changed files with 6155 additions and 447 deletions

View File

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

View File

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

View File

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