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:
@@ -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()
|
||||
|
||||
249
tests/integration/api/v1/admin/test_order_item_exceptions.py
Normal file
249
tests/integration/api/v1/admin/test_order_item_exceptions.py
Normal 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
|
||||
178
tests/integration/api/v1/vendor/test_letzshop.py
vendored
178
tests/integration/api/v1/vendor/test_letzshop.py
vendored
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user