diff --git a/app/utils/__init__.py b/app/utils/__init__.py
index e69de29b..6126d337 100644
--- a/app/utils/__init__.py
+++ b/app/utils/__init__.py
@@ -0,0 +1,16 @@
+# app/utils/__init__.py
+"""Utility modules for the application."""
+
+from app.utils.money import (
+ Money,
+ cents_to_euros,
+ euros_to_cents,
+ parse_price_to_cents,
+)
+
+__all__ = [
+ "Money",
+ "cents_to_euros",
+ "euros_to_cents",
+ "parse_price_to_cents",
+]
diff --git a/app/utils/csv_processor.py b/app/utils/csv_processor.py
index d81b9906..dc4f3083 100644
--- a/app/utils/csv_processor.py
+++ b/app/utils/csv_processor.py
@@ -18,6 +18,7 @@ import requests
from sqlalchemy import literal
from sqlalchemy.orm import Session
+from app.utils.money import euros_to_cents
from models.database.marketplace_import_job import MarketplaceImportError
from models.database.marketplace_product import MarketplaceProduct
from models.database.marketplace_product_translation import (
@@ -154,8 +155,18 @@ class CSVProcessor:
logger.info(f"Normalized columns: {list(df.columns)}")
return df
- def _parse_price_to_numeric(self, price_str: str | None) -> float | None:
- """Parse price string like '19.99 EUR' to float."""
+ def _parse_price_to_cents(self, price_str: str | None) -> int | None:
+ """Parse price string like '19.99 EUR' to integer cents.
+
+ Uses the money utility for precise conversion.
+ Example: '19.99 EUR' -> 1999
+
+ Args:
+ price_str: Price string with optional currency
+
+ Returns:
+ Price in integer cents, or None if parsing fails
+ """
if not price_str:
return None
@@ -164,8 +175,9 @@ class CSVProcessor:
if numbers:
num_str = numbers[0].replace(",", ".")
try:
- return float(num_str)
- except ValueError:
+ # Convert euros to cents using money utility
+ return euros_to_cents(num_str)
+ except (ValueError, TypeError):
pass
return None
@@ -185,10 +197,10 @@ class CSVProcessor:
parsed_price, currency = self.price_processor.parse_price_currency(
processed_data["price"]
)
- # Store both raw price string and numeric value
+ # Store both raw price string and numeric value in cents
raw_price = processed_data["price"]
processed_data["price"] = parsed_price
- processed_data["price_numeric"] = self._parse_price_to_numeric(raw_price)
+ processed_data["price_cents"] = self._parse_price_to_cents(raw_price)
processed_data["currency"] = currency
# Process sale_price
@@ -198,7 +210,7 @@ class CSVProcessor:
processed_data["sale_price"]
)
processed_data["sale_price"] = parsed_sale_price
- processed_data["sale_price_numeric"] = self._parse_price_to_numeric(
+ processed_data["sale_price_cents"] = self._parse_price_to_cents(
raw_sale_price
)
diff --git a/app/utils/money.py b/app/utils/money.py
new file mode 100644
index 00000000..85c3a354
--- /dev/null
+++ b/app/utils/money.py
@@ -0,0 +1,364 @@
+# app/utils/money.py
+"""
+Money handling utilities using integer cents.
+
+All monetary values are stored as integers representing cents.
+This eliminates floating-point precision issues common in financial applications.
+
+Example:
+ €105.91 is stored as 10591 (integer cents)
+
+Usage:
+ from app.utils.money import euros_to_cents, cents_to_euros, Money
+
+ # Convert euros to cents for storage
+ price_cents = euros_to_cents(105.91) # Returns 10591
+
+ # Convert cents to euros for display
+ price_euros = cents_to_euros(10591) # Returns 105.91
+
+ # Format for display
+ formatted = Money.format(10591) # Returns "105.91"
+ formatted = Money.format(10591, "EUR") # Returns "105.91 EUR"
+
+ # Parse price string from CSV/API
+ cents = parse_price_to_cents("19.99 EUR") # Returns 1999
+
+See docs/architecture/money-handling.md for full documentation.
+"""
+
+import re
+from decimal import ROUND_HALF_UP, Decimal
+from typing import Union
+
+# Type alias for clarity
+Cents = int
+Euros = float
+
+# Currency configurations
+CURRENCY_DECIMALS = {
+ "EUR": 2,
+ "USD": 2,
+ "GBP": 2,
+ "CHF": 2,
+ "JPY": 0, # Japanese Yen has no decimal places
+}
+
+DEFAULT_CURRENCY = "EUR"
+
+
+def euros_to_cents(euros: Union[float, str, Decimal, int, None]) -> Cents:
+ """
+ Convert a euro amount to cents.
+
+ Uses Decimal internally for precise conversion, avoiding floating-point errors.
+
+ Args:
+ euros: Amount in euros (float, string, Decimal, int, or None)
+
+ Returns:
+ Amount in cents as integer
+
+ Examples:
+ >>> euros_to_cents(105.91)
+ 10591
+ >>> euros_to_cents("19.99")
+ 1999
+ >>> euros_to_cents(None)
+ 0
+ """
+ if euros is None:
+ return 0
+
+ if isinstance(euros, int):
+ # Assume already an integer euro amount (rare)
+ return euros * 100
+
+ if isinstance(euros, str):
+ # Clean the string: remove currency symbols, spaces, handle comma decimals
+ cleaned = euros.strip()
+ cleaned = re.sub(r"[€$£\s]", "", cleaned)
+ cleaned = cleaned.replace(",", ".")
+ euros = cleaned
+
+ # Use Decimal for precise conversion
+ if isinstance(euros, Decimal):
+ d = euros
+ else:
+ # Convert via string to avoid float precision issues
+ d = Decimal(str(euros))
+
+ # Multiply by 100 and round to nearest integer
+ cents = d * Decimal("100")
+ return int(cents.quantize(Decimal("1"), rounding=ROUND_HALF_UP))
+
+
+def cents_to_euros(cents: Union[int, None]) -> Euros:
+ """
+ Convert cents to euros.
+
+ Args:
+ cents: Amount in cents (integer or None)
+
+ Returns:
+ Amount in euros as float, rounded to 2 decimal places
+
+ Examples:
+ >>> cents_to_euros(10591)
+ 105.91
+ >>> cents_to_euros(None)
+ 0.0
+ """
+ if cents is None:
+ return 0.0
+
+ # Simple integer division, then convert to float
+ # Using Decimal to ensure exact representation
+ euros = Decimal(cents) / Decimal("100")
+ return float(euros.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
+
+
+def parse_price_to_cents(
+ price_str: Union[str, float, int, None],
+ default_currency: str = DEFAULT_CURRENCY,
+) -> tuple[Cents, str]:
+ """
+ Parse a price string and return cents and currency.
+
+ Handles various formats:
+ - "19.99 EUR"
+ - "19,99 €"
+ - "19.99"
+ - 19.99 (float)
+
+ Args:
+ price_str: Price string or numeric value
+ default_currency: Currency to use if not specified in string
+
+ Returns:
+ Tuple of (cents, currency_code)
+
+ Examples:
+ >>> parse_price_to_cents("19.99 EUR")
+ (1999, "EUR")
+ >>> parse_price_to_cents("19,99 €")
+ (1999, "EUR")
+ >>> parse_price_to_cents(19.99)
+ (1999, "EUR")
+ """
+ if price_str is None:
+ return (0, default_currency)
+
+ if isinstance(price_str, (int, float)):
+ return (euros_to_cents(price_str), default_currency)
+
+ # Parse string
+ price_str = str(price_str).strip()
+
+ # Extract currency if present
+ currency = default_currency
+ currency_patterns = [
+ (r"\bEUR\b", "EUR"),
+ (r"€", "EUR"),
+ (r"\bUSD\b", "USD"),
+ (r"\$", "USD"),
+ (r"\bGBP\b", "GBP"),
+ (r"£", "GBP"),
+ (r"\bCHF\b", "CHF"),
+ ]
+
+ for pattern, curr in currency_patterns:
+ if re.search(pattern, price_str, re.IGNORECASE):
+ currency = curr
+ break
+
+ # Remove currency symbols and extract numeric part
+ numeric_str = re.sub(r"[€$£a-zA-Z\s]", "", price_str)
+
+ # Handle European decimal comma
+ numeric_str = numeric_str.replace(",", ".")
+
+ # Handle thousand separators (e.g., "1.000,00" -> "1000.00")
+ # If there are multiple dots, assume the last one is decimal
+ parts = numeric_str.split(".")
+ if len(parts) > 2:
+ # Multiple dots: join all but last as thousands, last as decimal
+ numeric_str = "".join(parts[:-1]) + "." + parts[-1]
+
+ try:
+ cents = euros_to_cents(numeric_str)
+ except (ValueError, TypeError):
+ cents = 0
+
+ return (cents, currency)
+
+
+class Money:
+ """
+ Money utility class for formatting and operations.
+
+ This class provides static methods for money operations.
+ All internal values are in cents (integers).
+ """
+
+ @staticmethod
+ def from_euros(euros: Union[float, str, Decimal, None]) -> Cents:
+ """Create cents from euro amount."""
+ return euros_to_cents(euros)
+
+ @staticmethod
+ def from_cents(cents: Union[int, None]) -> Cents:
+ """Passthrough for cents (for consistency)."""
+ return cents if cents is not None else 0
+
+ @staticmethod
+ def to_euros(cents: Union[int, None]) -> Euros:
+ """Convert cents to euros."""
+ return cents_to_euros(cents)
+
+ @staticmethod
+ def format(
+ cents: Union[int, None],
+ currency: str = "",
+ locale: str = "en",
+ ) -> str:
+ """
+ Format cents as a currency string.
+
+ Args:
+ cents: Amount in cents
+ currency: Currency code to append (optional)
+ locale: Locale for formatting (en, de, fr)
+
+ Returns:
+ Formatted price string
+
+ Examples:
+ >>> Money.format(10591)
+ "105.91"
+ >>> Money.format(10591, "EUR")
+ "105.91 EUR"
+ >>> Money.format(10591, "EUR", "de")
+ "105,91 EUR"
+ """
+ if cents is None:
+ cents = 0
+
+ euros = cents_to_euros(cents)
+
+ # Format based on locale
+ if locale in ("de", "fr", "lb"):
+ # European format: comma decimal, dot thousands
+ formatted = f"{euros:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
+ else:
+ # English format: dot decimal, comma thousands
+ formatted = f"{euros:,.2f}"
+
+ if currency:
+ return f"{formatted} {currency}"
+ return formatted
+
+ @staticmethod
+ def parse(price_str: Union[str, float, int, None]) -> Cents:
+ """
+ Parse a price value to cents.
+
+ Args:
+ price_str: Price string or numeric value
+
+ Returns:
+ Amount in cents
+ """
+ cents, _ = parse_price_to_cents(price_str)
+ return cents
+
+ @staticmethod
+ def add(*amounts: Union[int, None]) -> Cents:
+ """
+ Add multiple cent amounts.
+
+ Args:
+ *amounts: Cent amounts to add
+
+ Returns:
+ Sum in cents
+ """
+ return sum(a for a in amounts if a is not None)
+
+ @staticmethod
+ def subtract(amount: int, *deductions: Union[int, None]) -> Cents:
+ """
+ Subtract amounts from a base amount.
+
+ Args:
+ amount: Base amount in cents
+ *deductions: Amounts to subtract
+
+ Returns:
+ Result in cents
+ """
+ result = amount
+ for d in deductions:
+ if d is not None:
+ result -= d
+ return result
+
+ @staticmethod
+ def multiply(cents: int, quantity: int) -> Cents:
+ """
+ Multiply cents by quantity.
+
+ Args:
+ cents: Unit price in cents
+ quantity: Number of units
+
+ Returns:
+ Total in cents
+ """
+ return cents * quantity
+
+ @staticmethod
+ def calculate_line_total(unit_price_cents: int, quantity: int) -> Cents:
+ """
+ Calculate line total for an order item.
+
+ Args:
+ unit_price_cents: Price per unit in cents
+ quantity: Number of units
+
+ Returns:
+ Line total in cents
+ """
+ return unit_price_cents * quantity
+
+ @staticmethod
+ def calculate_order_total(
+ subtotal_cents: int,
+ tax_cents: int = 0,
+ shipping_cents: int = 0,
+ discount_cents: int = 0,
+ ) -> Cents:
+ """
+ Calculate order total.
+
+ Args:
+ subtotal_cents: Sum of line items in cents
+ tax_cents: Tax amount in cents
+ shipping_cents: Shipping cost in cents
+ discount_cents: Discount amount in cents
+
+ Returns:
+ Total in cents
+ """
+ return subtotal_cents + tax_cents + shipping_cents - discount_cents
+
+
+# Convenience exports
+__all__ = [
+ "euros_to_cents",
+ "cents_to_euros",
+ "parse_price_to_cents",
+ "Money",
+ "Cents",
+ "Euros",
+]
diff --git a/docs/architecture/money-handling.md b/docs/architecture/money-handling.md
new file mode 100644
index 00000000..7d18b83b
--- /dev/null
+++ b/docs/architecture/money-handling.md
@@ -0,0 +1,321 @@
+# Money Handling Architecture
+
+## Overview
+
+This document describes the architecture for handling monetary values in the application. Following industry best practices (Stripe, PayPal, Shopify), all monetary values are stored as **integers representing cents**.
+
+## Why Integer Cents?
+
+### The Problem with Floating-Point
+
+```python
+# Floating-point arithmetic is imprecise
+>>> 0.1 + 0.2
+0.30000000000000004
+
+>>> 105.91 * 1
+105.91000000000001
+```
+
+This causes issues in financial applications where precision is critical.
+
+### The Solution: Integer Cents
+
+```python
+# Integer arithmetic is always exact
+>>> 10 + 20 # 0.10 + 0.20 in cents
+30
+
+>>> 10591 * 1 # 105.91 EUR in cents
+10591
+```
+
+**€105.91** is stored as **10591** (integer)
+
+## Conventions
+
+### Database Schema
+
+All price/amount columns use `Integer` type with a `_cents` suffix:
+
+```python
+# Good - Integer cents
+price_cents = Column(Integer, nullable=False, default=0)
+total_amount_cents = Column(Integer, nullable=False)
+
+# Bad - Never use Float for money
+price = Column(Float) # DON'T DO THIS
+```
+
+**Column naming convention:**
+- `price_cents` - Product price in cents
+- `sale_price_cents` - Sale price in cents
+- `unit_price_cents` - Per-unit price in cents
+- `total_price_cents` - Line total in cents
+- `subtotal_cents` - Order subtotal in cents
+- `tax_amount_cents` - Tax in cents
+- `shipping_amount_cents` - Shipping cost in cents
+- `discount_amount_cents` - Discount in cents
+- `total_amount_cents` - Order total in cents
+
+### Python Code
+
+#### Using the Money Utility
+
+```python
+from app.utils.money import Money, cents_to_euros, euros_to_cents
+
+# Creating money values
+price = Money.from_euros(105.91) # Returns 10591
+price = Money.from_cents(10591) # Returns 10591
+
+# Converting for display
+euros = cents_to_euros(10591) # Returns 105.91
+formatted = Money.format(10591) # Returns "105.91"
+formatted = Money.format(10591, currency="EUR") # Returns "105.91 EUR"
+
+# Parsing user input
+cents = euros_to_cents("105.91") # Returns 10591
+cents = euros_to_cents(105.91) # Returns 10591
+
+# Arithmetic (always use integers)
+line_total = unit_price_cents * quantity
+order_total = subtotal_cents + shipping_cents - discount_cents
+```
+
+#### In Services
+
+```python
+from app.utils.money import euros_to_cents, cents_to_euros
+
+class OrderService:
+ def create_order(self, items: list):
+ subtotal_cents = 0
+ for item in items:
+ line_total_cents = item.unit_price_cents * item.quantity
+ subtotal_cents += line_total_cents
+
+ # All arithmetic in cents - no precision loss
+ total_cents = subtotal_cents + shipping_cents - discount_cents
+
+ order = Order(
+ subtotal_cents=subtotal_cents,
+ total_amount_cents=total_cents,
+ )
+```
+
+#### In Models (Properties)
+
+```python
+class Order(Base):
+ total_amount_cents = Column(Integer, nullable=False)
+
+ @property
+ def total_amount(self) -> float:
+ """Get total as euros (for display/API)."""
+ return cents_to_euros(self.total_amount_cents)
+
+ @total_amount.setter
+ def total_amount(self, value: float):
+ """Set total from euros."""
+ self.total_amount_cents = euros_to_cents(value)
+```
+
+### Pydantic Schemas
+
+#### Input Schemas (Accept Euros)
+
+```python
+from pydantic import BaseModel, field_validator
+from app.utils.money import euros_to_cents
+
+class ProductCreate(BaseModel):
+ price: float # Accept euros from API
+
+ @field_validator('price')
+ @classmethod
+ def convert_to_cents(cls, v):
+ # Validation only - actual conversion in service
+ if v < 0:
+ raise ValueError('Price must be non-negative')
+ return v
+```
+
+#### Output Schemas (Return Euros)
+
+```python
+from pydantic import BaseModel, computed_field
+from app.utils.money import cents_to_euros
+
+class ProductResponse(BaseModel):
+ price_cents: int # Internal storage
+
+ @computed_field
+ @property
+ def price(self) -> float:
+ """Return price in euros for API consumers."""
+ return cents_to_euros(self.price_cents)
+```
+
+### API Layer
+
+APIs accept and return values in **euros** (human-readable), while internally everything is stored in **cents**.
+
+```json
+// API Request (euros)
+{
+ "price": 105.91,
+ "quantity": 2
+}
+
+// API Response (euros)
+{
+ "price": 105.91,
+ "total": 211.82
+}
+
+// Database (cents)
+price_cents: 10591
+total_cents: 21182
+```
+
+### Frontend/Templates
+
+JavaScript helper for formatting:
+
+```javascript
+// In static/shared/js/money.js
+const Money = {
+ /**
+ * Format cents as currency string
+ * @param {number} cents - Amount in cents
+ * @param {string} currency - Currency code (default: EUR)
+ * @returns {string} Formatted price
+ */
+ format(cents, currency = 'EUR') {
+ const euros = cents / 100;
+ return new Intl.NumberFormat('de-DE', {
+ style: 'currency',
+ currency: currency
+ }).format(euros);
+ },
+
+ /**
+ * Convert euros to cents
+ * @param {number|string} euros - Amount in euros
+ * @returns {number} Amount in cents
+ */
+ toCents(euros) {
+ return Math.round(parseFloat(euros) * 100);
+ },
+
+ /**
+ * Convert cents to euros
+ * @param {number} cents - Amount in cents
+ * @returns {number} Amount in euros
+ */
+ toEuros(cents) {
+ return cents / 100;
+ }
+};
+```
+
+In templates:
+
+```html
+
+
+
+
+
+```
+
+## Data Import/Export
+
+### CSV Import
+
+When importing prices from CSV files:
+
+```python
+from app.utils.money import parse_price_to_cents
+
+# Parse "19.99 EUR" or "19,99" to cents
+price_cents = parse_price_to_cents("19.99 EUR") # Returns 1999
+price_cents = parse_price_to_cents("19,99") # Returns 1999
+```
+
+### Marketplace Import (Letzshop)
+
+When importing from Letzshop GraphQL API:
+
+```python
+from app.utils.money import euros_to_cents
+
+# Letzshop returns prices as floats
+letzshop_price = 105.91
+price_cents = euros_to_cents(letzshop_price) # Returns 10591
+```
+
+## Migration Strategy
+
+### Database Migration
+
+1. Add new `_cents` columns
+2. Migrate data: `UPDATE table SET price_cents = ROUND(price * 100)`
+3. Drop old float columns
+4. Rename columns if needed
+
+```python
+# Migration example
+def upgrade():
+ # Add cents columns
+ op.add_column('products', sa.Column('price_cents', sa.Integer()))
+
+ # Migrate data
+ op.execute('UPDATE products SET price_cents = ROUND(price * 100)')
+
+ # Drop old columns
+ op.drop_column('products', 'price')
+```
+
+## Currency Support
+
+The system is designed for EUR but supports multiple currencies:
+
+```python
+class Money:
+ # Currency decimal places (for future multi-currency support)
+ CURRENCY_DECIMALS = {
+ 'EUR': 2,
+ 'USD': 2,
+ 'GBP': 2,
+ 'JPY': 0, # Yen has no decimals
+ }
+```
+
+## Testing
+
+Always test with values that expose floating-point issues:
+
+```python
+def test_price_precision():
+ # These values cause issues with float
+ test_prices = [0.1, 0.2, 0.3, 19.99, 105.91]
+
+ for price in test_prices:
+ cents = euros_to_cents(price)
+ back_to_euros = cents_to_euros(cents)
+ assert back_to_euros == price
+```
+
+## Summary
+
+| Layer | Format | Example |
+|-------|--------|---------|
+| Database | Integer cents | `10591` |
+| Python services | Integer cents | `10591` |
+| Pydantic schemas | Float euros (I/O) | `105.91` |
+| API request/response | Float euros | `105.91` |
+| Frontend display | Formatted string | `"105,91 €"` |
+
+**Golden Rule:** All arithmetic happens with integers. Conversion to/from euros only at system boundaries (API, display).
diff --git a/docs/guides/letzshop-order-integration.md b/docs/guides/letzshop-order-integration.md
index 0e2213a2..6fab09ac 100644
--- a/docs/guides/letzshop-order-integration.md
+++ b/docs/guides/letzshop-order-integration.md
@@ -8,7 +8,9 @@ Complete guide for bidirectional order management with Letzshop marketplace via
- [Architecture](#architecture)
- [Setup and Configuration](#setup-and-configuration)
- [Order Import](#order-import)
+- [Product Exceptions](#product-exceptions)
- [Fulfillment Operations](#fulfillment-operations)
+- [Shipping and Tracking](#shipping-and-tracking)
- [API Reference](#api-reference)
- [Database Models](#database-models)
- [Troubleshooting](#troubleshooting)
@@ -196,6 +198,129 @@ Local orders track their sync status:
---
+## Product Exceptions
+
+When importing orders from Letzshop, products are matched by GTIN. If a product is not found in the vendor's catalog, the system **gracefully imports the order** with a placeholder product and creates an exception record for resolution.
+
+### Exception Workflow
+
+```
+Import Order → Product not found by GTIN
+ │
+ ▼
+ Create order with placeholder
+ + Flag item: needs_product_match=True
+ + Create OrderItemException record
+ │
+ ▼
+ Exception appears in QC dashboard
+ │
+ ┌───────────┴───────────┐
+ │ │
+ Resolve Ignore
+ (assign product) (with reason)
+ │ │
+ ▼ ▼
+ Order can be confirmed Still blocks confirmation
+```
+
+### Exception Types
+
+| Type | Description |
+|------|-------------|
+| `product_not_found` | GTIN not in vendor's product catalog |
+| `gtin_mismatch` | GTIN format issue |
+| `duplicate_gtin` | Multiple products with same GTIN |
+
+### Exception Statuses
+
+| Status | Description | Blocks Confirmation |
+|--------|-------------|---------------------|
+| `pending` | Awaiting resolution | **Yes** |
+| `resolved` | Product assigned | No |
+| `ignored` | Marked as ignored | **Yes** |
+
+**Important:** Both `pending` and `ignored` exceptions block order confirmation to Letzshop.
+
+### Viewing Exceptions
+
+Navigate to **Marketplace > Letzshop > Exceptions** tab to see all unmatched products.
+
+The dashboard shows:
+- **Pending**: Exceptions awaiting resolution
+- **Resolved**: Exceptions that have been matched
+- **Ignored**: Exceptions marked as ignored
+- **Orders Affected**: Orders with at least one exception
+
+### Resolving Exceptions
+
+#### Via Admin UI
+
+1. Navigate to **Marketplace > Letzshop > Exceptions**
+2. Click **Resolve** on the pending exception
+3. Search for the correct product by name, SKU, or GTIN
+4. Select the product and click **Confirm**
+5. Optionally check "Apply to all exceptions with this GTIN" for bulk resolution
+
+#### Via API
+
+```bash
+# Resolve a single exception
+curl -X POST /api/v1/admin/order-exceptions/{exception_id}/resolve \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "product_id": 123,
+ "notes": "Matched to correct product manually"
+ }'
+
+# Bulk resolve all exceptions with same GTIN
+curl -X POST /api/v1/admin/order-exceptions/bulk-resolve?vendor_id=1 \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "gtin": "4006381333931",
+ "product_id": 123,
+ "notes": "Product imported to catalog"
+ }'
+```
+
+### Auto-Matching
+
+When products are imported to the vendor catalog (via product sync or manual import), the system automatically:
+
+1. Collects GTINs of newly imported products
+2. Finds pending exceptions with matching GTINs
+3. Resolves them by assigning the new product
+
+This happens during:
+- Single product import (`copy_to_vendor_catalog`)
+- Bulk marketplace sync
+
+### Exception Statistics
+
+Get counts via API:
+
+```bash
+curl -X GET /api/v1/admin/order-exceptions/stats?vendor_id=1 \
+ -H "Authorization: Bearer $TOKEN"
+```
+
+Response:
+```json
+{
+ "pending": 15,
+ "resolved": 42,
+ "ignored": 3,
+ "total": 60,
+ "orders_with_exceptions": 8
+}
+```
+
+For more details, see [Order Item Exception System](../implementation/order-item-exceptions.md).
+
+---
+
## Fulfillment Operations
### Confirm Order
@@ -243,6 +368,106 @@ Supported carriers: `dhl`, `ups`, `fedex`, `post_lu`, etc.
---
+## Shipping and Tracking
+
+The system captures shipping information from Letzshop and provides local shipping management features.
+
+### Letzshop Nomenclature
+
+Letzshop uses specific terminology for order references:
+
+| Term | Example | Description |
+|------|---------|-------------|
+| **Order Number** | `R532332163` | Customer-facing order reference |
+| **Shipment Number** | `H74683403433` | Carrier shipment ID for tracking |
+| **Hash ID** | `nvDv5RQEmCwbjo` | Internal Letzshop reference |
+
+### Order Fields
+
+Orders imported from Letzshop include:
+
+| Field | Description |
+|-------|-------------|
+| `external_order_number` | Letzshop order number (e.g., R532332163) |
+| `shipment_number` | Carrier shipment number (e.g., H74683403433) |
+| `shipping_carrier` | Carrier code (greco, colissimo, xpresslogistics) |
+| `tracking_number` | Tracking number (if available) |
+| `tracking_url` | Full tracking URL |
+
+### Carrier Detection
+
+The system automatically detects the carrier from Letzshop shipment data:
+
+| Carrier | Code | Label URL Prefix |
+|---------|------|------------------|
+| Greco | `greco` | `https://dispatchweb.fr/Tracky/Home/` |
+| Colissimo | `colissimo` | Configurable in settings |
+| XpressLogistics | `xpresslogistics` | Configurable in settings |
+
+### Mark as Shipped
+
+Mark orders as shipped locally (does **not** sync to Letzshop):
+
+```bash
+curl -X POST /api/v1/admin/orders/{order_id}/ship \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "tracking_number": "1Z999AA10123456784",
+ "tracking_url": "https://tracking.example.com/1Z999AA10123456784",
+ "shipping_carrier": "ups"
+ }'
+```
+
+**Note:** This updates the local order status to `shipped` and sets the `shipped_at` timestamp. It does not send anything to Letzshop API.
+
+### Download Shipping Label
+
+Get the shipping label URL for an order:
+
+```bash
+curl -X GET /api/v1/admin/orders/{order_id}/shipping-label \
+ -H "Authorization: Bearer $TOKEN"
+```
+
+Response:
+```json
+{
+ "shipment_number": "H74683403433",
+ "shipping_carrier": "greco",
+ "label_url": "https://dispatchweb.fr/Tracky/Home/H74683403433",
+ "tracking_number": null,
+ "tracking_url": null
+}
+```
+
+The label URL is constructed from:
+- **Carrier label URL prefix** (configured in Admin Settings)
+- **Shipment number** from the order
+
+### Carrier Label Settings
+
+Configure carrier label URL prefixes in **Admin > Settings > Shipping**:
+
+| Setting | Default | Description |
+|---------|---------|-------------|
+| Greco Label URL | `https://dispatchweb.fr/Tracky/Home/` | Greco tracking/label prefix |
+| Colissimo Label URL | *(empty)* | Colissimo tracking prefix |
+| XpressLogistics Label URL | *(empty)* | XpressLogistics prefix |
+
+The full label URL is: `{prefix}{shipment_number}`
+
+### Tracking Information
+
+Letzshop does not expose Greco tracking information via API. The tracking URL visible in the Letzshop web UI is auto-generated by Letzshop using the dispatchweb.fr prefix.
+
+For orders using Greco carrier:
+1. The shipment number (e.g., `H74683403433`) is captured during import
+2. The tracking URL can be constructed: `https://dispatchweb.fr/Tracky/Home/{shipment_number}`
+3. Use the Download Label feature to get this URL
+
+---
+
## API Reference
### Vendor Endpoints
@@ -283,6 +508,33 @@ Base path: `/api/v1/admin/letzshop`
| GET | `/vendors/{id}/orders` | List vendor's Letzshop orders |
| POST | `/vendors/{id}/sync` | Trigger sync for vendor |
+### Order Endpoints
+
+Base path: `/api/v1/admin/orders`
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `` | List orders (cross-vendor) |
+| GET | `/stats` | Get order statistics |
+| GET | `/vendors` | Get vendors with orders |
+| GET | `/{id}` | Get order details |
+| PATCH | `/{id}/status` | Update order status |
+| POST | `/{id}/ship` | Mark as shipped |
+| GET | `/{id}/shipping-label` | Get shipping label URL |
+
+### Exception Endpoints
+
+Base path: `/api/v1/admin/order-exceptions`
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `` | List exceptions |
+| GET | `/stats` | Get exception statistics |
+| GET | `/{id}` | Get exception details |
+| POST | `/{id}/resolve` | Resolve with product |
+| POST | `/{id}/ignore` | Mark as ignored |
+| POST | `/bulk-resolve` | Bulk resolve by GTIN |
+
### Response Schemas
#### Credentials Response
@@ -502,6 +754,34 @@ curl -X GET /api/v1/vendor/letzshop/logs \
-H "Authorization: Bearer $TOKEN"
```
+### Order Has Unresolved Exceptions
+
+**Symptoms**: "Order has X unresolved exception(s)" error when confirming
+
+**Cause**: Order contains items that couldn't be matched to products during import
+
+**Solutions**:
+1. Navigate to **Marketplace > Letzshop > Exceptions** tab
+2. Find the pending exceptions for this order
+3. Either:
+ - **Resolve**: Assign the correct product from your catalog
+ - **Ignore**: Mark as ignored if product will never be matched (still blocks confirmation)
+4. Retry the confirmation after resolving all exceptions
+
+### Cannot Find Shipping Label
+
+**Symptoms**: "Download Label" returns empty or no URL
+
+**Possible Causes**:
+- Shipment number not captured during import
+- Carrier label URL prefix not configured
+- Unknown carrier type
+
+**Solutions**:
+1. Re-sync the order to capture shipment data
+2. Check **Admin > Settings > Shipping** for carrier URL prefixes
+3. Verify the order has a valid `shipping_carrier` and `shipment_number`
+
---
## Best Practices
@@ -525,6 +805,7 @@ curl -X GET /api/v1/vendor/letzshop/logs \
## Related Documentation
+- [Order Item Exception System](../implementation/order-item-exceptions.md)
- [Marketplace Integration (CSV Import)](marketplace-integration.md)
- [Vendor RBAC](../backend/vendor-rbac.md)
- [Admin Integration Guide](../backend/admin-integration-guide.md)
@@ -534,6 +815,22 @@ curl -X GET /api/v1/vendor/letzshop/logs \
## Version History
+- **v1.2** (2025-12-20): Shipping & Tracking enhancements
+ - Added `shipment_number`, `shipping_carrier`, `tracking_url` fields to orders
+ - Carrier detection from Letzshop shipment data (Greco, Colissimo, XpressLogistics)
+ - Mark as Shipped feature (local only, does not sync to Letzshop)
+ - Shipping label URL generation using configurable carrier prefixes
+ - Admin settings for carrier label URL prefixes
+
+- **v1.1** (2025-12-20): Product Exception System
+ - Graceful order import when products not found by GTIN
+ - Placeholder product per vendor for unmatched items
+ - Exception tracking with pending/resolved/ignored statuses
+ - Confirmation blocking until exceptions resolved
+ - Auto-matching when products are imported
+ - Exceptions tab in admin Letzshop management page
+ - Bulk resolution by GTIN
+
- **v1.0** (2025-12-13): Initial Letzshop order integration
- GraphQL client for order import
- Encrypted credential storage
diff --git a/docs/implementation/order-item-exceptions.md b/docs/implementation/order-item-exceptions.md
index d574cfb1..b115513a 100644
--- a/docs/implementation/order-item-exceptions.md
+++ b/docs/implementation/order-item-exceptions.md
@@ -254,6 +254,30 @@ Response:
}
```
+## Admin UI
+
+The exceptions tab is available in the Letzshop management page:
+
+**Location:** `/admin/marketplace/letzshop` → Exceptions tab
+
+### Features
+
+- **Stats Cards**: Shows pending, resolved, ignored, and affected orders counts
+- **Filters**: Search by GTIN/product name/order number, filter by status
+- **Exception Table**: Paginated list with product info, GTIN, order link, status
+- **Actions**:
+ - **Resolve**: Opens modal with product search (autocomplete)
+ - **Ignore**: Marks exception as ignored (still blocks confirmation)
+ - **Bulk Resolve**: Checkbox to apply resolution to all exceptions with same GTIN
+
+### Files
+
+| File | Description |
+|------|-------------|
+| `app/templates/admin/partials/letzshop-exceptions-tab.html` | Tab HTML template |
+| `app/templates/admin/marketplace-letzshop.html` | Main page (includes tab) |
+| `static/admin/js/marketplace-letzshop.js` | JavaScript handlers |
+
## Error Handling
| Exception | HTTP Status | When |
diff --git a/mkdocs.yml b/mkdocs.yml
index 2228087d..b84b931b 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -32,6 +32,7 @@ nav:
- Marketplace Integration: architecture/marketplace-integration.md
- Architecture Patterns: architecture/architecture-patterns.md
- Language & i18n: architecture/language-i18n.md
+ - Money Handling: architecture/money-handling.md
- Company-Vendor Management: architecture/company-vendor-management.md
- Multi-Tenant System: architecture/multi-tenant.md
- Middleware Stack: architecture/middleware.md
diff --git a/models/database/cart.py b/models/database/cart.py
index 8752dfc9..ea91bdb7 100644
--- a/models/database/cart.py
+++ b/models/database/cart.py
@@ -1,9 +1,12 @@
# models/database/cart.py
-"""Cart item database model."""
+"""Cart item database model.
+
+Money values are stored as integer cents (e.g., €105.91 = 10591).
+See docs/architecture/money-handling.md for details.
+"""
from sqlalchemy import (
Column,
- Float,
ForeignKey,
Index,
Integer,
@@ -13,6 +16,7 @@ from sqlalchemy import (
from sqlalchemy.orm import relationship
from app.core.database import Base
+from app.utils.money import cents_to_euros, euros_to_cents
from models.database.base import TimestampMixin
@@ -22,6 +26,8 @@ class CartItem(Base, TimestampMixin):
Stores cart items per session, vendor, and product.
Sessions are identified by a session_id string (from browser cookies).
+
+ Price is stored as integer cents for precision.
"""
__tablename__ = "cart_items"
@@ -33,7 +39,7 @@ class CartItem(Base, TimestampMixin):
# Cart details
quantity = Column(Integer, nullable=False, default=1)
- price_at_add = Column(Float, nullable=False) # Store price when added to cart
+ price_at_add_cents = Column(Integer, nullable=False) # Price in cents when added
# Relationships
vendor = relationship("Vendor")
@@ -49,7 +55,24 @@ class CartItem(Base, TimestampMixin):
def __repr__(self):
return f""
+ # === PRICE PROPERTIES (Euro convenience accessors) ===
+
+ @property
+ def price_at_add(self) -> float:
+ """Get price at add in euros."""
+ return cents_to_euros(self.price_at_add_cents)
+
+ @price_at_add.setter
+ def price_at_add(self, value: float):
+ """Set price at add from euros."""
+ self.price_at_add_cents = euros_to_cents(value)
+
+ @property
+ def line_total_cents(self) -> int:
+ """Calculate line total in cents."""
+ return self.price_at_add_cents * self.quantity
+
@property
def line_total(self) -> float:
- """Calculate line total."""
- return self.price_at_add * self.quantity
+ """Calculate line total in euros."""
+ return cents_to_euros(self.line_total_cents)
diff --git a/models/database/letzshop.py b/models/database/letzshop.py
index 03e33632..87d17ff8 100644
--- a/models/database/letzshop.py
+++ b/models/database/letzshop.py
@@ -52,6 +52,19 @@ class VendorLetzshopCredentials(Base, TimestampMixin):
auto_sync_enabled = Column(Boolean, default=False)
sync_interval_minutes = Column(Integer, default=15)
+ # Test mode (disables API mutations when enabled)
+ test_mode_enabled = Column(Boolean, default=False)
+
+ # Default carrier settings
+ default_carrier = Column(String(50), nullable=True) # greco, colissimo, xpresslogistics
+
+ # Carrier label URL prefixes
+ carrier_greco_label_url = Column(
+ String(500), default="https://dispatchweb.fr/Tracky/Home/"
+ )
+ carrier_colissimo_label_url = Column(String(500), nullable=True)
+ carrier_xpresslogistics_label_url = Column(String(500), nullable=True)
+
# Last sync status
last_sync_at = Column(DateTime(timezone=True), nullable=True)
last_sync_status = Column(String(50), nullable=True) # success, failed, partial
diff --git a/models/database/marketplace_product.py b/models/database/marketplace_product.py
index 5e558188..2d69bd2a 100644
--- a/models/database/marketplace_product.py
+++ b/models/database/marketplace_product.py
@@ -6,6 +6,10 @@ Amazon, eBay, CodesWholesale, etc.) in a universal format. It supports:
- Multi-language translations (via MarketplaceProductTranslation)
- Flexible attributes for marketplace-specific data
- Google Shopping fields for Letzshop compatibility
+
+Money values are stored as integer cents (e.g., €105.91 = 10591).
+Weight is stored as integer grams (e.g., 1.5kg = 1500g).
+See docs/architecture/money-handling.md for details.
"""
from enum import Enum
@@ -13,7 +17,6 @@ from enum import Enum
from sqlalchemy import (
Boolean,
Column,
- Float,
Index,
Integer,
String,
@@ -22,6 +25,7 @@ from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy.orm import relationship
from app.core.database import Base
+from app.utils.money import cents_to_euros, euros_to_cents
from models.database.base import TimestampMixin
@@ -49,6 +53,9 @@ class MarketplaceProduct(Base, TimestampMixin):
This table stores normalized product information from all marketplace sources.
Localized content (title, description) is stored in MarketplaceProductTranslation.
+
+ Price fields use integer cents for precision (€19.99 = 1999 cents).
+ Weight uses integer grams (1.5kg = 1500 grams).
"""
__tablename__ = "marketplace_products"
@@ -86,11 +93,11 @@ class MarketplaceProduct(Base, TimestampMixin):
category_path = Column(String) # Normalized category hierarchy
condition = Column(String)
- # === PRICING ===
+ # === PRICING (stored as integer cents) ===
price = Column(String) # Raw price string "19.99 EUR" (kept for reference)
- price_numeric = Column(Float) # Parsed numeric price
+ price_cents = Column(Integer) # Parsed numeric price in cents
sale_price = Column(String) # Raw sale price string
- sale_price_numeric = Column(Float) # Parsed numeric sale price
+ sale_price_cents = Column(Integer) # Parsed numeric sale price in cents
currency = Column(String(3), default="EUR")
# === MEDIA ===
@@ -102,8 +109,8 @@ class MarketplaceProduct(Base, TimestampMixin):
attributes = Column(JSON) # {color, size, material, etc.}
# === PHYSICAL PRODUCT FIELDS ===
- weight = Column(Float) # In kg
- weight_unit = Column(String(10), default="kg")
+ weight_grams = Column(Integer) # Weight in grams (1.5kg = 1500)
+ weight_unit = Column(String(10), default="kg") # Display unit
dimensions = Column(JSON) # {length, width, height, unit}
# === GOOGLE SHOPPING FIELDS (Preserved for Letzshop) ===
@@ -159,6 +166,44 @@ class MarketplaceProduct(Base, TimestampMixin):
f"vendor='{self.vendor_name}')>"
)
+ # === PRICE PROPERTIES (Euro convenience accessors) ===
+
+ @property
+ def price_numeric(self) -> float | None:
+ """Get price in euros (for API/display). Legacy name for compatibility."""
+ if self.price_cents is not None:
+ return cents_to_euros(self.price_cents)
+ return None
+
+ @price_numeric.setter
+ def price_numeric(self, value: float | None):
+ """Set price from euros. Legacy name for compatibility."""
+ self.price_cents = euros_to_cents(value) if value is not None else None
+
+ @property
+ def sale_price_numeric(self) -> float | None:
+ """Get sale price in euros (for API/display). Legacy name for compatibility."""
+ if self.sale_price_cents is not None:
+ return cents_to_euros(self.sale_price_cents)
+ return None
+
+ @sale_price_numeric.setter
+ def sale_price_numeric(self, value: float | None):
+ """Set sale price from euros. Legacy name for compatibility."""
+ self.sale_price_cents = euros_to_cents(value) if value is not None else None
+
+ @property
+ def weight(self) -> float | None:
+ """Get weight in kg (for API/display)."""
+ if self.weight_grams is not None:
+ return self.weight_grams / 1000.0
+ return None
+
+ @weight.setter
+ def weight(self, value: float | None):
+ """Set weight from kg."""
+ self.weight_grams = int(value * 1000) if value is not None else None
+
# === HELPER PROPERTIES ===
@property
@@ -228,12 +273,12 @@ class MarketplaceProduct(Base, TimestampMixin):
@property
def effective_price(self) -> float | None:
- """Get the effective numeric price."""
+ """Get the effective numeric price in euros."""
return self.price_numeric
@property
def effective_sale_price(self) -> float | None:
- """Get the effective numeric sale price."""
+ """Get the effective numeric sale price in euros."""
return self.sale_price_numeric
@property
diff --git a/models/database/order.py b/models/database/order.py
index 56d1d2c8..88bc5a2e 100644
--- a/models/database/order.py
+++ b/models/database/order.py
@@ -11,13 +11,15 @@ Design principles:
- customer_id FK links to Customer record (may be inactive for marketplace imports)
- channel field distinguishes order source
- external_* fields store marketplace-specific references
+
+Money values are stored as integer cents (e.g., €105.91 = 10591).
+See docs/architecture/money-handling.md for details.
"""
from sqlalchemy import (
Boolean,
Column,
DateTime,
- Float,
ForeignKey,
Index,
Integer,
@@ -32,6 +34,7 @@ from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy.orm import relationship
from app.core.database import Base
+from app.utils.money import cents_to_euros, euros_to_cents
from models.database.base import TimestampMixin
@@ -41,6 +44,8 @@ class Order(Base, TimestampMixin):
Stores orders from direct sales and marketplaces (Letzshop, etc.)
with snapshotted customer and address data.
+
+ All monetary amounts are stored as integer cents for precision.
"""
__tablename__ = "orders"
@@ -76,12 +81,12 @@ class Order(Base, TimestampMixin):
# refunded: order refunded
status = Column(String(50), nullable=False, default="pending", index=True)
- # === Financials ===
- subtotal = Column(Float, nullable=True) # May not be available from marketplace
- tax_amount = Column(Float, nullable=True)
- shipping_amount = Column(Float, nullable=True)
- discount_amount = Column(Float, nullable=True)
- total_amount = Column(Float, nullable=False)
+ # === Financials (stored as integer cents) ===
+ subtotal_cents = Column(Integer, nullable=True) # May not be available from marketplace
+ tax_amount_cents = Column(Integer, nullable=True)
+ shipping_amount_cents = Column(Integer, nullable=True)
+ discount_amount_cents = Column(Integer, nullable=True)
+ total_amount_cents = Column(Integer, nullable=False)
currency = Column(String(10), default="EUR")
# === Customer Snapshot (preserved at order time) ===
@@ -115,6 +120,9 @@ class Order(Base, TimestampMixin):
shipping_method = Column(String(100), nullable=True)
tracking_number = Column(String(100), nullable=True)
tracking_provider = Column(String(100), nullable=True)
+ tracking_url = Column(String(500), nullable=True) # Full tracking URL
+ shipment_number = Column(String(100), nullable=True) # Carrier shipment number (e.g., H74683403433)
+ shipping_carrier = Column(String(50), nullable=True) # Carrier code (greco, colissimo, etc.)
# === Notes ===
customer_notes = Column(Text, nullable=True)
@@ -146,6 +154,68 @@ class Order(Base, TimestampMixin):
def __repr__(self):
return f""
+ # === PRICE PROPERTIES (Euro convenience accessors) ===
+
+ @property
+ def subtotal(self) -> float | None:
+ """Get subtotal in euros."""
+ if self.subtotal_cents is not None:
+ return cents_to_euros(self.subtotal_cents)
+ return None
+
+ @subtotal.setter
+ def subtotal(self, value: float | None):
+ """Set subtotal from euros."""
+ self.subtotal_cents = euros_to_cents(value) if value is not None else None
+
+ @property
+ def tax_amount(self) -> float | None:
+ """Get tax amount in euros."""
+ if self.tax_amount_cents is not None:
+ return cents_to_euros(self.tax_amount_cents)
+ return None
+
+ @tax_amount.setter
+ def tax_amount(self, value: float | None):
+ """Set tax amount from euros."""
+ self.tax_amount_cents = euros_to_cents(value) if value is not None else None
+
+ @property
+ def shipping_amount(self) -> float | None:
+ """Get shipping amount in euros."""
+ if self.shipping_amount_cents is not None:
+ return cents_to_euros(self.shipping_amount_cents)
+ return None
+
+ @shipping_amount.setter
+ def shipping_amount(self, value: float | None):
+ """Set shipping amount from euros."""
+ self.shipping_amount_cents = euros_to_cents(value) if value is not None else None
+
+ @property
+ def discount_amount(self) -> float | None:
+ """Get discount amount in euros."""
+ if self.discount_amount_cents is not None:
+ return cents_to_euros(self.discount_amount_cents)
+ return None
+
+ @discount_amount.setter
+ def discount_amount(self, value: float | None):
+ """Set discount amount from euros."""
+ self.discount_amount_cents = euros_to_cents(value) if value is not None else None
+
+ @property
+ def total_amount(self) -> float:
+ """Get total amount in euros."""
+ return cents_to_euros(self.total_amount_cents)
+
+ @total_amount.setter
+ def total_amount(self, value: float):
+ """Set total amount from euros."""
+ self.total_amount_cents = euros_to_cents(value)
+
+ # === NAME PROPERTIES ===
+
@property
def customer_full_name(self) -> str:
"""Customer full name from snapshot."""
@@ -173,6 +243,8 @@ class OrderItem(Base, TimestampMixin):
Stores product snapshot at time of order plus external references
for marketplace items.
+
+ All monetary amounts are stored as integer cents for precision.
"""
__tablename__ = "order_items"
@@ -187,10 +259,10 @@ class OrderItem(Base, TimestampMixin):
gtin = Column(String(50), nullable=True) # EAN/UPC/ISBN etc.
gtin_type = Column(String(20), nullable=True) # ean13, upc, isbn, etc.
- # === Pricing ===
+ # === Pricing (stored as integer cents) ===
quantity = Column(Integer, nullable=False)
- unit_price = Column(Float, nullable=False)
- total_price = Column(Float, nullable=False)
+ unit_price_cents = Column(Integer, nullable=False)
+ total_price_cents = Column(Integer, nullable=False)
# === External References (for marketplace items) ===
external_item_id = Column(String(100), nullable=True) # e.g., Letzshop inventory unit ID
@@ -222,6 +294,30 @@ class OrderItem(Base, TimestampMixin):
def __repr__(self):
return f""
+ # === PRICE PROPERTIES (Euro convenience accessors) ===
+
+ @property
+ def unit_price(self) -> float:
+ """Get unit price in euros."""
+ return cents_to_euros(self.unit_price_cents)
+
+ @unit_price.setter
+ def unit_price(self, value: float):
+ """Set unit price from euros."""
+ self.unit_price_cents = euros_to_cents(value)
+
+ @property
+ def total_price(self) -> float:
+ """Get total price in euros."""
+ return cents_to_euros(self.total_price_cents)
+
+ @total_price.setter
+ def total_price(self, value: float):
+ """Set total price from euros."""
+ self.total_price_cents = euros_to_cents(value)
+
+ # === STATUS PROPERTIES ===
+
@property
def is_confirmed(self) -> bool:
"""Check if item has been confirmed (available or unavailable)."""
diff --git a/models/database/product.py b/models/database/product.py
index f943d4d8..58079fa3 100644
--- a/models/database/product.py
+++ b/models/database/product.py
@@ -7,12 +7,14 @@ to override any field. The override pattern works as follows:
This allows vendors to customize pricing, images, descriptions etc. while
still being able to "reset to source" by setting values back to NULL.
+
+Money values are stored as integer cents (e.g., €105.91 = 10591).
+See docs/architecture/money-handling.md for details.
"""
from sqlalchemy import (
Boolean,
Column,
- Float,
ForeignKey,
Index,
Integer,
@@ -23,6 +25,7 @@ from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy.orm import relationship
from app.core.database import Base
+from app.utils.money import cents_to_euros, euros_to_cents
from models.database.base import TimestampMixin
@@ -32,6 +35,8 @@ class Product(Base, TimestampMixin):
Each vendor can have their own version of a marketplace product with
custom pricing, images, and other overrides. Fields set to NULL
inherit their value from the linked marketplace_product.
+
+ Price fields use integer cents for precision (€19.99 = 1999 cents).
"""
__tablename__ = "products"
@@ -52,9 +57,9 @@ class Product(Base, TimestampMixin):
gtin_type = Column(String(20)) # Format: gtin13, gtin14, gtin12, gtin8, isbn13, isbn10
# === OVERRIDABLE FIELDS (NULL = inherit from marketplace_product) ===
- # Pricing
- price = Column(Float)
- sale_price = Column(Float)
+ # Pricing - stored as integer cents (€19.99 = 1999)
+ price_cents = Column(Integer) # Price in cents
+ sale_price_cents = Column(Integer) # Sale price in cents
currency = Column(String(3))
# Product Info
@@ -73,8 +78,8 @@ class Product(Base, TimestampMixin):
# === SUPPLIER TRACKING ===
supplier = Column(String(50)) # 'codeswholesale', 'internal', etc.
supplier_product_id = Column(String) # Supplier's product reference
- supplier_cost = Column(Float) # What we pay the supplier
- margin_percent = Column(Float) # Markup percentage
+ supplier_cost_cents = Column(Integer) # What we pay the supplier (in cents)
+ margin_percent_x100 = Column(Integer) # Markup percentage * 100 (e.g., 25.5% = 2550)
# === VENDOR-SPECIFIC (No inheritance) ===
is_featured = Column(Boolean, default=False)
@@ -115,8 +120,8 @@ class Product(Base, TimestampMixin):
# === OVERRIDABLE FIELDS LIST ===
OVERRIDABLE_FIELDS = [
- "price",
- "sale_price",
+ "price_cents",
+ "sale_price_cents",
"currency",
"brand",
"condition",
@@ -133,23 +138,85 @@ class Product(Base, TimestampMixin):
f"vendor_sku='{self.vendor_sku}')>"
)
+ # === PRICE PROPERTIES (Euro convenience accessors) ===
+
+ @property
+ def price(self) -> float | None:
+ """Get price in euros (for API/display)."""
+ if self.price_cents is not None:
+ return cents_to_euros(self.price_cents)
+ return None
+
+ @price.setter
+ def price(self, value: float | None):
+ """Set price from euros."""
+ self.price_cents = euros_to_cents(value) if value is not None else None
+
+ @property
+ def sale_price(self) -> float | None:
+ """Get sale price in euros (for API/display)."""
+ if self.sale_price_cents is not None:
+ return cents_to_euros(self.sale_price_cents)
+ return None
+
+ @sale_price.setter
+ def sale_price(self, value: float | None):
+ """Set sale price from euros."""
+ self.sale_price_cents = euros_to_cents(value) if value is not None else None
+
+ @property
+ def supplier_cost(self) -> float | None:
+ """Get supplier cost in euros."""
+ if self.supplier_cost_cents is not None:
+ return cents_to_euros(self.supplier_cost_cents)
+ return None
+
+ @supplier_cost.setter
+ def supplier_cost(self, value: float | None):
+ """Set supplier cost from euros."""
+ self.supplier_cost_cents = euros_to_cents(value) if value is not None else None
+
+ @property
+ def margin_percent(self) -> float | None:
+ """Get margin percent (e.g., 25.5)."""
+ if self.margin_percent_x100 is not None:
+ return self.margin_percent_x100 / 100.0
+ return None
+
+ @margin_percent.setter
+ def margin_percent(self, value: float | None):
+ """Set margin percent."""
+ self.margin_percent_x100 = int(value * 100) if value is not None else None
+
# === EFFECTIVE PROPERTIES (Override Pattern) ===
@property
- def effective_price(self) -> float | None:
- """Get price (vendor override or marketplace fallback)."""
- if self.price is not None:
- return self.price
+ def effective_price_cents(self) -> int | None:
+ """Get price in cents (vendor override or marketplace fallback)."""
+ if self.price_cents is not None:
+ return self.price_cents
mp = self.marketplace_product
- return mp.price_numeric if mp else None
+ return mp.price_cents if mp else None
+
+ @property
+ def effective_price(self) -> float | None:
+ """Get price in euros (vendor override or marketplace fallback)."""
+ cents = self.effective_price_cents
+ return cents_to_euros(cents) if cents is not None else None
+
+ @property
+ def effective_sale_price_cents(self) -> int | None:
+ """Get sale price in cents (vendor override or marketplace fallback)."""
+ if self.sale_price_cents is not None:
+ return self.sale_price_cents
+ mp = self.marketplace_product
+ return mp.sale_price_cents if mp else None
@property
def effective_sale_price(self) -> float | None:
- """Get sale price (vendor override or marketplace fallback)."""
- if self.sale_price is not None:
- return self.sale_price
- mp = self.marketplace_product
- return mp.sale_price_numeric if mp else None
+ """Get sale price in euros (vendor override or marketplace fallback)."""
+ cents = self.effective_sale_price_cents
+ return cents_to_euros(cents) if cents is not None else None
@property
def effective_currency(self) -> str:
@@ -260,12 +327,14 @@ class Product(Base, TimestampMixin):
return {
# Price
"price": self.effective_price,
- "price_overridden": self.price is not None,
- "price_source": mp.price_numeric if mp else None,
+ "price_cents": self.effective_price_cents,
+ "price_overridden": self.price_cents is not None,
+ "price_source": cents_to_euros(mp.price_cents) if mp and mp.price_cents else None,
# Sale Price
"sale_price": self.effective_sale_price,
- "sale_price_overridden": self.sale_price is not None,
- "sale_price_source": mp.sale_price_numeric if mp else None,
+ "sale_price_cents": self.effective_sale_price_cents,
+ "sale_price_overridden": self.sale_price_cents is not None,
+ "sale_price_source": cents_to_euros(mp.sale_price_cents) if mp and mp.sale_price_cents else None,
# Currency
"currency": self.effective_currency,
"currency_overridden": self.currency is not None,
diff --git a/models/schema/letzshop.py b/models/schema/letzshop.py
index 72fdba00..69b587d7 100644
--- a/models/schema/letzshop.py
+++ b/models/schema/letzshop.py
@@ -31,6 +31,21 @@ class LetzshopCredentialsCreate(BaseModel):
sync_interval_minutes: int = Field(
15, ge=5, le=1440, description="Sync interval in minutes (5-1440)"
)
+ test_mode_enabled: bool = Field(
+ False, description="Test mode - disables API mutations"
+ )
+ default_carrier: str | None = Field(
+ None, description="Default carrier (greco, colissimo, xpresslogistics)"
+ )
+ carrier_greco_label_url: str | None = Field(
+ "https://dispatchweb.fr/Tracky/Home/", description="Greco label URL prefix"
+ )
+ carrier_colissimo_label_url: str | None = Field(
+ None, description="Colissimo label URL prefix"
+ )
+ carrier_xpresslogistics_label_url: str | None = Field(
+ None, description="XpressLogistics label URL prefix"
+ )
class LetzshopCredentialsUpdate(BaseModel):
@@ -40,6 +55,11 @@ class LetzshopCredentialsUpdate(BaseModel):
api_endpoint: str | None = None
auto_sync_enabled: bool | None = None
sync_interval_minutes: int | None = Field(None, ge=5, le=1440)
+ test_mode_enabled: bool | None = None
+ default_carrier: str | None = None
+ carrier_greco_label_url: str | None = None
+ carrier_colissimo_label_url: str | None = None
+ carrier_xpresslogistics_label_url: str | None = None
class LetzshopCredentialsResponse(BaseModel):
@@ -53,6 +73,11 @@ class LetzshopCredentialsResponse(BaseModel):
api_endpoint: str
auto_sync_enabled: bool
sync_interval_minutes: int
+ test_mode_enabled: bool = False
+ default_carrier: str | None = None
+ carrier_greco_label_url: str | None = None
+ carrier_colissimo_label_url: str | None = None
+ carrier_xpresslogistics_label_url: str | None = None
last_sync_at: datetime | None
last_sync_status: str | None
last_sync_error: str | None
@@ -101,6 +126,7 @@ class LetzshopOrderResponse(BaseModel):
id: int
vendor_id: int
+ vendor_name: str | None = None # For cross-vendor views
order_number: str
# External references
diff --git a/models/schema/order.py b/models/schema/order.py
index 20cb2039..9963d42d 100644
--- a/models/schema/order.py
+++ b/models/schema/order.py
@@ -270,6 +270,9 @@ class OrderResponse(BaseModel):
shipping_method: str | None
tracking_number: str | None
tracking_provider: str | None
+ tracking_url: str | None = None
+ shipment_number: str | None = None
+ shipping_carrier: str | None = None
# Notes
customer_notes: str | None
@@ -302,6 +305,10 @@ class OrderDetailResponse(OrderResponse):
items: list[OrderItemResponse] = []
+ # Vendor info (enriched by API)
+ vendor_name: str | None = None
+ vendor_code: str | None = None
+
class OrderListResponse(BaseModel):
"""Schema for paginated order list."""
@@ -345,6 +352,9 @@ class OrderListItem(BaseModel):
# Tracking
tracking_number: str | None
tracking_provider: str | None
+ tracking_url: str | None = None
+ shipment_number: str | None = None
+ shipping_carrier: str | None = None
# Item count
item_count: int = 0
@@ -394,6 +404,9 @@ class AdminOrderItem(BaseModel):
ship_country_iso: str
tracking_number: str | None
tracking_provider: str | None
+ tracking_url: str | None = None
+ shipment_number: str | None = None
+ shipping_carrier: str | None = None
# Item count
item_count: int = 0
@@ -534,3 +547,26 @@ class LetzshopOrderConfirmRequest(BaseModel):
"""Schema for confirming/declining order items."""
items: list[LetzshopOrderConfirmItem]
+
+
+# ============================================================================
+# Mark as Shipped Schemas
+# ============================================================================
+
+
+class MarkAsShippedRequest(BaseModel):
+ """Schema for marking an order as shipped with tracking info."""
+
+ tracking_number: str | None = Field(None, max_length=100)
+ tracking_url: str | None = Field(None, max_length=500)
+ shipping_carrier: str | None = Field(None, max_length=50)
+
+
+class ShippingLabelInfo(BaseModel):
+ """Shipping label information for an order."""
+
+ shipment_number: str | None = None
+ shipping_carrier: str | None = None
+ label_url: str | None = None
+ tracking_number: str | None = None
+ tracking_url: str | None = None
diff --git a/models/schema/order_item_exception.py b/models/schema/order_item_exception.py
index a05827e8..341bb8d9 100644
--- a/models/schema/order_item_exception.py
+++ b/models/schema/order_item_exception.py
@@ -23,6 +23,7 @@ class OrderItemExceptionResponse(BaseModel):
id: int
order_item_id: int
vendor_id: int
+ vendor_name: str | None = None # For cross-vendor views
# Original data from marketplace
original_gtin: str | None
diff --git a/scripts/check_letzshop_shipment.py b/scripts/check_letzshop_shipment.py
new file mode 100644
index 00000000..3b6833ee
--- /dev/null
+++ b/scripts/check_letzshop_shipment.py
@@ -0,0 +1,206 @@
+#!/usr/bin/env python3
+"""
+Check shipment status directly from Letzshop API.
+
+Usage:
+ python scripts/check_letzshop_shipment.py YOUR_API_KEY [SHIPMENT_ID_OR_ORDER_NUMBER]
+
+Example:
+ python scripts/check_letzshop_shipment.py abc123 nvDv5RQEmCwbjo
+ python scripts/check_letzshop_shipment.py abc123 R532332163
+"""
+
+import sys
+import json
+import requests
+
+ENDPOINT = "https://letzshop.lu/graphql"
+
+# Query template - state is interpolated since Letzshop has enum issues
+QUERY_SHIPMENTS_TEMPLATE = """
+query GetShipmentsPaginated($first: Int!, $after: String) {{
+ shipments(state: {state}, first: $first, after: $after) {{
+ pageInfo {{
+ hasNextPage
+ endCursor
+ }}
+ nodes {{
+ id
+ number
+ state
+ order {{
+ id
+ number
+ email
+ total
+ completedAt
+ locale
+ shipAddress {{
+ firstName
+ lastName
+ company
+ streetName
+ streetNumber
+ city
+ zipCode
+ phone
+ country {{
+ iso
+ }}
+ }}
+ }}
+ inventoryUnits {{
+ id
+ state
+ variant {{
+ id
+ sku
+ mpn
+ price
+ tradeId {{
+ number
+ parser
+ }}
+ product {{
+ name {{
+ en
+ fr
+ de
+ }}
+ }}
+ }}
+ }}
+ }}
+ }}
+}}
+"""
+
+
+def search_shipment(api_key: str, search_term: str):
+ """Search for a shipment across all states."""
+
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {api_key}",
+ }
+
+ # States to search through
+ states = ["shipped", "confirmed", "unconfirmed", "declined"]
+
+ print(f"Searching for: {search_term}")
+ print("=" * 60)
+
+ for state in states:
+ print(f"\nSearching in state: {state}...")
+
+ query = QUERY_SHIPMENTS_TEMPLATE.format(state=state)
+
+ # Paginate through results
+ has_next = True
+ after = None
+ page = 0
+
+ while has_next and page < 20: # Max 20 pages to avoid infinite loop
+ page += 1
+ variables = {"first": 50, "after": after}
+
+ response = requests.post(
+ ENDPOINT,
+ headers=headers,
+ json={"query": query, "variables": variables},
+ )
+
+ if response.status_code != 200:
+ print(f" Error: HTTP {response.status_code}")
+ break
+
+ data = response.json()
+
+ if "errors" in data:
+ print(f" GraphQL errors: {data['errors']}")
+ break
+
+ result = data.get("data", {}).get("shipments", {})
+ nodes = result.get("nodes", [])
+ page_info = result.get("pageInfo", {})
+
+ # Search for matching shipment
+ for shipment in nodes:
+ shipment_id = shipment.get("id", "")
+ shipment_number = shipment.get("number", "")
+ order = shipment.get("order", {})
+ order_number = order.get("number", "")
+
+ # Check if this matches our search term
+ if (search_term in shipment_id or
+ search_term in shipment_number or
+ search_term in order_number or
+ search_term == shipment_id or
+ search_term == order_number):
+
+ print(f"\n{'=' * 60}")
+ print(f"FOUND SHIPMENT!")
+ print(f"{'=' * 60}")
+
+ print(f"\n--- Shipment Info ---")
+ print(f" ID: {shipment.get('id')}")
+ print(f" Number: {shipment.get('number')}")
+ print(f" State: {shipment.get('state')}")
+
+ print(f"\n--- Order Info ---")
+ print(f" Order ID: {order.get('id')}")
+ print(f" Order Number: {order.get('number')}")
+ print(f" Email: {order.get('email')}")
+ print(f" Total: {order.get('total')}")
+ print(f" Completed At: {order.get('completedAt')}")
+
+ ship_addr = order.get('shipAddress', {})
+ if ship_addr:
+ print(f"\n--- Shipping Address ---")
+ print(f" Name: {ship_addr.get('firstName')} {ship_addr.get('lastName')}")
+ print(f" Street: {ship_addr.get('streetName')} {ship_addr.get('streetNumber')}")
+ print(f" City: {ship_addr.get('zipCode')} {ship_addr.get('city')}")
+ country = ship_addr.get('country', {})
+ print(f" Country: {country.get('iso')}")
+
+ print(f"\n--- Inventory Units ---")
+ units = shipment.get('inventoryUnits', [])
+ for i, unit in enumerate(units, 1):
+ print(f" Unit {i}:")
+ print(f" ID: {unit.get('id')}")
+ print(f" State: {unit.get('state')}")
+ variant = unit.get('variant', {})
+ print(f" SKU: {variant.get('sku')}")
+ trade_id = variant.get('tradeId', {})
+ print(f" GTIN: {trade_id.get('number')}")
+ product = variant.get('product', {})
+ name = product.get('name', {})
+ print(f" Product: {name.get('en')}")
+
+ print(f"\n--- Raw Response ---")
+ print(json.dumps(shipment, indent=2, default=str))
+ return shipment
+
+ has_next = page_info.get("hasNextPage", False)
+ after = page_info.get("endCursor")
+
+ if not has_next:
+ print(f" Searched {page} page(s), {len(nodes)} shipments in last page")
+
+ print(f"\nShipment not found for: {search_term}")
+ return None
+
+
+if __name__ == "__main__":
+ if len(sys.argv) < 2:
+ print("Usage: python scripts/check_letzshop_shipment.py YOUR_API_KEY [SHIPMENT_ID_OR_ORDER_NUMBER]")
+ print("\nExample:")
+ print(" python scripts/check_letzshop_shipment.py abc123 nvDv5RQEmCwbjo")
+ print(" python scripts/check_letzshop_shipment.py abc123 R532332163")
+ sys.exit(1)
+
+ api_key = sys.argv[1]
+ # Default to the order number R532332163
+ search_term = sys.argv[2] if len(sys.argv) > 2 else "R532332163"
+
+ search_shipment(api_key, search_term)
diff --git a/scripts/check_letzshop_tracking.py b/scripts/check_letzshop_tracking.py
new file mode 100644
index 00000000..643a2aed
--- /dev/null
+++ b/scripts/check_letzshop_tracking.py
@@ -0,0 +1,146 @@
+#!/usr/bin/env python3
+"""
+Check tracking info for a specific shipment from Letzshop API.
+
+Usage:
+ python scripts/check_letzshop_tracking.py YOUR_API_KEY SHIPMENT_ID
+
+Example:
+ python scripts/check_letzshop_tracking.py abc123 nvDv5RQEmCwbjo
+"""
+
+import sys
+import json
+import requests
+
+ENDPOINT = "https://letzshop.lu/graphql"
+
+# Query with tracking field included
+QUERY_SHIPMENT_WITH_TRACKING = """
+query GetShipmentsPaginated($first: Int!, $after: String) {
+ shipments(state: confirmed, first: $first, after: $after) {
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ nodes {
+ id
+ number
+ state
+ tracking {
+ number
+ url
+ carrier {
+ name
+ code
+ }
+ }
+ order {
+ id
+ number
+ email
+ total
+ }
+ inventoryUnits {
+ id
+ state
+ }
+ }
+ }
+}
+"""
+
+
+def get_tracking_info(api_key: str, target_shipment_id: str):
+ """Get tracking info for a specific shipment."""
+
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {api_key}",
+ }
+
+ print(f"Looking for shipment: {target_shipment_id}")
+ print("=" * 60)
+
+ # Paginate through confirmed shipments
+ has_next = True
+ after = None
+ page = 0
+
+ while has_next and page < 20:
+ page += 1
+ variables = {"first": 50, "after": after}
+
+ response = requests.post(
+ ENDPOINT,
+ headers=headers,
+ json={"query": QUERY_SHIPMENT_WITH_TRACKING, "variables": variables},
+ )
+
+ if response.status_code != 200:
+ print(f"Error: HTTP {response.status_code}")
+ print(response.text)
+ return
+
+ data = response.json()
+
+ if "errors" in data:
+ print(f"GraphQL errors: {json.dumps(data['errors'], indent=2)}")
+ return
+
+ result = data.get("data", {}).get("shipments", {})
+ nodes = result.get("nodes", [])
+ page_info = result.get("pageInfo", {})
+
+ print(f"Page {page}: {len(nodes)} shipments")
+
+ for shipment in nodes:
+ if shipment.get("id") == target_shipment_id:
+ print(f"\n{'=' * 60}")
+ print("FOUND SHIPMENT!")
+ print(f"{'=' * 60}")
+
+ print(f"\n--- Shipment Info ---")
+ print(f" ID: {shipment.get('id')}")
+ print(f" Number: {shipment.get('number')}")
+ print(f" State: {shipment.get('state')}")
+
+ print(f"\n--- Tracking Info ---")
+ tracking = shipment.get('tracking')
+ if tracking:
+ print(f" Tracking Number: {tracking.get('number')}")
+ print(f" Tracking URL: {tracking.get('url')}")
+ carrier = tracking.get('carrier', {})
+ if carrier:
+ print(f" Carrier Name: {carrier.get('name')}")
+ print(f" Carrier Code: {carrier.get('code')}")
+ else:
+ print(" No tracking object returned")
+
+ print(f"\n--- Order Info ---")
+ order = shipment.get('order', {})
+ print(f" Order Number: {order.get('number')}")
+ print(f" Email: {order.get('email')}")
+
+ print(f"\n--- Raw Response ---")
+ print(json.dumps(shipment, indent=2, default=str))
+ return shipment
+
+ has_next = page_info.get("hasNextPage", False)
+ after = page_info.get("endCursor")
+
+ print(f"\nShipment {target_shipment_id} not found in confirmed shipments")
+ return None
+
+
+if __name__ == "__main__":
+ if len(sys.argv) < 3:
+ print("Usage: python scripts/check_letzshop_tracking.py YOUR_API_KEY SHIPMENT_ID")
+ print("\nExample:")
+ print(" python scripts/check_letzshop_tracking.py abc123 nvDv5RQEmCwbjo")
+ sys.exit(1)
+
+ api_key = sys.argv[1]
+ shipment_id = sys.argv[2]
+
+ get_tracking_info(api_key, shipment_id)
diff --git a/scripts/check_tracking_schema.py b/scripts/check_tracking_schema.py
new file mode 100644
index 00000000..ec9b671b
--- /dev/null
+++ b/scripts/check_tracking_schema.py
@@ -0,0 +1,201 @@
+#!/usr/bin/env python3
+"""
+Quick script to check Letzshop tracking schema and test tracking query.
+
+Usage:
+ python scripts/check_tracking_schema.py YOUR_API_KEY
+"""
+
+import sys
+import json
+import requests
+
+ENDPOINT = "https://letzshop.lu/graphql"
+
+
+def run_query(api_key: str, query: str, variables: dict = None) -> dict:
+ """Execute a GraphQL query."""
+ headers = {
+ "Authorization": f"Bearer {api_key}",
+ "Content-Type": "application/json",
+ }
+ payload = {"query": query}
+ if variables:
+ payload["variables"] = variables
+ response = requests.post(ENDPOINT, json=payload, headers=headers, timeout=30)
+ return response.json()
+
+
+def main():
+ if len(sys.argv) < 2:
+ print("Usage: python scripts/check_tracking_schema.py YOUR_API_KEY")
+ sys.exit(1)
+
+ api_key = sys.argv[1]
+
+ print("=" * 60)
+ print("Letzshop Tracking Schema Investigation")
+ print("=" * 60)
+
+ # 1. Introspect Tracking type
+ print("\n1. Checking Tracking type schema...")
+ tracking_query = """
+ {
+ __type(name: "Tracking") {
+ name
+ kind
+ fields {
+ name
+ type {
+ name
+ kind
+ ofType { name kind }
+ }
+ }
+ }
+ }
+ """
+ result = run_query(api_key, tracking_query)
+ if "errors" in result:
+ print(f" Error: {result['errors']}")
+ else:
+ type_data = result.get("data", {}).get("__type")
+ if type_data and type_data.get("fields"):
+ print(" Tracking type fields:")
+ for field in type_data["fields"]:
+ type_info = field.get("type", {})
+ type_name = type_info.get("name") or type_info.get("ofType", {}).get("name", "?")
+ print(f" - {field['name']}: {type_name}")
+ else:
+ print(" Tracking type not found or has no fields")
+
+ # 2. Check Shipment tracking-related fields
+ print("\n2. Checking Shipment type for tracking fields...")
+ shipment_query = """
+ {
+ __type(name: "Shipment") {
+ name
+ fields {
+ name
+ type {
+ name
+ kind
+ ofType { name kind }
+ }
+ }
+ }
+ }
+ """
+ result = run_query(api_key, shipment_query)
+ if "errors" in result:
+ print(f" Error: {result['errors']}")
+ else:
+ type_data = result.get("data", {}).get("__type")
+ if type_data and type_data.get("fields"):
+ tracking_fields = [
+ f for f in type_data["fields"]
+ if any(term in f["name"].lower() for term in ["track", "carrier", "number"])
+ ]
+ print(" Tracking-related fields on Shipment:")
+ for field in tracking_fields:
+ type_info = field.get("type", {})
+ type_name = type_info.get("name") or type_info.get("ofType", {}).get("name", "?")
+ print(f" - {field['name']}: {type_name}")
+
+ # 3. Test querying tracking on a confirmed shipment
+ print("\n3. Testing tracking query on confirmed shipments...")
+ test_query = """
+ query {
+ shipments(state: confirmed, first: 1) {
+ nodes {
+ id
+ number
+ state
+ tracking {
+ code
+ provider
+ }
+ }
+ }
+ }
+ """
+ result = run_query(api_key, test_query)
+ if "errors" in result:
+ print(f" Error querying tracking: {result['errors'][0].get('message', 'Unknown')}")
+ else:
+ nodes = result.get("data", {}).get("shipments", {}).get("nodes", [])
+ if nodes:
+ shipment = nodes[0]
+ tracking = shipment.get("tracking")
+ print(f" Shipment {shipment.get('number')}:")
+ if tracking:
+ print(f" Tracking code: {tracking.get('code')}")
+ print(f" Tracking provider: {tracking.get('provider')}")
+ else:
+ print(" No tracking data returned")
+ else:
+ print(" No confirmed shipments found")
+
+ # 4. Try alternative field names
+ print("\n4. Testing alternative tracking field names...")
+ alt_query = """
+ query {
+ shipments(state: confirmed, first: 1) {
+ nodes {
+ id
+ number
+ }
+ }
+ }
+ """
+ result = run_query(api_key, alt_query)
+ if "errors" not in result:
+ nodes = result.get("data", {}).get("shipments", {}).get("nodes", [])
+ if nodes:
+ shipment_id = nodes[0].get("id")
+ print(f" Found shipment: {shipment_id}")
+
+ # Try node query with tracking
+ node_query = """
+ query GetShipment($id: ID!) {
+ node(id: $id) {
+ ... on Shipment {
+ id
+ number
+ tracking {
+ code
+ provider
+ }
+ }
+ }
+ }
+ """
+ result = run_query(api_key, node_query, {"id": shipment_id})
+ if "errors" in result:
+ print(f" Node query error: {result['errors'][0].get('message', 'Unknown')}")
+ else:
+ node = result.get("data", {}).get("node", {})
+ tracking = node.get("tracking")
+ if tracking:
+ print(f" Tracking via node query:")
+ print(f" Code: {tracking.get('code')}")
+ print(f" Provider: {tracking.get('provider')}")
+ else:
+ print(" No tracking in node query response")
+
+ print("\n" + "=" * 60)
+ print("Summary")
+ print("=" * 60)
+ print("""
+Based on the tests above:
+- If tracking fields exist but return null: tracking may not be set for this shipment
+- If errors occur: the API may have issues with tracking field
+- The shipment number (H74683403433) you see is different from tracking code
+
+Note: The 'number' field on Shipment is the shipment number (H74683403433),
+NOT the tracking code. The tracking code should be in tracking.code field.
+""")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/create_dummy_letzshop_order.py b/scripts/create_dummy_letzshop_order.py
new file mode 100755
index 00000000..8a584e34
--- /dev/null
+++ b/scripts/create_dummy_letzshop_order.py
@@ -0,0 +1,273 @@
+#!/usr/bin/env python3
+"""
+Create a dummy Letzshop order for testing purposes.
+
+This script creates a realistic Letzshop order in the database without
+calling the actual Letzshop API. Useful for testing the order UI and workflow.
+
+Usage:
+ python scripts/create_dummy_letzshop_order.py --vendor-id 1
+ python scripts/create_dummy_letzshop_order.py --vendor-id 1 --status confirmed
+ python scripts/create_dummy_letzshop_order.py --vendor-id 1 --with-tracking
+"""
+
+import argparse
+import random
+import string
+import sys
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+
+# Add project root to path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from app.core.database import SessionLocal
+from app.utils.money import cents_to_euros, euros_to_cents
+from models.database import Order, OrderItem, Product, Vendor
+
+
+def generate_order_number():
+ """Generate a realistic Letzshop order number like R532332163."""
+ return f"R{random.randint(100000000, 999999999)}"
+
+
+def generate_shipment_number():
+ """Generate a realistic shipment number like H74683403433."""
+ return f"H{random.randint(10000000000, 99999999999)}"
+
+
+def generate_hash_id():
+ """Generate a realistic hash ID like nvDv5RQEmCwbjo."""
+ chars = string.ascii_letters + string.digits
+ return ''.join(random.choice(chars) for _ in range(14))
+
+
+def create_dummy_order(
+ db,
+ vendor_id: int,
+ status: str = "pending",
+ with_tracking: bool = False,
+ carrier: str = "greco",
+ items_count: int = 2,
+):
+ """Create a dummy Letzshop order with realistic data."""
+
+ # Verify vendor exists
+ vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
+ if not vendor:
+ print(f"Error: Vendor with ID {vendor_id} not found")
+ return None
+
+ # Get some products from the vendor (or create placeholder if none exist)
+ products = db.query(Product).filter(
+ Product.vendor_id == vendor_id,
+ Product.is_active == True
+ ).limit(items_count).all()
+
+ if not products:
+ print(f"Warning: No active products found for vendor {vendor_id}, creating placeholder")
+ # Create placeholder products with prices in cents
+ products = [
+ Product(
+ vendor_id=vendor_id,
+ vendor_sku="TEST-001",
+ gtin="4006381333931",
+ gtin_type="ean13",
+ price_cents=2999, # €29.99
+ is_active=True,
+ is_featured=False,
+ ),
+ Product(
+ vendor_id=vendor_id,
+ vendor_sku="TEST-002",
+ gtin="5901234123457",
+ gtin_type="ean13",
+ price_cents=4999, # €49.99
+ is_active=True,
+ is_featured=False,
+ ),
+ ]
+ for p in products:
+ db.add(p)
+ db.flush()
+
+ # Generate order data
+ order_number = generate_order_number()
+ shipment_number = generate_shipment_number()
+ hash_id = generate_hash_id()
+ order_date = datetime.now(timezone.utc) - timedelta(days=random.randint(0, 7))
+
+ # Customer data
+ first_names = ["Jean", "Marie", "Pierre", "Sophie", "Michel", "Anne", "Thomas", "Claire"]
+ last_names = ["Dupont", "Martin", "Bernard", "Dubois", "Thomas", "Robert", "Richard", "Petit"]
+ cities = ["Luxembourg", "Esch-sur-Alzette", "Differdange", "Dudelange", "Ettelbruck"]
+
+ customer_first = random.choice(first_names)
+ customer_last = random.choice(last_names)
+ customer_email = f"{customer_first.lower()}.{customer_last.lower()}@example.lu"
+
+ # Calculate totals in cents
+ subtotal_cents = sum((p.effective_price_cents or 0) * random.randint(1, 3) for p in products[:items_count])
+ shipping_cents = 595 # €5.95
+ total_cents = subtotal_cents + shipping_cents
+
+ # Create the order
+ order = Order(
+ vendor_id=vendor_id,
+ customer_id=1, # Placeholder customer ID
+ order_number=f"LS-{vendor_id}-{order_number}",
+ channel="letzshop",
+ external_order_id=f"gid://letzshop/Order/{random.randint(10000, 99999)}",
+ external_order_number=order_number,
+ external_shipment_id=hash_id,
+ shipment_number=shipment_number,
+ shipping_carrier=carrier,
+ status=status,
+ subtotal_cents=subtotal_cents,
+ tax_amount_cents=0,
+ shipping_amount_cents=shipping_cents,
+ discount_amount_cents=0,
+ total_amount_cents=total_cents,
+ currency="EUR",
+ # Customer snapshot
+ customer_first_name=customer_first,
+ customer_last_name=customer_last,
+ customer_email=customer_email,
+ customer_phone=f"+352 {random.randint(600000, 699999)}",
+ customer_locale="fr",
+ # Shipping address
+ ship_first_name=customer_first,
+ ship_last_name=customer_last,
+ ship_company=None,
+ ship_address_line_1=f"{random.randint(1, 200)} Rue du Test",
+ ship_address_line_2=None,
+ ship_city=random.choice(cities),
+ ship_postal_code=f"L-{random.randint(1000, 9999)}",
+ ship_country_iso="LU",
+ # Billing address (same as shipping)
+ bill_first_name=customer_first,
+ bill_last_name=customer_last,
+ bill_company=None,
+ bill_address_line_1=f"{random.randint(1, 200)} Rue du Test",
+ bill_address_line_2=None,
+ bill_city=random.choice(cities),
+ bill_postal_code=f"L-{random.randint(1000, 9999)}",
+ bill_country_iso="LU",
+ # Timestamps
+ order_date=order_date,
+ )
+
+ # Set status-specific timestamps
+ if status in ["processing", "shipped", "delivered"]:
+ order.confirmed_at = order_date + timedelta(hours=random.randint(1, 24))
+ if status in ["shipped", "delivered"]:
+ order.shipped_at = order.confirmed_at + timedelta(days=random.randint(1, 3))
+ if status == "delivered":
+ order.delivered_at = order.shipped_at + timedelta(days=random.randint(1, 5))
+ if status == "cancelled":
+ order.cancelled_at = order_date + timedelta(hours=random.randint(1, 48))
+
+ # Add tracking if requested
+ if with_tracking or status == "shipped":
+ order.tracking_number = f"LU{random.randint(100000000, 999999999)}"
+ order.tracking_provider = carrier
+ if carrier == "greco":
+ order.tracking_url = f"https://dispatchweb.fr/Tracky/Home/{shipment_number}"
+
+ db.add(order)
+ db.flush()
+
+ # Create order items with prices in cents
+ for i, product in enumerate(products[:items_count]):
+ quantity = random.randint(1, 3)
+ unit_price_cents = product.effective_price_cents or 0
+ product_name = product.get_effective_title("en") or f"Product {product.id}"
+ item = OrderItem(
+ order_id=order.id,
+ product_id=product.id,
+ product_name=product_name,
+ product_sku=product.vendor_sku,
+ gtin=product.gtin,
+ gtin_type=product.gtin_type,
+ quantity=quantity,
+ unit_price_cents=unit_price_cents,
+ total_price_cents=unit_price_cents * quantity,
+ external_item_id=f"gid://letzshop/InventoryUnit/{random.randint(10000, 99999)}",
+ item_state="confirmed_available" if status != "pending" else None,
+ inventory_reserved=status != "pending",
+ inventory_fulfilled=status in ["shipped", "delivered"],
+ needs_product_match=False,
+ )
+ db.add(item)
+
+ db.commit()
+ db.refresh(order)
+
+ return order
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Create a dummy Letzshop order for testing")
+ parser.add_argument("--vendor-id", type=int, required=True, help="Vendor ID to create order for")
+ parser.add_argument(
+ "--status",
+ choices=["pending", "processing", "shipped", "delivered", "cancelled"],
+ default="pending",
+ help="Order status (default: pending)"
+ )
+ parser.add_argument(
+ "--carrier",
+ choices=["greco", "colissimo", "xpresslogistics"],
+ default="greco",
+ help="Shipping carrier (default: greco)"
+ )
+ parser.add_argument("--with-tracking", action="store_true", help="Add tracking information")
+ parser.add_argument("--items", type=int, default=2, help="Number of items in order (default: 2)")
+
+ args = parser.parse_args()
+
+ db = SessionLocal()
+ try:
+ print(f"Creating dummy Letzshop order for vendor {args.vendor_id}...")
+ print(f" Status: {args.status}")
+ print(f" Carrier: {args.carrier}")
+ print(f" Items: {args.items}")
+ print(f" With tracking: {args.with_tracking}")
+ print()
+
+ order = create_dummy_order(
+ db,
+ vendor_id=args.vendor_id,
+ status=args.status,
+ with_tracking=args.with_tracking,
+ carrier=args.carrier,
+ items_count=args.items,
+ )
+
+ if order:
+ print("Order created successfully!")
+ print()
+ print("Order Details:")
+ print(f" ID: {order.id}")
+ print(f" Internal Number: {order.order_number}")
+ print(f" Letzshop Order Number: {order.external_order_number}")
+ print(f" Shipment Number: {order.shipment_number}")
+ print(f" Hash ID: {order.external_shipment_id}")
+ print(f" Carrier: {order.shipping_carrier}")
+ print(f" Status: {order.status}")
+ print(f" Total: {order.total_amount} {order.currency}")
+ print(f" Customer: {order.customer_first_name} {order.customer_last_name}")
+ print(f" Email: {order.customer_email}")
+ print(f" Items: {len(order.items)}")
+ if order.tracking_number:
+ print(f" Tracking: {order.tracking_number}")
+ if order.tracking_url:
+ print(f" Tracking URL: {order.tracking_url}")
+ print()
+ print(f"View order at: http://localhost:8000/admin/letzshop/orders/{order.id}")
+ finally:
+ db.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/investigate_order.py b/scripts/investigate_order.py
new file mode 100644
index 00000000..5d29edb1
--- /dev/null
+++ b/scripts/investigate_order.py
@@ -0,0 +1,126 @@
+#!/usr/bin/env python3
+"""Debug script to investigate order shipping information."""
+
+import sys
+sys.path.insert(0, ".")
+
+from app.core.database import SessionLocal
+from models.database.order import Order, OrderItem
+
+
+def investigate_order(order_number: str):
+ """Investigate shipping info for a specific order."""
+ db = SessionLocal()
+ try:
+ # Try to find the order by various number formats
+ order = db.query(Order).filter(
+ (Order.order_number == order_number) |
+ (Order.external_order_number == order_number) |
+ (Order.order_number.contains(order_number)) |
+ (Order.external_order_number.contains(order_number))
+ ).first()
+
+ if not order:
+ print(f"Order not found: {order_number}")
+ print("\nSearching for similar orders...")
+ similar = db.query(Order).filter(
+ Order.order_number.ilike(f"%{order_number[-6:]}%")
+ ).limit(5).all()
+ if similar:
+ print("Found similar orders:")
+ for o in similar:
+ print(f" - {o.order_number} (external: {o.external_order_number})")
+ return
+
+ print("=" * 60)
+ print(f"ORDER FOUND: {order.order_number}")
+ print("=" * 60)
+
+ print("\n--- Basic Info ---")
+ print(f" ID: {order.id}")
+ print(f" Order Number: {order.order_number}")
+ print(f" External Order Number: {order.external_order_number}")
+ print(f" External Order ID: {order.external_order_id}")
+ print(f" External Shipment ID: {order.external_shipment_id}")
+ print(f" Channel: {order.channel}")
+ print(f" Status: {order.status}")
+ print(f" Vendor ID: {order.vendor_id}")
+
+ print("\n--- Dates ---")
+ print(f" Order Date: {order.order_date}")
+ print(f" Confirmed At: {order.confirmed_at}")
+ print(f" Shipped At: {order.shipped_at}")
+ print(f" Cancelled At: {order.cancelled_at}")
+ print(f" Created At: {order.created_at}")
+ print(f" Updated At: {order.updated_at}")
+
+ print("\n--- Shipping Info ---")
+ print(f" Tracking Number: {order.tracking_number}")
+ print(f" Tracking Provider: {order.tracking_provider}")
+ print(f" Ship First Name: {order.ship_first_name}")
+ print(f" Ship Last Name: {order.ship_last_name}")
+ print(f" Ship Address: {order.ship_address_line_1}")
+ print(f" Ship City: {order.ship_city}")
+ print(f" Ship Postal Code: {order.ship_postal_code}")
+ print(f" Ship Country: {order.ship_country_iso}")
+
+ print("\n--- Customer Info ---")
+ print(f" Customer Name: {order.customer_first_name} {order.customer_last_name}")
+ print(f" Customer Email: {order.customer_email}")
+
+ print("\n--- Financial ---")
+ print(f" Subtotal: {order.subtotal}")
+ print(f" Tax: {order.tax_amount}")
+ print(f" Shipping: {order.shipping_amount}")
+ print(f" Total: {order.total_amount} {order.currency}")
+
+ print("\n--- External Data (raw from Letzshop) ---")
+ if order.external_data:
+ import json
+ # Look for shipping-related fields
+ ext = order.external_data
+ print(f" Keys: {list(ext.keys())}")
+
+ # Check for tracking info in external data
+ if 'tracking' in ext:
+ print(f" tracking: {ext['tracking']}")
+ if 'trackingNumber' in ext:
+ print(f" trackingNumber: {ext['trackingNumber']}")
+ if 'carrier' in ext:
+ print(f" carrier: {ext['carrier']}")
+ if 'state' in ext:
+ print(f" state: {ext['state']}")
+ if 'shipmentState' in ext:
+ print(f" shipmentState: {ext['shipmentState']}")
+
+ # Print full external data (truncated if too long)
+ ext_str = json.dumps(ext, indent=2, default=str)
+ if len(ext_str) > 2000:
+ print(f"\n Full data (truncated):\n{ext_str[:2000]}...")
+ else:
+ print(f"\n Full data:\n{ext_str}")
+ else:
+ print(" No external data stored")
+
+ print("\n--- Order Items ---")
+ items = db.query(OrderItem).filter(OrderItem.order_id == order.id).all()
+ print(f" Total items: {len(items)}")
+ for i, item in enumerate(items, 1):
+ print(f"\n Item {i}:")
+ print(f" Product: {item.product_name}")
+ print(f" SKU: {item.product_sku}")
+ print(f" GTIN: {item.gtin}")
+ print(f" Qty: {item.quantity}")
+ print(f" Price: {item.unit_price} (total: {item.total_price})")
+ print(f" Item State: {item.item_state}")
+ print(f" External Item ID: {item.external_item_id}")
+ print(f" Needs Product Match: {item.needs_product_match}")
+
+ finally:
+ db.close()
+
+
+if __name__ == "__main__":
+ order_number = sys.argv[1] if len(sys.argv) > 1 else "R532332163"
+ print(f"Investigating order: {order_number}\n")
+ investigate_order(order_number)
diff --git a/scripts/validate_architecture.py b/scripts/validate_architecture.py
index fe7a28a8..1f463b6f 100755
--- a/scripts/validate_architecture.py
+++ b/scripts/validate_architecture.py
@@ -1878,6 +1878,7 @@ class ArchitectureValidator:
Transaction control belongs at the API endpoint level.
Exception: log_service.py may need immediate commits for audit logs.
+ Exception: Background task processing may need incremental commits.
"""
rule = self._get_rule("SVC-006")
if not rule:
@@ -1887,6 +1888,10 @@ class ArchitectureValidator:
if "log_service.py" in str(file_path):
return
+ # Check for file-level noqa comment
+ if "noqa: svc-006" in content.lower():
+ return
+
for i, line in enumerate(lines, 1):
if "db.commit()" in line:
# Skip if it's a comment
@@ -1894,6 +1899,10 @@ class ArchitectureValidator:
if stripped.startswith("#"):
continue
+ # Skip if line has inline noqa comment
+ if "noqa: svc-006" in line.lower():
+ continue
+
self._add_violation(
rule_id="SVC-006",
rule_name=rule["name"],
@@ -1902,7 +1911,7 @@ class ArchitectureValidator:
line_number=i,
message="Service calls db.commit() - transaction control should be at endpoint level",
context=stripped,
- suggestion="Remove db.commit() from service; let endpoint handle transaction",
+ suggestion="Remove db.commit() from service; let endpoint handle transaction. For background tasks, add # noqa: SVC-006",
)
def _validate_models(self, target_path: Path):
diff --git a/static/admin/js/marketplace-letzshop.js b/static/admin/js/marketplace-letzshop.js
index 290c07c3..80c67395 100644
--- a/static/admin/js/marketplace-letzshop.js
+++ b/static/admin/js/marketplace-letzshop.js
@@ -79,10 +79,16 @@ function adminMarketplaceLetzshop() {
api_key: '',
auto_sync_enabled: false,
sync_interval_minutes: 15,
+ test_mode_enabled: false,
letzshop_csv_url_fr: '',
letzshop_csv_url_en: '',
- letzshop_csv_url_de: ''
+ letzshop_csv_url_de: '',
+ default_carrier: '',
+ carrier_greco_label_url: 'https://dispatchweb.fr/Tracky/Home/',
+ carrier_colissimo_label_url: '',
+ carrier_xpresslogistics_label_url: ''
},
+ savingCarrierSettings: false,
// Orders
orders: [],
@@ -137,9 +143,75 @@ function adminMarketplaceLetzshop() {
this.initTomSelect();
});
+ // Check localStorage for last selected vendor
+ const savedVendorId = localStorage.getItem('letzshop_selected_vendor_id');
+ if (savedVendorId) {
+ marketplaceLetzshopLog.info('Restoring saved vendor:', savedVendorId);
+ // Load saved vendor after TomSelect is ready
+ setTimeout(async () => {
+ await this.restoreSavedVendor(parseInt(savedVendorId));
+ }, 200);
+ } else {
+ // Load cross-vendor data when no vendor selected
+ await this.loadCrossVendorData();
+ }
+
marketplaceLetzshopLog.info('Initialization complete');
},
+ /**
+ * Restore previously selected vendor from localStorage
+ */
+ async restoreSavedVendor(vendorId) {
+ try {
+ // Load vendor details first
+ const vendor = await apiClient.get(`/admin/vendors/${vendorId}`);
+
+ // Add to TomSelect and select (silent to avoid double-triggering)
+ if (this.tomSelectInstance) {
+ this.tomSelectInstance.addOption({
+ id: vendor.id,
+ name: vendor.name,
+ vendor_code: vendor.vendor_code
+ });
+ this.tomSelectInstance.setValue(vendor.id, true);
+ }
+
+ // Manually call selectVendor since we used silent mode above
+ // This sets selectedVendor and loads all vendor-specific data
+ await this.selectVendor(vendor.id);
+
+ marketplaceLetzshopLog.info('Restored saved vendor:', vendor.name);
+ } catch (error) {
+ marketplaceLetzshopLog.error('Failed to restore saved vendor:', error);
+ // Clear invalid saved vendor
+ localStorage.removeItem('letzshop_selected_vendor_id');
+ // Load cross-vendor data instead
+ await this.loadCrossVendorData();
+ }
+ },
+
+ /**
+ * Load cross-vendor aggregate data
+ */
+ async loadCrossVendorData() {
+ marketplaceLetzshopLog.info('Loading cross-vendor data');
+ this.loading = true;
+
+ try {
+ await Promise.all([
+ this.loadOrders(),
+ this.loadExceptions(),
+ this.loadExceptionStats(),
+ this.loadJobs()
+ ]);
+ } catch (error) {
+ marketplaceLetzshopLog.error('Failed to load cross-vendor data:', error);
+ } finally {
+ this.loading = false;
+ }
+ },
+
/**
* Initialize Tom Select for vendor autocomplete
*/
@@ -217,6 +289,9 @@ function adminMarketplaceLetzshop() {
const vendor = await apiClient.get(`/admin/vendors/${vendorId}`);
this.selectedVendor = vendor;
+ // Save to localStorage for persistence
+ localStorage.setItem('letzshop_selected_vendor_id', vendorId.toString());
+
// Pre-fill settings form with CSV URLs
this.settingsForm.letzshop_csv_url_fr = vendor.letzshop_csv_url_fr || '';
this.settingsForm.letzshop_csv_url_en = vendor.letzshop_csv_url_en || '';
@@ -245,27 +320,39 @@ function adminMarketplaceLetzshop() {
/**
* Clear vendor selection
*/
- clearVendorSelection() {
+ async clearVendorSelection() {
+ // Clear TomSelect dropdown
+ if (this.tomSelectInstance) {
+ this.tomSelectInstance.clear();
+ }
+
this.selectedVendor = null;
this.letzshopStatus = { is_configured: false };
this.credentials = null;
- this.orders = [];
this.ordersFilter = '';
this.ordersSearch = '';
this.ordersHasDeclinedItems = false;
- this.exceptions = [];
this.exceptionsFilter = '';
this.exceptionsSearch = '';
- this.exceptionStats = { pending: 0, resolved: 0, ignored: 0, total: 0, orders_with_exceptions: 0 };
- this.jobs = [];
this.settingsForm = {
api_key: '',
auto_sync_enabled: false,
sync_interval_minutes: 15,
+ test_mode_enabled: false,
letzshop_csv_url_fr: '',
letzshop_csv_url_en: '',
- letzshop_csv_url_de: ''
+ letzshop_csv_url_de: '',
+ default_carrier: '',
+ carrier_greco_label_url: 'https://dispatchweb.fr/Tracky/Home/',
+ carrier_colissimo_label_url: '',
+ carrier_xpresslogistics_label_url: ''
};
+
+ // Clear localStorage
+ localStorage.removeItem('letzshop_selected_vendor_id');
+
+ // Load cross-vendor data
+ await this.loadCrossVendorData();
},
/**
@@ -285,6 +372,11 @@ function adminMarketplaceLetzshop() {
};
this.settingsForm.auto_sync_enabled = response.auto_sync_enabled;
this.settingsForm.sync_interval_minutes = response.sync_interval_minutes || 15;
+ this.settingsForm.test_mode_enabled = response.test_mode_enabled || false;
+ this.settingsForm.default_carrier = response.default_carrier || '';
+ this.settingsForm.carrier_greco_label_url = response.carrier_greco_label_url || 'https://dispatchweb.fr/Tracky/Home/';
+ this.settingsForm.carrier_colissimo_label_url = response.carrier_colissimo_label_url || '';
+ this.settingsForm.carrier_xpresslogistics_label_url = response.carrier_xpresslogistics_label_url || '';
} catch (error) {
if (error.status === 404) {
// Not configured
@@ -403,15 +495,9 @@ function adminMarketplaceLetzshop() {
// ═══════════════════════════════════════════════════════════════
/**
- * Load orders for selected vendor
+ * Load orders for selected vendor (or all vendors if none selected)
*/
async loadOrders() {
- if (!this.selectedVendor || !this.letzshopStatus.is_configured) {
- this.orders = [];
- this.totalOrders = 0;
- return;
- }
-
this.loadingOrders = true;
this.error = '';
@@ -433,7 +519,13 @@ function adminMarketplaceLetzshop() {
params.append('search', this.ordersSearch);
}
- const response = await apiClient.get(`/admin/letzshop/vendors/${this.selectedVendor.id}/orders?${params}`);
+ // Use cross-vendor endpoint (with optional vendor_id filter)
+ let url = '/admin/letzshop/orders';
+ if (this.selectedVendor) {
+ params.append('vendor_id', this.selectedVendor.id.toString());
+ }
+
+ const response = await apiClient.get(`${url}?${params}`);
this.orders = response.orders || [];
this.totalOrders = response.total || 0;
@@ -845,7 +937,8 @@ function adminMarketplaceLetzshop() {
try {
const payload = {
auto_sync_enabled: this.settingsForm.auto_sync_enabled,
- sync_interval_minutes: parseInt(this.settingsForm.sync_interval_minutes)
+ sync_interval_minutes: parseInt(this.settingsForm.sync_interval_minutes),
+ test_mode_enabled: this.settingsForm.test_mode_enabled
};
// Only include API key if it was provided (not just placeholder)
@@ -950,20 +1043,41 @@ function adminMarketplaceLetzshop() {
}
},
+ /**
+ * Save carrier settings
+ */
+ async saveCarrierSettings() {
+ if (!this.selectedVendor || !this.credentials) return;
+
+ this.savingCarrierSettings = true;
+ this.error = '';
+ this.successMessage = '';
+
+ try {
+ await apiClient.patch(`/admin/letzshop/vendors/${this.selectedVendor.id}/credentials`, {
+ default_carrier: this.settingsForm.default_carrier || null,
+ carrier_greco_label_url: this.settingsForm.carrier_greco_label_url || null,
+ carrier_colissimo_label_url: this.settingsForm.carrier_colissimo_label_url || null,
+ carrier_xpresslogistics_label_url: this.settingsForm.carrier_xpresslogistics_label_url || null
+ });
+
+ this.successMessage = 'Carrier settings saved successfully';
+ } catch (error) {
+ marketplaceLetzshopLog.error('Failed to save carrier settings:', error);
+ this.error = error.message || 'Failed to save carrier settings';
+ } finally {
+ this.savingCarrierSettings = false;
+ }
+ },
+
// ═══════════════════════════════════════════════════════════════
// EXCEPTIONS
// ═══════════════════════════════════════════════════════════════
/**
- * Load exceptions for selected vendor
+ * Load exceptions for selected vendor (or all vendors if none selected)
*/
async loadExceptions() {
- if (!this.selectedVendor) {
- this.exceptions = [];
- this.totalExceptions = 0;
- return;
- }
-
this.loadingExceptions = true;
try {
@@ -980,7 +1094,12 @@ function adminMarketplaceLetzshop() {
params.append('search', this.exceptionsSearch);
}
- const response = await apiClient.get(`/admin/order-exceptions?vendor_id=${this.selectedVendor.id}&${params}`);
+ // Add vendor filter if a vendor is selected
+ if (this.selectedVendor) {
+ params.append('vendor_id', this.selectedVendor.id.toString());
+ }
+
+ const response = await apiClient.get(`/admin/order-exceptions?${params}`);
this.exceptions = response.exceptions || [];
this.totalExceptions = response.total || 0;
} catch (error) {
@@ -992,19 +1111,20 @@ function adminMarketplaceLetzshop() {
},
/**
- * Load exception statistics for selected vendor
+ * Load exception statistics for selected vendor (or all vendors if none selected)
*/
async loadExceptionStats() {
- if (!this.selectedVendor) {
- this.exceptionStats = { pending: 0, resolved: 0, ignored: 0, total: 0, orders_with_exceptions: 0 };
- return;
- }
-
try {
- const response = await apiClient.get(`/admin/order-exceptions/stats?vendor_id=${this.selectedVendor.id}`);
+ const params = new URLSearchParams();
+ if (this.selectedVendor) {
+ params.append('vendor_id', this.selectedVendor.id.toString());
+ }
+
+ const response = await apiClient.get(`/admin/order-exceptions/stats?${params}`);
this.exceptionStats = response;
} catch (error) {
marketplaceLetzshopLog.error('Failed to load exception stats:', error);
+ this.exceptionStats = { pending: 0, resolved: 0, ignored: 0, total: 0, orders_with_exceptions: 0 };
}
},
@@ -1119,10 +1239,13 @@ function adminMarketplaceLetzshop() {
/**
* Load jobs for selected vendor
+ * Note: Jobs are vendor-specific, so we need a vendor selected to show them
*/
async loadJobs() {
+ // Jobs require a vendor to be selected (they are vendor-specific)
if (!this.selectedVendor) {
this.jobs = [];
+ this.jobsPagination.total = 0;
return;
}
diff --git a/static/admin/js/orders.js b/static/admin/js/orders.js
index 68395109..854672ec 100644
--- a/static/admin/js/orders.js
+++ b/static/admin/js/orders.js
@@ -76,6 +76,15 @@ function adminOrders() {
reason: ''
},
+ // Mark as shipped modal
+ showMarkAsShippedModal: false,
+ markingAsShipped: false,
+ shipForm: {
+ tracking_number: '',
+ tracking_url: '',
+ shipping_carrier: ''
+ },
+
// Debounce timer
searchTimeout: null,
@@ -137,16 +146,64 @@ function adminOrders() {
// Initialize Tom Select for vendor filter
this.initVendorSelect();
- // Load data in parallel
- await Promise.all([
- this.loadStats(),
- this.loadVendors(),
- this.loadOrders()
- ]);
+ // Check localStorage for saved vendor
+ const savedVendorId = localStorage.getItem('orders_selected_vendor_id');
+ if (savedVendorId) {
+ adminOrdersLog.info('Restoring saved vendor:', savedVendorId);
+ // Restore vendor after a short delay to ensure TomSelect is ready
+ // restoreSavedVendor will call loadOrders() after setting the filter
+ setTimeout(async () => {
+ await this.restoreSavedVendor(parseInt(savedVendorId));
+ }, 200);
+ // Load stats and vendors, but not orders (restoreSavedVendor will do that)
+ await Promise.all([
+ this.loadStats(),
+ this.loadVendors()
+ ]);
+ } else {
+ // No saved vendor - load all data including unfiltered orders
+ await Promise.all([
+ this.loadStats(),
+ this.loadVendors(),
+ this.loadOrders()
+ ]);
+ }
adminOrdersLog.info('Orders initialization complete');
},
+ /**
+ * Restore saved vendor from localStorage
+ */
+ async restoreSavedVendor(vendorId) {
+ try {
+ const vendor = await apiClient.get(`/admin/vendors/${vendorId}`);
+ if (this.vendorSelectInstance && vendor) {
+ // Add the vendor as an option and select it
+ this.vendorSelectInstance.addOption({
+ id: vendor.id,
+ name: vendor.name,
+ vendor_code: vendor.vendor_code
+ });
+ this.vendorSelectInstance.setValue(vendor.id, true);
+
+ // Set the filter state (this is the key fix!)
+ this.selectedVendor = vendor;
+ this.filters.vendor_id = vendor.id;
+
+ adminOrdersLog.info('Restored vendor:', vendor.name);
+
+ // Load orders with the vendor filter applied
+ await this.loadOrders();
+ }
+ } catch (error) {
+ adminOrdersLog.warn('Failed to restore saved vendor, clearing localStorage:', error);
+ localStorage.removeItem('orders_selected_vendor_id');
+ // Load unfiltered orders as fallback
+ await this.loadOrders();
+ }
+ },
+
/**
* Initialize Tom Select for vendor autocomplete
*/
@@ -168,7 +225,7 @@ function adminOrders() {
valueField: 'id',
labelField: 'name',
searchField: ['name', 'vendor_code'],
- placeholder: 'All vendors...',
+ placeholder: 'Search vendor by name or code...',
allowEmptyOption: true,
load: async (query, callback) => {
try {
@@ -198,9 +255,13 @@ function adminOrders() {
const vendor = this.vendorSelectInstance.options[value];
this.selectedVendor = vendor;
this.filters.vendor_id = value;
+ // Save to localStorage
+ localStorage.setItem('orders_selected_vendor_id', value.toString());
} else {
this.selectedVendor = null;
this.filters.vendor_id = '';
+ // Clear from localStorage
+ localStorage.removeItem('orders_selected_vendor_id');
}
this.pagination.page = 1;
this.loadOrders();
@@ -219,6 +280,8 @@ function adminOrders() {
}
this.selectedVendor = null;
this.filters.vendor_id = '';
+ // Clear from localStorage
+ localStorage.removeItem('orders_selected_vendor_id');
this.pagination.page = 1;
this.loadOrders();
},
@@ -378,6 +441,76 @@ function adminOrders() {
}
},
+ /**
+ * Open mark as shipped modal
+ */
+ openMarkAsShippedModal(order) {
+ this.selectedOrder = order;
+ this.shipForm = {
+ tracking_number: order.tracking_number || '',
+ tracking_url: order.tracking_url || '',
+ shipping_carrier: order.shipping_carrier || ''
+ };
+ this.showMarkAsShippedModal = true;
+ },
+
+ /**
+ * Mark order as shipped
+ */
+ async markAsShipped() {
+ if (!this.selectedOrder) return;
+
+ this.markingAsShipped = true;
+ try {
+ const payload = {};
+
+ if (this.shipForm.tracking_number) {
+ payload.tracking_number = this.shipForm.tracking_number;
+ }
+ if (this.shipForm.tracking_url) {
+ payload.tracking_url = this.shipForm.tracking_url;
+ }
+ if (this.shipForm.shipping_carrier) {
+ payload.shipping_carrier = this.shipForm.shipping_carrier;
+ }
+
+ await apiClient.post(`/admin/orders/${this.selectedOrder.id}/ship`, payload);
+
+ adminOrdersLog.info('Marked order as shipped:', this.selectedOrder.id);
+
+ this.showMarkAsShippedModal = false;
+ this.selectedOrder = null;
+
+ Utils.showToast('Order marked as shipped successfully.', 'success');
+
+ await this.refresh();
+ } catch (error) {
+ adminOrdersLog.error('Failed to mark order as shipped:', error);
+ Utils.showToast(error.message || 'Failed to mark as shipped.', 'error');
+ } finally {
+ this.markingAsShipped = false;
+ }
+ },
+
+ /**
+ * Download shipping label for an order
+ */
+ async downloadShippingLabel(order) {
+ try {
+ const labelInfo = await apiClient.get(`/admin/orders/${order.id}/shipping-label`);
+
+ if (labelInfo.label_url) {
+ // Open label URL in new tab
+ window.open(labelInfo.label_url, '_blank');
+ } else {
+ Utils.showToast('No shipping label URL available for this order.', 'warning');
+ }
+ } catch (error) {
+ adminOrdersLog.error('Failed to get shipping label:', error);
+ Utils.showToast(error.message || 'Failed to get shipping label.', 'error');
+ }
+ },
+
/**
* Get CSS class for status badge
*/
diff --git a/static/admin/js/settings.js b/static/admin/js/settings.js
index 13ed6531..d2c508fe 100644
--- a/static/admin/js/settings.js
+++ b/static/admin/js/settings.js
@@ -30,11 +30,19 @@ function adminSettings() {
in_app_enabled: true,
critical_only: false
},
+ shippingSettings: {
+ carrier_greco_label_url: 'https://dispatchweb.fr/Tracky/Home/',
+ carrier_colissimo_label_url: '',
+ carrier_xpresslogistics_label_url: ''
+ },
async init() {
try {
settingsLog.info('=== SETTINGS PAGE INITIALIZING ===');
- await this.loadLogSettings();
+ await Promise.all([
+ this.loadLogSettings(),
+ this.loadShippingSettings()
+ ]);
} catch (error) {
settingsLog.error('Init failed:', error);
this.error = 'Failed to initialize settings page';
@@ -44,7 +52,10 @@ function adminSettings() {
async refresh() {
this.error = null;
this.successMessage = null;
- await this.loadLogSettings();
+ await Promise.all([
+ this.loadLogSettings(),
+ this.loadShippingSettings()
+ ]);
},
async loadLogSettings() {
@@ -136,6 +147,75 @@ function adminSettings() {
} finally {
this.saving = false;
}
+ },
+
+ async loadShippingSettings() {
+ try {
+ // Load each carrier setting
+ const carriers = ['greco', 'colissimo', 'xpresslogistics'];
+ for (const carrier of carriers) {
+ try {
+ const key = `carrier_${carrier}_label_url`;
+ const data = await apiClient.get(`/admin/settings/${key}`);
+ if (data && data.value) {
+ this.shippingSettings[key] = data.value;
+ }
+ } catch (error) {
+ // Setting doesn't exist yet, use default
+ settingsLog.debug(`Setting carrier_${carrier}_label_url not found, using default`);
+ }
+ }
+ settingsLog.info('Shipping settings loaded:', this.shippingSettings);
+ } catch (error) {
+ settingsLog.error('Failed to load shipping settings:', error);
+ // Don't show error for missing settings, just use defaults
+ }
+ },
+
+ async saveShippingSettings() {
+ this.saving = true;
+ this.error = null;
+ this.successMessage = null;
+
+ try {
+ // Save each carrier setting using upsert
+ const carriers = [
+ { key: 'carrier_greco_label_url', name: 'Greco' },
+ { key: 'carrier_colissimo_label_url', name: 'Colissimo' },
+ { key: 'carrier_xpresslogistics_label_url', name: 'XpressLogistics' }
+ ];
+
+ for (const carrier of carriers) {
+ await apiClient.post('/admin/settings/upsert', {
+ key: carrier.key,
+ value: this.shippingSettings[carrier.key] || '',
+ category: 'shipping',
+ value_type: 'string',
+ description: `Label URL prefix for ${carrier.name} carrier`
+ });
+ }
+
+ this.successMessage = 'Shipping settings saved successfully';
+
+ // Auto-hide success message after 5 seconds
+ setTimeout(() => {
+ this.successMessage = null;
+ }, 5000);
+
+ settingsLog.info('Shipping settings saved:', this.shippingSettings);
+ } catch (error) {
+ settingsLog.error('Failed to save shipping settings:', error);
+ this.error = error.response?.data?.detail || 'Failed to save shipping settings';
+ } finally {
+ this.saving = false;
+ }
+ },
+
+ getShippingLabelUrl(carrier, shipmentNumber) {
+ // Helper to generate full label URL
+ const prefix = this.shippingSettings[`carrier_${carrier}_label_url`] || '';
+ if (!prefix || !shipmentNumber) return null;
+ return prefix + shipmentNumber;
}
};
}
diff --git a/static/shared/js/money.js b/static/shared/js/money.js
new file mode 100644
index 00000000..ea6c4384
--- /dev/null
+++ b/static/shared/js/money.js
@@ -0,0 +1,193 @@
+// static/shared/js/money.js
+/**
+ * Money handling utilities using integer cents.
+ *
+ * All monetary values are stored as integers representing cents in the database.
+ * The API returns euros (converted from cents on the backend), but these utilities
+ * can be used if cents are passed to the frontend.
+ *
+ * Example:
+ * 105.91 EUR is stored as 10591 (integer cents)
+ *
+ * Usage:
+ * Money.format(10591) // Returns "105.91"
+ * Money.format(10591, 'EUR') // Returns "105,91 EUR"
+ * Money.toCents(105.91) // Returns 10591
+ * Money.toEuros(10591) // Returns 105.91
+ * Money.formatEuros(105.91, 'EUR') // Returns "105,91 EUR"
+ *
+ * See docs/architecture/money-handling.md for full documentation.
+ */
+
+const Money = {
+ /**
+ * Format cents as a currency string.
+ *
+ * @param {number} cents - Amount in cents
+ * @param {string} currency - Currency code (default: '', no currency shown)
+ * @param {string} locale - Locale for formatting (default: 'de-DE')
+ * @returns {string} Formatted price string
+ *
+ * @example
+ * Money.format(10591) // "105.91"
+ * Money.format(10591, 'EUR') // "105,91 EUR" (German locale)
+ * Money.format(1999) // "19.99"
+ */
+ format(cents, currency = '', locale = 'de-DE') {
+ if (cents === null || cents === undefined) {
+ cents = 0;
+ }
+
+ const euros = cents / 100;
+
+ if (currency) {
+ return new Intl.NumberFormat(locale, {
+ style: 'currency',
+ currency: currency
+ }).format(euros);
+ }
+
+ return new Intl.NumberFormat(locale, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2
+ }).format(euros);
+ },
+
+ /**
+ * Convert euros to cents.
+ *
+ * @param {number|string} euros - Amount in euros
+ * @returns {number} Amount in cents (integer)
+ *
+ * @example
+ * Money.toCents(105.91) // 10591
+ * Money.toCents('19.99') // 1999
+ * Money.toCents(null) // 0
+ */
+ toCents(euros) {
+ if (euros === null || euros === undefined || euros === '') {
+ return 0;
+ }
+ return Math.round(parseFloat(euros) * 100);
+ },
+
+ /**
+ * Convert cents to euros.
+ *
+ * @param {number} cents - Amount in cents
+ * @returns {number} Amount in euros
+ *
+ * @example
+ * Money.toEuros(10591) // 105.91
+ * Money.toEuros(1999) // 19.99
+ * Money.toEuros(null) // 0
+ */
+ toEuros(cents) {
+ if (cents === null || cents === undefined) {
+ return 0;
+ }
+ return cents / 100;
+ },
+
+ /**
+ * Format a euro amount for display.
+ *
+ * Use this when the value is already in euros (e.g., from API response).
+ *
+ * @param {number} euros - Amount in euros
+ * @param {string} currency - Currency code (default: 'EUR')
+ * @param {string} locale - Locale for formatting (default: 'de-DE')
+ * @returns {string} Formatted price string
+ *
+ * @example
+ * Money.formatEuros(105.91, 'EUR') // "105,91 EUR"
+ * Money.formatEuros(19.99) // "19.99"
+ */
+ formatEuros(euros, currency = '', locale = 'de-DE') {
+ if (euros === null || euros === undefined) {
+ euros = 0;
+ }
+
+ if (currency) {
+ return new Intl.NumberFormat(locale, {
+ style: 'currency',
+ currency: currency
+ }).format(euros);
+ }
+
+ return new Intl.NumberFormat(locale, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2
+ }).format(euros);
+ },
+
+ /**
+ * Parse a price string to cents.
+ *
+ * Handles various formats:
+ * - "19.99 EUR"
+ * - "19,99"
+ * - 19.99
+ *
+ * @param {string|number} priceStr - Price string or number
+ * @returns {number} Amount in cents
+ *
+ * @example
+ * Money.parse("19.99 EUR") // 1999
+ * Money.parse("19,99") // 1999
+ * Money.parse(19.99) // 1999
+ */
+ parse(priceStr) {
+ if (priceStr === null || priceStr === undefined || priceStr === '') {
+ return 0;
+ }
+
+ if (typeof priceStr === 'number') {
+ return Math.round(priceStr * 100);
+ }
+
+ // Remove currency symbols and spaces
+ let cleaned = priceStr.toString().replace(/[^\d,.-]/g, '');
+
+ // Handle European decimal comma
+ cleaned = cleaned.replace(',', '.');
+
+ try {
+ return Math.round(parseFloat(cleaned) * 100);
+ } catch {
+ return 0;
+ }
+ },
+
+ /**
+ * Calculate line total (unit price * quantity).
+ *
+ * @param {number} unitPriceCents - Price per unit in cents
+ * @param {number} quantity - Number of units
+ * @returns {number} Total in cents
+ */
+ calculateLineTotal(unitPriceCents, quantity) {
+ return unitPriceCents * quantity;
+ },
+
+ /**
+ * Calculate order total.
+ *
+ * @param {number} subtotalCents - Sum of line items in cents
+ * @param {number} taxCents - Tax amount in cents (default: 0)
+ * @param {number} shippingCents - Shipping cost in cents (default: 0)
+ * @param {number} discountCents - Discount amount in cents (default: 0)
+ * @returns {number} Total in cents
+ */
+ calculateOrderTotal(subtotalCents, taxCents = 0, shippingCents = 0, discountCents = 0) {
+ return subtotalCents + taxCents + shippingCents - discountCents;
+ }
+};
+
+// Make available globally
+window.Money = Money;
+
+// Export for modules
+if (typeof module !== 'undefined' && module.exports) {
+ module.exports = Money;
+}
diff --git a/tests/fixtures/customer_fixtures.py b/tests/fixtures/customer_fixtures.py
index bd192d4c..96ba92b2 100644
--- a/tests/fixtures/customer_fixtures.py
+++ b/tests/fixtures/customer_fixtures.py
@@ -53,19 +53,60 @@ def test_customer_address(db, test_vendor, test_customer):
@pytest.fixture
def test_order(db, test_vendor, test_customer, test_customer_address):
- """Create a test order."""
+ """Create a test order with customer/address snapshots."""
+ from datetime import datetime, timezone
+
order = Order(
vendor_id=test_vendor.id,
customer_id=test_customer.id,
order_number="TEST-ORD-001",
status="pending",
+ channel="direct",
subtotal=99.99,
total_amount=99.99,
currency="EUR",
- shipping_address_id=test_customer_address.id,
- billing_address_id=test_customer_address.id,
+ order_date=datetime.now(timezone.utc),
+ # Customer snapshot
+ customer_first_name=test_customer.first_name,
+ customer_last_name=test_customer.last_name,
+ customer_email=test_customer.email,
+ customer_phone=None,
+ # Shipping address snapshot
+ ship_first_name=test_customer_address.first_name,
+ ship_last_name=test_customer_address.last_name,
+ ship_address_line_1=test_customer_address.address_line_1,
+ ship_city=test_customer_address.city,
+ ship_postal_code=test_customer_address.postal_code,
+ ship_country_iso="LU",
+ # Billing address snapshot
+ bill_first_name=test_customer_address.first_name,
+ bill_last_name=test_customer_address.last_name,
+ bill_address_line_1=test_customer_address.address_line_1,
+ bill_city=test_customer_address.city,
+ bill_postal_code=test_customer_address.postal_code,
+ bill_country_iso="LU",
)
db.add(order)
db.commit()
db.refresh(order)
return order
+
+
+@pytest.fixture
+def test_order_item(db, test_order, test_product):
+ """Create a test order item."""
+ from models.database.order import OrderItem
+
+ order_item = OrderItem(
+ order_id=test_order.id,
+ product_id=test_product.id,
+ product_name="Test Product",
+ product_sku="TEST-SKU-001",
+ quantity=1,
+ unit_price=49.99,
+ total_price=49.99,
+ )
+ db.add(order_item)
+ db.commit()
+ db.refresh(order_item)
+ return order_item
diff --git a/tests/integration/api/v1/admin/test_letzshop.py b/tests/integration/api/v1/admin/test_letzshop.py
index fc4502f2..051ea560 100644
--- a/tests/integration/api/v1/admin/test_letzshop.py
+++ b/tests/integration/api/v1/admin/test_letzshop.py
@@ -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()
diff --git a/tests/integration/api/v1/admin/test_order_item_exceptions.py b/tests/integration/api/v1/admin/test_order_item_exceptions.py
new file mode 100644
index 00000000..ab18bffa
--- /dev/null
+++ b/tests/integration/api/v1/admin/test_order_item_exceptions.py
@@ -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
diff --git a/tests/integration/api/v1/vendor/test_letzshop.py b/tests/integration/api/v1/vendor/test_letzshop.py
index 9389a4b8..08436e66 100644
--- a/tests/integration/api/v1/vendor/test_letzshop.py
+++ b/tests/integration/api/v1/vendor/test_letzshop.py
@@ -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()
diff --git a/tests/unit/models/database/test_order.py b/tests/unit/models/database/test_order.py
index 3b105275..0c41242d 100644
--- a/tests/unit/models/database/test_order.py
+++ b/tests/unit/models/database/test_order.py
@@ -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
diff --git a/tests/unit/models/database/test_order_item_exception.py b/tests/unit/models/database/test_order_item_exception.py
new file mode 100644
index 00000000..e4c40853
--- /dev/null
+++ b/tests/unit/models/database/test_order_item_exception.py
@@ -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
diff --git a/tests/unit/models/database/test_product.py b/tests/unit/models/database/test_product.py
index 82ebf6b2..213daa87 100644
--- a/tests/unit/models/database/test_product.py
+++ b/tests/unit/models/database/test_product.py
@@ -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()
diff --git a/tests/unit/models/schema/test_order.py b/tests/unit/models/schema/test_order.py
index 02db8157..6ef30b75 100644
--- a/tests/unit/models/schema/test_order.py
+++ b/tests/unit/models/schema/test_order.py
@@ -1,11 +1,16 @@
# tests/unit/models/schema/test_order.py
"""Unit tests for order Pydantic schemas."""
+from datetime import datetime, timezone
+
import pytest
from pydantic import ValidationError
from models.schema.order import (
- OrderAddressCreate,
+ AddressSnapshot,
+ AddressSnapshotResponse,
+ CustomerSnapshot,
+ CustomerSnapshotResponse,
OrderCreate,
OrderItemCreate,
OrderItemResponse,
@@ -61,81 +66,155 @@ class TestOrderItemCreateSchema:
@pytest.mark.unit
@pytest.mark.schema
-class TestOrderAddressCreateSchema:
- """Test OrderAddressCreate schema validation."""
+class TestAddressSnapshotSchema:
+ """Test AddressSnapshot schema validation."""
def test_valid_address(self):
- """Test valid order address creation."""
- address = OrderAddressCreate(
+ """Test valid address creation."""
+ address = AddressSnapshot(
first_name="John",
last_name="Doe",
address_line_1="123 Main St",
city="Luxembourg",
postal_code="L-1234",
- country="Luxembourg",
+ country_iso="LU",
)
assert address.first_name == "John"
assert address.city == "Luxembourg"
+ assert address.country_iso == "LU"
def test_required_fields(self):
"""Test required fields validation."""
with pytest.raises(ValidationError):
- OrderAddressCreate(
+ AddressSnapshot(
first_name="John",
# missing required fields
)
def test_optional_company(self):
"""Test optional company field."""
- address = OrderAddressCreate(
+ address = AddressSnapshot(
first_name="John",
last_name="Doe",
company="Tech Corp",
address_line_1="123 Main St",
city="Luxembourg",
postal_code="L-1234",
- country="Luxembourg",
+ country_iso="LU",
)
assert address.company == "Tech Corp"
def test_optional_address_line_2(self):
"""Test optional address_line_2 field."""
- address = OrderAddressCreate(
+ address = AddressSnapshot(
first_name="John",
last_name="Doe",
address_line_1="123 Main St",
address_line_2="Suite 500",
city="Luxembourg",
postal_code="L-1234",
- country="Luxembourg",
+ country_iso="LU",
)
assert address.address_line_2 == "Suite 500"
def test_first_name_min_length(self):
"""Test first_name minimum length."""
with pytest.raises(ValidationError):
- OrderAddressCreate(
+ AddressSnapshot(
first_name="",
last_name="Doe",
address_line_1="123 Main St",
city="Luxembourg",
postal_code="L-1234",
- country="Luxembourg",
+ country_iso="LU",
)
- def test_country_min_length(self):
- """Test country minimum length (2)."""
+ def test_country_iso_min_length(self):
+ """Test country_iso minimum length (2)."""
with pytest.raises(ValidationError):
- OrderAddressCreate(
+ AddressSnapshot(
first_name="John",
last_name="Doe",
address_line_1="123 Main St",
city="Luxembourg",
postal_code="L-1234",
- country="L",
+ country_iso="L", # Too short
)
+@pytest.mark.unit
+@pytest.mark.schema
+class TestAddressSnapshotResponseSchema:
+ """Test AddressSnapshotResponse schema."""
+
+ def test_full_name_property(self):
+ """Test full_name property."""
+ response = AddressSnapshotResponse(
+ first_name="John",
+ last_name="Doe",
+ company=None,
+ address_line_1="123 Main St",
+ address_line_2=None,
+ city="Luxembourg",
+ postal_code="L-1234",
+ country_iso="LU",
+ )
+ assert response.full_name == "John Doe"
+
+
+@pytest.mark.unit
+@pytest.mark.schema
+class TestCustomerSnapshotSchema:
+ """Test CustomerSnapshot schema validation."""
+
+ def test_valid_customer(self):
+ """Test valid customer snapshot."""
+ customer = CustomerSnapshot(
+ first_name="John",
+ last_name="Doe",
+ email="john@example.com",
+ phone="+352123456",
+ locale="en",
+ )
+ assert customer.first_name == "John"
+ assert customer.email == "john@example.com"
+
+ def test_optional_phone(self):
+ """Test phone is optional."""
+ customer = CustomerSnapshot(
+ first_name="John",
+ last_name="Doe",
+ email="john@example.com",
+ )
+ assert customer.phone is None
+
+ def test_optional_locale(self):
+ """Test locale is optional."""
+ customer = CustomerSnapshot(
+ first_name="John",
+ last_name="Doe",
+ email="john@example.com",
+ )
+ assert customer.locale is None
+
+
+@pytest.mark.unit
+@pytest.mark.schema
+class TestCustomerSnapshotResponseSchema:
+ """Test CustomerSnapshotResponse schema."""
+
+ def test_full_name_property(self):
+ """Test full_name property."""
+ response = CustomerSnapshotResponse(
+ first_name="John",
+ last_name="Doe",
+ email="john@example.com",
+ phone=None,
+ locale=None,
+ )
+ assert response.full_name == "John Doe"
+
+
@pytest.mark.unit
@pytest.mark.schema
class TestOrderCreateSchema:
@@ -148,13 +227,18 @@ class TestOrderCreateSchema:
OrderItemCreate(product_id=1, quantity=2),
OrderItemCreate(product_id=2, quantity=1),
],
- shipping_address=OrderAddressCreate(
+ customer=CustomerSnapshot(
+ first_name="John",
+ last_name="Doe",
+ email="john@example.com",
+ ),
+ shipping_address=AddressSnapshot(
first_name="John",
last_name="Doe",
address_line_1="123 Main St",
city="Luxembourg",
postal_code="L-1234",
- country="Luxembourg",
+ country_iso="LU",
),
)
assert len(order.items) == 2
@@ -164,13 +248,18 @@ class TestOrderCreateSchema:
"""Test items are required."""
with pytest.raises(ValidationError) as exc_info:
OrderCreate(
- shipping_address=OrderAddressCreate(
+ customer=CustomerSnapshot(
+ first_name="John",
+ last_name="Doe",
+ email="john@example.com",
+ ),
+ shipping_address=AddressSnapshot(
first_name="John",
last_name="Doe",
address_line_1="123 Main St",
city="Luxembourg",
postal_code="L-1234",
- country="Luxembourg",
+ country_iso="LU",
),
)
assert "items" in str(exc_info.value).lower()
@@ -180,22 +269,48 @@ class TestOrderCreateSchema:
with pytest.raises(ValidationError) as exc_info:
OrderCreate(
items=[],
- shipping_address=OrderAddressCreate(
+ customer=CustomerSnapshot(
+ first_name="John",
+ last_name="Doe",
+ email="john@example.com",
+ ),
+ shipping_address=AddressSnapshot(
first_name="John",
last_name="Doe",
address_line_1="123 Main St",
city="Luxembourg",
postal_code="L-1234",
- country="Luxembourg",
+ country_iso="LU",
),
)
assert "items" in str(exc_info.value).lower()
+ def test_customer_required(self):
+ """Test customer is required."""
+ with pytest.raises(ValidationError) as exc_info:
+ OrderCreate(
+ items=[OrderItemCreate(product_id=1, quantity=1)],
+ shipping_address=AddressSnapshot(
+ first_name="John",
+ last_name="Doe",
+ address_line_1="123 Main St",
+ city="Luxembourg",
+ postal_code="L-1234",
+ country_iso="LU",
+ ),
+ )
+ assert "customer" in str(exc_info.value).lower()
+
def test_shipping_address_required(self):
"""Test shipping_address is required."""
with pytest.raises(ValidationError) as exc_info:
OrderCreate(
items=[OrderItemCreate(product_id=1, quantity=1)],
+ customer=CustomerSnapshot(
+ first_name="John",
+ last_name="Doe",
+ email="john@example.com",
+ ),
)
assert "shipping_address" in str(exc_info.value).lower()
@@ -203,21 +318,26 @@ class TestOrderCreateSchema:
"""Test billing_address is optional."""
order = OrderCreate(
items=[OrderItemCreate(product_id=1, quantity=1)],
- shipping_address=OrderAddressCreate(
+ customer=CustomerSnapshot(
+ first_name="John",
+ last_name="Doe",
+ email="john@example.com",
+ ),
+ shipping_address=AddressSnapshot(
first_name="John",
last_name="Doe",
address_line_1="123 Main St",
city="Luxembourg",
postal_code="L-1234",
- country="Luxembourg",
+ country_iso="LU",
),
- billing_address=OrderAddressCreate(
+ billing_address=AddressSnapshot(
first_name="Jane",
last_name="Doe",
address_line_1="456 Other St",
city="Esch",
postal_code="L-4321",
- country="Luxembourg",
+ country_iso="LU",
),
)
assert order.billing_address is not None
@@ -227,13 +347,18 @@ class TestOrderCreateSchema:
"""Test optional customer_notes."""
order = OrderCreate(
items=[OrderItemCreate(product_id=1, quantity=1)],
- shipping_address=OrderAddressCreate(
+ customer=CustomerSnapshot(
+ first_name="John",
+ last_name="Doe",
+ email="john@example.com",
+ ),
+ shipping_address=AddressSnapshot(
first_name="John",
last_name="Doe",
address_line_1="123 Main St",
city="Luxembourg",
postal_code="L-1234",
- country="Luxembourg",
+ country_iso="LU",
),
customer_notes="Please leave at door",
)
@@ -293,13 +418,13 @@ class TestOrderResponseSchema:
def test_from_dict(self):
"""Test creating response from dict."""
- from datetime import datetime
-
+ now = datetime.now(timezone.utc)
data = {
"id": 1,
"vendor_id": 1,
"customer_id": 1,
"order_number": "ORD-001",
+ "channel": "direct",
"status": "pending",
"subtotal": 100.00,
"tax_amount": 20.00,
@@ -307,21 +432,97 @@ class TestOrderResponseSchema:
"discount_amount": 5.00,
"total_amount": 125.00,
"currency": "EUR",
+ # Customer snapshot
+ "customer_first_name": "John",
+ "customer_last_name": "Doe",
+ "customer_email": "john@example.com",
+ "customer_phone": None,
+ "customer_locale": "en",
+ # Ship address snapshot
+ "ship_first_name": "John",
+ "ship_last_name": "Doe",
+ "ship_company": None,
+ "ship_address_line_1": "123 Main St",
+ "ship_address_line_2": None,
+ "ship_city": "Luxembourg",
+ "ship_postal_code": "L-1234",
+ "ship_country_iso": "LU",
+ # Bill address snapshot
+ "bill_first_name": "John",
+ "bill_last_name": "Doe",
+ "bill_company": None,
+ "bill_address_line_1": "123 Main St",
+ "bill_address_line_2": None,
+ "bill_city": "Luxembourg",
+ "bill_postal_code": "L-1234",
+ "bill_country_iso": "LU",
+ # Tracking
"shipping_method": "standard",
"tracking_number": None,
+ "tracking_provider": None,
+ # Notes
"customer_notes": None,
"internal_notes": None,
- "created_at": datetime.now(),
- "updated_at": datetime.now(),
- "paid_at": None,
+ # Timestamps
+ "order_date": now,
+ "confirmed_at": None,
"shipped_at": None,
"delivered_at": None,
"cancelled_at": None,
+ "created_at": now,
+ "updated_at": now,
}
response = OrderResponse(**data)
assert response.id == 1
assert response.order_number == "ORD-001"
assert response.total_amount == 125.00
+ assert response.channel == "direct"
+ assert response.customer_full_name == "John Doe"
+
+ def test_is_marketplace_order(self):
+ """Test is_marketplace_order property."""
+ now = datetime.now(timezone.utc)
+ # Direct order
+ direct_order = OrderResponse(
+ id=1, vendor_id=1, customer_id=1, order_number="ORD-001",
+ channel="direct", status="pending",
+ subtotal=100.0, tax_amount=0.0, shipping_amount=0.0, discount_amount=0.0,
+ total_amount=100.0, currency="EUR",
+ customer_first_name="John", customer_last_name="Doe",
+ customer_email="john@example.com", customer_phone=None, customer_locale=None,
+ ship_first_name="John", ship_last_name="Doe", ship_company=None,
+ ship_address_line_1="123 Main", ship_address_line_2=None,
+ ship_city="Luxembourg", ship_postal_code="L-1234", ship_country_iso="LU",
+ bill_first_name="John", bill_last_name="Doe", bill_company=None,
+ bill_address_line_1="123 Main", bill_address_line_2=None,
+ bill_city="Luxembourg", bill_postal_code="L-1234", bill_country_iso="LU",
+ shipping_method=None, tracking_number=None, tracking_provider=None,
+ customer_notes=None, internal_notes=None,
+ order_date=now, confirmed_at=None, shipped_at=None,
+ delivered_at=None, cancelled_at=None, created_at=now, updated_at=now,
+ )
+ assert direct_order.is_marketplace_order is False
+
+ # Marketplace order
+ marketplace_order = OrderResponse(
+ id=2, vendor_id=1, customer_id=1, order_number="LS-001",
+ channel="letzshop", status="pending",
+ subtotal=100.0, tax_amount=0.0, shipping_amount=0.0, discount_amount=0.0,
+ total_amount=100.0, currency="EUR",
+ customer_first_name="John", customer_last_name="Doe",
+ customer_email="john@example.com", customer_phone=None, customer_locale=None,
+ ship_first_name="John", ship_last_name="Doe", ship_company=None,
+ ship_address_line_1="123 Main", ship_address_line_2=None,
+ ship_city="Luxembourg", ship_postal_code="L-1234", ship_country_iso="LU",
+ bill_first_name="John", bill_last_name="Doe", bill_company=None,
+ bill_address_line_1="123 Main", bill_address_line_2=None,
+ bill_city="Luxembourg", bill_postal_code="L-1234", bill_country_iso="LU",
+ shipping_method=None, tracking_number=None, tracking_provider=None,
+ customer_notes=None, internal_notes=None,
+ order_date=now, confirmed_at=None, shipped_at=None,
+ delivered_at=None, cancelled_at=None, created_at=now, updated_at=now,
+ )
+ assert marketplace_order.is_marketplace_order is True
@pytest.mark.unit
@@ -331,26 +532,64 @@ class TestOrderItemResponseSchema:
def test_from_dict(self):
"""Test creating response from dict."""
- from datetime import datetime
-
+ now = datetime.now(timezone.utc)
data = {
"id": 1,
"order_id": 1,
"product_id": 1,
"product_name": "Test Product",
"product_sku": "SKU-001",
+ "gtin": "4006381333931",
+ "gtin_type": "EAN13",
"quantity": 2,
"unit_price": 50.00,
"total_price": 100.00,
"inventory_reserved": True,
"inventory_fulfilled": False,
- "created_at": datetime.now(),
- "updated_at": datetime.now(),
+ "needs_product_match": False,
+ "created_at": now,
+ "updated_at": now,
}
response = OrderItemResponse(**data)
assert response.id == 1
assert response.quantity == 2
assert response.total_price == 100.00
+ assert response.gtin == "4006381333931"
+
+ def test_has_unresolved_exception(self):
+ """Test has_unresolved_exception property."""
+ now = datetime.now(timezone.utc)
+ base_data = {
+ "id": 1, "order_id": 1, "product_id": 1,
+ "product_name": "Test", "product_sku": "SKU-001",
+ "gtin": None, "gtin_type": None,
+ "quantity": 1, "unit_price": 10.0, "total_price": 10.0,
+ "inventory_reserved": False, "inventory_fulfilled": False,
+ "created_at": now, "updated_at": now,
+ }
+
+ # No exception
+ response = OrderItemResponse(**base_data, needs_product_match=False, exception=None)
+ assert response.has_unresolved_exception is False
+
+ # Pending exception
+ from models.schema.order import OrderItemExceptionBrief
+ pending_exc = OrderItemExceptionBrief(
+ id=1, original_gtin="123", original_product_name="Test",
+ exception_type="product_not_found", status="pending",
+ resolved_product_id=None,
+ )
+ response = OrderItemResponse(**base_data, needs_product_match=True, exception=pending_exc)
+ assert response.has_unresolved_exception is True
+
+ # Resolved exception
+ resolved_exc = OrderItemExceptionBrief(
+ id=1, original_gtin="123", original_product_name="Test",
+ exception_type="product_not_found", status="resolved",
+ resolved_product_id=5,
+ )
+ response = OrderItemResponse(**base_data, needs_product_match=False, exception=resolved_exc)
+ assert response.has_unresolved_exception is False
@pytest.mark.unit
diff --git a/tests/unit/services/test_letzshop_service.py b/tests/unit/services/test_letzshop_service.py
index 74952f2a..26c0d331 100644
--- a/tests/unit/services/test_letzshop_service.py
+++ b/tests/unit/services/test_letzshop_service.py
@@ -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."""
diff --git a/tests/unit/services/test_order_item_exception_service.py b/tests/unit/services/test_order_item_exception_service.py
new file mode 100644
index 00000000..e97d8f4d
--- /dev/null
+++ b/tests/unit/services/test_order_item_exception_service.py
@@ -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
diff --git a/tests/unit/utils/test_csv_processor.py b/tests/unit/utils/test_csv_processor.py
index 1314627d..e51d87a6 100644
--- a/tests/unit/utils/test_csv_processor.py
+++ b/tests/unit/utils/test_csv_processor.py
@@ -148,17 +148,17 @@ TEST001,Product 1,Description 1,19.99 EUR,Brand1,Category1"""
assert product_data["price"] == "19.99"
assert product_data["brand"] == "TestBrand"
- def test_parse_price_to_numeric(self):
- """Test price string to numeric conversion"""
- assert self.processor._parse_price_to_numeric("19.99 EUR") == 19.99
- assert self.processor._parse_price_to_numeric("19,99 EUR") == 19.99
- assert self.processor._parse_price_to_numeric("$29.99") == 29.99
- assert self.processor._parse_price_to_numeric("100") == 100.0
- assert self.processor._parse_price_to_numeric(None) is None
- assert self.processor._parse_price_to_numeric("") is None
+ def test_parse_price_to_cents(self):
+ """Test price string to cents conversion"""
+ assert self.processor._parse_price_to_cents("19.99 EUR") == 1999
+ assert self.processor._parse_price_to_cents("19,99 EUR") == 1999
+ assert self.processor._parse_price_to_cents("$29.99") == 2999
+ assert self.processor._parse_price_to_cents("100") == 10000
+ assert self.processor._parse_price_to_cents(None) is None
+ assert self.processor._parse_price_to_cents("") is None
def test_clean_row_data_with_prices(self):
- """Test row data cleaning with price parsing"""
+ """Test row data cleaning with price parsing to cents"""
row_data = {
"marketplace_product_id": "TEST001",
"title": "Test Product",
@@ -169,8 +169,8 @@ TEST001,Product 1,Description 1,19.99 EUR,Brand1,Category1"""
cleaned = self.processor._clean_row_data(row_data)
- assert cleaned["price_numeric"] == 19.99
- assert cleaned["sale_price_numeric"] == 14.99
+ assert cleaned["price_cents"] == 1999 # Integer cents
+ assert cleaned["sale_price_cents"] == 1499 # Integer cents
assert cleaned["currency"] == "EUR"
@pytest.mark.asyncio