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

@@ -1,12 +1,65 @@
# tests/unit/models/database/test_order.py
"""Unit tests for Order and OrderItem database models."""
from datetime import datetime, timezone
import pytest
from sqlalchemy.exc import IntegrityError
from models.database.order import Order, OrderItem
def create_order_with_snapshots(
db,
vendor,
customer,
customer_address,
order_number,
status="pending",
subtotal=99.99,
total_amount=99.99,
**kwargs
):
"""Helper to create Order with required address snapshots."""
# Remove channel from kwargs if present (we set it explicitly)
channel = kwargs.pop("channel", "direct")
order = Order(
vendor_id=vendor.id,
customer_id=customer.id,
order_number=order_number,
status=status,
channel=channel,
subtotal=subtotal,
total_amount=total_amount,
currency="EUR",
order_date=datetime.now(timezone.utc),
# Customer snapshot
customer_first_name=customer.first_name,
customer_last_name=customer.last_name,
customer_email=customer.email,
# Shipping address snapshot
ship_first_name=customer_address.first_name,
ship_last_name=customer_address.last_name,
ship_address_line_1=customer_address.address_line_1,
ship_city=customer_address.city,
ship_postal_code=customer_address.postal_code,
ship_country_iso="LU",
# Billing address snapshot
bill_first_name=customer_address.first_name,
bill_last_name=customer_address.last_name,
bill_address_line_1=customer_address.address_line_1,
bill_city=customer_address.city,
bill_postal_code=customer_address.postal_code,
bill_country_iso="LU",
**kwargs
)
db.add(order)
db.commit()
db.refresh(order)
return order
@pytest.mark.unit
@pytest.mark.database
class TestOrderModel:
@@ -16,60 +69,37 @@ class TestOrderModel:
self, db, test_vendor, test_customer, test_customer_address
):
"""Test Order model with customer relationship."""
order = Order(
vendor_id=test_vendor.id,
customer_id=test_customer.id,
order = create_order_with_snapshots(
db, test_vendor, test_customer, test_customer_address,
order_number="ORD-001",
status="pending",
subtotal=99.99,
total_amount=99.99,
currency="EUR",
shipping_address_id=test_customer_address.id,
billing_address_id=test_customer_address.id,
)
db.add(order)
db.commit()
db.refresh(order)
assert order.id is not None
assert order.vendor_id == test_vendor.id
assert order.customer_id == test_customer.id
assert order.order_number == "ORD-001"
assert order.status == "pending"
assert float(order.total_amount) == 99.99
# Verify snapshots
assert order.customer_first_name == test_customer.first_name
assert order.ship_city == test_customer_address.city
assert order.ship_country_iso == "LU"
def test_order_number_uniqueness(
self, db, test_vendor, test_customer, test_customer_address
):
"""Test order_number unique constraint."""
order1 = Order(
vendor_id=test_vendor.id,
customer_id=test_customer.id,
create_order_with_snapshots(
db, test_vendor, test_customer, test_customer_address,
order_number="UNIQUE-ORD-001",
status="pending",
subtotal=50.00,
total_amount=50.00,
shipping_address_id=test_customer_address.id,
billing_address_id=test_customer_address.id,
)
db.add(order1)
db.commit()
# Duplicate order number should fail
with pytest.raises(IntegrityError):
order2 = Order(
vendor_id=test_vendor.id,
customer_id=test_customer.id,
create_order_with_snapshots(
db, test_vendor, test_customer, test_customer_address,
order_number="UNIQUE-ORD-001",
status="pending",
subtotal=75.00,
total_amount=75.00,
shipping_address_id=test_customer_address.id,
billing_address_id=test_customer_address.id,
)
db.add(order2)
db.commit()
def test_order_status_values(
self, db, test_vendor, test_customer, test_customer_address
@@ -77,49 +107,32 @@ class TestOrderModel:
"""Test Order with different status values."""
statuses = [
"pending",
"confirmed",
"processing",
"shipped",
"delivered",
"cancelled",
"refunded",
]
for i, status in enumerate(statuses):
order = Order(
vendor_id=test_vendor.id,
customer_id=test_customer.id,
order = create_order_with_snapshots(
db, test_vendor, test_customer, test_customer_address,
order_number=f"STATUS-ORD-{i:03d}",
status=status,
subtotal=50.00,
total_amount=50.00,
shipping_address_id=test_customer_address.id,
billing_address_id=test_customer_address.id,
)
db.add(order)
db.commit()
db.refresh(order)
assert order.status == status
def test_order_amounts(self, db, test_vendor, test_customer, test_customer_address):
"""Test Order amount fields."""
order = Order(
vendor_id=test_vendor.id,
customer_id=test_customer.id,
order = create_order_with_snapshots(
db, test_vendor, test_customer, test_customer_address,
order_number="AMOUNTS-ORD-001",
status="pending",
subtotal=100.00,
tax_amount=20.00,
shipping_amount=10.00,
discount_amount=5.00,
total_amount=125.00,
currency="EUR",
shipping_address_id=test_customer_address.id,
billing_address_id=test_customer_address.id,
)
db.add(order)
db.commit()
db.refresh(order)
assert float(order.subtotal) == 100.00
assert float(order.tax_amount) == 20.00
@@ -131,25 +144,32 @@ class TestOrderModel:
self, db, test_vendor, test_customer, test_customer_address
):
"""Test Order relationships."""
order = Order(
vendor_id=test_vendor.id,
customer_id=test_customer.id,
order = create_order_with_snapshots(
db, test_vendor, test_customer, test_customer_address,
order_number="REL-ORD-001",
status="pending",
subtotal=50.00,
total_amount=50.00,
shipping_address_id=test_customer_address.id,
billing_address_id=test_customer_address.id,
)
db.add(order)
db.commit()
db.refresh(order)
assert order.vendor is not None
assert order.customer is not None
assert order.vendor.id == test_vendor.id
assert order.customer.id == test_customer.id
def test_order_channel_field(
self, db, test_vendor, test_customer, test_customer_address
):
"""Test Order channel field for marketplace support."""
order = create_order_with_snapshots(
db, test_vendor, test_customer, test_customer_address,
order_number="CHANNEL-ORD-001",
channel="letzshop",
external_order_id="LS-12345",
external_shipment_id="SHIP-67890",
)
assert order.channel == "letzshop"
assert order.external_order_id == "LS-12345"
assert order.external_shipment_id == "SHIP-67890"
@pytest.mark.unit
@pytest.mark.database
@@ -249,3 +269,29 @@ class TestOrderItemModel:
assert item1.order_id == item2.order_id
assert item1.id != item2.id
assert item1.product_id == item2.product_id # Same product, different items
def test_order_item_needs_product_match(self, db, test_order, test_product):
"""Test OrderItem needs_product_match flag for exceptions."""
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=50.00,
total_price=50.00,
needs_product_match=True,
)
db.add(order_item)
db.commit()
db.refresh(order_item)
assert order_item.needs_product_match is True
# Resolve the match
order_item.needs_product_match = False
db.commit()
db.refresh(order_item)
assert order_item.needs_product_match is False

View File

@@ -0,0 +1,242 @@
# tests/unit/models/database/test_order_item_exception.py
"""Unit tests for OrderItemException database model."""
import pytest
from sqlalchemy.exc import IntegrityError
from models.database.order_item_exception import OrderItemException
@pytest.mark.unit
@pytest.mark.database
class TestOrderItemExceptionModel:
"""Test OrderItemException model."""
def test_exception_creation(self, db, test_order_item, test_vendor):
"""Test OrderItemException model creation."""
exception = OrderItemException(
order_item_id=test_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)
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.original_product_name == "Test Missing Product"
assert exception.original_sku == "MISSING-SKU-001"
assert exception.exception_type == "product_not_found"
assert exception.status == "pending"
assert exception.created_at is not None
def test_exception_unique_order_item(self, db, test_order_item, test_vendor):
"""Test that only one exception can exist per order item."""
exception1 = OrderItemException(
order_item_id=test_order_item.id,
vendor_id=test_vendor.id,
original_gtin="4006381333931",
exception_type="product_not_found",
)
db.add(exception1)
db.commit()
# Second exception for the same order item should fail
with pytest.raises(IntegrityError):
exception2 = OrderItemException(
order_item_id=test_order_item.id,
vendor_id=test_vendor.id,
original_gtin="4006381333932",
exception_type="product_not_found",
)
db.add(exception2)
db.commit()
def test_exception_types(self, db, test_order_item, test_vendor):
"""Test different exception types."""
exception_types = ["product_not_found", "gtin_mismatch", "duplicate_gtin"]
# Create new order items for each test to avoid unique constraint
from models.database.order import OrderItem
for i, exc_type in enumerate(exception_types):
order_item = OrderItem(
order_id=test_order_item.order_id,
product_id=test_order_item.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()
db.refresh(order_item)
exception = OrderItemException(
order_item_id=order_item.id,
vendor_id=test_vendor.id,
original_gtin=f"400638133393{i}",
exception_type=exc_type,
)
db.add(exception)
db.commit()
db.refresh(exception)
assert exception.exception_type == exc_type
def test_exception_status_values(self, db, test_order_item, test_vendor):
"""Test different status values."""
exception = OrderItemException(
order_item_id=test_order_item.id,
vendor_id=test_vendor.id,
original_gtin="4006381333931",
exception_type="product_not_found",
status="pending",
)
db.add(exception)
db.commit()
# Test pending status
assert exception.status == "pending"
assert exception.is_pending is True
assert exception.is_resolved is False
assert exception.is_ignored is False
assert exception.blocks_confirmation is True
# Test resolved status
exception.status = "resolved"
db.commit()
db.refresh(exception)
assert exception.is_pending is False
assert exception.is_resolved is True
assert exception.is_ignored is False
assert exception.blocks_confirmation is False
# Test ignored status
exception.status = "ignored"
db.commit()
db.refresh(exception)
assert exception.is_pending is False
assert exception.is_resolved is False
assert exception.is_ignored is True
assert exception.blocks_confirmation is True # Ignored still blocks
def test_exception_nullable_fields(self, db, test_order_item, test_vendor):
"""Test that GTIN and other fields can be null."""
exception = OrderItemException(
order_item_id=test_order_item.id,
vendor_id=test_vendor.id,
original_gtin=None, # Can be null for vouchers etc.
original_product_name="Gift Voucher",
original_sku=None,
exception_type="product_not_found",
)
db.add(exception)
db.commit()
db.refresh(exception)
assert exception.id is not None
assert exception.original_gtin is None
assert exception.original_sku is None
assert exception.original_product_name == "Gift Voucher"
def test_exception_resolution(self, db, test_order_item, test_vendor, test_product, test_user):
"""Test resolving an exception with a product."""
from datetime import datetime, timezone
exception = OrderItemException(
order_item_id=test_order_item.id,
vendor_id=test_vendor.id,
original_gtin="4006381333931",
exception_type="product_not_found",
status="pending",
)
db.add(exception)
db.commit()
# Resolve the exception
now = datetime.now(timezone.utc)
exception.status = "resolved"
exception.resolved_product_id = test_product.id
exception.resolved_at = now
exception.resolved_by = test_user.id
exception.resolution_notes = "Matched to existing product"
db.commit()
db.refresh(exception)
assert exception.status == "resolved"
assert exception.resolved_product_id == test_product.id
assert exception.resolved_at is not None
assert exception.resolved_by == test_user.id
assert exception.resolution_notes == "Matched to existing product"
def test_exception_relationships(self, db, test_order_item, test_vendor):
"""Test OrderItemException relationships."""
exception = OrderItemException(
order_item_id=test_order_item.id,
vendor_id=test_vendor.id,
original_gtin="4006381333931",
exception_type="product_not_found",
)
db.add(exception)
db.commit()
db.refresh(exception)
assert exception.order_item is not None
assert exception.order_item.id == test_order_item.id
assert exception.vendor is not None
assert exception.vendor.id == test_vendor.id
def test_exception_repr(self, db, test_order_item, test_vendor):
"""Test OrderItemException __repr__ method."""
exception = OrderItemException(
order_item_id=test_order_item.id,
vendor_id=test_vendor.id,
original_gtin="4006381333931",
exception_type="product_not_found",
status="pending",
)
db.add(exception)
db.commit()
db.refresh(exception)
repr_str = repr(exception)
assert "OrderItemException" in repr_str
assert str(exception.id) in repr_str
assert "4006381333931" in repr_str
assert "pending" in repr_str
def test_exception_cascade_delete(self, db, test_order_item, test_vendor):
"""Test that exception is deleted when order item is deleted."""
exception = OrderItemException(
order_item_id=test_order_item.id,
vendor_id=test_vendor.id,
original_gtin="4006381333931",
exception_type="product_not_found",
)
db.add(exception)
db.commit()
exception_id = exception.id
# Delete the order item
db.delete(test_order_item)
db.commit()
# Exception should be cascade deleted
deleted_exception = (
db.query(OrderItemException)
.filter(OrderItemException.id == exception_id)
.first()
)
assert deleted_exception is None

View File

@@ -165,16 +165,16 @@ class TestProductModel:
def test_product_reset_to_source(self, db, test_vendor, test_marketplace_product):
"""Test reset_to_source methods."""
# Set up marketplace product values
test_marketplace_product.price_numeric = 100.00
# Set up marketplace product values (use cents internally)
test_marketplace_product.price_cents = 10000 # €100.00
test_marketplace_product.brand = "SourceBrand"
db.commit()
# Create product with overrides
# Create product with overrides (price property converts euros to cents)
product = Product(
vendor_id=test_vendor.id,
marketplace_product_id=test_marketplace_product.id,
price=89.99,
price_cents=8999, # €89.99
brand="OverrideBrand",
)
db.add(product)
@@ -184,13 +184,14 @@ class TestProductModel:
assert product.effective_price == 89.99
assert product.effective_brand == "OverrideBrand"
# Reset price to source
product.reset_field_to_source("price")
# Reset price_cents to source (OVERRIDABLE_FIELDS now uses _cents names)
product.reset_field_to_source("price_cents")
db.commit()
db.refresh(product)
assert product.price is None
assert product.effective_price == 100.00 # Now inherits
assert product.price_cents is None
assert product.price is None # Property returns None when cents is None
assert product.effective_price == 100.00 # Now inherits from marketplace
# Reset all fields
product.reset_all_to_source()