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

@@ -581,7 +581,7 @@ class TestLetzshopOrderService:
"id": "order_123",
"number": "R123456",
"email": "test@example.com",
"total": 29.99,
"total": "29.99 EUR",
"locale": "fr",
"shipAddress": {
"firstName": "Jean",
@@ -598,8 +598,8 @@ class TestLetzshopOrderService:
order = service.create_order(test_vendor.id, shipment_data)
assert order.customer_locale == "fr"
assert order.shipping_country_iso == "LU"
assert order.billing_country_iso == "FR"
assert order.ship_country_iso == "LU" # Correct attribute name
assert order.bill_country_iso == "FR" # Correct attribute name
def test_create_order_extracts_ean(self, db, test_vendor):
"""Test that create_order extracts EAN from tradeId."""
@@ -640,14 +640,15 @@ class TestLetzshopOrderService:
order = service.create_order(test_vendor.id, shipment_data)
assert len(order.inventory_units) == 1
unit = order.inventory_units[0]
assert unit["ean"] == "0889698273022"
assert unit["ean_type"] == "gtin13"
assert unit["sku"] == "SKU123"
assert unit["mpn"] == "MPN456"
assert unit["product_name"] == "Test Product"
assert unit["price"] == 19.99
# Check order items (unified model uses items relationship)
assert len(order.items) == 1
item = order.items[0]
assert item.gtin == "0889698273022"
assert item.gtin_type == "gtin13"
assert item.product_sku == "SKU123"
assert item.product_name == "Test Product"
# Price is stored in cents (19.99 EUR = 1999 cents)
assert item.unit_price_cents == 1999
def test_import_historical_shipments_deduplication(self, db, test_vendor):
"""Test that historical import deduplicates existing orders."""

View File

@@ -0,0 +1,570 @@
# tests/unit/services/test_order_item_exception_service.py
"""Unit tests for OrderItemExceptionService."""
import pytest
from app.exceptions import (
ExceptionAlreadyResolvedException,
InvalidProductForExceptionException,
OrderItemExceptionNotFoundException,
ProductNotFoundException,
)
from app.services.order_item_exception_service import OrderItemExceptionService
from models.database.order import OrderItem
from models.database.order_item_exception import OrderItemException
@pytest.fixture
def exception_service():
"""Create an OrderItemExceptionService instance."""
return OrderItemExceptionService()
@pytest.mark.unit
class TestOrderItemExceptionServiceCreate:
"""Test exception creation."""
def test_create_exception(
self, db, exception_service, test_order_item, test_vendor
):
"""Test creating an exception."""
exception = exception_service.create_exception(
db=db,
order_item=test_order_item,
vendor_id=test_vendor.id,
original_gtin="4006381333931",
original_product_name="Test Product",
original_sku="SKU-001",
exception_type="product_not_found",
)
db.commit()
assert exception.id is not None
assert exception.order_item_id == test_order_item.id
assert exception.vendor_id == test_vendor.id
assert exception.original_gtin == "4006381333931"
assert exception.status == "pending"
def test_create_exception_no_gtin(
self, db, exception_service, test_order_item, test_vendor
):
"""Test creating an exception without GTIN (e.g., for vouchers)."""
exception = exception_service.create_exception(
db=db,
order_item=test_order_item,
vendor_id=test_vendor.id,
original_gtin=None,
original_product_name="Gift Voucher",
original_sku=None,
exception_type="product_not_found",
)
db.commit()
assert exception.id is not None
assert exception.original_gtin is None
@pytest.mark.unit
class TestOrderItemExceptionServiceGet:
"""Test exception retrieval."""
def test_get_exception_by_id(
self, db, exception_service, test_order_item, test_vendor
):
"""Test getting an exception by ID."""
created = exception_service.create_exception(
db=db,
order_item=test_order_item,
vendor_id=test_vendor.id,
original_gtin="4006381333931",
original_product_name="Test Product",
original_sku="SKU-001",
)
db.commit()
fetched = exception_service.get_exception_by_id(db, created.id)
assert fetched.id == created.id
assert fetched.original_gtin == "4006381333931"
def test_get_exception_by_id_with_vendor_filter(
self, db, exception_service, test_order_item, test_vendor
):
"""Test getting an exception with vendor filter."""
created = exception_service.create_exception(
db=db,
order_item=test_order_item,
vendor_id=test_vendor.id,
original_gtin="4006381333931",
original_product_name="Test Product",
original_sku="SKU-001",
)
db.commit()
# Should find with correct vendor
fetched = exception_service.get_exception_by_id(
db, created.id, vendor_id=test_vendor.id
)
assert fetched.id == created.id
# Should not find with wrong vendor
with pytest.raises(OrderItemExceptionNotFoundException):
exception_service.get_exception_by_id(db, created.id, vendor_id=99999)
def test_get_exception_not_found(self, db, exception_service):
"""Test getting a non-existent exception."""
with pytest.raises(OrderItemExceptionNotFoundException):
exception_service.get_exception_by_id(db, 99999)
def test_get_pending_exceptions(
self, db, exception_service, test_order, test_product, test_vendor
):
"""Test getting pending exceptions with pagination."""
# Create multiple order items and exceptions
for i in range(5):
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,
)
db.add(order_item)
db.commit()
exception_service.create_exception(
db=db,
order_item=order_item,
vendor_id=test_vendor.id,
original_gtin=f"400638133393{i}",
original_product_name=f"Product {i}",
original_sku=f"SKU-{i}",
)
db.commit()
# Get all
exceptions, total = exception_service.get_pending_exceptions(
db, vendor_id=test_vendor.id
)
assert total == 5
assert len(exceptions) == 5
# Test pagination
exceptions, total = exception_service.get_pending_exceptions(
db, vendor_id=test_vendor.id, skip=0, limit=2
)
assert total == 5
assert len(exceptions) == 2
def test_get_pending_exceptions_with_status_filter(
self, db, exception_service, test_order, test_product, test_vendor
):
"""Test filtering exceptions by status."""
# Create order items
order_items = []
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,
)
db.add(order_item)
order_items.append(order_item)
db.commit()
# Create exceptions with different statuses
statuses = ["pending", "resolved", "ignored"]
for i, status in enumerate(statuses):
exc = exception_service.create_exception(
db=db,
order_item=order_items[i],
vendor_id=test_vendor.id,
original_gtin=f"400638133393{i}",
original_product_name=f"Product {i}",
original_sku=f"SKU-{i}",
)
exc.status = status
db.commit()
# Filter by pending
exceptions, total = exception_service.get_pending_exceptions(
db, vendor_id=test_vendor.id, status="pending"
)
assert total == 1
assert exceptions[0].status == "pending"
def test_get_pending_exceptions_with_search(
self, db, exception_service, test_order, test_product, test_vendor
):
"""Test searching exceptions."""
order_item = OrderItem(
order_id=test_order.id,
product_id=test_product.id,
product_name="Unique Product",
product_sku="UNIQUE-SKU",
quantity=1,
unit_price=10.00,
total_price=10.00,
)
db.add(order_item)
db.commit()
exception_service.create_exception(
db=db,
order_item=order_item,
vendor_id=test_vendor.id,
original_gtin="9876543210123",
original_product_name="Searchable Product Name",
original_sku="SEARCH-SKU",
)
db.commit()
# Search by GTIN
exceptions, total = exception_service.get_pending_exceptions(
db, vendor_id=test_vendor.id, search="9876543210123"
)
assert total == 1
# Search by product name
exceptions, total = exception_service.get_pending_exceptions(
db, vendor_id=test_vendor.id, search="Searchable"
)
assert total == 1
@pytest.mark.unit
class TestOrderItemExceptionServiceStats:
"""Test exception statistics."""
def test_get_exception_stats(
self, db, exception_service, test_order, test_product, test_vendor
):
"""Test getting exception statistics."""
# Create order items and exceptions with different statuses
statuses_to_create = ["pending", "pending", "resolved", "ignored"]
for i, status in enumerate(statuses_to_create):
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,
)
db.add(order_item)
db.commit()
exc = exception_service.create_exception(
db=db,
order_item=order_item,
vendor_id=test_vendor.id,
original_gtin=f"400638133393{i}",
original_product_name=f"Product {i}",
original_sku=f"SKU-{i}",
)
exc.status = status
db.commit()
stats = exception_service.get_exception_stats(db, vendor_id=test_vendor.id)
assert stats["pending"] == 2
assert stats["resolved"] == 1
assert stats["ignored"] == 1
assert stats["total"] == 4
assert stats["orders_with_exceptions"] == 1 # All same order
@pytest.mark.unit
class TestOrderItemExceptionServiceResolve:
"""Test exception resolution."""
def test_resolve_exception(
self, db, exception_service, test_order_item, test_vendor, test_product, test_user
):
"""Test resolving an exception."""
exception = exception_service.create_exception(
db=db,
order_item=test_order_item,
vendor_id=test_vendor.id,
original_gtin="4006381333931",
original_product_name="Test Product",
original_sku="SKU-001",
)
db.commit()
resolved = exception_service.resolve_exception(
db=db,
exception_id=exception.id,
product_id=test_product.id,
resolved_by=test_user.id,
notes="Matched to existing product",
)
db.commit()
assert resolved.status == "resolved"
assert resolved.resolved_product_id == test_product.id
assert resolved.resolved_by == test_user.id
assert resolved.resolved_at is not None
assert resolved.resolution_notes == "Matched to existing product"
# Order item should be updated
db.refresh(test_order_item)
assert test_order_item.product_id == test_product.id
assert test_order_item.needs_product_match is False
def test_resolve_already_resolved_exception(
self, db, exception_service, test_order_item, test_vendor, test_product, test_user
):
"""Test that resolving an already resolved exception raises error."""
exception = exception_service.create_exception(
db=db,
order_item=test_order_item,
vendor_id=test_vendor.id,
original_gtin="4006381333931",
original_product_name="Test Product",
original_sku="SKU-001",
)
db.commit()
# Resolve first time
exception_service.resolve_exception(
db=db,
exception_id=exception.id,
product_id=test_product.id,
resolved_by=test_user.id,
)
db.commit()
# Try to resolve again
with pytest.raises(ExceptionAlreadyResolvedException):
exception_service.resolve_exception(
db=db,
exception_id=exception.id,
product_id=test_product.id,
resolved_by=test_user.id,
)
def test_resolve_with_invalid_product(
self, db, exception_service, test_order_item, test_vendor, test_user
):
"""Test resolving with non-existent product."""
exception = exception_service.create_exception(
db=db,
order_item=test_order_item,
vendor_id=test_vendor.id,
original_gtin="4006381333931",
original_product_name="Test Product",
original_sku="SKU-001",
)
db.commit()
with pytest.raises(ProductNotFoundException):
exception_service.resolve_exception(
db=db,
exception_id=exception.id,
product_id=99999,
resolved_by=test_user.id,
)
def test_ignore_exception(
self, db, exception_service, test_order_item, test_vendor, test_user
):
"""Test ignoring an exception."""
exception = exception_service.create_exception(
db=db,
order_item=test_order_item,
vendor_id=test_vendor.id,
original_gtin="4006381333931",
original_product_name="Test Product",
original_sku="SKU-001",
)
db.commit()
ignored = exception_service.ignore_exception(
db=db,
exception_id=exception.id,
resolved_by=test_user.id,
notes="Product discontinued",
)
db.commit()
assert ignored.status == "ignored"
assert ignored.resolved_by == test_user.id
assert ignored.resolution_notes == "Product discontinued"
@pytest.mark.unit
class TestOrderItemExceptionServiceAutoMatch:
"""Test auto-matching."""
def test_auto_match_by_gtin(
self, db, exception_service, test_order, test_product, test_vendor
):
"""Test auto-matching exceptions by GTIN."""
# Set the product GTIN
test_product.gtin = "4006381333931"
db.commit()
# Create order items with exceptions for this 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_service.create_exception(
db=db,
order_item=order_item,
vendor_id=test_vendor.id,
original_gtin="4006381333931", # Same GTIN
original_product_name=f"Product {i}",
original_sku=f"SKU-{i}",
)
db.commit()
# Auto-match should resolve all 3
resolved = exception_service.auto_match_by_gtin(
db=db,
vendor_id=test_vendor.id,
gtin="4006381333931",
product_id=test_product.id,
)
db.commit()
assert len(resolved) == 3
for exc in resolved:
assert exc.status == "resolved"
assert exc.resolved_product_id == test_product.id
assert "Auto-matched" in exc.resolution_notes
def test_auto_match_empty_gtin(self, db, exception_service, test_vendor):
"""Test that empty GTIN returns empty list."""
resolved = exception_service.auto_match_by_gtin(
db=db, vendor_id=test_vendor.id, gtin="", product_id=1
)
assert resolved == []
@pytest.mark.unit
class TestOrderItemExceptionServiceConfirmation:
"""Test confirmation checks."""
def test_order_has_unresolved_exceptions(
self, db, exception_service, test_order_item, test_vendor
):
"""Test checking for unresolved exceptions."""
order_id = test_order_item.order_id
# Initially no exceptions
assert exception_service.order_has_unresolved_exceptions(db, order_id) is False
# Add pending exception
exception_service.create_exception(
db=db,
order_item=test_order_item,
vendor_id=test_vendor.id,
original_gtin="4006381333931",
original_product_name="Test Product",
original_sku="SKU-001",
)
db.commit()
assert exception_service.order_has_unresolved_exceptions(db, order_id) is True
def test_get_unresolved_exception_count(
self, db, exception_service, test_order, test_product, test_vendor
):
"""Test getting unresolved exception count."""
# Create multiple exceptions
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,
)
db.add(order_item)
db.commit()
exception_service.create_exception(
db=db,
order_item=order_item,
vendor_id=test_vendor.id,
original_gtin=f"400638133393{i}",
original_product_name=f"Product {i}",
original_sku=f"SKU-{i}",
)
db.commit()
count = exception_service.get_unresolved_exception_count(db, test_order.id)
assert count == 3
@pytest.mark.unit
class TestOrderItemExceptionServiceBulkResolve:
"""Test bulk operations."""
def test_bulk_resolve_by_gtin(
self, db, exception_service, test_order, test_product, test_vendor, test_user
):
"""Test bulk resolving exceptions by GTIN."""
gtin = "4006381333931"
# Create multiple exceptions for 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,
)
db.add(order_item)
db.commit()
exception_service.create_exception(
db=db,
order_item=order_item,
vendor_id=test_vendor.id,
original_gtin=gtin,
original_product_name=f"Product {i}",
original_sku=f"SKU-{i}",
)
db.commit()
count = exception_service.bulk_resolve_by_gtin(
db=db,
vendor_id=test_vendor.id,
gtin=gtin,
product_id=test_product.id,
resolved_by=test_user.id,
notes="Bulk resolved",
)
db.commit()
assert count == 3
# Verify all are resolved
exceptions, total = exception_service.get_pending_exceptions(
db, vendor_id=test_vendor.id, status="pending"
)
assert total == 0