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

@@ -246,16 +246,33 @@ class TestAdminLetzshopOrdersAPI:
def test_list_vendor_orders_with_data(self, client, db, admin_headers, test_vendor):
"""Test listing vendor orders with data."""
from models.database.letzshop import LetzshopOrder
from models.database.order import Order
# Create test orders
order = LetzshopOrder(
# Create test order using unified Order model with all required fields
order = Order(
vendor_id=test_vendor.id,
letzshop_order_id="admin_order_1",
letzshop_state="unconfirmed",
customer_id=1,
order_number=f"LS-{test_vendor.id}-admin_order_1",
channel="letzshop",
external_order_id="admin_order_1",
status="pending",
customer_first_name="Admin",
customer_last_name="Test",
customer_email="admin-test@example.com",
total_amount="150.00",
sync_status="pending",
ship_first_name="Admin",
ship_last_name="Test",
ship_address_line_1="123 Test Street",
ship_city="Luxembourg",
ship_postal_code="1234",
ship_country_iso="LU",
bill_first_name="Admin",
bill_last_name="Test",
bill_address_line_1="123 Test Street",
bill_city="Luxembourg",
bill_postal_code="1234",
bill_country_iso="LU",
total_amount_cents=15000, # €150.00
currency="EUR",
)
db.add(order)
db.commit()

View File

@@ -0,0 +1,249 @@
# tests/integration/api/v1/admin/test_order_item_exceptions.py
"""
Integration tests for admin order item exception endpoints.
Tests the /api/v1/admin/order-exceptions/* endpoints.
All endpoints require admin JWT authentication.
"""
import pytest
from datetime import datetime, timezone
from models.database.order import OrderItem
from models.database.order_item_exception import OrderItemException
@pytest.fixture
def test_exception(db, test_order, test_product, test_vendor):
"""Create a test order item exception."""
# Create an order item
order_item = OrderItem(
order_id=test_order.id,
product_id=test_product.id,
product_name="Unmatched Product",
product_sku="UNMATCHED-001",
quantity=1,
unit_price=25.00,
total_price=25.00,
needs_product_match=True,
)
db.add(order_item)
db.commit()
db.refresh(order_item)
# Create exception
exception = OrderItemException(
order_item_id=order_item.id,
vendor_id=test_vendor.id,
original_gtin="4006381333931",
original_product_name="Test Missing Product",
original_sku="MISSING-SKU-001",
exception_type="product_not_found",
status="pending",
)
db.add(exception)
db.commit()
db.refresh(exception)
return exception
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.admin
class TestAdminOrderItemExceptionAPI:
"""Tests for admin order item exception endpoints."""
# ========================================================================
# List & Statistics Tests
# ========================================================================
def test_list_exceptions(self, client, admin_headers, test_exception):
"""Test listing order item exceptions."""
response = client.get(
"/api/v1/admin/order-exceptions",
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert "exceptions" in data
assert "total" in data
assert data["total"] >= 1
def test_list_exceptions_non_admin(self, client, auth_headers):
"""Test non-admin cannot access exceptions endpoint."""
response = client.get(
"/api/v1/admin/order-exceptions",
headers=auth_headers,
)
assert response.status_code == 403
def test_list_exceptions_with_status_filter(
self, client, admin_headers, test_exception
):
"""Test filtering exceptions by status."""
response = client.get(
"/api/v1/admin/order-exceptions",
params={"status": "pending"},
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
for exc in data["exceptions"]:
assert exc["status"] == "pending"
def test_get_exception_stats(self, client, admin_headers, test_exception):
"""Test getting exception statistics."""
response = client.get(
"/api/v1/admin/order-exceptions/stats",
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert "pending" in data
assert "resolved" in data
assert "ignored" in data
assert "total" in data
assert data["pending"] >= 1
# ========================================================================
# Get Single Exception
# ========================================================================
def test_get_exception_by_id(self, client, admin_headers, test_exception):
"""Test getting a single exception by ID."""
response = client.get(
f"/api/v1/admin/order-exceptions/{test_exception.id}",
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["id"] == test_exception.id
assert data["original_gtin"] == test_exception.original_gtin
assert data["status"] == "pending"
def test_get_exception_not_found(self, client, admin_headers):
"""Test getting non-existent exception."""
response = client.get(
"/api/v1/admin/order-exceptions/99999",
headers=admin_headers,
)
assert response.status_code == 404
# ========================================================================
# Resolution Tests
# ========================================================================
def test_resolve_exception(
self, client, admin_headers, test_exception, test_product
):
"""Test resolving an exception by assigning a product."""
response = client.post(
f"/api/v1/admin/order-exceptions/{test_exception.id}/resolve",
headers=admin_headers,
json={
"product_id": test_product.id,
"notes": "Matched to existing product",
},
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "resolved"
assert data["resolved_product_id"] == test_product.id
assert data["resolution_notes"] == "Matched to existing product"
def test_ignore_exception(self, client, admin_headers, test_exception):
"""Test ignoring an exception."""
response = client.post(
f"/api/v1/admin/order-exceptions/{test_exception.id}/ignore",
headers=admin_headers,
json={
"notes": "Product discontinued, will never be matched",
},
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "ignored"
assert "discontinued" in data["resolution_notes"]
def test_resolve_already_resolved(
self, client, admin_headers, db, test_exception, test_product
):
"""Test that resolving an already resolved exception fails."""
# First resolve it
test_exception.status = "resolved"
test_exception.resolved_product_id = test_product.id
test_exception.resolved_at = datetime.now(timezone.utc)
db.commit()
# Try to resolve again
response = client.post(
f"/api/v1/admin/order-exceptions/{test_exception.id}/resolve",
headers=admin_headers,
json={
"product_id": test_product.id,
},
)
assert response.status_code == 400
# ========================================================================
# Bulk Resolution Tests
# ========================================================================
def test_bulk_resolve_by_gtin(
self, client, admin_headers, db, test_order, test_product, test_vendor
):
"""Test bulk resolving exceptions by GTIN."""
gtin = "9876543210123"
# Create multiple exceptions for the same GTIN
for i in range(3):
order_item = OrderItem(
order_id=test_order.id,
product_id=test_product.id,
product_name=f"Product {i}",
product_sku=f"SKU-{i}",
quantity=1,
unit_price=10.00,
total_price=10.00,
needs_product_match=True,
)
db.add(order_item)
db.commit()
exception = OrderItemException(
order_item_id=order_item.id,
vendor_id=test_vendor.id,
original_gtin=gtin,
original_product_name=f"Product {i}",
exception_type="product_not_found",
)
db.add(exception)
db.commit()
# Bulk resolve
response = client.post(
"/api/v1/admin/order-exceptions/bulk-resolve",
params={"vendor_id": test_vendor.id},
headers=admin_headers,
json={
"gtin": gtin,
"product_id": test_product.id,
"notes": "Bulk resolved during import",
},
)
assert response.status_code == 200
data = response.json()
assert data["resolved_count"] == 3
assert data["gtin"] == gtin
assert data["product_id"] == test_product.id

View File

@@ -238,50 +238,105 @@ class TestVendorLetzshopOrdersAPI:
self, client, db, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test listing orders with status filter."""
from models.database.letzshop import LetzshopOrder
from models.database.order import Order
# Create test orders
order1 = LetzshopOrder(
# Create test orders using unified Order model with all required fields
order1 = Order(
vendor_id=test_vendor_with_vendor_user.id,
letzshop_order_id="order_1",
letzshop_state="unconfirmed",
sync_status="pending",
customer_id=1,
order_number=f"LS-{test_vendor_with_vendor_user.id}-order_1",
channel="letzshop",
external_order_id="order_1",
status="pending",
customer_first_name="Test",
customer_last_name="User",
customer_email="test1@example.com",
ship_first_name="Test",
ship_last_name="User",
ship_address_line_1="123 Test Street",
ship_city="Luxembourg",
ship_postal_code="1234",
ship_country_iso="LU",
bill_first_name="Test",
bill_last_name="User",
bill_address_line_1="123 Test Street",
bill_city="Luxembourg",
bill_postal_code="1234",
bill_country_iso="LU",
total_amount_cents=10000,
currency="EUR",
)
order2 = LetzshopOrder(
order2 = Order(
vendor_id=test_vendor_with_vendor_user.id,
letzshop_order_id="order_2",
letzshop_state="confirmed",
sync_status="confirmed",
customer_id=1,
order_number=f"LS-{test_vendor_with_vendor_user.id}-order_2",
channel="letzshop",
external_order_id="order_2",
status="processing",
customer_first_name="Test",
customer_last_name="User",
customer_email="test2@example.com",
ship_first_name="Test",
ship_last_name="User",
ship_address_line_1="456 Test Avenue",
ship_city="Luxembourg",
ship_postal_code="5678",
ship_country_iso="LU",
bill_first_name="Test",
bill_last_name="User",
bill_address_line_1="456 Test Avenue",
bill_city="Luxembourg",
bill_postal_code="5678",
bill_country_iso="LU",
total_amount_cents=20000,
currency="EUR",
)
db.add_all([order1, order2])
db.commit()
# List pending only
response = client.get(
"/api/v1/vendor/letzshop/orders?sync_status=pending",
"/api/v1/vendor/letzshop/orders?status=pending",
headers=vendor_user_headers,
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert data["orders"][0]["sync_status"] == "pending"
assert data["orders"][0]["status"] == "pending"
def test_get_order_detail(
self, client, db, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test getting order detail."""
from models.database.letzshop import LetzshopOrder
from models.database.order import Order
order = LetzshopOrder(
order = Order(
vendor_id=test_vendor_with_vendor_user.id,
letzshop_order_id="order_detail_test",
letzshop_shipment_id="shipment_1",
letzshop_state="unconfirmed",
customer_id=1,
order_number=f"LS-{test_vendor_with_vendor_user.id}-order_detail_test",
channel="letzshop",
external_order_id="order_detail_test",
external_shipment_id="shipment_1",
status="pending",
customer_first_name="Test",
customer_last_name="User",
customer_email="test@example.com",
total_amount="99.99",
sync_status="pending",
raw_order_data={"test": "data"},
ship_first_name="Test",
ship_last_name="User",
ship_address_line_1="123 Test Street",
ship_city="Luxembourg",
ship_postal_code="1234",
ship_country_iso="LU",
bill_first_name="Test",
bill_last_name="User",
bill_address_line_1="123 Test Street",
bill_city="Luxembourg",
bill_postal_code="1234",
bill_country_iso="LU",
total_amount_cents=9999, # €99.99
currency="EUR",
external_data={"test": "data"},
)
db.add(order)
db.commit()
@@ -293,9 +348,9 @@ class TestVendorLetzshopOrdersAPI:
assert response.status_code == 200
data = response.json()
assert data["letzshop_order_id"] == "order_detail_test"
assert data["external_order_id"] == "order_detail_test"
assert data["customer_email"] == "test@example.com"
assert data["raw_order_data"] == {"test": "data"}
assert data["external_data"] == {"test": "data"}
def test_get_order_not_found(
self, client, vendor_user_headers, test_vendor_with_vendor_user
@@ -393,18 +448,50 @@ class TestVendorLetzshopFulfillmentAPI:
test_vendor_with_vendor_user,
):
"""Test confirming an order."""
from models.database.letzshop import LetzshopOrder
from models.database.order import Order, OrderItem
# Create test order
order = LetzshopOrder(
# Create test order using unified Order model with all required fields
order = Order(
vendor_id=test_vendor_with_vendor_user.id,
letzshop_order_id="order_confirm",
letzshop_shipment_id="shipment_1",
letzshop_state="unconfirmed",
sync_status="pending",
inventory_units=[{"id": "unit_1", "state": "unconfirmed"}],
customer_id=1,
order_number=f"LS-{test_vendor_with_vendor_user.id}-order_confirm",
channel="letzshop",
external_order_id="order_confirm",
external_shipment_id="shipment_1",
status="pending",
customer_first_name="Test",
customer_last_name="User",
customer_email="test@example.com",
ship_first_name="Test",
ship_last_name="User",
ship_address_line_1="123 Test Street",
ship_city="Luxembourg",
ship_postal_code="1234",
ship_country_iso="LU",
bill_first_name="Test",
bill_last_name="User",
bill_address_line_1="123 Test Street",
bill_city="Luxembourg",
bill_postal_code="1234",
bill_country_iso="LU",
total_amount_cents=10000,
currency="EUR",
)
db.add(order)
db.flush()
# Add order item
item = OrderItem(
order_id=order.id,
product_id=1,
product_name="Test Product",
quantity=1,
unit_price_cents=10000,
total_price_cents=10000,
external_item_id="unit_1",
item_state="unconfirmed",
)
db.add(item)
db.commit()
# Save credentials
@@ -447,14 +534,33 @@ class TestVendorLetzshopFulfillmentAPI:
test_vendor_with_vendor_user,
):
"""Test setting tracking information."""
from models.database.letzshop import LetzshopOrder
from models.database.order import Order
order = LetzshopOrder(
order = Order(
vendor_id=test_vendor_with_vendor_user.id,
letzshop_order_id="order_tracking",
letzshop_shipment_id="shipment_track",
letzshop_state="confirmed",
sync_status="confirmed",
customer_id=1,
order_number=f"LS-{test_vendor_with_vendor_user.id}-order_tracking",
channel="letzshop",
external_order_id="order_tracking",
external_shipment_id="shipment_track",
status="processing", # confirmed state
customer_first_name="Test",
customer_last_name="User",
customer_email="test@example.com",
ship_first_name="Test",
ship_last_name="User",
ship_address_line_1="123 Test Street",
ship_city="Luxembourg",
ship_postal_code="1234",
ship_country_iso="LU",
bill_first_name="Test",
bill_last_name="User",
bill_address_line_1="123 Test Street",
bill_city="Luxembourg",
bill_postal_code="1234",
bill_country_iso="LU",
total_amount_cents=10000,
currency="EUR",
)
db.add(order)
db.commit()