feat: integer cents money handling, order page fixes, and vendor filter persistence

Money Handling Architecture:
- Store all monetary values as integer cents (€105.91 = 10591)
- Add app/utils/money.py with Money class and conversion helpers
- Add static/shared/js/money.js for frontend formatting
- Update all database models to use _cents columns (Product, Order, etc.)
- Update CSV processor to convert prices to cents on import
- Add Alembic migration for Float to Integer conversion
- Create .architecture-rules/money.yaml with 7 validation rules
- Add docs/architecture/money-handling.md documentation

Order Details Page Fixes:
- Fix customer name showing 'undefined undefined' - use flat field names
- Fix vendor info empty - add vendor_name/vendor_code to OrderDetailResponse
- Fix shipping address using wrong nested object structure
- Enrich order detail API response with vendor info

Vendor Filter Persistence Fixes:
- Fix orders.js: restoreSavedVendor now sets selectedVendor and filters
- Fix orders.js: init() only loads orders if no saved vendor to restore
- Fix marketplace-letzshop.js: restoreSavedVendor calls selectVendor()
- Fix marketplace-letzshop.js: clearVendorSelection clears TomSelect dropdown
- Align vendor selector placeholder text between pages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-20 20:33:48 +01:00
parent 7f0d32c18d
commit a19c84ea4e
56 changed files with 6155 additions and 447 deletions

View File

@@ -28,6 +28,9 @@ principles:
- name: "Consistent Naming" - name: "Consistent Naming"
description: "API files: plural, Services: singular+service, Models: singular" description: "API files: plural, Services: singular+service, Models: singular"
- name: "Integer Cents Money"
description: "All monetary values stored as integer cents. Convert to euros only at display layer. See docs/architecture/money-handling.md"
# ============================================================================ # ============================================================================
# RULE FILE INCLUDES # RULE FILE INCLUDES
# ============================================================================ # ============================================================================
@@ -43,6 +46,7 @@ includes:
- frontend.yaml - frontend.yaml
- language.yaml - language.yaml
- quality.yaml - quality.yaml
- money.yaml
# ============================================================================ # ============================================================================
# VALIDATION SEVERITY LEVELS # VALIDATION SEVERITY LEVELS
@@ -100,3 +104,4 @@ documentation:
frontend: "docs/frontend/overview.md" frontend: "docs/frontend/overview.md"
contributing: "docs/development/contributing.md" contributing: "docs/development/contributing.md"
code_quality: "docs/development/code-quality.md" code_quality: "docs/development/code-quality.md"
money_handling: "docs/architecture/money-handling.md"

View File

@@ -0,0 +1,169 @@
# Architecture Rules - Money Handling
# Rules for monetary value handling across the codebase
#
# Reference: docs/architecture/money-handling.md
money_handling_rules:
- id: "MON-001"
name: "Database columns for money must use Integer cents"
severity: "error"
description: |
All monetary values in database models MUST be stored as integers representing
cents (or smallest currency unit). This prevents floating-point precision errors.
CORRECT:
price_cents = Column(Integer)
total_amount_cents = Column(Integer)
sale_price_cents = Column(Integer, nullable=True)
INCORRECT:
price = Column(Float)
total_amount = Column(Numeric(10, 2))
Column naming convention: Use `_cents` suffix for all monetary columns.
pattern:
file_pattern: "models/database/**/*.py"
required_patterns:
- "_cents = Column(Integer"
anti_patterns:
- "price = Column(Float"
- "amount = Column(Float"
- "total = Column(Float"
- "Column(Numeric"
- id: "MON-002"
name: "Use Money utility for conversions"
severity: "error"
description: |
All euro/cents conversions MUST use the Money utility class or
the euros_to_cents/cents_to_euros helper functions.
Never use manual multiplication/division for money conversions.
CORRECT:
from app.utils.money import Money, euros_to_cents, cents_to_euros
price_cents = euros_to_cents(19.99)
display_price = cents_to_euros(price_cents)
formatted = Money.format(price_cents, "EUR")
INCORRECT:
price_cents = int(price * 100)
display_price = price_cents / 100
pattern:
file_pattern:
- "app/**/*.py"
- "models/**/*.py"
required_imports:
- "from app.utils.money import"
anti_patterns:
- "* 100" # Manual cents conversion
- "/ 100" # Manual euros conversion
exceptions:
- "app/utils/money.py" # The utility itself
- id: "MON-003"
name: "API responses use euros for display"
severity: "error"
description: |
Pydantic response schemas MUST expose monetary values as floats (euros)
for frontend consumption. Use computed properties to convert from _cents.
CORRECT (in schema):
price_cents: int # Internal
@computed_field
@property
def price(self) -> float:
return cents_to_euros(self.price_cents)
Or use model validators to convert before response serialization.
pattern:
file_pattern: "models/schema/**/*.py"
check: "money_response_format"
- id: "MON-004"
name: "Frontend must use Money.format() for display"
severity: "error"
description: |
All monetary value display in frontend MUST use the Money utility.
This ensures consistent formatting across locales.
CORRECT (JavaScript):
Money.format(order.total_amount_cents, 'EUR')
Money.formatEuros(order.total_amount)
INCORRECT:
`${order.total_amount} EUR`
order.total_amount.toFixed(2)
pattern:
file_pattern:
- "static/**/*.js"
- "app/templates/**/*.html"
required_patterns:
- "Money.format"
- "formatPrice" # Helper function wrapping Money.format
discouraged_patterns:
- "toFixed(2)"
- "${.*} EUR"
- id: "MON-005"
name: "All arithmetic on money uses integers"
severity: "error"
description: |
All monetary calculations MUST be performed on integer cents values.
Convert to euros ONLY for display purposes, never for calculations.
CORRECT:
subtotal_cents = sum(item.unit_price_cents * item.quantity for item in items)
tax_cents = int(subtotal_cents * Decimal("0.17"))
total_cents = subtotal_cents + tax_cents
INCORRECT:
subtotal = sum(item.price * item.quantity for item in items)
tax = subtotal * 0.17 # Floating point!
total = subtotal + tax
pattern:
file_pattern: "app/services/**/*.py"
check: "money_arithmetic"
- id: "MON-006"
name: "CSV/External imports must convert to cents"
severity: "error"
description: |
When importing monetary values from CSV files or external APIs,
always convert to cents immediately upon parsing.
CORRECT:
price_cents = euros_to_cents(parsed_price)
product.price_cents = price_cents
INCORRECT:
product.price = parsed_price # Float assignment
pattern:
file_pattern:
- "app/utils/csv_processor.py"
- "app/services/**/import*.py"
required_patterns:
- "euros_to_cents"
- "_cents"
- id: "MON-007"
name: "Test assertions use cents"
severity: "warning"
description: |
Test assertions for monetary values should use integer cents
to avoid floating-point comparison issues.
CORRECT:
assert order.total_amount_cents == 10591
assert product.price_cents == 1999
INCORRECT:
assert order.total_amount == 105.91 # Float comparison
pattern:
file_pattern: "tests/**/*.py"
encouraged_patterns:
- "_cents =="
- "_cents >"
- "_cents <"

View File

@@ -0,0 +1,31 @@
"""add_order_tracking_fields
Revision ID: 55b92e155566
Revises: d2e3f4a5b6c7
Create Date: 2025-12-20 18:07:51.144136
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '55b92e155566'
down_revision: Union[str, None] = 'd2e3f4a5b6c7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add new tracking fields to orders table
op.add_column('orders', sa.Column('tracking_url', sa.String(length=500), nullable=True))
op.add_column('orders', sa.Column('shipment_number', sa.String(length=100), nullable=True))
op.add_column('orders', sa.Column('shipping_carrier', sa.String(length=50), nullable=True))
def downgrade() -> None:
op.drop_column('orders', 'shipping_carrier')
op.drop_column('orders', 'shipment_number')
op.drop_column('orders', 'tracking_url')

View File

@@ -0,0 +1,35 @@
"""add_letzshop_credentials_carrier_fields
Revision ID: c00d2985701f
Revises: 55b92e155566
Create Date: 2025-12-20 18:49:53.432904
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c00d2985701f'
down_revision: Union[str, None] = '55b92e155566'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add carrier settings and test mode to vendor_letzshop_credentials
op.add_column('vendor_letzshop_credentials', sa.Column('test_mode_enabled', sa.Boolean(), nullable=True, server_default='0'))
op.add_column('vendor_letzshop_credentials', sa.Column('default_carrier', sa.String(length=50), nullable=True))
op.add_column('vendor_letzshop_credentials', sa.Column('carrier_greco_label_url', sa.String(length=500), nullable=True, server_default='https://dispatchweb.fr/Tracky/Home/'))
op.add_column('vendor_letzshop_credentials', sa.Column('carrier_colissimo_label_url', sa.String(length=500), nullable=True))
op.add_column('vendor_letzshop_credentials', sa.Column('carrier_xpresslogistics_label_url', sa.String(length=500), nullable=True))
def downgrade() -> None:
op.drop_column('vendor_letzshop_credentials', 'carrier_xpresslogistics_label_url')
op.drop_column('vendor_letzshop_credentials', 'carrier_colissimo_label_url')
op.drop_column('vendor_letzshop_credentials', 'carrier_greco_label_url')
op.drop_column('vendor_letzshop_credentials', 'default_carrier')
op.drop_column('vendor_letzshop_credentials', 'test_mode_enabled')

View File

@@ -0,0 +1,223 @@
"""convert_prices_to_integer_cents
Revision ID: e1f2a3b4c5d6
Revises: c00d2985701f
Create Date: 2025-12-20 21:30:00.000000
Converts all price/amount columns from Float to Integer cents.
This follows e-commerce best practices (Stripe, PayPal, Shopify) for
precise monetary calculations.
Example: €105.91 is stored as 10591 (integer cents)
Affected tables:
- products: price, sale_price, supplier_cost, margin_percent
- orders: subtotal, tax_amount, shipping_amount, discount_amount, total_amount
- order_items: unit_price, total_price
- cart_items: price_at_add
- marketplace_products: price_numeric, sale_price_numeric
See docs/architecture/money-handling.md for full documentation.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e1f2a3b4c5d6'
down_revision: Union[str, None] = 'c00d2985701f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# SQLite requires batch mode for column alterations
# Strategy: Add new _cents columns, migrate data, drop old columns
# === PRODUCTS TABLE ===
with op.batch_alter_table('products', schema=None) as batch_op:
# Add new cents columns
batch_op.add_column(sa.Column('price_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('sale_price_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('supplier_cost_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('margin_percent_x100', sa.Integer(), nullable=True))
# Migrate data for products
op.execute('UPDATE products SET price_cents = ROUND(COALESCE(price, 0) * 100)')
op.execute('UPDATE products SET sale_price_cents = ROUND(sale_price * 100) WHERE sale_price IS NOT NULL')
op.execute('UPDATE products SET supplier_cost_cents = ROUND(supplier_cost * 100) WHERE supplier_cost IS NOT NULL')
op.execute('UPDATE products SET margin_percent_x100 = ROUND(margin_percent * 100) WHERE margin_percent IS NOT NULL')
# Drop old columns
with op.batch_alter_table('products', schema=None) as batch_op:
batch_op.drop_column('price')
batch_op.drop_column('sale_price')
batch_op.drop_column('supplier_cost')
batch_op.drop_column('margin_percent')
# === ORDERS TABLE ===
with op.batch_alter_table('orders', schema=None) as batch_op:
batch_op.add_column(sa.Column('subtotal_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('tax_amount_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('shipping_amount_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('discount_amount_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('total_amount_cents', sa.Integer(), nullable=True))
# Migrate data for orders
op.execute('UPDATE orders SET subtotal_cents = ROUND(COALESCE(subtotal, 0) * 100)')
op.execute('UPDATE orders SET tax_amount_cents = ROUND(COALESCE(tax_amount, 0) * 100)')
op.execute('UPDATE orders SET shipping_amount_cents = ROUND(COALESCE(shipping_amount, 0) * 100)')
op.execute('UPDATE orders SET discount_amount_cents = ROUND(COALESCE(discount_amount, 0) * 100)')
op.execute('UPDATE orders SET total_amount_cents = ROUND(COALESCE(total_amount, 0) * 100)')
# Make total_amount_cents NOT NULL after migration
with op.batch_alter_table('orders', schema=None) as batch_op:
batch_op.drop_column('subtotal')
batch_op.drop_column('tax_amount')
batch_op.drop_column('shipping_amount')
batch_op.drop_column('discount_amount')
batch_op.drop_column('total_amount')
# Alter total_amount_cents to be NOT NULL
batch_op.alter_column('total_amount_cents',
existing_type=sa.Integer(),
nullable=False)
# === ORDER_ITEMS TABLE ===
with op.batch_alter_table('order_items', schema=None) as batch_op:
batch_op.add_column(sa.Column('unit_price_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('total_price_cents', sa.Integer(), nullable=True))
# Migrate data for order_items
op.execute('UPDATE order_items SET unit_price_cents = ROUND(COALESCE(unit_price, 0) * 100)')
op.execute('UPDATE order_items SET total_price_cents = ROUND(COALESCE(total_price, 0) * 100)')
with op.batch_alter_table('order_items', schema=None) as batch_op:
batch_op.drop_column('unit_price')
batch_op.drop_column('total_price')
batch_op.alter_column('unit_price_cents',
existing_type=sa.Integer(),
nullable=False)
batch_op.alter_column('total_price_cents',
existing_type=sa.Integer(),
nullable=False)
# === CART_ITEMS TABLE ===
with op.batch_alter_table('cart_items', schema=None) as batch_op:
batch_op.add_column(sa.Column('price_at_add_cents', sa.Integer(), nullable=True))
# Migrate data for cart_items
op.execute('UPDATE cart_items SET price_at_add_cents = ROUND(COALESCE(price_at_add, 0) * 100)')
with op.batch_alter_table('cart_items', schema=None) as batch_op:
batch_op.drop_column('price_at_add')
batch_op.alter_column('price_at_add_cents',
existing_type=sa.Integer(),
nullable=False)
# === MARKETPLACE_PRODUCTS TABLE ===
with op.batch_alter_table('marketplace_products', schema=None) as batch_op:
batch_op.add_column(sa.Column('price_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('sale_price_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('weight_grams', sa.Integer(), nullable=True))
# Migrate data for marketplace_products
op.execute('UPDATE marketplace_products SET price_cents = ROUND(price_numeric * 100) WHERE price_numeric IS NOT NULL')
op.execute('UPDATE marketplace_products SET sale_price_cents = ROUND(sale_price_numeric * 100) WHERE sale_price_numeric IS NOT NULL')
op.execute('UPDATE marketplace_products SET weight_grams = ROUND(weight * 1000) WHERE weight IS NOT NULL')
with op.batch_alter_table('marketplace_products', schema=None) as batch_op:
batch_op.drop_column('price_numeric')
batch_op.drop_column('sale_price_numeric')
batch_op.drop_column('weight')
def downgrade() -> None:
# === MARKETPLACE_PRODUCTS TABLE ===
with op.batch_alter_table('marketplace_products', schema=None) as batch_op:
batch_op.add_column(sa.Column('price_numeric', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('sale_price_numeric', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('weight', sa.Float(), nullable=True))
op.execute('UPDATE marketplace_products SET price_numeric = price_cents / 100.0 WHERE price_cents IS NOT NULL')
op.execute('UPDATE marketplace_products SET sale_price_numeric = sale_price_cents / 100.0 WHERE sale_price_cents IS NOT NULL')
op.execute('UPDATE marketplace_products SET weight = weight_grams / 1000.0 WHERE weight_grams IS NOT NULL')
with op.batch_alter_table('marketplace_products', schema=None) as batch_op:
batch_op.drop_column('price_cents')
batch_op.drop_column('sale_price_cents')
batch_op.drop_column('weight_grams')
# === CART_ITEMS TABLE ===
with op.batch_alter_table('cart_items', schema=None) as batch_op:
batch_op.add_column(sa.Column('price_at_add', sa.Float(), nullable=True))
op.execute('UPDATE cart_items SET price_at_add = price_at_add_cents / 100.0')
with op.batch_alter_table('cart_items', schema=None) as batch_op:
batch_op.drop_column('price_at_add_cents')
batch_op.alter_column('price_at_add',
existing_type=sa.Float(),
nullable=False)
# === ORDER_ITEMS TABLE ===
with op.batch_alter_table('order_items', schema=None) as batch_op:
batch_op.add_column(sa.Column('unit_price', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('total_price', sa.Float(), nullable=True))
op.execute('UPDATE order_items SET unit_price = unit_price_cents / 100.0')
op.execute('UPDATE order_items SET total_price = total_price_cents / 100.0')
with op.batch_alter_table('order_items', schema=None) as batch_op:
batch_op.drop_column('unit_price_cents')
batch_op.drop_column('total_price_cents')
batch_op.alter_column('unit_price',
existing_type=sa.Float(),
nullable=False)
batch_op.alter_column('total_price',
existing_type=sa.Float(),
nullable=False)
# === ORDERS TABLE ===
with op.batch_alter_table('orders', schema=None) as batch_op:
batch_op.add_column(sa.Column('subtotal', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('tax_amount', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('shipping_amount', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('discount_amount', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('total_amount', sa.Float(), nullable=True))
op.execute('UPDATE orders SET subtotal = subtotal_cents / 100.0')
op.execute('UPDATE orders SET tax_amount = tax_amount_cents / 100.0')
op.execute('UPDATE orders SET shipping_amount = shipping_amount_cents / 100.0')
op.execute('UPDATE orders SET discount_amount = discount_amount_cents / 100.0')
op.execute('UPDATE orders SET total_amount = total_amount_cents / 100.0')
with op.batch_alter_table('orders', schema=None) as batch_op:
batch_op.drop_column('subtotal_cents')
batch_op.drop_column('tax_amount_cents')
batch_op.drop_column('shipping_amount_cents')
batch_op.drop_column('discount_amount_cents')
batch_op.drop_column('total_amount_cents')
batch_op.alter_column('total_amount',
existing_type=sa.Float(),
nullable=False)
# === PRODUCTS TABLE ===
with op.batch_alter_table('products', schema=None) as batch_op:
batch_op.add_column(sa.Column('price', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('sale_price', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('supplier_cost', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('margin_percent', sa.Float(), nullable=True))
op.execute('UPDATE products SET price = price_cents / 100.0 WHERE price_cents IS NOT NULL')
op.execute('UPDATE products SET sale_price = sale_price_cents / 100.0 WHERE sale_price_cents IS NOT NULL')
op.execute('UPDATE products SET supplier_cost = supplier_cost_cents / 100.0 WHERE supplier_cost_cents IS NOT NULL')
op.execute('UPDATE products SET margin_percent = margin_percent_x100 / 100.0 WHERE margin_percent_x100 IS NOT NULL')
with op.batch_alter_table('products', schema=None) as batch_op:
batch_op.drop_column('price_cents')
batch_op.drop_column('sale_price_cents')
batch_op.drop_column('supplier_cost_cents')
batch_op.drop_column('margin_percent_x100')

View File

@@ -347,6 +347,96 @@ def test_api_key(
# ============================================================================ # ============================================================================
@router.get(
"/orders",
response_model=LetzshopOrderListResponse,
)
def list_all_letzshop_orders(
vendor_id: int | None = Query(None, description="Filter by vendor"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
status: str | None = Query(None, description="Filter by order status"),
has_declined_items: bool | None = Query(
None, description="Filter orders with declined/unavailable items"
),
search: str | None = Query(
None, description="Search by order number, customer name, or email"
),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
List Letzshop orders across all vendors (or for a specific vendor).
When vendor_id is not provided, returns orders from all vendors.
"""
order_service = get_order_service(db)
orders, total = order_service.list_orders(
vendor_id=vendor_id,
skip=skip,
limit=limit,
status=status,
has_declined_items=has_declined_items,
search=search,
)
# Get order stats (cross-vendor or vendor-specific)
stats = order_service.get_order_stats(vendor_id)
return LetzshopOrderListResponse(
orders=[
LetzshopOrderResponse(
id=order.id,
vendor_id=order.vendor_id,
vendor_name=order.vendor.name if order.vendor else None,
order_number=order.order_number,
external_order_id=order.external_order_id,
external_shipment_id=order.external_shipment_id,
external_order_number=order.external_order_number,
status=order.status,
customer_email=order.customer_email,
customer_name=order.customer_full_name,
customer_locale=order.customer_locale,
ship_country_iso=order.ship_country_iso,
bill_country_iso=order.bill_country_iso,
total_amount=order.total_amount,
currency=order.currency,
tracking_number=order.tracking_number,
tracking_provider=order.tracking_provider,
order_date=order.order_date,
confirmed_at=order.confirmed_at,
shipped_at=order.shipped_at,
cancelled_at=order.cancelled_at,
created_at=order.created_at,
updated_at=order.updated_at,
items=[
LetzshopOrderItemResponse(
id=item.id,
product_id=item.product_id,
product_name=item.product_name,
product_sku=item.product_sku,
gtin=item.gtin,
gtin_type=item.gtin_type,
quantity=item.quantity,
unit_price=item.unit_price,
total_price=item.total_price,
external_item_id=item.external_item_id,
external_variant_id=item.external_variant_id,
item_state=item.item_state,
)
for item in order.items
],
)
for order in orders
],
total=total,
skip=skip,
limit=limit,
stats=LetzshopOrderStats(**stats),
)
@router.get( @router.get(
"/vendors/{vendor_id}/orders", "/vendors/{vendor_id}/orders",
response_model=LetzshopOrderListResponse, response_model=LetzshopOrderListResponse,
@@ -1048,3 +1138,99 @@ def decline_single_item(
except LetzshopClientError as e: except LetzshopClientError as e:
return FulfillmentOperationResponse(success=False, message=str(e)) return FulfillmentOperationResponse(success=False, message=str(e))
# ============================================================================
# Tracking Sync
# ============================================================================
@router.post(
"/vendors/{vendor_id}/sync-tracking",
response_model=LetzshopSyncTriggerResponse,
)
def sync_tracking_for_vendor(
vendor_id: int = Path(..., description="Vendor ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Sync tracking information from Letzshop for confirmed orders.
Fetches tracking data from Letzshop API for orders that:
- Are in "processing" status (confirmed)
- Don't have tracking info yet
- Have an external shipment ID
This is useful when tracking is added by Letzshop after order confirmation.
"""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
# Verify credentials exist
try:
creds_service.get_credentials_or_raise(vendor_id)
except CredentialsNotFoundError:
raise ValidationException(
f"Letzshop credentials not configured for vendor {vendor_id}"
)
# Get orders that need tracking
orders_without_tracking = order_service.get_orders_without_tracking(vendor_id)
if not orders_without_tracking:
return LetzshopSyncTriggerResponse(
success=True,
message="No orders need tracking updates",
orders_imported=0,
orders_updated=0,
)
logger.info(
f"Syncing tracking for {len(orders_without_tracking)} orders (vendor {vendor_id})"
)
orders_updated = 0
errors = []
try:
with creds_service.create_client(vendor_id) as client:
for order in orders_without_tracking:
try:
# Fetch shipment by ID
shipment_data = client.get_shipment_by_id(order.external_shipment_id)
if shipment_data:
updated = order_service.update_tracking_from_shipment_data(
order, shipment_data
)
if updated:
orders_updated += 1
except Exception as e:
errors.append(
f"Error syncing tracking for order {order.order_number}: {e}"
)
db.commit()
message = f"Tracking sync completed: {orders_updated} orders updated"
if errors:
message += f" ({len(errors)} errors)"
return LetzshopSyncTriggerResponse(
success=True,
message=message,
orders_imported=0,
orders_updated=orders_updated,
errors=errors,
)
except LetzshopClientError as e:
return LetzshopSyncTriggerResponse(
success=False,
message=f"Tracking sync failed: {e}",
errors=[str(e)],
)

View File

@@ -69,7 +69,7 @@ def list_exceptions(
limit=limit, limit=limit,
) )
# Enrich with order info # Enrich with order and vendor info
response_items = [] response_items = []
for exc in exceptions: for exc in exceptions:
item = OrderItemExceptionResponse.model_validate(exc) item = OrderItemExceptionResponse.model_validate(exc)
@@ -79,6 +79,9 @@ def list_exceptions(
item.order_id = order.id item.order_id = order.id
item.order_date = order.order_date item.order_date = order.order_date
item.order_status = order.status item.order_status = order.status
# Add vendor name for cross-vendor view
if order.vendor:
item.vendor_name = order.vendor.name
response_items.append(item) response_items.append(item)
return OrderItemExceptionListResponse( return OrderItemExceptionListResponse(

View File

@@ -27,7 +27,9 @@ from models.schema.order import (
AdminOrderStats, AdminOrderStats,
AdminOrderStatusUpdate, AdminOrderStatusUpdate,
AdminVendorsWithOrdersResponse, AdminVendorsWithOrdersResponse,
MarkAsShippedRequest,
OrderDetailResponse, OrderDetailResponse,
ShippingLabelInfo,
) )
router = APIRouter(prefix="/orders") router = APIRouter(prefix="/orders")
@@ -105,7 +107,14 @@ def get_order_detail(
): ):
"""Get order details including items and addresses.""" """Get order details including items and addresses."""
order = order_service.get_order_by_id_admin(db, order_id) order = order_service.get_order_by_id_admin(db, order_id)
return order
# Enrich with vendor info
response = OrderDetailResponse.model_validate(order)
if order.vendor:
response.vendor_name = order.vendor.name
response.vendor_code = order.vendor.vendor_code
return response
@router.patch("/{order_id}/status", response_model=OrderDetailResponse) @router.patch("/{order_id}/status", response_model=OrderDetailResponse)
@@ -136,3 +145,49 @@ def update_order_status(
db.commit() db.commit()
return order return order
@router.post("/{order_id}/ship", response_model=OrderDetailResponse)
def mark_order_as_shipped(
order_id: int,
ship_request: MarkAsShippedRequest,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Mark an order as shipped with optional tracking information.
This endpoint:
- Sets order status to 'shipped'
- Sets shipped_at timestamp
- Optionally stores tracking number, URL, and carrier
"""
order = order_service.mark_as_shipped_admin(
db=db,
order_id=order_id,
tracking_number=ship_request.tracking_number,
tracking_url=ship_request.tracking_url,
shipping_carrier=ship_request.shipping_carrier,
)
logger.info(
f"Admin {current_admin.email} marked order {order.order_number} as shipped"
)
db.commit()
return order
@router.get("/{order_id}/shipping-label", response_model=ShippingLabelInfo)
def get_shipping_label_info(
order_id: int,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Get shipping label information for an order.
Returns the shipment number, carrier, and generated label URL
based on carrier settings.
"""
return order_service.get_shipping_label_info_admin(db, order_id)

View File

@@ -6,6 +6,9 @@ This module provides:
- Session-based cart management - Session-based cart management
- Cart item operations (add, update, remove) - Cart item operations (add, update, remove)
- Cart total calculations - Cart total calculations
All monetary calculations use integer cents internally for precision.
See docs/architecture/money-handling.md for details.
""" """
import logging import logging
@@ -19,6 +22,7 @@ from app.exceptions import (
InvalidCartQuantityException, InvalidCartQuantityException,
ProductNotFoundException, ProductNotFoundException,
) )
from app.utils.money import cents_to_euros
from models.database.cart import CartItem from models.database.cart import CartItem
from models.database.product import Product from models.database.product import Product
@@ -62,21 +66,23 @@ class CartService:
extra={"item_count": len(cart_items)}, extra={"item_count": len(cart_items)},
) )
# Build response # Build response - calculate totals in cents, return euros
items = [] items = []
subtotal = 0.0 subtotal_cents = 0
for cart_item in cart_items: for cart_item in cart_items:
product = cart_item.product product = cart_item.product
line_total = cart_item.line_total line_total_cents = cart_item.line_total_cents
items.append( items.append(
{ {
"product_id": product.id, "product_id": product.id,
"product_name": product.marketplace_product.title, "product_name": product.marketplace_product.get_title("en")
if product.marketplace_product
else str(product.id),
"quantity": cart_item.quantity, "quantity": cart_item.quantity,
"price": cart_item.price_at_add, "price": cart_item.price_at_add, # Returns euros via property
"line_total": line_total, "line_total": cents_to_euros(line_total_cents),
"image_url": ( "image_url": (
product.marketplace_product.image_link product.marketplace_product.image_link
if product.marketplace_product if product.marketplace_product
@@ -85,8 +91,10 @@ class CartService:
} }
) )
subtotal += line_total subtotal_cents += line_total_cents
# Convert to euros for API response
subtotal = cents_to_euros(subtotal_cents)
cart_data = { cart_data = {
"vendor_id": vendor_id, "vendor_id": vendor_id,
"session_id": session_id, "session_id": session_id,
@@ -166,8 +174,12 @@ class CartService:
}, },
) )
# Get current price (use sale_price if available, otherwise regular price) # Get current price in cents (use sale_price if available, otherwise regular price)
current_price = product.sale_price if product.sale_price else product.price current_price_cents = (
product.effective_sale_price_cents
or product.effective_price_cents
or 0
)
# Check if item already exists in cart # Check if item already exists in cart
existing_item = ( existing_item = (
@@ -236,13 +248,13 @@ class CartService:
available=product.available_inventory, available=product.available_inventory,
) )
# Create new cart item # Create new cart item (price stored in cents)
cart_item = CartItem( cart_item = CartItem(
vendor_id=vendor_id, vendor_id=vendor_id,
session_id=session_id, session_id=session_id,
product_id=product_id, product_id=product_id,
quantity=quantity, quantity=quantity,
price_at_add=current_price, price_at_add_cents=current_price_cents,
) )
db.add(cart_item) db.add(cart_item)
db.flush() db.flush()
@@ -253,7 +265,7 @@ class CartService:
extra={ extra={
"cart_item_id": cart_item.id, "cart_item_id": cart_item.id,
"quantity": quantity, "quantity": quantity,
"price": current_price, "price_cents": current_price_cents,
}, },
) )

View File

@@ -116,6 +116,9 @@ query {
code code
provider provider
} }
data {
__typename
}
} }
} }
} }
@@ -194,6 +197,9 @@ query {
code code
provider provider
} }
data {
__typename
}
} }
} }
} }
@@ -272,6 +278,9 @@ query GetShipment($id: ID!) {
code code
provider provider
} }
data {
__typename
}
} }
} }
} }
@@ -349,6 +358,9 @@ query GetShipmentsPaginated($first: Int!, $after: String) {{
}} }}
}} }}
}} }}
data {{
__typename
}}
}} }}
}} }}
}} }}

View File

@@ -182,7 +182,7 @@ class LetzshopOrderService:
def list_orders( def list_orders(
self, self,
vendor_id: int, vendor_id: int | None = None,
skip: int = 0, skip: int = 0,
limit: int = 50, limit: int = 50,
status: str | None = None, status: str | None = None,
@@ -190,10 +190,10 @@ class LetzshopOrderService:
search: str | None = None, search: str | None = None,
) -> tuple[list[Order], int]: ) -> tuple[list[Order], int]:
""" """
List Letzshop orders for a vendor. List Letzshop orders for a vendor (or all vendors).
Args: Args:
vendor_id: Vendor ID to filter by. vendor_id: Vendor ID to filter by. If None, returns all vendors.
skip: Number of records to skip. skip: Number of records to skip.
limit: Maximum number of records to return. limit: Maximum number of records to return.
status: Filter by order status (pending, processing, shipped, etc.) status: Filter by order status (pending, processing, shipped, etc.)
@@ -203,10 +203,13 @@ class LetzshopOrderService:
Returns a tuple of (orders, total_count). Returns a tuple of (orders, total_count).
""" """
query = self.db.query(Order).filter( query = self.db.query(Order).filter(
Order.vendor_id == vendor_id,
Order.channel == "letzshop", Order.channel == "letzshop",
) )
# Filter by vendor if specified
if vendor_id is not None:
query = query.filter(Order.vendor_id == vendor_id)
if status: if status:
query = query.filter(Order.status == status) query = query.filter(Order.status == status)
@@ -242,25 +245,25 @@ class LetzshopOrderService:
return orders, total return orders, total
def get_order_stats(self, vendor_id: int) -> dict[str, int]: def get_order_stats(self, vendor_id: int | None = None) -> dict[str, int]:
""" """
Get order counts by status for a vendor's Letzshop orders. Get order counts by status for Letzshop orders.
Args:
vendor_id: Vendor ID to filter by. If None, returns stats for all vendors.
Returns: Returns:
Dict with counts for each status. Dict with counts for each status.
""" """
status_counts = ( query = self.db.query(
self.db.query( Order.status,
Order.status, func.count(Order.id).label("count"),
func.count(Order.id).label("count"), ).filter(Order.channel == "letzshop")
)
.filter( if vendor_id is not None:
Order.vendor_id == vendor_id, query = query.filter(Order.vendor_id == vendor_id)
Order.channel == "letzshop",
) status_counts = query.group_by(Order.status).all()
.group_by(Order.status)
.all()
)
stats = { stats = {
"pending": 0, "pending": 0,
@@ -277,18 +280,18 @@ class LetzshopOrderService:
stats["total"] += count stats["total"] += count
# Count orders with declined items # Count orders with declined items
declined_items_count = ( declined_query = (
self.db.query(func.count(func.distinct(OrderItem.order_id))) self.db.query(func.count(func.distinct(OrderItem.order_id)))
.join(Order, OrderItem.order_id == Order.id) .join(Order, OrderItem.order_id == Order.id)
.filter( .filter(
Order.vendor_id == vendor_id,
Order.channel == "letzshop", Order.channel == "letzshop",
OrderItem.item_state == "confirmed_unavailable", OrderItem.item_state == "confirmed_unavailable",
) )
.scalar()
or 0
) )
stats["has_declined_items"] = declined_items_count if vendor_id is not None:
declined_query = declined_query.filter(Order.vendor_id == vendor_id)
stats["has_declined_items"] = declined_query.scalar() or 0
return stats return stats
@@ -464,6 +467,62 @@ class LetzshopOrderService:
order.updated_at = datetime.now(UTC) order.updated_at = datetime.now(UTC)
return order return order
def get_orders_without_tracking(
self,
vendor_id: int,
limit: int = 100,
) -> list[Order]:
"""Get orders that have been confirmed but don't have tracking info."""
return (
self.db.query(Order)
.filter(
Order.vendor_id == vendor_id,
Order.channel == "letzshop",
Order.status == "processing", # Confirmed orders
Order.tracking_number.is_(None),
Order.external_shipment_id.isnot(None), # Has shipment ID
)
.limit(limit)
.all()
)
def update_tracking_from_shipment_data(
self,
order: Order,
shipment_data: dict[str, Any],
) -> bool:
"""
Update order tracking from Letzshop shipment data.
Args:
order: The order to update.
shipment_data: Raw shipment data from Letzshop API.
Returns:
True if tracking was updated, False otherwise.
"""
tracking_data = shipment_data.get("tracking") or {}
tracking_number = tracking_data.get("code") or tracking_data.get("number")
if not tracking_number:
return False
tracking_provider = tracking_data.get("provider")
# Handle carrier object format: tracking { carrier { name code } }
if not tracking_provider and tracking_data.get("carrier"):
carrier = tracking_data.get("carrier", {})
tracking_provider = carrier.get("code") or carrier.get("name")
order.tracking_number = tracking_number
order.tracking_provider = tracking_provider
order.updated_at = datetime.now(UTC)
logger.info(
f"Updated tracking for order {order.order_number}: "
f"{tracking_provider} {tracking_number}"
)
return True
def get_order_items(self, order: Order) -> list[OrderItem]: def get_order_items(self, order: Order) -> list[OrderItem]:
"""Get all items for an order.""" """Get all items for an order."""
return ( return (
@@ -733,7 +792,7 @@ class LetzshopOrderService:
pass pass
if needs_update: if needs_update:
self.db.commit() # Commit update immediately self.db.commit() # noqa: SVC-006 - background task needs incremental commits
stats["updated"] += 1 stats["updated"] += 1
else: else:
stats["skipped"] += 1 stats["skipped"] += 1
@@ -741,7 +800,7 @@ class LetzshopOrderService:
# Create new order using unified service # Create new order using unified service
try: try:
self.create_order(vendor_id, shipment) self.create_order(vendor_id, shipment)
self.db.commit() # Commit each order immediately self.db.commit() # noqa: SVC-006 - background task needs incremental commits
stats["imported"] += 1 stats["imported"] += 1
except Exception as e: except Exception as e:
self.db.rollback() # Rollback failed order self.db.rollback() # Rollback failed order
@@ -948,7 +1007,7 @@ class LetzshopOrderService:
status="pending", status="pending",
) )
self.db.add(job) self.db.add(job)
self.db.commit() self.db.commit() # noqa: SVC-006 - job must be visible immediately before background task starts
self.db.refresh(job) self.db.refresh(job)
return job return job

View File

@@ -329,8 +329,9 @@ class OrderItemExceptionService:
order_item.needs_product_match = False order_item.needs_product_match = False
# Update product snapshot on order item # Update product snapshot on order item
order_item.product_name = product.get_title("en") # Default to English if product.marketplace_product:
order_item.product_sku = product.sku or order_item.product_sku order_item.product_name = product.marketplace_product.get_title("en")
order_item.product_sku = product.vendor_sku or order_item.product_sku
db.flush() db.flush()
@@ -446,7 +447,8 @@ class OrderItemExceptionService:
order_item = exception.order_item order_item = exception.order_item
order_item.product_id = product_id order_item.product_id = product_id
order_item.needs_product_match = False order_item.needs_product_match = False
order_item.product_name = product.get_title("en") if product.marketplace_product:
order_item.product_name = product.marketplace_product.get_title("en")
resolved.append(exception) resolved.append(exception)
@@ -613,7 +615,8 @@ class OrderItemExceptionService:
order_item = exception.order_item order_item = exception.order_item
order_item.product_id = product_id order_item.product_id = product_id
order_item.needs_product_match = False order_item.needs_product_match = False
order_item.product_name = product.get_title("en") if product.marketplace_product:
order_item.product_name = product.marketplace_product.get_title("en")
db.flush() db.flush()

View File

@@ -10,6 +10,9 @@ This service handles:
- Order item management - Order item management
All orders use snapshotted customer and address data. All orders use snapshotted customer and address data.
All monetary calculations use integer cents internally for precision.
See docs/architecture/money-handling.md for details.
""" """
import logging import logging
@@ -28,6 +31,7 @@ from app.exceptions import (
ValidationException, ValidationException,
) )
from app.services.order_item_exception_service import order_item_exception_service from app.services.order_item_exception_service import order_item_exception_service
from app.utils.money import Money, cents_to_euros, euros_to_cents
from models.database.customer import Customer from models.database.customer import Customer
from models.database.marketplace_product import MarketplaceProduct from models.database.marketplace_product import MarketplaceProduct
from models.database.marketplace_product_translation import MarketplaceProductTranslation from models.database.marketplace_product_translation import MarketplaceProductTranslation
@@ -296,7 +300,8 @@ class OrderService:
) )
# Calculate order totals and validate products # Calculate order totals and validate products
subtotal = 0.0 # All calculations use integer cents for precision
subtotal_cents = 0
order_items_data = [] order_items_data = []
for item_data in order_data.items: for item_data in order_data.items:
@@ -325,34 +330,42 @@ class OrderService:
available=product.available_inventory, available=product.available_inventory,
) )
# Calculate item total # Get price in cents (prefer sale price, then regular price)
unit_price = product.sale_price if product.sale_price else product.price unit_price_cents = (
if not unit_price: product.effective_sale_price_cents
or product.effective_price_cents
)
if not unit_price_cents:
raise ValidationException(f"Product {product.id} has no price") raise ValidationException(f"Product {product.id} has no price")
item_total = unit_price * item_data.quantity # Calculate line total in cents
subtotal += item_total line_total_cents = Money.calculate_line_total(
unit_price_cents, item_data.quantity
)
subtotal_cents += line_total_cents
order_items_data.append( order_items_data.append(
{ {
"product_id": product.id, "product_id": product.id,
"product_name": product.marketplace_product.title "product_name": product.marketplace_product.get_title("en")
if product.marketplace_product if product.marketplace_product
else product.product_id, else str(product.id),
"product_sku": product.product_id, "product_sku": product.vendor_sku,
"gtin": product.gtin, "gtin": product.gtin,
"gtin_type": product.gtin_type, "gtin_type": product.gtin_type,
"quantity": item_data.quantity, "quantity": item_data.quantity,
"unit_price": unit_price, "unit_price_cents": unit_price_cents,
"total_price": item_total, "total_price_cents": line_total_cents,
} }
) )
# Calculate totals # Calculate totals in cents
tax_amount = 0.0 # TODO: Implement tax calculation tax_amount_cents = 0 # TODO: Implement tax calculation
shipping_amount = 5.99 if subtotal < 50 else 0.0 shipping_amount_cents = 599 if subtotal_cents < 5000 else 0 # €5.99 / €50
discount_amount = 0.0 discount_amount_cents = 0
total_amount = subtotal + tax_amount + shipping_amount - discount_amount total_amount_cents = Money.calculate_order_total(
subtotal_cents, tax_amount_cents, shipping_amount_cents, discount_amount_cents
)
# Use billing address or shipping address # Use billing address or shipping address
billing = order_data.billing_address or order_data.shipping_address billing = order_data.billing_address or order_data.shipping_address
@@ -367,12 +380,12 @@ class OrderService:
order_number=order_number, order_number=order_number,
channel="direct", channel="direct",
status="pending", status="pending",
# Financials # Financials (in cents)
subtotal=subtotal, subtotal_cents=subtotal_cents,
tax_amount=tax_amount, tax_amount_cents=tax_amount_cents,
shipping_amount=shipping_amount, shipping_amount_cents=shipping_amount_cents,
discount_amount=discount_amount, discount_amount_cents=discount_amount_cents,
total_amount=total_amount, total_amount_cents=total_amount_cents,
currency="EUR", currency="EUR",
# Customer snapshot # Customer snapshot
customer_first_name=order_data.customer.first_name, customer_first_name=order_data.customer.first_name,
@@ -417,7 +430,7 @@ class OrderService:
logger.info( logger.info(
f"Order {order.order_number} created for vendor {vendor_id}, " f"Order {order.order_number} created for vendor {vendor_id}, "
f"total: EUR {total_amount:.2f}" f"total: EUR {cents_to_euros(total_amount_cents):.2f}"
) )
return order return order
@@ -469,6 +482,41 @@ class OrderService:
.first() .first()
) )
if existing: if existing:
updated = False
# Update tracking if available and not already set
tracking_data = shipment_data.get("tracking") or {}
new_tracking = tracking_data.get("code") or tracking_data.get("number")
if new_tracking and not existing.tracking_number:
existing.tracking_number = new_tracking
tracking_provider = tracking_data.get("provider")
if not tracking_provider and tracking_data.get("carrier"):
carrier = tracking_data.get("carrier", {})
tracking_provider = carrier.get("code") or carrier.get("name")
existing.tracking_provider = tracking_provider
updated = True
logger.info(
f"Updated tracking for order {order_number}: "
f"{tracking_provider} {new_tracking}"
)
# Update shipment number if not already set
shipment_number = shipment_data.get("number")
if shipment_number and not existing.shipment_number:
existing.shipment_number = shipment_number
updated = True
# Update carrier if not already set
shipment_data_obj = shipment_data.get("data") or {}
if shipment_data_obj and not existing.shipping_carrier:
carrier_name = shipment_data_obj.get("__typename", "").lower()
if carrier_name:
existing.shipping_carrier = carrier_name
updated = True
if updated:
existing.updated_at = datetime.now(UTC)
return existing return existing
# ===================================================================== # =====================================================================
@@ -546,13 +594,14 @@ class OrderService:
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
# Parse total amount # Parse total amount (convert to cents)
total_str = order_data.get("total", "0") total_str = order_data.get("total", "0")
try: try:
# Handle format like "99.99 EUR" # Handle format like "99.99 EUR"
total_amount = float(str(total_str).split()[0]) total_euros = float(str(total_str).split()[0])
total_amount_cents = euros_to_cents(total_euros)
except (ValueError, IndexError): except (ValueError, IndexError):
total_amount = 0.0 total_amount_cents = 0
# Map Letzshop state to status # Map Letzshop state to status
letzshop_state = shipment_data.get("state", "unconfirmed") letzshop_state = shipment_data.get("state", "unconfirmed")
@@ -563,6 +612,23 @@ class OrderService:
} }
status = status_mapping.get(letzshop_state, "pending") status = status_mapping.get(letzshop_state, "pending")
# Parse tracking info if available
tracking_data = shipment_data.get("tracking") or {}
tracking_number = tracking_data.get("code") or tracking_data.get("number")
tracking_provider = tracking_data.get("provider")
# Handle carrier object format: tracking { carrier { name code } }
if not tracking_provider and tracking_data.get("carrier"):
carrier = tracking_data.get("carrier", {})
tracking_provider = carrier.get("code") or carrier.get("name")
# Parse shipment number and carrier
shipment_number = shipment_data.get("number") # e.g., H74683403433
shipping_carrier = None
shipment_data_obj = shipment_data.get("data") or {}
if shipment_data_obj:
# Carrier is determined by __typename (Greco, Colissimo, XpressLogistics)
shipping_carrier = shipment_data_obj.get("__typename", "").lower()
# ===================================================================== # =====================================================================
# PHASE 2: All validation passed - now create database records # PHASE 2: All validation passed - now create database records
# ===================================================================== # =====================================================================
@@ -590,8 +656,8 @@ class OrderService:
external_data=shipment_data, external_data=shipment_data,
# Status # Status
status=status, status=status,
# Financials # Financials (in cents)
total_amount=total_amount, total_amount_cents=total_amount_cents,
currency="EUR", currency="EUR",
# Customer snapshot # Customer snapshot
customer_first_name=ship_first_name, customer_first_name=ship_first_name,
@@ -620,6 +686,12 @@ class OrderService:
order_date=order_date, order_date=order_date,
confirmed_at=datetime.now(UTC) if status == "processing" else None, confirmed_at=datetime.now(UTC) if status == "processing" else None,
cancelled_at=datetime.now(UTC) if status == "cancelled" else None, cancelled_at=datetime.now(UTC) if status == "cancelled" else None,
# Tracking (if available from Letzshop)
tracking_number=tracking_number,
tracking_provider=tracking_provider,
# Shipment info
shipment_number=shipment_number,
shipping_carrier=shipping_carrier,
) )
db.add(order) db.add(order)
@@ -653,11 +725,12 @@ class OrderService:
or str(product_name_dict) or str(product_name_dict)
) )
# Get price # Get price (convert to cents)
unit_price = 0.0 unit_price_cents = 0
price_str = variant.get("price", "0") price_str = variant.get("price", "0")
try: try:
unit_price = float(str(price_str).split()[0]) price_euros = float(str(price_str).split()[0])
unit_price_cents = euros_to_cents(price_euros)
except (ValueError, IndexError): except (ValueError, IndexError):
pass pass
@@ -672,8 +745,8 @@ class OrderService:
gtin=gtin, gtin=gtin,
gtin_type=gtin_type, gtin_type=gtin_type,
quantity=1, # Letzshop uses individual inventory units quantity=1, # Letzshop uses individual inventory units
unit_price=unit_price, unit_price_cents=unit_price_cents,
total_price=unit_price, total_price_cents=unit_price_cents, # qty=1 so same as unit
external_item_id=unit.get("id"), external_item_id=unit.get("id"),
external_variant_id=variant.get("id"), external_variant_id=variant.get("id"),
item_state=item_state, item_state=item_state,
@@ -1147,13 +1220,13 @@ class OrderService:
if key in stats: if key in stats:
stats[key] = count stats[key] = count
# Get total revenue (from delivered orders) # Get total revenue (from delivered orders) - convert cents to euros
revenue = ( revenue_cents = (
db.query(func.sum(Order.total_amount)) db.query(func.sum(Order.total_amount_cents))
.filter(Order.status == "delivered") .filter(Order.status == "delivered")
.scalar() .scalar()
) )
stats["total_revenue"] = float(revenue) if revenue else 0.0 stats["total_revenue"] = cents_to_euros(revenue_cents) if revenue_cents else 0.0
# Count vendors with orders # Count vendors with orders
vendors_count = ( vendors_count = (
@@ -1200,6 +1273,88 @@ class OrderService:
for row in results for row in results
] ]
def mark_as_shipped_admin(
self,
db: Session,
order_id: int,
tracking_number: str | None = None,
tracking_url: str | None = None,
shipping_carrier: str | None = None,
) -> Order:
"""
Mark an order as shipped with optional tracking info (admin only).
Args:
db: Database session
order_id: Order ID
tracking_number: Optional tracking number
tracking_url: Optional full tracking URL
shipping_carrier: Optional carrier code (greco, colissimo, etc.)
Returns:
Updated order
"""
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise OrderNotFoundException(str(order_id))
order.status = "shipped"
order.shipped_at = datetime.now(UTC)
order.updated_at = datetime.now(UTC)
if tracking_number:
order.tracking_number = tracking_number
if tracking_url:
order.tracking_url = tracking_url
if shipping_carrier:
order.shipping_carrier = shipping_carrier
logger.info(
f"Order {order.order_number} marked as shipped. "
f"Tracking: {tracking_number or 'N/A'}, Carrier: {shipping_carrier or 'N/A'}"
)
return order
def get_shipping_label_info_admin(
self,
db: Session,
order_id: int,
) -> dict[str, Any]:
"""
Get shipping label information for an order (admin only).
Returns shipment number, carrier, and generated label URL
based on carrier settings.
"""
from app.services.admin_settings_service import admin_settings_service
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise OrderNotFoundException(str(order_id))
label_url = None
carrier = order.shipping_carrier
# Generate label URL based on carrier
if order.shipment_number and carrier:
# Get carrier label URL prefix from settings
setting_key = f"carrier_{carrier}_label_url"
prefix = admin_settings_service.get_setting_value(db, setting_key)
if prefix:
label_url = prefix + order.shipment_number
return {
"shipment_number": order.shipment_number,
"shipping_carrier": carrier,
"label_url": label_url,
"tracking_number": order.tracking_number,
"tracking_url": order.tracking_url,
}
# Create service instance # Create service instance
order_service = OrderService() order_service = OrderService()

View File

@@ -59,17 +59,21 @@
Order Information Order Information
</h4> </h4>
<div class="space-y-3 text-sm"> <div class="space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Order Number</span>
<span class="font-medium text-gray-700 dark:text-gray-300" x-text="order?.external_order_number || order?.order_number"></span>
</div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Order Date</span> <span class="text-gray-500 dark:text-gray-400">Order Date</span>
<span class="font-medium text-gray-700 dark:text-gray-300" x-text="formatDate(order?.order_date || order?.created_at)"></span> <span class="font-medium text-gray-700 dark:text-gray-300" x-text="formatDate(order?.order_date || order?.created_at)"></span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Shipment ID</span> <span class="text-gray-500 dark:text-gray-400">Order Number</span>
<span class="font-mono text-xs text-gray-600 dark:text-gray-400" x-text="order?.external_shipment_id"></span> <span class="font-medium text-gray-700 dark:text-gray-300" x-text="order?.external_order_number || order?.order_number"></span>
</div>
<div class="flex justify-between" x-show="order?.shipment_number">
<span class="text-gray-500 dark:text-gray-400">Shipment Number</span>
<span class="font-mono font-medium text-gray-700 dark:text-gray-300" x-text="order?.shipment_number"></span>
</div>
<div class="flex justify-between" x-show="order?.external_shipment_id">
<span class="text-gray-500 dark:text-gray-400">Hash ID</span>
<span class="font-mono text-xs text-gray-600 dark:text-gray-400" x-text="order?.external_shipment_id?.split('/').pop()"></span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Total</span> <span class="text-gray-500 dark:text-gray-400">Total</span>
@@ -128,28 +132,47 @@
</div> </div>
</div> </div>
<!-- Tracking Information --> <!-- Shipping & Tracking Information -->
<div class="min-w-0 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800"> <div class="min-w-0 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h4 class="mb-4 font-semibold text-gray-600 dark:text-gray-300 flex items-center gap-2"> <h4 class="mb-4 font-semibold text-gray-600 dark:text-gray-300 flex items-center gap-2">
<span x-html="$icon('truck', 'w-5 h-5')"></span> <span x-html="$icon('truck', 'w-5 h-5')"></span>
Tracking Information Shipping & Tracking
</h4> </h4>
<div x-show="order?.tracking_number" class="space-y-3 text-sm"> <div x-show="order?.shipment_number || order?.shipping_carrier || order?.tracking_number || order?.tracking_url" class="space-y-3 text-sm">
<div class="flex justify-between"> <div class="flex justify-between" x-show="order?.shipping_carrier">
<span class="text-gray-500 dark:text-gray-400">Carrier</span> <span class="text-gray-500 dark:text-gray-400">Carrier</span>
<span class="font-medium text-gray-700 dark:text-gray-300" x-text="order?.tracking_provider"></span> <span class="font-medium text-gray-700 dark:text-gray-300 capitalize" x-text="order?.shipping_carrier"></span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between" x-show="order?.shipment_number">
<span class="text-gray-500 dark:text-gray-400">Shipment Number</span>
<span class="font-mono text-gray-700 dark:text-gray-300" x-text="order?.shipment_number"></span>
</div>
<div class="flex justify-between" x-show="order?.tracking_number">
<span class="text-gray-500 dark:text-gray-400">Tracking Number</span> <span class="text-gray-500 dark:text-gray-400">Tracking Number</span>
<span class="font-mono text-gray-700 dark:text-gray-300" x-text="order?.tracking_number"></span> <span class="font-mono text-gray-700 dark:text-gray-300" x-text="order?.tracking_number"></span>
</div> </div>
<div class="flex justify-between" x-show="order?.tracking_provider">
<span class="text-gray-500 dark:text-gray-400">Tracking Provider</span>
<span class="text-gray-700 dark:text-gray-300" x-text="order?.tracking_provider"></span>
</div>
<div class="flex justify-between" x-show="order?.shipped_at"> <div class="flex justify-between" x-show="order?.shipped_at">
<span class="text-gray-500 dark:text-gray-400">Shipped At</span> <span class="text-gray-500 dark:text-gray-400">Shipped At</span>
<span class="text-gray-700 dark:text-gray-300" x-text="formatDate(order?.shipped_at)"></span> <span class="text-gray-700 dark:text-gray-300" x-text="formatDate(order?.shipped_at)"></span>
</div> </div>
<!-- Tracking Link -->
<div x-show="order?.tracking_url || (order?.shipping_carrier === 'greco' && order?.shipment_number)" class="pt-2 border-t dark:border-gray-700">
<a
:href="order?.tracking_url || ('https://dispatchweb.fr/Tracky/Home/' + order?.shipment_number)"
target="_blank"
class="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-purple-600 bg-purple-50 dark:bg-purple-900/30 dark:text-purple-400 rounded-lg hover:bg-purple-100 dark:hover:bg-purple-900/50 transition-colors"
>
<span x-html="$icon('external-link', 'w-4 h-4')"></span>
View Tracking / Download Label
</a>
</div>
</div> </div>
<div x-show="!order?.tracking_number" class="text-sm text-gray-500 dark:text-gray-400 italic"> <div x-show="!order?.shipment_number && !order?.shipping_carrier && !order?.tracking_number && !order?.tracking_url" class="text-sm text-gray-500 dark:text-gray-400 italic">
No tracking information available No shipping information available yet
</div> </div>
</div> </div>
</div> </div>

View File

@@ -78,73 +78,86 @@
<!-- Error Message --> <!-- Error Message -->
{{ error_state('Error', show_condition='error && !loading') }} {{ error_state('Error', show_condition='error && !loading') }}
<!-- Vendor Required Warning --> <!-- Cross-vendor info banner (shown when no vendor selected) -->
<div x-show="!selectedVendor && !loading" class="mb-8 p-6 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800"> <div x-show="!selectedVendor && !loading" class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div class="flex items-center"> <div class="flex items-center">
<span x-html="$icon('exclamation', 'w-6 h-6 text-yellow-500 mr-3')"></span> <span x-html="$icon('information-circle', 'w-6 h-6 text-blue-500 mr-3')"></span>
<div> <div>
<h3 class="font-medium text-yellow-800 dark:text-yellow-200">Select a Vendor</h3> <h3 class="font-medium text-blue-800 dark:text-blue-200">All Vendors View</h3>
<p class="text-sm text-yellow-700 dark:text-yellow-300">Please select a vendor from the dropdown above to manage their Letzshop integration.</p> <p class="text-sm text-blue-700 dark:text-blue-300">Showing data across all vendors. Select a vendor above to manage products, import orders, or access settings.</p>
</div> </div>
</div> </div>
</div> </div>
<!-- Main Content (shown when vendor selected) --> <!-- Main Content -->
<div x-show="selectedVendor" x-transition x-cloak> <div x-show="!loading" x-transition x-cloak>
<!-- Vendor Info Bar --> <!-- Selected Vendor Filter (same pattern as orders page) -->
<div class="mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs flex items-center justify-between"> <div x-show="selectedVendor" x-transition class="mb-6 p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
<div class="flex items-center gap-3"> <div class="flex items-center justify-between">
<div class="w-10 h-10 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center"> <div class="flex items-center gap-3">
<span class="text-lg font-semibold text-purple-600 dark:text-purple-300" x-text="selectedVendor?.name?.charAt(0).toUpperCase()"></span> <div class="w-8 h-8 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300" x-text="selectedVendor?.name?.charAt(0).toUpperCase()"></span>
</div>
<div class="flex items-center gap-3">
<div>
<span class="font-medium text-purple-800 dark:text-purple-200" x-text="selectedVendor?.name"></span>
<span class="ml-2 text-xs text-purple-600 dark:text-purple-400 font-mono" x-text="selectedVendor?.vendor_code"></span>
</div>
<!-- Status badges -->
<span class="px-2 py-0.5 text-xs font-medium rounded-full"
:class="letzshopStatus.is_configured ? 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
x-text="letzshopStatus.is_configured ? 'Configured' : 'Not Configured'">
</span>
<span x-show="letzshopStatus.auto_sync_enabled" class="px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300">
Auto-sync
</span>
</div>
</div> </div>
<div> <button @click="clearVendorSelection()" class="text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 text-sm flex items-center gap-1">
<h3 class="font-semibold text-gray-800 dark:text-gray-200" x-text="selectedVendor?.name"></h3> <span x-html="$icon('x', 'w-4 h-4')"></span>
<p class="text-sm text-gray-500" x-text="selectedVendor?.vendor_code"></p> Clear filter
</div> </button>
</div>
<div class="flex items-center gap-2">
<!-- Status Badge -->
<span class="px-3 py-1 text-sm font-medium rounded-full"
:class="letzshopStatus.is_configured ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'"
x-text="letzshopStatus.is_configured ? 'Configured' : 'Not Configured'">
</span>
<!-- Auto-sync indicator -->
<span x-show="letzshopStatus.auto_sync_enabled" class="px-3 py-1 text-sm font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300">
Auto-sync
</span>
</div> </div>
</div> </div>
<!-- Tabs --> <!-- Tabs -->
{% call tabs_nav(tab_var='activeTab') %} {% call tabs_nav(tab_var='activeTab') %}
{{ tab_button('products', 'Products', tab_var='activeTab', icon='cube') }} <template x-if="selectedVendor">
<span>{{ tab_button('products', 'Products', tab_var='activeTab', icon='cube') }}</span>
</template>
{{ tab_button('orders', 'Orders', tab_var='activeTab', icon='shopping-cart', count_var='orderStats.pending') }} {{ tab_button('orders', 'Orders', tab_var='activeTab', icon='shopping-cart', count_var='orderStats.pending') }}
{{ tab_button('exceptions', 'Exceptions', tab_var='activeTab', icon='exclamation-circle', count_var='exceptionStats.pending') }} {{ tab_button('exceptions', 'Exceptions', tab_var='activeTab', icon='exclamation-circle', count_var='exceptionStats.pending') }}
{{ tab_button('settings', 'Settings', tab_var='activeTab', icon='cog') }} <template x-if="selectedVendor">
<span>{{ tab_button('settings', 'Settings', tab_var='activeTab', icon='cog') }}</span>
</template>
{% endcall %} {% endcall %}
<!-- Products Tab (Import + Export) --> <!-- Products Tab (Import + Export) - Vendor only -->
{{ tab_panel('products', tab_var='activeTab') }} <template x-if="selectedVendor">
{% include 'admin/partials/letzshop-products-tab.html' %} {{ tab_panel('products', tab_var='activeTab') }}
{{ endtab_panel() }} {% include 'admin/partials/letzshop-products-tab.html' %}
{{ endtab_panel() }}
</template>
<!-- Orders Tab --> <!-- Orders Tab -->
{{ tab_panel('orders', tab_var='activeTab') }} {{ tab_panel('orders', tab_var='activeTab') }}
{% include 'admin/partials/letzshop-orders-tab.html' %} {% include 'admin/partials/letzshop-orders-tab.html' %}
{{ endtab_panel() }} {{ endtab_panel() }}
<!-- Settings Tab --> <!-- Settings Tab - Vendor only -->
{{ tab_panel('settings', tab_var='activeTab') }} <template x-if="selectedVendor">
{% include 'admin/partials/letzshop-settings-tab.html' %} {{ tab_panel('settings', tab_var='activeTab') }}
{{ endtab_panel() }} {% include 'admin/partials/letzshop-settings-tab.html' %}
{{ endtab_panel() }}
</template>
<!-- Exceptions Tab --> <!-- Exceptions Tab -->
{{ tab_panel('exceptions', tab_var='activeTab') }} {{ tab_panel('exceptions', tab_var='activeTab') }}
{% include 'admin/partials/letzshop-exceptions-tab.html' %} {% include 'admin/partials/letzshop-exceptions-tab.html' %}
{{ endtab_panel() }} {{ endtab_panel() }}
<!-- Unified Jobs Table (below all tabs) --> <!-- Unified Jobs Table (below all tabs) - Vendor only -->
<div class="mt-8"> <div x-show="selectedVendor" class="mt-8">
{% include 'admin/partials/letzshop-jobs-table.html' %} {% include 'admin/partials/letzshop-jobs-table.html' %}
</div> </div>
</div> </div>
@@ -361,7 +374,7 @@
(<span x-text="selectedOrder?.items?.length"></span> item<span x-show="selectedOrder?.items?.length > 1">s</span>) (<span x-text="selectedOrder?.items?.length"></span> item<span x-show="selectedOrder?.items?.length > 1">s</span>)
</span> </span>
</h4> </h4>
<div class="space-y-2"> <div class="space-y-2 max-h-64 overflow-y-auto">
<template x-for="(item, index) in selectedOrder?.items || []" :key="item.id"> <template x-for="(item, index) in selectedOrder?.items || []" :key="item.id">
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-3"> <div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
<div class="flex justify-between items-start"> <div class="flex justify-between items-start">

View File

@@ -59,7 +59,7 @@
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<!-- Vendor Autocomplete (Tom Select) --> <!-- Vendor Autocomplete (Tom Select) -->
<div class="w-80"> <div class="w-80">
<select id="vendor-select" x-ref="vendorSelect" placeholder="All vendors..."> <select id="vendor-select" x-ref="vendorSelect" placeholder="Search vendor...">
</select> </select>
</div> </div>
{{ refresh_button(loading_var='loading', onclick='refresh()', variant='secondary') }} {{ refresh_button(loading_var='loading', onclick='refresh()', variant='secondary') }}
@@ -401,20 +401,23 @@
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Customer</p> <p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Customer</p>
<p class="text-sm font-medium text-gray-800 dark:text-gray-200" x-text="selectedOrderDetail?.customer?.first_name + ' ' + selectedOrderDetail?.customer?.last_name"></p> <p class="text-sm font-medium text-gray-800 dark:text-gray-200" x-text="(selectedOrderDetail?.customer_first_name || '') + ' ' + (selectedOrderDetail?.customer_last_name || '')"></p>
<p class="text-xs text-gray-500" x-text="selectedOrderDetail?.customer?.email"></p> <p class="text-xs text-gray-500" x-text="selectedOrderDetail?.customer_email"></p>
</div> </div>
<div> <div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Vendor</p> <p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Vendor</p>
<p class="text-sm font-medium text-gray-800 dark:text-gray-200" x-text="selectedOrderDetail?.vendor?.name"></p> <p class="text-sm font-medium text-gray-800 dark:text-gray-200" x-text="selectedOrderDetail?.vendor_name || 'Unknown'"></p>
<p class="text-xs text-gray-500 font-mono" x-text="selectedOrderDetail?.vendor?.vendor_code"></p> <p class="text-xs text-gray-500 font-mono" x-text="selectedOrderDetail?.vendor_code || ''"></p>
</div> </div>
</div> </div>
<!-- Items --> <!-- Items -->
<div> <div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-2">Items</p> <div class="flex items-center justify-between mb-2">
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg overflow-hidden"> <p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Items</p>
<span class="text-xs text-gray-400" x-text="(selectedOrderDetail?.items?.length || 0) + ' item(s)'"></span>
</div>
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg overflow-hidden max-h-48 overflow-y-auto">
<template x-for="item in selectedOrderDetail?.items || []" :key="item.id"> <template x-for="item in selectedOrderDetail?.items || []" :key="item.id">
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-200 dark:border-gray-600 last:border-0"> <div class="flex items-center justify-between px-4 py-2 border-b border-gray-200 dark:border-gray-600 last:border-0">
<div> <div>
@@ -457,21 +460,65 @@
</div> </div>
<!-- Shipping Address --> <!-- Shipping Address -->
<div x-show="selectedOrderDetail?.shipping_address"> <div x-show="selectedOrderDetail?.ship_address_line_1">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-2">Shipping Address</p> <p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-2">Shipping Address</p>
<div class="text-sm text-gray-700 dark:text-gray-300"> <div class="text-sm text-gray-700 dark:text-gray-300">
<p x-text="selectedOrderDetail?.shipping_address?.first_name + ' ' + selectedOrderDetail?.shipping_address?.last_name"></p> <p x-text="(selectedOrderDetail?.ship_first_name || '') + ' ' + (selectedOrderDetail?.ship_last_name || '')"></p>
<p x-text="selectedOrderDetail?.shipping_address?.address_line_1"></p> <p x-text="selectedOrderDetail?.ship_address_line_1"></p>
<p x-show="selectedOrderDetail?.shipping_address?.address_line_2" x-text="selectedOrderDetail?.shipping_address?.address_line_2"></p> <p x-show="selectedOrderDetail?.ship_address_line_2" x-text="selectedOrderDetail?.ship_address_line_2"></p>
<p x-text="selectedOrderDetail?.shipping_address?.postal_code + ' ' + selectedOrderDetail?.shipping_address?.city"></p> <p x-text="(selectedOrderDetail?.ship_postal_code || '') + ' ' + (selectedOrderDetail?.ship_city || '')"></p>
<p x-text="selectedOrderDetail?.shipping_address?.country"></p> <p x-text="selectedOrderDetail?.ship_country_iso"></p>
</div> </div>
</div> </div>
<!-- Tracking Info --> <!-- Shipping & Tracking Info -->
<div x-show="selectedOrderDetail?.tracking_number"> <div x-show="selectedOrderDetail?.shipment_number || selectedOrderDetail?.tracking_number || selectedOrderDetail?.shipping_carrier">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Tracking Number</p> <p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-2">Shipping & Tracking</p>
<p class="text-sm font-mono text-gray-800 dark:text-gray-200" x-text="selectedOrderDetail?.tracking_number"></p> <div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-3 space-y-2">
<!-- Shipment Number -->
<div x-show="selectedOrderDetail?.shipment_number" class="flex items-center justify-between">
<div>
<span class="text-xs text-gray-500 dark:text-gray-400">Shipment #:</span>
<span class="ml-2 text-sm font-mono text-gray-800 dark:text-gray-200" x-text="selectedOrderDetail?.shipment_number"></span>
</div>
<!-- Download Label Button -->
<button
x-show="selectedOrderDetail?.shipping_carrier"
@click="downloadShippingLabel(selectedOrderDetail)"
class="flex items-center gap-1 px-2 py-1 text-xs font-medium text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 bg-purple-50 dark:bg-purple-900/30 rounded"
title="Download shipping label"
>
<span x-html="$icon('download', 'w-3 h-3')"></span>
Label
</button>
</div>
<!-- Carrier -->
<div x-show="selectedOrderDetail?.shipping_carrier" class="flex items-center">
<span class="text-xs text-gray-500 dark:text-gray-400">Carrier:</span>
<span class="ml-2 px-2 py-0.5 text-xs font-medium rounded capitalize"
:class="{
'bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200': selectedOrderDetail?.shipping_carrier === 'greco',
'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200': selectedOrderDetail?.shipping_carrier === 'colissimo',
'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200': selectedOrderDetail?.shipping_carrier === 'xpresslogistics',
'bg-gray-100 dark:bg-gray-600 text-gray-800 dark:text-gray-200': !['greco', 'colissimo', 'xpresslogistics'].includes(selectedOrderDetail?.shipping_carrier)
}"
x-text="selectedOrderDetail?.shipping_carrier"></span>
</div>
<!-- Tracking Number (if different from shipment) -->
<div x-show="selectedOrderDetail?.tracking_number" class="flex items-center">
<span class="text-xs text-gray-500 dark:text-gray-400">Tracking #:</span>
<span class="ml-2 text-sm font-mono text-gray-800 dark:text-gray-200" x-text="selectedOrderDetail?.tracking_number"></span>
</div>
<!-- Tracking URL -->
<div x-show="selectedOrderDetail?.tracking_url" class="flex items-center">
<span class="text-xs text-gray-500 dark:text-gray-400">Track:</span>
<a :href="selectedOrderDetail?.tracking_url" target="_blank"
class="ml-2 text-xs text-purple-600 dark:text-purple-400 hover:underline flex items-center gap-1">
View tracking
<span x-html="$icon('external-link', 'w-3 h-3')"></span>
</a>
</div>
</div>
</div> </div>
<!-- Notes --> <!-- Notes -->
@@ -480,18 +527,113 @@
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap" x-text="selectedOrderDetail?.internal_notes"></p> <p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap" x-text="selectedOrderDetail?.internal_notes"></p>
</div> </div>
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700"> <div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<button <div>
@click="showDetailModal = false" <!-- Mark as Shipped button (only for processing orders) -->
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors" <button
x-show="selectedOrderDetail?.status === 'processing'"
@click="showDetailModal = false; openMarkAsShippedModal(selectedOrderDetail)"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
>
<span x-html="$icon('truck', 'w-4 h-4')"></span>
Mark as Shipped
</button>
</div>
<div class="flex items-center gap-3">
<button
@click="showDetailModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
Close
</button>
<button
@click="showDetailModal = false; openStatusModal(selectedOrderDetail)"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors"
>
Update Status
</button>
</div>
</div>
</div>
{% endcall %}
<!-- Mark as Shipped Modal -->
{% call modal_simple('markAsShippedModal', 'Mark Order as Shipped', show_var='showMarkAsShippedModal', size='sm') %}
<div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">
Mark order <span class="font-mono font-medium" x-text="selectedOrder?.order_number"></span> as shipped.
</p>
<!-- Shipment Info (read-only if from Letzshop) -->
<div x-show="selectedOrder?.shipment_number" class="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Letzshop Shipment</p>
<p class="text-sm font-mono text-gray-800 dark:text-gray-200" x-text="selectedOrder?.shipment_number"></p>
<p x-show="selectedOrder?.shipping_carrier" class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Carrier: <span class="capitalize" x-text="selectedOrder?.shipping_carrier"></span>
</p>
</div>
<!-- Tracking Number Input -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Tracking Number <span class="text-gray-400">(optional)</span>
</label>
<input
type="text"
x-model="shipForm.tracking_number"
placeholder="e.g., 3XYVi85dDE8l6bov97122"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-600"
/>
</div>
<!-- Tracking URL Input -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Tracking URL <span class="text-gray-400">(optional)</span>
</label>
<input
type="url"
x-model="shipForm.tracking_url"
placeholder="https://dispatchweb.fr/Tracky/Home/..."
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-600"
/>
</div>
<!-- Carrier Select (if not already set) -->
<div x-show="!selectedOrder?.shipping_carrier">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Carrier <span class="text-gray-400">(optional)</span>
</label>
<select
x-model="shipForm.shipping_carrier"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-600"
> >
Close <option value="">Select carrier...</option>
<option value="greco">Greco</option>
<option value="colissimo">Colissimo</option>
<option value="xpresslogistics">XpressLogistics</option>
<option value="dhl">DHL</option>
<option value="ups">UPS</option>
<option value="fedex">FedEx</option>
<option value="other">Other</option>
</select>
</div>
<div class="flex justify-end gap-3 pt-4">
<button
@click="showMarkAsShippedModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
>
Cancel
</button> </button>
<button <button
@click="showDetailModal = false; openStatusModal(selectedOrderDetail)" @click="markAsShipped()"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors" :disabled="markingAsShipped"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
> >
Update Status <span x-show="!markingAsShipped" x-html="$icon('truck', 'w-4 h-4')"></span>
<span x-show="markingAsShipped" class="animate-spin" x-html="$icon('refresh', 'w-4 h-4')"></span>
<span x-text="markingAsShipped ? 'Shipping...' : 'Mark as Shipped'"></span>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -5,7 +5,7 @@
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<div> <div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Product Exceptions</h3> <h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Product Exceptions</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Resolve unmatched products from order imports</p> <p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedVendor ? 'Resolve unmatched products from order imports' : 'All exceptions across vendors'"></p>
</div> </div>
<button <button
@click="loadExceptions()" @click="loadExceptions()"
@@ -106,6 +106,7 @@
<thead> <thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800"> <tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Product Info</th> <th class="px-4 py-3">Product Info</th>
<th x-show="!selectedVendor" class="px-4 py-3">Vendor</th>
<th class="px-4 py-3">GTIN</th> <th class="px-4 py-3">GTIN</th>
<th class="px-4 py-3">Order</th> <th class="px-4 py-3">Order</th>
<th class="px-4 py-3">Status</th> <th class="px-4 py-3">Status</th>
@@ -116,7 +117,7 @@
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800"> <tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="loadingExceptions && exceptions.length === 0"> <template x-if="loadingExceptions && exceptions.length === 0">
<tr> <tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400"> <td :colspan="selectedVendor ? 6 : 7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('spinner', 'w-6 h-6 mx-auto mb-2')"></span> <span x-html="$icon('spinner', 'w-6 h-6 mx-auto mb-2')"></span>
<p>Loading exceptions...</p> <p>Loading exceptions...</p>
</td> </td>
@@ -124,7 +125,7 @@
</template> </template>
<template x-if="!loadingExceptions && exceptions.length === 0"> <template x-if="!loadingExceptions && exceptions.length === 0">
<tr> <tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400"> <td :colspan="selectedVendor ? 6 : 7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('check-circle', 'w-12 h-12 mx-auto mb-2 text-green-300')"></span> <span x-html="$icon('check-circle', 'w-12 h-12 mx-auto mb-2 text-green-300')"></span>
<p class="font-medium">No exceptions found</p> <p class="font-medium">No exceptions found</p>
<p class="text-sm mt-1">All order items are properly matched to products</p> <p class="text-sm mt-1">All order items are properly matched to products</p>
@@ -141,6 +142,10 @@
</div> </div>
</div> </div>
</td> </td>
<!-- Vendor column (only in cross-vendor view) -->
<td x-show="!selectedVendor" class="px-4 py-3 text-sm">
<p class="font-medium text-gray-700 dark:text-gray-200" x-text="exc.vendor_name || 'N/A'"></p>
</td>
<td class="px-4 py-3 text-sm"> <td class="px-4 py-3 text-sm">
<code class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded" x-text="exc.original_gtin || 'No GTIN'"></code> <code class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded" x-text="exc.original_gtin || 'No GTIN'"></code>
</td> </td>

View File

@@ -5,9 +5,10 @@
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<div> <div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Orders</h3> <h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Orders</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Manage Letzshop orders for this vendor</p> <p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedVendor ? 'Manage Letzshop orders for this vendor' : 'All Letzshop orders across vendors'"></p>
</div> </div>
<div class="flex gap-2"> <!-- Import buttons only shown when vendor is selected -->
<div x-show="selectedVendor" class="flex gap-2">
<button <button
@click="importHistoricalOrders()" @click="importHistoricalOrders()"
:disabled="!letzshopStatus.is_configured || importingHistorical" :disabled="!letzshopStatus.is_configured || importingHistorical"
@@ -78,9 +79,9 @@
</div> </div>
<!-- Status Cards --> <!-- Status Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-5"> <div class="grid gap-6 mb-8" :class="selectedVendor ? 'md:grid-cols-5' : 'md:grid-cols-4'">
<!-- Connection Status --> <!-- Connection Status (only when vendor selected) -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800"> <div x-show="selectedVendor" class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div :class="letzshopStatus.is_configured ? 'bg-green-100 dark:bg-green-900' : 'bg-gray-100 dark:bg-gray-700'" class="p-3 mr-4 rounded-full"> <div :class="letzshopStatus.is_configured ? 'bg-green-100 dark:bg-green-900' : 'bg-gray-100 dark:bg-gray-700'" class="p-3 mr-4 rounded-full">
<span x-html="$icon(letzshopStatus.is_configured ? 'check' : 'x', letzshopStatus.is_configured ? 'w-5 h-5 text-green-500' : 'w-5 h-5 text-gray-400')"></span> <span x-html="$icon(letzshopStatus.is_configured ? 'check' : 'x', letzshopStatus.is_configured ? 'w-5 h-5 text-green-500' : 'w-5 h-5 text-gray-400')"></span>
</div> </div>
@@ -182,8 +183,8 @@
</button> </button>
</div> </div>
<!-- Not Configured Warning --> <!-- Not Configured Warning (only when vendor selected) -->
<div x-show="!letzshopStatus.is_configured" class="mb-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800"> <div x-show="selectedVendor && !letzshopStatus.is_configured" class="mb-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
<div class="flex items-center"> <div class="flex items-center">
<span x-html="$icon('exclamation', 'w-5 h-5 text-yellow-500 mr-3')"></span> <span x-html="$icon('exclamation', 'w-5 h-5 text-yellow-500 mr-3')"></span>
<div> <div>
@@ -194,12 +195,13 @@
</div> </div>
<!-- Orders Table --> <!-- Orders Table -->
<div class="w-full overflow-hidden rounded-lg shadow-xs" x-show="letzshopStatus.is_configured"> <div class="w-full overflow-hidden rounded-lg shadow-xs">
<div class="w-full overflow-x-auto"> <div class="w-full overflow-x-auto">
<table class="w-full whitespace-no-wrap"> <table class="w-full whitespace-no-wrap">
<thead> <thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800"> <tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Order</th> <th class="px-4 py-3">Order</th>
<th x-show="!selectedVendor" class="px-4 py-3">Vendor</th>
<th class="px-4 py-3">Customer</th> <th class="px-4 py-3">Customer</th>
<th class="px-4 py-3">Total</th> <th class="px-4 py-3">Total</th>
<th class="px-4 py-3">Status</th> <th class="px-4 py-3">Status</th>
@@ -210,7 +212,7 @@
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800"> <tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="loadingOrders && orders.length === 0"> <template x-if="loadingOrders && orders.length === 0">
<tr> <tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400"> <td :colspan="selectedVendor ? 6 : 7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('spinner', 'w-6 h-6 mx-auto mb-2')"></span> <span x-html="$icon('spinner', 'w-6 h-6 mx-auto mb-2')"></span>
<p>Loading orders...</p> <p>Loading orders...</p>
</td> </td>
@@ -218,10 +220,10 @@
</template> </template>
<template x-if="!loadingOrders && orders.length === 0"> <template x-if="!loadingOrders && orders.length === 0">
<tr> <tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400"> <td :colspan="selectedVendor ? 6 : 7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('inbox', 'w-12 h-12 mx-auto mb-2 text-gray-300')"></span> <span x-html="$icon('inbox', 'w-12 h-12 mx-auto mb-2 text-gray-300')"></span>
<p class="font-medium">No orders found</p> <p class="font-medium">No orders found</p>
<p class="text-sm mt-1">Click "Import Orders" to fetch orders from Letzshop</p> <p class="text-sm mt-1" x-text="selectedVendor ? 'Click \"Import Orders\" to fetch orders from Letzshop' : 'Select a vendor to import orders'"></p>
</td> </td>
</tr> </tr>
</template> </template>
@@ -235,6 +237,10 @@
</div> </div>
</div> </div>
</td> </td>
<!-- Vendor column (only in cross-vendor view) -->
<td x-show="!selectedVendor" class="px-4 py-3 text-sm">
<p class="font-medium text-gray-700 dark:text-gray-200" x-text="order.vendor_name || 'N/A'"></p>
</td>
<td class="px-4 py-3 text-sm"> <td class="px-4 py-3 text-sm">
<p x-text="order.customer_email || 'N/A'"></p> <p x-text="order.customer_email || 'N/A'"></p>
</td> </td>

View File

@@ -35,6 +35,32 @@
</p> </p>
</div> </div>
<!-- Test Mode -->
<div class="mb-4">
<label class="flex items-center cursor-pointer">
<input
type="checkbox"
x-model="settingsForm.test_mode_enabled"
class="form-checkbox h-5 w-5 text-orange-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:ring-orange-500"
/>
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-400">Test Mode</span>
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-8">
When enabled, operations (confirm, reject, tracking) will NOT be sent to Letzshop API
</p>
</div>
<!-- Test Mode Warning -->
<div x-show="settingsForm.test_mode_enabled" class="mb-4 p-3 bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg">
<div class="flex items-center">
<span x-html="$icon('exclamation', 'w-5 h-5 text-orange-500 mr-2')"></span>
<span class="text-sm text-orange-700 dark:text-orange-300 font-medium">Test Mode Active</span>
</div>
<p class="mt-1 text-xs text-orange-600 dark:text-orange-400 ml-7">
All Letzshop API mutations are disabled. Orders can be imported but confirmations/rejections will only be saved locally.
</p>
</div>
<!-- Auto Sync --> <!-- Auto Sync -->
<div class="mb-4"> <div class="mb-4">
<label class="flex items-center cursor-pointer"> <label class="flex items-center cursor-pointer">
@@ -209,4 +235,106 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Carrier Settings Card -->
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 lg:col-span-2">
<div class="p-6">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Carrier Settings
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
Configure default carrier and label URL prefixes for shipping labels.
</p>
<form @submit.prevent="saveCarrierSettings()">
<div class="grid gap-6 lg:grid-cols-2">
<!-- Default Carrier -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Default Carrier
</label>
<select
x-model="settingsForm.default_carrier"
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
>
<option value="">-- Select carrier --</option>
<option value="greco">Greco</option>
<option value="colissimo">Colissimo</option>
<option value="xpresslogistics">XpressLogistics</option>
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Letzshop automatically assigns carriers based on shipment data
</p>
</div>
<!-- Placeholder for alignment -->
<div></div>
<!-- Greco Label URL -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
<span class="inline-flex items-center">
<span class="w-3 h-3 rounded-full bg-blue-500 mr-2"></span>
Greco Label URL Prefix
</span>
</label>
<input
x-model="settingsForm.carrier_greco_label_url"
type="url"
placeholder="https://dispatchweb.fr/Tracky/Home/"
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Label URL = Prefix + Shipment Number (e.g., H74683403433)
</p>
</div>
<!-- Colissimo Label URL -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
<span class="inline-flex items-center">
<span class="w-3 h-3 rounded-full bg-yellow-500 mr-2"></span>
Colissimo Label URL Prefix
</span>
</label>
<input
x-model="settingsForm.carrier_colissimo_label_url"
type="url"
placeholder="https://..."
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
/>
</div>
<!-- XpressLogistics Label URL -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
<span class="inline-flex items-center">
<span class="w-3 h-3 rounded-full bg-green-500 mr-2"></span>
XpressLogistics Label URL Prefix
</span>
</label>
<input
x-model="settingsForm.carrier_xpresslogistics_label_url"
type="url"
placeholder="https://..."
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
/>
</div>
</div>
<!-- Save Button -->
<div class="mt-6">
<button
type="submit"
:disabled="savingCarrierSettings"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
>
<span x-show="!savingCarrierSettings" x-html="$icon('save', 'w-4 h-4 mr-2')"></span>
<span x-show="savingCarrierSettings" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="savingCarrierSettings ? 'Saving...' : 'Save Carrier Settings'"></span>
</button>
</div>
</form>
</div>
</div>
</div> </div>

View File

@@ -19,6 +19,7 @@
<!-- Settings Categories Tabs --> <!-- Settings Categories Tabs -->
{% call tabs_nav() %} {% call tabs_nav() %}
{{ tab_button('logging', 'Logging', icon='document-text') }} {{ tab_button('logging', 'Logging', icon='document-text') }}
{{ tab_button('shipping', 'Shipping', icon='truck') }}
{{ tab_button('system', 'System', icon='cog') }} {{ tab_button('system', 'System', icon='cog') }}
{{ tab_button('security', 'Security', icon='shield-check') }} {{ tab_button('security', 'Security', icon='shield-check') }}
{{ tab_button('notifications', 'Notifications', icon='bell') }} {{ tab_button('notifications', 'Notifications', icon='bell') }}
@@ -156,6 +157,102 @@
</div> </div>
</div> </div>
<!-- Shipping Settings Tab -->
<div x-show="activeTab === 'shipping'" x-transition>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">
Shipping & Carrier Configuration
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
Configure shipping carrier label URL prefixes. These are used to generate shipping label download links.
</p>
<!-- Carrier Label URL Settings -->
<div class="space-y-6">
<!-- Greco (Letzshop default) -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<span class="flex items-center gap-2">
<span class="px-2 py-0.5 text-xs font-medium bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 rounded">Greco</span>
Label URL Prefix
</span>
</label>
<input
type="url"
x-model="shippingSettings.carrier_greco_label_url"
placeholder="https://dispatchweb.fr/Tracky/Home/"
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
/>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
The shipment number will be appended to this URL. Default for Letzshop: <code class="bg-gray-100 dark:bg-gray-700 px-1 rounded">https://dispatchweb.fr/Tracky/Home/</code>
</p>
</div>
<!-- Colissimo -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<span class="flex items-center gap-2">
<span class="px-2 py-0.5 text-xs font-medium bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 rounded">Colissimo</span>
Label URL Prefix
</span>
</label>
<input
type="url"
x-model="shippingSettings.carrier_colissimo_label_url"
placeholder="https://www.laposte.fr/outils/suivre-vos-envois?code="
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
/>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Enter the tracking URL prefix for Colissimo shipments.
</p>
</div>
<!-- XpressLogistics -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<span class="flex items-center gap-2">
<span class="px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded">XpressLogistics</span>
Label URL Prefix
</span>
</label>
<input
type="url"
x-model="shippingSettings.carrier_xpresslogistics_label_url"
placeholder="https://tracking.xpresslogistics.com/"
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
/>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Enter the tracking URL prefix for XpressLogistics shipments.
</p>
</div>
</div>
<!-- Info Box -->
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div class="flex items-start">
<span x-html="$icon('information-circle', 'w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3 flex-shrink-0')"></span>
<div class="text-sm text-blue-800 dark:text-blue-200">
<p class="font-medium mb-1">How label URLs work</p>
<p>When viewing an order, the system will combine the URL prefix with the shipment number to create a downloadable label link.</p>
<p class="mt-1">Example: <code class="bg-blue-100 dark:bg-blue-800 px-1 rounded">https://dispatchweb.fr/Tracky/Home/</code> + <code class="bg-blue-100 dark:bg-blue-800 px-1 rounded">H74683403433</code></p>
</div>
</div>
</div>
<!-- Save Button -->
<div class="flex items-center justify-end pt-6 mt-6 border-t border-gray-200 dark:border-gray-700">
<button
@click="saveShippingSettings()"
:disabled="saving"
class="px-6 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!saving">Save Shipping Settings</span>
<span x-show="saving">Saving...</span>
</button>
</div>
</div>
</div>
<!-- System Settings Tab --> <!-- System Settings Tab -->
<div x-show="activeTab === 'system'" x-transition> <div x-show="activeTab === 'system'" x-transition>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6"> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">

View File

@@ -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",
]

View File

@@ -18,6 +18,7 @@ import requests
from sqlalchemy import literal from sqlalchemy import literal
from sqlalchemy.orm import Session 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_import_job import MarketplaceImportError
from models.database.marketplace_product import MarketplaceProduct from models.database.marketplace_product import MarketplaceProduct
from models.database.marketplace_product_translation import ( from models.database.marketplace_product_translation import (
@@ -154,8 +155,18 @@ class CSVProcessor:
logger.info(f"Normalized columns: {list(df.columns)}") logger.info(f"Normalized columns: {list(df.columns)}")
return df return df
def _parse_price_to_numeric(self, price_str: str | None) -> float | None: def _parse_price_to_cents(self, price_str: str | None) -> int | None:
"""Parse price string like '19.99 EUR' to float.""" """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: if not price_str:
return None return None
@@ -164,8 +175,9 @@ class CSVProcessor:
if numbers: if numbers:
num_str = numbers[0].replace(",", ".") num_str = numbers[0].replace(",", ".")
try: try:
return float(num_str) # Convert euros to cents using money utility
except ValueError: return euros_to_cents(num_str)
except (ValueError, TypeError):
pass pass
return None return None
@@ -185,10 +197,10 @@ class CSVProcessor:
parsed_price, currency = self.price_processor.parse_price_currency( parsed_price, currency = self.price_processor.parse_price_currency(
processed_data["price"] 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"] raw_price = processed_data["price"]
processed_data["price"] = parsed_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 processed_data["currency"] = currency
# Process sale_price # Process sale_price
@@ -198,7 +210,7 @@ class CSVProcessor:
processed_data["sale_price"] processed_data["sale_price"]
) )
processed_data["sale_price"] = parsed_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 raw_sale_price
) )

364
app/utils/money.py Normal file
View File

@@ -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",
]

View File

@@ -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
<!-- Display price from cents -->
<span x-text="Money.format(product.price_cents)"></span>
<!-- Or using computed euro value from API -->
<span x-text="formatPrice(product.price, product.currency)"></span>
```
## 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).

View File

@@ -8,7 +8,9 @@ Complete guide for bidirectional order management with Letzshop marketplace via
- [Architecture](#architecture) - [Architecture](#architecture)
- [Setup and Configuration](#setup-and-configuration) - [Setup and Configuration](#setup-and-configuration)
- [Order Import](#order-import) - [Order Import](#order-import)
- [Product Exceptions](#product-exceptions)
- [Fulfillment Operations](#fulfillment-operations) - [Fulfillment Operations](#fulfillment-operations)
- [Shipping and Tracking](#shipping-and-tracking)
- [API Reference](#api-reference) - [API Reference](#api-reference)
- [Database Models](#database-models) - [Database Models](#database-models)
- [Troubleshooting](#troubleshooting) - [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 ## Fulfillment Operations
### Confirm Order ### 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 ## API Reference
### Vendor Endpoints ### Vendor Endpoints
@@ -283,6 +508,33 @@ Base path: `/api/v1/admin/letzshop`
| GET | `/vendors/{id}/orders` | List vendor's Letzshop orders | | GET | `/vendors/{id}/orders` | List vendor's Letzshop orders |
| POST | `/vendors/{id}/sync` | Trigger sync for vendor | | 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 ### Response Schemas
#### Credentials Response #### Credentials Response
@@ -502,6 +754,34 @@ curl -X GET /api/v1/vendor/letzshop/logs \
-H "Authorization: Bearer $TOKEN" -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 ## Best Practices
@@ -525,6 +805,7 @@ curl -X GET /api/v1/vendor/letzshop/logs \
## Related Documentation ## Related Documentation
- [Order Item Exception System](../implementation/order-item-exceptions.md)
- [Marketplace Integration (CSV Import)](marketplace-integration.md) - [Marketplace Integration (CSV Import)](marketplace-integration.md)
- [Vendor RBAC](../backend/vendor-rbac.md) - [Vendor RBAC](../backend/vendor-rbac.md)
- [Admin Integration Guide](../backend/admin-integration-guide.md) - [Admin Integration Guide](../backend/admin-integration-guide.md)
@@ -534,6 +815,22 @@ curl -X GET /api/v1/vendor/letzshop/logs \
## Version History ## 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 - **v1.0** (2025-12-13): Initial Letzshop order integration
- GraphQL client for order import - GraphQL client for order import
- Encrypted credential storage - Encrypted credential storage

View File

@@ -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 ## Error Handling
| Exception | HTTP Status | When | | Exception | HTTP Status | When |

View File

@@ -32,6 +32,7 @@ nav:
- Marketplace Integration: architecture/marketplace-integration.md - Marketplace Integration: architecture/marketplace-integration.md
- Architecture Patterns: architecture/architecture-patterns.md - Architecture Patterns: architecture/architecture-patterns.md
- Language & i18n: architecture/language-i18n.md - Language & i18n: architecture/language-i18n.md
- Money Handling: architecture/money-handling.md
- Company-Vendor Management: architecture/company-vendor-management.md - Company-Vendor Management: architecture/company-vendor-management.md
- Multi-Tenant System: architecture/multi-tenant.md - Multi-Tenant System: architecture/multi-tenant.md
- Middleware Stack: architecture/middleware.md - Middleware Stack: architecture/middleware.md

View File

@@ -1,9 +1,12 @@
# models/database/cart.py # 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 ( from sqlalchemy import (
Column, Column,
Float,
ForeignKey, ForeignKey,
Index, Index,
Integer, Integer,
@@ -13,6 +16,7 @@ from sqlalchemy import (
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.core.database import Base from app.core.database import Base
from app.utils.money import cents_to_euros, euros_to_cents
from models.database.base import TimestampMixin from models.database.base import TimestampMixin
@@ -22,6 +26,8 @@ class CartItem(Base, TimestampMixin):
Stores cart items per session, vendor, and product. Stores cart items per session, vendor, and product.
Sessions are identified by a session_id string (from browser cookies). Sessions are identified by a session_id string (from browser cookies).
Price is stored as integer cents for precision.
""" """
__tablename__ = "cart_items" __tablename__ = "cart_items"
@@ -33,7 +39,7 @@ class CartItem(Base, TimestampMixin):
# Cart details # Cart details
quantity = Column(Integer, nullable=False, default=1) 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 # Relationships
vendor = relationship("Vendor") vendor = relationship("Vendor")
@@ -49,7 +55,24 @@ class CartItem(Base, TimestampMixin):
def __repr__(self): def __repr__(self):
return f"<CartItem(id={self.id}, session='{self.session_id}', product_id={self.product_id}, qty={self.quantity})>" return f"<CartItem(id={self.id}, session='{self.session_id}', product_id={self.product_id}, qty={self.quantity})>"
# === 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 @property
def line_total(self) -> float: def line_total(self) -> float:
"""Calculate line total.""" """Calculate line total in euros."""
return self.price_at_add * self.quantity return cents_to_euros(self.line_total_cents)

View File

@@ -52,6 +52,19 @@ class VendorLetzshopCredentials(Base, TimestampMixin):
auto_sync_enabled = Column(Boolean, default=False) auto_sync_enabled = Column(Boolean, default=False)
sync_interval_minutes = Column(Integer, default=15) 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 status
last_sync_at = Column(DateTime(timezone=True), nullable=True) last_sync_at = Column(DateTime(timezone=True), nullable=True)
last_sync_status = Column(String(50), nullable=True) # success, failed, partial last_sync_status = Column(String(50), nullable=True) # success, failed, partial

View File

@@ -6,6 +6,10 @@ Amazon, eBay, CodesWholesale, etc.) in a universal format. It supports:
- Multi-language translations (via MarketplaceProductTranslation) - Multi-language translations (via MarketplaceProductTranslation)
- Flexible attributes for marketplace-specific data - Flexible attributes for marketplace-specific data
- Google Shopping fields for Letzshop compatibility - 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 from enum import Enum
@@ -13,7 +17,6 @@ from enum import Enum
from sqlalchemy import ( from sqlalchemy import (
Boolean, Boolean,
Column, Column,
Float,
Index, Index,
Integer, Integer,
String, String,
@@ -22,6 +25,7 @@ from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.core.database import Base from app.core.database import Base
from app.utils.money import cents_to_euros, euros_to_cents
from models.database.base import TimestampMixin from models.database.base import TimestampMixin
@@ -49,6 +53,9 @@ class MarketplaceProduct(Base, TimestampMixin):
This table stores normalized product information from all marketplace sources. This table stores normalized product information from all marketplace sources.
Localized content (title, description) is stored in MarketplaceProductTranslation. 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" __tablename__ = "marketplace_products"
@@ -86,11 +93,11 @@ class MarketplaceProduct(Base, TimestampMixin):
category_path = Column(String) # Normalized category hierarchy category_path = Column(String) # Normalized category hierarchy
condition = Column(String) condition = Column(String)
# === PRICING === # === PRICING (stored as integer cents) ===
price = Column(String) # Raw price string "19.99 EUR" (kept for reference) 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 = 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") currency = Column(String(3), default="EUR")
# === MEDIA === # === MEDIA ===
@@ -102,8 +109,8 @@ class MarketplaceProduct(Base, TimestampMixin):
attributes = Column(JSON) # {color, size, material, etc.} attributes = Column(JSON) # {color, size, material, etc.}
# === PHYSICAL PRODUCT FIELDS === # === PHYSICAL PRODUCT FIELDS ===
weight = Column(Float) # In kg weight_grams = Column(Integer) # Weight in grams (1.5kg = 1500)
weight_unit = Column(String(10), default="kg") weight_unit = Column(String(10), default="kg") # Display unit
dimensions = Column(JSON) # {length, width, height, unit} dimensions = Column(JSON) # {length, width, height, unit}
# === GOOGLE SHOPPING FIELDS (Preserved for Letzshop) === # === GOOGLE SHOPPING FIELDS (Preserved for Letzshop) ===
@@ -159,6 +166,44 @@ class MarketplaceProduct(Base, TimestampMixin):
f"vendor='{self.vendor_name}')>" 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 === # === HELPER PROPERTIES ===
@property @property
@@ -228,12 +273,12 @@ class MarketplaceProduct(Base, TimestampMixin):
@property @property
def effective_price(self) -> float | None: def effective_price(self) -> float | None:
"""Get the effective numeric price.""" """Get the effective numeric price in euros."""
return self.price_numeric return self.price_numeric
@property @property
def effective_sale_price(self) -> float | None: 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 return self.sale_price_numeric
@property @property

View File

@@ -11,13 +11,15 @@ Design principles:
- customer_id FK links to Customer record (may be inactive for marketplace imports) - customer_id FK links to Customer record (may be inactive for marketplace imports)
- channel field distinguishes order source - channel field distinguishes order source
- external_* fields store marketplace-specific references - 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 ( from sqlalchemy import (
Boolean, Boolean,
Column, Column,
DateTime, DateTime,
Float,
ForeignKey, ForeignKey,
Index, Index,
Integer, Integer,
@@ -32,6 +34,7 @@ from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.core.database import Base from app.core.database import Base
from app.utils.money import cents_to_euros, euros_to_cents
from models.database.base import TimestampMixin from models.database.base import TimestampMixin
@@ -41,6 +44,8 @@ class Order(Base, TimestampMixin):
Stores orders from direct sales and marketplaces (Letzshop, etc.) Stores orders from direct sales and marketplaces (Letzshop, etc.)
with snapshotted customer and address data. with snapshotted customer and address data.
All monetary amounts are stored as integer cents for precision.
""" """
__tablename__ = "orders" __tablename__ = "orders"
@@ -76,12 +81,12 @@ class Order(Base, TimestampMixin):
# refunded: order refunded # refunded: order refunded
status = Column(String(50), nullable=False, default="pending", index=True) status = Column(String(50), nullable=False, default="pending", index=True)
# === Financials === # === Financials (stored as integer cents) ===
subtotal = Column(Float, nullable=True) # May not be available from marketplace subtotal_cents = Column(Integer, nullable=True) # May not be available from marketplace
tax_amount = Column(Float, nullable=True) tax_amount_cents = Column(Integer, nullable=True)
shipping_amount = Column(Float, nullable=True) shipping_amount_cents = Column(Integer, nullable=True)
discount_amount = Column(Float, nullable=True) discount_amount_cents = Column(Integer, nullable=True)
total_amount = Column(Float, nullable=False) total_amount_cents = Column(Integer, nullable=False)
currency = Column(String(10), default="EUR") currency = Column(String(10), default="EUR")
# === Customer Snapshot (preserved at order time) === # === Customer Snapshot (preserved at order time) ===
@@ -115,6 +120,9 @@ class Order(Base, TimestampMixin):
shipping_method = Column(String(100), nullable=True) shipping_method = Column(String(100), nullable=True)
tracking_number = Column(String(100), nullable=True) tracking_number = Column(String(100), nullable=True)
tracking_provider = 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 === # === Notes ===
customer_notes = Column(Text, nullable=True) customer_notes = Column(Text, nullable=True)
@@ -146,6 +154,68 @@ class Order(Base, TimestampMixin):
def __repr__(self): def __repr__(self):
return f"<Order(id={self.id}, order_number='{self.order_number}', channel='{self.channel}', status='{self.status}')>" return f"<Order(id={self.id}, order_number='{self.order_number}', channel='{self.channel}', status='{self.status}')>"
# === 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 @property
def customer_full_name(self) -> str: def customer_full_name(self) -> str:
"""Customer full name from snapshot.""" """Customer full name from snapshot."""
@@ -173,6 +243,8 @@ class OrderItem(Base, TimestampMixin):
Stores product snapshot at time of order plus external references Stores product snapshot at time of order plus external references
for marketplace items. for marketplace items.
All monetary amounts are stored as integer cents for precision.
""" """
__tablename__ = "order_items" __tablename__ = "order_items"
@@ -187,10 +259,10 @@ class OrderItem(Base, TimestampMixin):
gtin = Column(String(50), nullable=True) # EAN/UPC/ISBN etc. gtin = Column(String(50), nullable=True) # EAN/UPC/ISBN etc.
gtin_type = Column(String(20), nullable=True) # ean13, 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) quantity = Column(Integer, nullable=False)
unit_price = Column(Float, nullable=False) unit_price_cents = Column(Integer, nullable=False)
total_price = Column(Float, nullable=False) total_price_cents = Column(Integer, nullable=False)
# === External References (for marketplace items) === # === External References (for marketplace items) ===
external_item_id = Column(String(100), nullable=True) # e.g., Letzshop inventory unit ID 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): def __repr__(self):
return f"<OrderItem(id={self.id}, order_id={self.order_id}, product_id={self.product_id}, gtin='{self.gtin}')>" return f"<OrderItem(id={self.id}, order_id={self.order_id}, product_id={self.product_id}, gtin='{self.gtin}')>"
# === 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 @property
def is_confirmed(self) -> bool: def is_confirmed(self) -> bool:
"""Check if item has been confirmed (available or unavailable).""" """Check if item has been confirmed (available or unavailable)."""

View File

@@ -7,12 +7,14 @@ to override any field. The override pattern works as follows:
This allows vendors to customize pricing, images, descriptions etc. while This allows vendors to customize pricing, images, descriptions etc. while
still being able to "reset to source" by setting values back to NULL. 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 ( from sqlalchemy import (
Boolean, Boolean,
Column, Column,
Float,
ForeignKey, ForeignKey,
Index, Index,
Integer, Integer,
@@ -23,6 +25,7 @@ from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.core.database import Base from app.core.database import Base
from app.utils.money import cents_to_euros, euros_to_cents
from models.database.base import TimestampMixin 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 Each vendor can have their own version of a marketplace product with
custom pricing, images, and other overrides. Fields set to NULL custom pricing, images, and other overrides. Fields set to NULL
inherit their value from the linked marketplace_product. inherit their value from the linked marketplace_product.
Price fields use integer cents for precision (€19.99 = 1999 cents).
""" """
__tablename__ = "products" __tablename__ = "products"
@@ -52,9 +57,9 @@ class Product(Base, TimestampMixin):
gtin_type = Column(String(20)) # Format: gtin13, gtin14, gtin12, gtin8, isbn13, isbn10 gtin_type = Column(String(20)) # Format: gtin13, gtin14, gtin12, gtin8, isbn13, isbn10
# === OVERRIDABLE FIELDS (NULL = inherit from marketplace_product) === # === OVERRIDABLE FIELDS (NULL = inherit from marketplace_product) ===
# Pricing # Pricing - stored as integer cents (€19.99 = 1999)
price = Column(Float) price_cents = Column(Integer) # Price in cents
sale_price = Column(Float) sale_price_cents = Column(Integer) # Sale price in cents
currency = Column(String(3)) currency = Column(String(3))
# Product Info # Product Info
@@ -73,8 +78,8 @@ class Product(Base, TimestampMixin):
# === SUPPLIER TRACKING === # === SUPPLIER TRACKING ===
supplier = Column(String(50)) # 'codeswholesale', 'internal', etc. supplier = Column(String(50)) # 'codeswholesale', 'internal', etc.
supplier_product_id = Column(String) # Supplier's product reference supplier_product_id = Column(String) # Supplier's product reference
supplier_cost = Column(Float) # What we pay the supplier supplier_cost_cents = Column(Integer) # What we pay the supplier (in cents)
margin_percent = Column(Float) # Markup percentage margin_percent_x100 = Column(Integer) # Markup percentage * 100 (e.g., 25.5% = 2550)
# === VENDOR-SPECIFIC (No inheritance) === # === VENDOR-SPECIFIC (No inheritance) ===
is_featured = Column(Boolean, default=False) is_featured = Column(Boolean, default=False)
@@ -115,8 +120,8 @@ class Product(Base, TimestampMixin):
# === OVERRIDABLE FIELDS LIST === # === OVERRIDABLE FIELDS LIST ===
OVERRIDABLE_FIELDS = [ OVERRIDABLE_FIELDS = [
"price", "price_cents",
"sale_price", "sale_price_cents",
"currency", "currency",
"brand", "brand",
"condition", "condition",
@@ -133,23 +138,85 @@ class Product(Base, TimestampMixin):
f"vendor_sku='{self.vendor_sku}')>" 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) === # === EFFECTIVE PROPERTIES (Override Pattern) ===
@property @property
def effective_price(self) -> float | None: def effective_price_cents(self) -> int | None:
"""Get price (vendor override or marketplace fallback).""" """Get price in cents (vendor override or marketplace fallback)."""
if self.price is not None: if self.price_cents is not None:
return self.price return self.price_cents
mp = self.marketplace_product 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 @property
def effective_sale_price(self) -> float | None: def effective_sale_price(self) -> float | None:
"""Get sale price (vendor override or marketplace fallback).""" """Get sale price in euros (vendor override or marketplace fallback)."""
if self.sale_price is not None: cents = self.effective_sale_price_cents
return self.sale_price return cents_to_euros(cents) if cents is not None else None
mp = self.marketplace_product
return mp.sale_price_numeric if mp else None
@property @property
def effective_currency(self) -> str: def effective_currency(self) -> str:
@@ -260,12 +327,14 @@ class Product(Base, TimestampMixin):
return { return {
# Price # Price
"price": self.effective_price, "price": self.effective_price,
"price_overridden": self.price is not None, "price_cents": self.effective_price_cents,
"price_source": mp.price_numeric if mp else None, "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
"sale_price": self.effective_sale_price, "sale_price": self.effective_sale_price,
"sale_price_overridden": self.sale_price is not None, "sale_price_cents": self.effective_sale_price_cents,
"sale_price_source": mp.sale_price_numeric if mp else None, "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
"currency": self.effective_currency, "currency": self.effective_currency,
"currency_overridden": self.currency is not None, "currency_overridden": self.currency is not None,

View File

@@ -31,6 +31,21 @@ class LetzshopCredentialsCreate(BaseModel):
sync_interval_minutes: int = Field( sync_interval_minutes: int = Field(
15, ge=5, le=1440, description="Sync interval in minutes (5-1440)" 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): class LetzshopCredentialsUpdate(BaseModel):
@@ -40,6 +55,11 @@ class LetzshopCredentialsUpdate(BaseModel):
api_endpoint: str | None = None api_endpoint: str | None = None
auto_sync_enabled: bool | None = None auto_sync_enabled: bool | None = None
sync_interval_minutes: int | None = Field(None, ge=5, le=1440) 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): class LetzshopCredentialsResponse(BaseModel):
@@ -53,6 +73,11 @@ class LetzshopCredentialsResponse(BaseModel):
api_endpoint: str api_endpoint: str
auto_sync_enabled: bool auto_sync_enabled: bool
sync_interval_minutes: int 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_at: datetime | None
last_sync_status: str | None last_sync_status: str | None
last_sync_error: str | None last_sync_error: str | None
@@ -101,6 +126,7 @@ class LetzshopOrderResponse(BaseModel):
id: int id: int
vendor_id: int vendor_id: int
vendor_name: str | None = None # For cross-vendor views
order_number: str order_number: str
# External references # External references

View File

@@ -270,6 +270,9 @@ class OrderResponse(BaseModel):
shipping_method: str | None shipping_method: str | None
tracking_number: str | None tracking_number: str | None
tracking_provider: str | None tracking_provider: str | None
tracking_url: str | None = None
shipment_number: str | None = None
shipping_carrier: str | None = None
# Notes # Notes
customer_notes: str | None customer_notes: str | None
@@ -302,6 +305,10 @@ class OrderDetailResponse(OrderResponse):
items: list[OrderItemResponse] = [] items: list[OrderItemResponse] = []
# Vendor info (enriched by API)
vendor_name: str | None = None
vendor_code: str | None = None
class OrderListResponse(BaseModel): class OrderListResponse(BaseModel):
"""Schema for paginated order list.""" """Schema for paginated order list."""
@@ -345,6 +352,9 @@ class OrderListItem(BaseModel):
# Tracking # Tracking
tracking_number: str | None tracking_number: str | None
tracking_provider: 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
item_count: int = 0 item_count: int = 0
@@ -394,6 +404,9 @@ class AdminOrderItem(BaseModel):
ship_country_iso: str ship_country_iso: str
tracking_number: str | None tracking_number: str | None
tracking_provider: 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
item_count: int = 0 item_count: int = 0
@@ -534,3 +547,26 @@ class LetzshopOrderConfirmRequest(BaseModel):
"""Schema for confirming/declining order items.""" """Schema for confirming/declining order items."""
items: list[LetzshopOrderConfirmItem] 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

View File

@@ -23,6 +23,7 @@ class OrderItemExceptionResponse(BaseModel):
id: int id: int
order_item_id: int order_item_id: int
vendor_id: int vendor_id: int
vendor_name: str | None = None # For cross-vendor views
# Original data from marketplace # Original data from marketplace
original_gtin: str | None original_gtin: str | None

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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)

View File

@@ -1878,6 +1878,7 @@ class ArchitectureValidator:
Transaction control belongs at the API endpoint level. Transaction control belongs at the API endpoint level.
Exception: log_service.py may need immediate commits for audit logs. 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") rule = self._get_rule("SVC-006")
if not rule: if not rule:
@@ -1887,6 +1888,10 @@ class ArchitectureValidator:
if "log_service.py" in str(file_path): if "log_service.py" in str(file_path):
return return
# Check for file-level noqa comment
if "noqa: svc-006" in content.lower():
return
for i, line in enumerate(lines, 1): for i, line in enumerate(lines, 1):
if "db.commit()" in line: if "db.commit()" in line:
# Skip if it's a comment # Skip if it's a comment
@@ -1894,6 +1899,10 @@ class ArchitectureValidator:
if stripped.startswith("#"): if stripped.startswith("#"):
continue continue
# Skip if line has inline noqa comment
if "noqa: svc-006" in line.lower():
continue
self._add_violation( self._add_violation(
rule_id="SVC-006", rule_id="SVC-006",
rule_name=rule["name"], rule_name=rule["name"],
@@ -1902,7 +1911,7 @@ class ArchitectureValidator:
line_number=i, line_number=i,
message="Service calls db.commit() - transaction control should be at endpoint level", message="Service calls db.commit() - transaction control should be at endpoint level",
context=stripped, 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): def _validate_models(self, target_path: Path):

View File

@@ -79,10 +79,16 @@ function adminMarketplaceLetzshop() {
api_key: '', api_key: '',
auto_sync_enabled: false, auto_sync_enabled: false,
sync_interval_minutes: 15, sync_interval_minutes: 15,
test_mode_enabled: false,
letzshop_csv_url_fr: '', letzshop_csv_url_fr: '',
letzshop_csv_url_en: '', 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
orders: [], orders: [],
@@ -137,9 +143,75 @@ function adminMarketplaceLetzshop() {
this.initTomSelect(); 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'); 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 * Initialize Tom Select for vendor autocomplete
*/ */
@@ -217,6 +289,9 @@ function adminMarketplaceLetzshop() {
const vendor = await apiClient.get(`/admin/vendors/${vendorId}`); const vendor = await apiClient.get(`/admin/vendors/${vendorId}`);
this.selectedVendor = vendor; this.selectedVendor = vendor;
// Save to localStorage for persistence
localStorage.setItem('letzshop_selected_vendor_id', vendorId.toString());
// Pre-fill settings form with CSV URLs // Pre-fill settings form with CSV URLs
this.settingsForm.letzshop_csv_url_fr = vendor.letzshop_csv_url_fr || ''; this.settingsForm.letzshop_csv_url_fr = vendor.letzshop_csv_url_fr || '';
this.settingsForm.letzshop_csv_url_en = vendor.letzshop_csv_url_en || ''; this.settingsForm.letzshop_csv_url_en = vendor.letzshop_csv_url_en || '';
@@ -245,27 +320,39 @@ function adminMarketplaceLetzshop() {
/** /**
* Clear vendor selection * Clear vendor selection
*/ */
clearVendorSelection() { async clearVendorSelection() {
// Clear TomSelect dropdown
if (this.tomSelectInstance) {
this.tomSelectInstance.clear();
}
this.selectedVendor = null; this.selectedVendor = null;
this.letzshopStatus = { is_configured: false }; this.letzshopStatus = { is_configured: false };
this.credentials = null; this.credentials = null;
this.orders = [];
this.ordersFilter = ''; this.ordersFilter = '';
this.ordersSearch = ''; this.ordersSearch = '';
this.ordersHasDeclinedItems = false; this.ordersHasDeclinedItems = false;
this.exceptions = [];
this.exceptionsFilter = ''; this.exceptionsFilter = '';
this.exceptionsSearch = ''; this.exceptionsSearch = '';
this.exceptionStats = { pending: 0, resolved: 0, ignored: 0, total: 0, orders_with_exceptions: 0 };
this.jobs = [];
this.settingsForm = { this.settingsForm = {
api_key: '', api_key: '',
auto_sync_enabled: false, auto_sync_enabled: false,
sync_interval_minutes: 15, sync_interval_minutes: 15,
test_mode_enabled: false,
letzshop_csv_url_fr: '', letzshop_csv_url_fr: '',
letzshop_csv_url_en: '', 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.auto_sync_enabled = response.auto_sync_enabled;
this.settingsForm.sync_interval_minutes = response.sync_interval_minutes || 15; 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) { } catch (error) {
if (error.status === 404) { if (error.status === 404) {
// Not configured // 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() { async loadOrders() {
if (!this.selectedVendor || !this.letzshopStatus.is_configured) {
this.orders = [];
this.totalOrders = 0;
return;
}
this.loadingOrders = true; this.loadingOrders = true;
this.error = ''; this.error = '';
@@ -433,7 +519,13 @@ function adminMarketplaceLetzshop() {
params.append('search', this.ordersSearch); 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.orders = response.orders || [];
this.totalOrders = response.total || 0; this.totalOrders = response.total || 0;
@@ -845,7 +937,8 @@ function adminMarketplaceLetzshop() {
try { try {
const payload = { const payload = {
auto_sync_enabled: this.settingsForm.auto_sync_enabled, 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) // 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 // EXCEPTIONS
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
/** /**
* Load exceptions for selected vendor * Load exceptions for selected vendor (or all vendors if none selected)
*/ */
async loadExceptions() { async loadExceptions() {
if (!this.selectedVendor) {
this.exceptions = [];
this.totalExceptions = 0;
return;
}
this.loadingExceptions = true; this.loadingExceptions = true;
try { try {
@@ -980,7 +1094,12 @@ function adminMarketplaceLetzshop() {
params.append('search', this.exceptionsSearch); 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.exceptions = response.exceptions || [];
this.totalExceptions = response.total || 0; this.totalExceptions = response.total || 0;
} catch (error) { } 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() { async loadExceptionStats() {
if (!this.selectedVendor) {
this.exceptionStats = { pending: 0, resolved: 0, ignored: 0, total: 0, orders_with_exceptions: 0 };
return;
}
try { 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; this.exceptionStats = response;
} catch (error) { } catch (error) {
marketplaceLetzshopLog.error('Failed to load exception stats:', 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 * Load jobs for selected vendor
* Note: Jobs are vendor-specific, so we need a vendor selected to show them
*/ */
async loadJobs() { async loadJobs() {
// Jobs require a vendor to be selected (they are vendor-specific)
if (!this.selectedVendor) { if (!this.selectedVendor) {
this.jobs = []; this.jobs = [];
this.jobsPagination.total = 0;
return; return;
} }

View File

@@ -76,6 +76,15 @@ function adminOrders() {
reason: '' reason: ''
}, },
// Mark as shipped modal
showMarkAsShippedModal: false,
markingAsShipped: false,
shipForm: {
tracking_number: '',
tracking_url: '',
shipping_carrier: ''
},
// Debounce timer // Debounce timer
searchTimeout: null, searchTimeout: null,
@@ -137,16 +146,64 @@ function adminOrders() {
// Initialize Tom Select for vendor filter // Initialize Tom Select for vendor filter
this.initVendorSelect(); this.initVendorSelect();
// Load data in parallel // Check localStorage for saved vendor
await Promise.all([ const savedVendorId = localStorage.getItem('orders_selected_vendor_id');
this.loadStats(), if (savedVendorId) {
this.loadVendors(), adminOrdersLog.info('Restoring saved vendor:', savedVendorId);
this.loadOrders() // 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'); 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 * Initialize Tom Select for vendor autocomplete
*/ */
@@ -168,7 +225,7 @@ function adminOrders() {
valueField: 'id', valueField: 'id',
labelField: 'name', labelField: 'name',
searchField: ['name', 'vendor_code'], searchField: ['name', 'vendor_code'],
placeholder: 'All vendors...', placeholder: 'Search vendor by name or code...',
allowEmptyOption: true, allowEmptyOption: true,
load: async (query, callback) => { load: async (query, callback) => {
try { try {
@@ -198,9 +255,13 @@ function adminOrders() {
const vendor = this.vendorSelectInstance.options[value]; const vendor = this.vendorSelectInstance.options[value];
this.selectedVendor = vendor; this.selectedVendor = vendor;
this.filters.vendor_id = value; this.filters.vendor_id = value;
// Save to localStorage
localStorage.setItem('orders_selected_vendor_id', value.toString());
} else { } else {
this.selectedVendor = null; this.selectedVendor = null;
this.filters.vendor_id = ''; this.filters.vendor_id = '';
// Clear from localStorage
localStorage.removeItem('orders_selected_vendor_id');
} }
this.pagination.page = 1; this.pagination.page = 1;
this.loadOrders(); this.loadOrders();
@@ -219,6 +280,8 @@ function adminOrders() {
} }
this.selectedVendor = null; this.selectedVendor = null;
this.filters.vendor_id = ''; this.filters.vendor_id = '';
// Clear from localStorage
localStorage.removeItem('orders_selected_vendor_id');
this.pagination.page = 1; this.pagination.page = 1;
this.loadOrders(); 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 * Get CSS class for status badge
*/ */

View File

@@ -30,11 +30,19 @@ function adminSettings() {
in_app_enabled: true, in_app_enabled: true,
critical_only: false critical_only: false
}, },
shippingSettings: {
carrier_greco_label_url: 'https://dispatchweb.fr/Tracky/Home/',
carrier_colissimo_label_url: '',
carrier_xpresslogistics_label_url: ''
},
async init() { async init() {
try { try {
settingsLog.info('=== SETTINGS PAGE INITIALIZING ==='); settingsLog.info('=== SETTINGS PAGE INITIALIZING ===');
await this.loadLogSettings(); await Promise.all([
this.loadLogSettings(),
this.loadShippingSettings()
]);
} catch (error) { } catch (error) {
settingsLog.error('Init failed:', error); settingsLog.error('Init failed:', error);
this.error = 'Failed to initialize settings page'; this.error = 'Failed to initialize settings page';
@@ -44,7 +52,10 @@ function adminSettings() {
async refresh() { async refresh() {
this.error = null; this.error = null;
this.successMessage = null; this.successMessage = null;
await this.loadLogSettings(); await Promise.all([
this.loadLogSettings(),
this.loadShippingSettings()
]);
}, },
async loadLogSettings() { async loadLogSettings() {
@@ -136,6 +147,75 @@ function adminSettings() {
} finally { } finally {
this.saving = false; 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;
} }
}; };
} }

193
static/shared/js/money.js Normal file
View File

@@ -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;
}

View File

@@ -53,19 +53,60 @@ def test_customer_address(db, test_vendor, test_customer):
@pytest.fixture @pytest.fixture
def test_order(db, test_vendor, test_customer, test_customer_address): 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( order = Order(
vendor_id=test_vendor.id, vendor_id=test_vendor.id,
customer_id=test_customer.id, customer_id=test_customer.id,
order_number="TEST-ORD-001", order_number="TEST-ORD-001",
status="pending", status="pending",
channel="direct",
subtotal=99.99, subtotal=99.99,
total_amount=99.99, total_amount=99.99,
currency="EUR", currency="EUR",
shipping_address_id=test_customer_address.id, order_date=datetime.now(timezone.utc),
billing_address_id=test_customer_address.id, # 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.add(order)
db.commit() db.commit()
db.refresh(order) db.refresh(order)
return 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

View File

@@ -246,16 +246,33 @@ class TestAdminLetzshopOrdersAPI:
def test_list_vendor_orders_with_data(self, client, db, admin_headers, test_vendor): def test_list_vendor_orders_with_data(self, client, db, admin_headers, test_vendor):
"""Test listing vendor orders with data.""" """Test listing vendor orders with data."""
from models.database.letzshop import LetzshopOrder from models.database.order import Order
# Create test orders # Create test order using unified Order model with all required fields
order = LetzshopOrder( order = Order(
vendor_id=test_vendor.id, vendor_id=test_vendor.id,
letzshop_order_id="admin_order_1", customer_id=1,
letzshop_state="unconfirmed", 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", customer_email="admin-test@example.com",
total_amount="150.00", ship_first_name="Admin",
sync_status="pending", 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.add(order)
db.commit() db.commit()

View File

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

View File

@@ -238,50 +238,105 @@ class TestVendorLetzshopOrdersAPI:
self, client, db, vendor_user_headers, test_vendor_with_vendor_user self, client, db, vendor_user_headers, test_vendor_with_vendor_user
): ):
"""Test listing orders with status filter.""" """Test listing orders with status filter."""
from models.database.letzshop import LetzshopOrder from models.database.order import Order
# Create test orders # Create test orders using unified Order model with all required fields
order1 = LetzshopOrder( order1 = Order(
vendor_id=test_vendor_with_vendor_user.id, vendor_id=test_vendor_with_vendor_user.id,
letzshop_order_id="order_1", customer_id=1,
letzshop_state="unconfirmed", order_number=f"LS-{test_vendor_with_vendor_user.id}-order_1",
sync_status="pending", 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, vendor_id=test_vendor_with_vendor_user.id,
letzshop_order_id="order_2", customer_id=1,
letzshop_state="confirmed", order_number=f"LS-{test_vendor_with_vendor_user.id}-order_2",
sync_status="confirmed", 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.add_all([order1, order2])
db.commit() db.commit()
# List pending only # List pending only
response = client.get( response = client.get(
"/api/v1/vendor/letzshop/orders?sync_status=pending", "/api/v1/vendor/letzshop/orders?status=pending",
headers=vendor_user_headers, headers=vendor_user_headers,
) )
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["total"] == 1 assert data["total"] == 1
assert data["orders"][0]["sync_status"] == "pending" assert data["orders"][0]["status"] == "pending"
def test_get_order_detail( def test_get_order_detail(
self, client, db, vendor_user_headers, test_vendor_with_vendor_user self, client, db, vendor_user_headers, test_vendor_with_vendor_user
): ):
"""Test getting order detail.""" """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, vendor_id=test_vendor_with_vendor_user.id,
letzshop_order_id="order_detail_test", customer_id=1,
letzshop_shipment_id="shipment_1", order_number=f"LS-{test_vendor_with_vendor_user.id}-order_detail_test",
letzshop_state="unconfirmed", 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", customer_email="test@example.com",
total_amount="99.99", ship_first_name="Test",
sync_status="pending", ship_last_name="User",
raw_order_data={"test": "data"}, 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.add(order)
db.commit() db.commit()
@@ -293,9 +348,9 @@ class TestVendorLetzshopOrdersAPI:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() 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["customer_email"] == "test@example.com"
assert data["raw_order_data"] == {"test": "data"} assert data["external_data"] == {"test": "data"}
def test_get_order_not_found( def test_get_order_not_found(
self, client, vendor_user_headers, test_vendor_with_vendor_user self, client, vendor_user_headers, test_vendor_with_vendor_user
@@ -393,18 +448,50 @@ class TestVendorLetzshopFulfillmentAPI:
test_vendor_with_vendor_user, test_vendor_with_vendor_user,
): ):
"""Test confirming an order.""" """Test confirming an order."""
from models.database.letzshop import LetzshopOrder from models.database.order import Order, OrderItem
# Create test order # Create test order using unified Order model with all required fields
order = LetzshopOrder( order = Order(
vendor_id=test_vendor_with_vendor_user.id, vendor_id=test_vendor_with_vendor_user.id,
letzshop_order_id="order_confirm", customer_id=1,
letzshop_shipment_id="shipment_1", order_number=f"LS-{test_vendor_with_vendor_user.id}-order_confirm",
letzshop_state="unconfirmed", channel="letzshop",
sync_status="pending", external_order_id="order_confirm",
inventory_units=[{"id": "unit_1", "state": "unconfirmed"}], 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.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() db.commit()
# Save credentials # Save credentials
@@ -447,14 +534,33 @@ class TestVendorLetzshopFulfillmentAPI:
test_vendor_with_vendor_user, test_vendor_with_vendor_user,
): ):
"""Test setting tracking information.""" """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, vendor_id=test_vendor_with_vendor_user.id,
letzshop_order_id="order_tracking", customer_id=1,
letzshop_shipment_id="shipment_track", order_number=f"LS-{test_vendor_with_vendor_user.id}-order_tracking",
letzshop_state="confirmed", channel="letzshop",
sync_status="confirmed", 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.add(order)
db.commit() db.commit()

View File

@@ -1,12 +1,65 @@
# tests/unit/models/database/test_order.py # tests/unit/models/database/test_order.py
"""Unit tests for Order and OrderItem database models.""" """Unit tests for Order and OrderItem database models."""
from datetime import datetime, timezone
import pytest import pytest
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from models.database.order import Order, OrderItem 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.unit
@pytest.mark.database @pytest.mark.database
class TestOrderModel: class TestOrderModel:
@@ -16,60 +69,37 @@ class TestOrderModel:
self, db, test_vendor, test_customer, test_customer_address self, db, test_vendor, test_customer, test_customer_address
): ):
"""Test Order model with customer relationship.""" """Test Order model with customer relationship."""
order = Order( order = create_order_with_snapshots(
vendor_id=test_vendor.id, db, test_vendor, test_customer, test_customer_address,
customer_id=test_customer.id,
order_number="ORD-001", 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.id is not None
assert order.vendor_id == test_vendor.id assert order.vendor_id == test_vendor.id
assert order.customer_id == test_customer.id assert order.customer_id == test_customer.id
assert order.order_number == "ORD-001" assert order.order_number == "ORD-001"
assert order.status == "pending" assert order.status == "pending"
assert float(order.total_amount) == 99.99 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( def test_order_number_uniqueness(
self, db, test_vendor, test_customer, test_customer_address self, db, test_vendor, test_customer, test_customer_address
): ):
"""Test order_number unique constraint.""" """Test order_number unique constraint."""
order1 = Order( create_order_with_snapshots(
vendor_id=test_vendor.id, db, test_vendor, test_customer, test_customer_address,
customer_id=test_customer.id,
order_number="UNIQUE-ORD-001", 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 # Duplicate order number should fail
with pytest.raises(IntegrityError): with pytest.raises(IntegrityError):
order2 = Order( create_order_with_snapshots(
vendor_id=test_vendor.id, db, test_vendor, test_customer, test_customer_address,
customer_id=test_customer.id,
order_number="UNIQUE-ORD-001", 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( def test_order_status_values(
self, db, test_vendor, test_customer, test_customer_address self, db, test_vendor, test_customer, test_customer_address
@@ -77,49 +107,32 @@ class TestOrderModel:
"""Test Order with different status values.""" """Test Order with different status values."""
statuses = [ statuses = [
"pending", "pending",
"confirmed",
"processing", "processing",
"shipped", "shipped",
"delivered", "delivered",
"cancelled", "cancelled",
"refunded",
] ]
for i, status in enumerate(statuses): for i, status in enumerate(statuses):
order = Order( order = create_order_with_snapshots(
vendor_id=test_vendor.id, db, test_vendor, test_customer, test_customer_address,
customer_id=test_customer.id,
order_number=f"STATUS-ORD-{i:03d}", order_number=f"STATUS-ORD-{i:03d}",
status=status, 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 assert order.status == status
def test_order_amounts(self, db, test_vendor, test_customer, test_customer_address): def test_order_amounts(self, db, test_vendor, test_customer, test_customer_address):
"""Test Order amount fields.""" """Test Order amount fields."""
order = Order( order = create_order_with_snapshots(
vendor_id=test_vendor.id, db, test_vendor, test_customer, test_customer_address,
customer_id=test_customer.id,
order_number="AMOUNTS-ORD-001", order_number="AMOUNTS-ORD-001",
status="pending",
subtotal=100.00, subtotal=100.00,
tax_amount=20.00, tax_amount=20.00,
shipping_amount=10.00, shipping_amount=10.00,
discount_amount=5.00, discount_amount=5.00,
total_amount=125.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.subtotal) == 100.00
assert float(order.tax_amount) == 20.00 assert float(order.tax_amount) == 20.00
@@ -131,25 +144,32 @@ class TestOrderModel:
self, db, test_vendor, test_customer, test_customer_address self, db, test_vendor, test_customer, test_customer_address
): ):
"""Test Order relationships.""" """Test Order relationships."""
order = Order( order = create_order_with_snapshots(
vendor_id=test_vendor.id, db, test_vendor, test_customer, test_customer_address,
customer_id=test_customer.id,
order_number="REL-ORD-001", 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.vendor is not None
assert order.customer is not None assert order.customer is not None
assert order.vendor.id == test_vendor.id assert order.vendor.id == test_vendor.id
assert order.customer.id == test_customer.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.unit
@pytest.mark.database @pytest.mark.database
@@ -249,3 +269,29 @@ class TestOrderItemModel:
assert item1.order_id == item2.order_id assert item1.order_id == item2.order_id
assert item1.id != item2.id assert item1.id != item2.id
assert item1.product_id == item2.product_id # Same product, different items assert item1.product_id == item2.product_id # Same product, different items
def test_order_item_needs_product_match(self, db, test_order, test_product):
"""Test OrderItem needs_product_match flag for exceptions."""
order_item = OrderItem(
order_id=test_order.id,
product_id=test_product.id,
product_name="Unmatched Product",
product_sku="UNMATCHED-001",
quantity=1,
unit_price=50.00,
total_price=50.00,
needs_product_match=True,
)
db.add(order_item)
db.commit()
db.refresh(order_item)
assert order_item.needs_product_match is True
# Resolve the match
order_item.needs_product_match = False
db.commit()
db.refresh(order_item)
assert order_item.needs_product_match is False

View File

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

View File

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

View File

@@ -1,11 +1,16 @@
# tests/unit/models/schema/test_order.py # tests/unit/models/schema/test_order.py
"""Unit tests for order Pydantic schemas.""" """Unit tests for order Pydantic schemas."""
from datetime import datetime, timezone
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
from models.schema.order import ( from models.schema.order import (
OrderAddressCreate, AddressSnapshot,
AddressSnapshotResponse,
CustomerSnapshot,
CustomerSnapshotResponse,
OrderCreate, OrderCreate,
OrderItemCreate, OrderItemCreate,
OrderItemResponse, OrderItemResponse,
@@ -61,81 +66,155 @@ class TestOrderItemCreateSchema:
@pytest.mark.unit @pytest.mark.unit
@pytest.mark.schema @pytest.mark.schema
class TestOrderAddressCreateSchema: class TestAddressSnapshotSchema:
"""Test OrderAddressCreate schema validation.""" """Test AddressSnapshot schema validation."""
def test_valid_address(self): def test_valid_address(self):
"""Test valid order address creation.""" """Test valid address creation."""
address = OrderAddressCreate( address = AddressSnapshot(
first_name="John", first_name="John",
last_name="Doe", last_name="Doe",
address_line_1="123 Main St", address_line_1="123 Main St",
city="Luxembourg", city="Luxembourg",
postal_code="L-1234", postal_code="L-1234",
country="Luxembourg", country_iso="LU",
) )
assert address.first_name == "John" assert address.first_name == "John"
assert address.city == "Luxembourg" assert address.city == "Luxembourg"
assert address.country_iso == "LU"
def test_required_fields(self): def test_required_fields(self):
"""Test required fields validation.""" """Test required fields validation."""
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
OrderAddressCreate( AddressSnapshot(
first_name="John", first_name="John",
# missing required fields # missing required fields
) )
def test_optional_company(self): def test_optional_company(self):
"""Test optional company field.""" """Test optional company field."""
address = OrderAddressCreate( address = AddressSnapshot(
first_name="John", first_name="John",
last_name="Doe", last_name="Doe",
company="Tech Corp", company="Tech Corp",
address_line_1="123 Main St", address_line_1="123 Main St",
city="Luxembourg", city="Luxembourg",
postal_code="L-1234", postal_code="L-1234",
country="Luxembourg", country_iso="LU",
) )
assert address.company == "Tech Corp" assert address.company == "Tech Corp"
def test_optional_address_line_2(self): def test_optional_address_line_2(self):
"""Test optional address_line_2 field.""" """Test optional address_line_2 field."""
address = OrderAddressCreate( address = AddressSnapshot(
first_name="John", first_name="John",
last_name="Doe", last_name="Doe",
address_line_1="123 Main St", address_line_1="123 Main St",
address_line_2="Suite 500", address_line_2="Suite 500",
city="Luxembourg", city="Luxembourg",
postal_code="L-1234", postal_code="L-1234",
country="Luxembourg", country_iso="LU",
) )
assert address.address_line_2 == "Suite 500" assert address.address_line_2 == "Suite 500"
def test_first_name_min_length(self): def test_first_name_min_length(self):
"""Test first_name minimum length.""" """Test first_name minimum length."""
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
OrderAddressCreate( AddressSnapshot(
first_name="", first_name="",
last_name="Doe", last_name="Doe",
address_line_1="123 Main St", address_line_1="123 Main St",
city="Luxembourg", city="Luxembourg",
postal_code="L-1234", postal_code="L-1234",
country="Luxembourg", country_iso="LU",
) )
def test_country_min_length(self): def test_country_iso_min_length(self):
"""Test country minimum length (2).""" """Test country_iso minimum length (2)."""
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
OrderAddressCreate( AddressSnapshot(
first_name="John", first_name="John",
last_name="Doe", last_name="Doe",
address_line_1="123 Main St", address_line_1="123 Main St",
city="Luxembourg", city="Luxembourg",
postal_code="L-1234", 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.unit
@pytest.mark.schema @pytest.mark.schema
class TestOrderCreateSchema: class TestOrderCreateSchema:
@@ -148,13 +227,18 @@ class TestOrderCreateSchema:
OrderItemCreate(product_id=1, quantity=2), OrderItemCreate(product_id=1, quantity=2),
OrderItemCreate(product_id=2, quantity=1), 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", first_name="John",
last_name="Doe", last_name="Doe",
address_line_1="123 Main St", address_line_1="123 Main St",
city="Luxembourg", city="Luxembourg",
postal_code="L-1234", postal_code="L-1234",
country="Luxembourg", country_iso="LU",
), ),
) )
assert len(order.items) == 2 assert len(order.items) == 2
@@ -164,13 +248,18 @@ class TestOrderCreateSchema:
"""Test items are required.""" """Test items are required."""
with pytest.raises(ValidationError) as exc_info: with pytest.raises(ValidationError) as exc_info:
OrderCreate( OrderCreate(
shipping_address=OrderAddressCreate( customer=CustomerSnapshot(
first_name="John",
last_name="Doe",
email="john@example.com",
),
shipping_address=AddressSnapshot(
first_name="John", first_name="John",
last_name="Doe", last_name="Doe",
address_line_1="123 Main St", address_line_1="123 Main St",
city="Luxembourg", city="Luxembourg",
postal_code="L-1234", postal_code="L-1234",
country="Luxembourg", country_iso="LU",
), ),
) )
assert "items" in str(exc_info.value).lower() assert "items" in str(exc_info.value).lower()
@@ -180,22 +269,48 @@ class TestOrderCreateSchema:
with pytest.raises(ValidationError) as exc_info: with pytest.raises(ValidationError) as exc_info:
OrderCreate( OrderCreate(
items=[], items=[],
shipping_address=OrderAddressCreate( customer=CustomerSnapshot(
first_name="John",
last_name="Doe",
email="john@example.com",
),
shipping_address=AddressSnapshot(
first_name="John", first_name="John",
last_name="Doe", last_name="Doe",
address_line_1="123 Main St", address_line_1="123 Main St",
city="Luxembourg", city="Luxembourg",
postal_code="L-1234", postal_code="L-1234",
country="Luxembourg", country_iso="LU",
), ),
) )
assert "items" in str(exc_info.value).lower() 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): def test_shipping_address_required(self):
"""Test shipping_address is required.""" """Test shipping_address is required."""
with pytest.raises(ValidationError) as exc_info: with pytest.raises(ValidationError) as exc_info:
OrderCreate( OrderCreate(
items=[OrderItemCreate(product_id=1, quantity=1)], 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() assert "shipping_address" in str(exc_info.value).lower()
@@ -203,21 +318,26 @@ class TestOrderCreateSchema:
"""Test billing_address is optional.""" """Test billing_address is optional."""
order = OrderCreate( order = OrderCreate(
items=[OrderItemCreate(product_id=1, quantity=1)], 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", first_name="John",
last_name="Doe", last_name="Doe",
address_line_1="123 Main St", address_line_1="123 Main St",
city="Luxembourg", city="Luxembourg",
postal_code="L-1234", postal_code="L-1234",
country="Luxembourg", country_iso="LU",
), ),
billing_address=OrderAddressCreate( billing_address=AddressSnapshot(
first_name="Jane", first_name="Jane",
last_name="Doe", last_name="Doe",
address_line_1="456 Other St", address_line_1="456 Other St",
city="Esch", city="Esch",
postal_code="L-4321", postal_code="L-4321",
country="Luxembourg", country_iso="LU",
), ),
) )
assert order.billing_address is not None assert order.billing_address is not None
@@ -227,13 +347,18 @@ class TestOrderCreateSchema:
"""Test optional customer_notes.""" """Test optional customer_notes."""
order = OrderCreate( order = OrderCreate(
items=[OrderItemCreate(product_id=1, quantity=1)], 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", first_name="John",
last_name="Doe", last_name="Doe",
address_line_1="123 Main St", address_line_1="123 Main St",
city="Luxembourg", city="Luxembourg",
postal_code="L-1234", postal_code="L-1234",
country="Luxembourg", country_iso="LU",
), ),
customer_notes="Please leave at door", customer_notes="Please leave at door",
) )
@@ -293,13 +418,13 @@ class TestOrderResponseSchema:
def test_from_dict(self): def test_from_dict(self):
"""Test creating response from dict.""" """Test creating response from dict."""
from datetime import datetime now = datetime.now(timezone.utc)
data = { data = {
"id": 1, "id": 1,
"vendor_id": 1, "vendor_id": 1,
"customer_id": 1, "customer_id": 1,
"order_number": "ORD-001", "order_number": "ORD-001",
"channel": "direct",
"status": "pending", "status": "pending",
"subtotal": 100.00, "subtotal": 100.00,
"tax_amount": 20.00, "tax_amount": 20.00,
@@ -307,21 +432,97 @@ class TestOrderResponseSchema:
"discount_amount": 5.00, "discount_amount": 5.00,
"total_amount": 125.00, "total_amount": 125.00,
"currency": "EUR", "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", "shipping_method": "standard",
"tracking_number": None, "tracking_number": None,
"tracking_provider": None,
# Notes
"customer_notes": None, "customer_notes": None,
"internal_notes": None, "internal_notes": None,
"created_at": datetime.now(), # Timestamps
"updated_at": datetime.now(), "order_date": now,
"paid_at": None, "confirmed_at": None,
"shipped_at": None, "shipped_at": None,
"delivered_at": None, "delivered_at": None,
"cancelled_at": None, "cancelled_at": None,
"created_at": now,
"updated_at": now,
} }
response = OrderResponse(**data) response = OrderResponse(**data)
assert response.id == 1 assert response.id == 1
assert response.order_number == "ORD-001" assert response.order_number == "ORD-001"
assert response.total_amount == 125.00 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 @pytest.mark.unit
@@ -331,26 +532,64 @@ class TestOrderItemResponseSchema:
def test_from_dict(self): def test_from_dict(self):
"""Test creating response from dict.""" """Test creating response from dict."""
from datetime import datetime now = datetime.now(timezone.utc)
data = { data = {
"id": 1, "id": 1,
"order_id": 1, "order_id": 1,
"product_id": 1, "product_id": 1,
"product_name": "Test Product", "product_name": "Test Product",
"product_sku": "SKU-001", "product_sku": "SKU-001",
"gtin": "4006381333931",
"gtin_type": "EAN13",
"quantity": 2, "quantity": 2,
"unit_price": 50.00, "unit_price": 50.00,
"total_price": 100.00, "total_price": 100.00,
"inventory_reserved": True, "inventory_reserved": True,
"inventory_fulfilled": False, "inventory_fulfilled": False,
"created_at": datetime.now(), "needs_product_match": False,
"updated_at": datetime.now(), "created_at": now,
"updated_at": now,
} }
response = OrderItemResponse(**data) response = OrderItemResponse(**data)
assert response.id == 1 assert response.id == 1
assert response.quantity == 2 assert response.quantity == 2
assert response.total_price == 100.00 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 @pytest.mark.unit

View File

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

View File

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

View File

@@ -148,17 +148,17 @@ TEST001,Product 1,Description 1,19.99 EUR,Brand1,Category1"""
assert product_data["price"] == "19.99" assert product_data["price"] == "19.99"
assert product_data["brand"] == "TestBrand" assert product_data["brand"] == "TestBrand"
def test_parse_price_to_numeric(self): def test_parse_price_to_cents(self):
"""Test price string to numeric conversion""" """Test price string to cents conversion"""
assert self.processor._parse_price_to_numeric("19.99 EUR") == 19.99 assert self.processor._parse_price_to_cents("19.99 EUR") == 1999
assert self.processor._parse_price_to_numeric("19,99 EUR") == 19.99 assert self.processor._parse_price_to_cents("19,99 EUR") == 1999
assert self.processor._parse_price_to_numeric("$29.99") == 29.99 assert self.processor._parse_price_to_cents("$29.99") == 2999
assert self.processor._parse_price_to_numeric("100") == 100.0 assert self.processor._parse_price_to_cents("100") == 10000
assert self.processor._parse_price_to_numeric(None) is None assert self.processor._parse_price_to_cents(None) is None
assert self.processor._parse_price_to_numeric("") is None assert self.processor._parse_price_to_cents("") is None
def test_clean_row_data_with_prices(self): 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 = { row_data = {
"marketplace_product_id": "TEST001", "marketplace_product_id": "TEST001",
"title": "Test Product", "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) cleaned = self.processor._clean_row_data(row_data)
assert cleaned["price_numeric"] == 19.99 assert cleaned["price_cents"] == 1999 # Integer cents
assert cleaned["sale_price_numeric"] == 14.99 assert cleaned["sale_price_cents"] == 1499 # Integer cents
assert cleaned["currency"] == "EUR" assert cleaned["currency"] == "EUR"
@pytest.mark.asyncio @pytest.mark.asyncio