diff --git a/.architecture-rules/_main.yaml b/.architecture-rules/_main.yaml index 0da20547..02c071d1 100644 --- a/.architecture-rules/_main.yaml +++ b/.architecture-rules/_main.yaml @@ -28,6 +28,9 @@ principles: - name: "Consistent Naming" 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 # ============================================================================ @@ -43,6 +46,7 @@ includes: - frontend.yaml - language.yaml - quality.yaml + - money.yaml # ============================================================================ # VALIDATION SEVERITY LEVELS @@ -100,3 +104,4 @@ documentation: frontend: "docs/frontend/overview.md" contributing: "docs/development/contributing.md" code_quality: "docs/development/code-quality.md" + money_handling: "docs/architecture/money-handling.md" diff --git a/.architecture-rules/money.yaml b/.architecture-rules/money.yaml new file mode 100644 index 00000000..c4ce7fed --- /dev/null +++ b/.architecture-rules/money.yaml @@ -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 <" diff --git a/alembic/versions/55b92e155566_add_order_tracking_fields.py b/alembic/versions/55b92e155566_add_order_tracking_fields.py new file mode 100644 index 00000000..8e80682d --- /dev/null +++ b/alembic/versions/55b92e155566_add_order_tracking_fields.py @@ -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') diff --git a/alembic/versions/c00d2985701f_add_letzshop_credentials_carrier_fields.py b/alembic/versions/c00d2985701f_add_letzshop_credentials_carrier_fields.py new file mode 100644 index 00000000..c4a9bab2 --- /dev/null +++ b/alembic/versions/c00d2985701f_add_letzshop_credentials_carrier_fields.py @@ -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') diff --git a/alembic/versions/e1f2a3b4c5d6_convert_prices_to_integer_cents.py b/alembic/versions/e1f2a3b4c5d6_convert_prices_to_integer_cents.py new file mode 100644 index 00000000..e35312a9 --- /dev/null +++ b/alembic/versions/e1f2a3b4c5d6_convert_prices_to_integer_cents.py @@ -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') diff --git a/app/api/v1/admin/letzshop.py b/app/api/v1/admin/letzshop.py index f5852a06..471603dc 100644 --- a/app/api/v1/admin/letzshop.py +++ b/app/api/v1/admin/letzshop.py @@ -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( "/vendors/{vendor_id}/orders", response_model=LetzshopOrderListResponse, @@ -1048,3 +1138,99 @@ def decline_single_item( except LetzshopClientError as 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)], + ) diff --git a/app/api/v1/admin/order_item_exceptions.py b/app/api/v1/admin/order_item_exceptions.py index 5e1319f8..89172ad1 100644 --- a/app/api/v1/admin/order_item_exceptions.py +++ b/app/api/v1/admin/order_item_exceptions.py @@ -69,7 +69,7 @@ def list_exceptions( limit=limit, ) - # Enrich with order info + # Enrich with order and vendor info response_items = [] for exc in exceptions: item = OrderItemExceptionResponse.model_validate(exc) @@ -79,6 +79,9 @@ def list_exceptions( item.order_id = order.id item.order_date = order.order_date 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) return OrderItemExceptionListResponse( diff --git a/app/api/v1/admin/orders.py b/app/api/v1/admin/orders.py index 9401a7f5..79044336 100644 --- a/app/api/v1/admin/orders.py +++ b/app/api/v1/admin/orders.py @@ -27,7 +27,9 @@ from models.schema.order import ( AdminOrderStats, AdminOrderStatusUpdate, AdminVendorsWithOrdersResponse, + MarkAsShippedRequest, OrderDetailResponse, + ShippingLabelInfo, ) router = APIRouter(prefix="/orders") @@ -105,7 +107,14 @@ def get_order_detail( ): """Get order details including items and addresses.""" 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) @@ -136,3 +145,49 @@ def update_order_status( db.commit() 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) diff --git a/app/services/cart_service.py b/app/services/cart_service.py index 5947fc52..3209d34f 100644 --- a/app/services/cart_service.py +++ b/app/services/cart_service.py @@ -6,6 +6,9 @@ This module provides: - Session-based cart management - Cart item operations (add, update, remove) - Cart total calculations + +All monetary calculations use integer cents internally for precision. +See docs/architecture/money-handling.md for details. """ import logging @@ -19,6 +22,7 @@ from app.exceptions import ( InvalidCartQuantityException, ProductNotFoundException, ) +from app.utils.money import cents_to_euros from models.database.cart import CartItem from models.database.product import Product @@ -62,21 +66,23 @@ class CartService: extra={"item_count": len(cart_items)}, ) - # Build response + # Build response - calculate totals in cents, return euros items = [] - subtotal = 0.0 + subtotal_cents = 0 for cart_item in cart_items: product = cart_item.product - line_total = cart_item.line_total + line_total_cents = cart_item.line_total_cents items.append( { "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, - "price": cart_item.price_at_add, - "line_total": line_total, + "price": cart_item.price_at_add, # Returns euros via property + "line_total": cents_to_euros(line_total_cents), "image_url": ( product.marketplace_product.image_link 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 = { "vendor_id": vendor_id, "session_id": session_id, @@ -166,8 +174,12 @@ class CartService: }, ) - # Get current price (use sale_price if available, otherwise regular price) - current_price = product.sale_price if product.sale_price else product.price + # Get current price in cents (use sale_price if available, otherwise regular price) + current_price_cents = ( + product.effective_sale_price_cents + or product.effective_price_cents + or 0 + ) # Check if item already exists in cart existing_item = ( @@ -236,13 +248,13 @@ class CartService: available=product.available_inventory, ) - # Create new cart item + # Create new cart item (price stored in cents) cart_item = CartItem( vendor_id=vendor_id, session_id=session_id, product_id=product_id, quantity=quantity, - price_at_add=current_price, + price_at_add_cents=current_price_cents, ) db.add(cart_item) db.flush() @@ -253,7 +265,7 @@ class CartService: extra={ "cart_item_id": cart_item.id, "quantity": quantity, - "price": current_price, + "price_cents": current_price_cents, }, ) diff --git a/app/services/letzshop/client_service.py b/app/services/letzshop/client_service.py index 315fe807..e2363daa 100644 --- a/app/services/letzshop/client_service.py +++ b/app/services/letzshop/client_service.py @@ -116,6 +116,9 @@ query { code provider } + data { + __typename + } } } } @@ -194,6 +197,9 @@ query { code provider } + data { + __typename + } } } } @@ -272,6 +278,9 @@ query GetShipment($id: ID!) { code provider } + data { + __typename + } } } } @@ -349,6 +358,9 @@ query GetShipmentsPaginated($first: Int!, $after: String) {{ }} }} }} + data {{ + __typename + }} }} }} }} diff --git a/app/services/letzshop/order_service.py b/app/services/letzshop/order_service.py index ffeca103..ae4db076 100644 --- a/app/services/letzshop/order_service.py +++ b/app/services/letzshop/order_service.py @@ -182,7 +182,7 @@ class LetzshopOrderService: def list_orders( self, - vendor_id: int, + vendor_id: int | None = None, skip: int = 0, limit: int = 50, status: str | None = None, @@ -190,10 +190,10 @@ class LetzshopOrderService: search: str | None = None, ) -> tuple[list[Order], int]: """ - List Letzshop orders for a vendor. + List Letzshop orders for a vendor (or all vendors). 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. limit: Maximum number of records to return. status: Filter by order status (pending, processing, shipped, etc.) @@ -203,10 +203,13 @@ class LetzshopOrderService: Returns a tuple of (orders, total_count). """ query = self.db.query(Order).filter( - Order.vendor_id == vendor_id, Order.channel == "letzshop", ) + # Filter by vendor if specified + if vendor_id is not None: + query = query.filter(Order.vendor_id == vendor_id) + if status: query = query.filter(Order.status == status) @@ -242,25 +245,25 @@ class LetzshopOrderService: 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: Dict with counts for each status. """ - status_counts = ( - self.db.query( - Order.status, - func.count(Order.id).label("count"), - ) - .filter( - Order.vendor_id == vendor_id, - Order.channel == "letzshop", - ) - .group_by(Order.status) - .all() - ) + query = self.db.query( + Order.status, + func.count(Order.id).label("count"), + ).filter(Order.channel == "letzshop") + + if vendor_id is not None: + query = query.filter(Order.vendor_id == vendor_id) + + status_counts = query.group_by(Order.status).all() stats = { "pending": 0, @@ -277,18 +280,18 @@ class LetzshopOrderService: stats["total"] += count # Count orders with declined items - declined_items_count = ( + declined_query = ( self.db.query(func.count(func.distinct(OrderItem.order_id))) .join(Order, OrderItem.order_id == Order.id) .filter( - Order.vendor_id == vendor_id, Order.channel == "letzshop", 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 @@ -464,6 +467,62 @@ class LetzshopOrderService: order.updated_at = datetime.now(UTC) 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]: """Get all items for an order.""" return ( @@ -733,7 +792,7 @@ class LetzshopOrderService: pass if needs_update: - self.db.commit() # Commit update immediately + self.db.commit() # noqa: SVC-006 - background task needs incremental commits stats["updated"] += 1 else: stats["skipped"] += 1 @@ -741,7 +800,7 @@ class LetzshopOrderService: # Create new order using unified service try: 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 except Exception as e: self.db.rollback() # Rollback failed order @@ -948,7 +1007,7 @@ class LetzshopOrderService: status="pending", ) 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) return job diff --git a/app/services/order_item_exception_service.py b/app/services/order_item_exception_service.py index 680fe6dd..3141e1e8 100644 --- a/app/services/order_item_exception_service.py +++ b/app/services/order_item_exception_service.py @@ -329,8 +329,9 @@ class OrderItemExceptionService: order_item.needs_product_match = False # Update product snapshot on order item - order_item.product_name = product.get_title("en") # Default to English - order_item.product_sku = product.sku or order_item.product_sku + if product.marketplace_product: + order_item.product_name = product.marketplace_product.get_title("en") + order_item.product_sku = product.vendor_sku or order_item.product_sku db.flush() @@ -446,7 +447,8 @@ class OrderItemExceptionService: order_item = exception.order_item order_item.product_id = product_id 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) @@ -613,7 +615,8 @@ class OrderItemExceptionService: order_item = exception.order_item order_item.product_id = product_id 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() diff --git a/app/services/order_service.py b/app/services/order_service.py index 89dc06f2..bfef78d8 100644 --- a/app/services/order_service.py +++ b/app/services/order_service.py @@ -10,6 +10,9 @@ This service handles: - Order item management 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 @@ -28,6 +31,7 @@ from app.exceptions import ( ValidationException, ) 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.marketplace_product import MarketplaceProduct from models.database.marketplace_product_translation import MarketplaceProductTranslation @@ -296,7 +300,8 @@ class OrderService: ) # Calculate order totals and validate products - subtotal = 0.0 + # All calculations use integer cents for precision + subtotal_cents = 0 order_items_data = [] for item_data in order_data.items: @@ -325,34 +330,42 @@ class OrderService: available=product.available_inventory, ) - # Calculate item total - unit_price = product.sale_price if product.sale_price else product.price - if not unit_price: + # Get price in cents (prefer sale price, then regular price) + unit_price_cents = ( + product.effective_sale_price_cents + or product.effective_price_cents + ) + if not unit_price_cents: raise ValidationException(f"Product {product.id} has no price") - item_total = unit_price * item_data.quantity - subtotal += item_total + # Calculate line total in cents + line_total_cents = Money.calculate_line_total( + unit_price_cents, item_data.quantity + ) + subtotal_cents += line_total_cents order_items_data.append( { "product_id": product.id, - "product_name": product.marketplace_product.title + "product_name": product.marketplace_product.get_title("en") if product.marketplace_product - else product.product_id, - "product_sku": product.product_id, + else str(product.id), + "product_sku": product.vendor_sku, "gtin": product.gtin, "gtin_type": product.gtin_type, "quantity": item_data.quantity, - "unit_price": unit_price, - "total_price": item_total, + "unit_price_cents": unit_price_cents, + "total_price_cents": line_total_cents, } ) - # Calculate totals - tax_amount = 0.0 # TODO: Implement tax calculation - shipping_amount = 5.99 if subtotal < 50 else 0.0 - discount_amount = 0.0 - total_amount = subtotal + tax_amount + shipping_amount - discount_amount + # Calculate totals in cents + tax_amount_cents = 0 # TODO: Implement tax calculation + shipping_amount_cents = 599 if subtotal_cents < 5000 else 0 # €5.99 / €50 + discount_amount_cents = 0 + total_amount_cents = Money.calculate_order_total( + subtotal_cents, tax_amount_cents, shipping_amount_cents, discount_amount_cents + ) # Use billing address or shipping address billing = order_data.billing_address or order_data.shipping_address @@ -367,12 +380,12 @@ class OrderService: order_number=order_number, channel="direct", status="pending", - # Financials - subtotal=subtotal, - tax_amount=tax_amount, - shipping_amount=shipping_amount, - discount_amount=discount_amount, - total_amount=total_amount, + # Financials (in cents) + subtotal_cents=subtotal_cents, + tax_amount_cents=tax_amount_cents, + shipping_amount_cents=shipping_amount_cents, + discount_amount_cents=discount_amount_cents, + total_amount_cents=total_amount_cents, currency="EUR", # Customer snapshot customer_first_name=order_data.customer.first_name, @@ -417,7 +430,7 @@ class OrderService: logger.info( 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 @@ -469,6 +482,41 @@ class OrderService: .first() ) 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 # ===================================================================== @@ -546,13 +594,14 @@ class OrderService: except (ValueError, TypeError): pass - # Parse total amount + # Parse total amount (convert to cents) total_str = order_data.get("total", "0") try: # 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): - total_amount = 0.0 + total_amount_cents = 0 # Map Letzshop state to status letzshop_state = shipment_data.get("state", "unconfirmed") @@ -563,6 +612,23 @@ class OrderService: } 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 # ===================================================================== @@ -590,8 +656,8 @@ class OrderService: external_data=shipment_data, # Status status=status, - # Financials - total_amount=total_amount, + # Financials (in cents) + total_amount_cents=total_amount_cents, currency="EUR", # Customer snapshot customer_first_name=ship_first_name, @@ -620,6 +686,12 @@ class OrderService: order_date=order_date, confirmed_at=datetime.now(UTC) if status == "processing" 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) @@ -653,11 +725,12 @@ class OrderService: or str(product_name_dict) ) - # Get price - unit_price = 0.0 + # Get price (convert to cents) + unit_price_cents = 0 price_str = variant.get("price", "0") 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): pass @@ -672,8 +745,8 @@ class OrderService: gtin=gtin, gtin_type=gtin_type, quantity=1, # Letzshop uses individual inventory units - unit_price=unit_price, - total_price=unit_price, + unit_price_cents=unit_price_cents, + total_price_cents=unit_price_cents, # qty=1 so same as unit external_item_id=unit.get("id"), external_variant_id=variant.get("id"), item_state=item_state, @@ -1147,13 +1220,13 @@ class OrderService: if key in stats: stats[key] = count - # Get total revenue (from delivered orders) - revenue = ( - db.query(func.sum(Order.total_amount)) + # Get total revenue (from delivered orders) - convert cents to euros + revenue_cents = ( + db.query(func.sum(Order.total_amount_cents)) .filter(Order.status == "delivered") .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 vendors_count = ( @@ -1200,6 +1273,88 @@ class OrderService: 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 order_service = OrderService() diff --git a/app/templates/admin/letzshop-order-detail.html b/app/templates/admin/letzshop-order-detail.html index 5cf67cc0..5cdb51d1 100644 --- a/app/templates/admin/letzshop-order-detail.html +++ b/app/templates/admin/letzshop-order-detail.html @@ -59,17 +59,21 @@ Order Information
-
- Order Number - -
Order Date
- Shipment ID - + Order Number + +
+
+ Shipment Number + +
+
+ Hash ID +
Total @@ -128,28 +132,47 @@
- +

- Tracking Information + Shipping & Tracking

-
-
+
+
Carrier - +
-
+
+ Shipment Number + +
+
Tracking Number
+
+ Tracking Provider + +
Shipped At
+ +
-
- No tracking information available +
+ No shipping information available yet
diff --git a/app/templates/admin/marketplace-letzshop.html b/app/templates/admin/marketplace-letzshop.html index 8e6b44d1..c3c2b7b7 100644 --- a/app/templates/admin/marketplace-letzshop.html +++ b/app/templates/admin/marketplace-letzshop.html @@ -78,73 +78,86 @@ {{ error_state('Error', show_condition='error && !loading') }} - -
+ +
- +
-

Select a Vendor

-

Please select a vendor from the dropdown above to manage their Letzshop integration.

+

All Vendors View

+

Showing data across all vendors. Select a vendor above to manage products, import orders, or access settings.

- -
- -
-
-
- + +
+ +
+
+
+
+ +
+
+
+ + +
+ + + + + Auto-sync + +
-
-

-

-
-
-
- - - - - - Auto-sync - +
{% call tabs_nav(tab_var='activeTab') %} - {{ tab_button('products', 'Products', tab_var='activeTab', icon='cube') }} + {{ 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('settings', 'Settings', tab_var='activeTab', icon='cog') }} + {% endcall %} - - {{ tab_panel('products', tab_var='activeTab') }} - {% include 'admin/partials/letzshop-products-tab.html' %} - {{ endtab_panel() }} + + {{ tab_panel('orders', tab_var='activeTab') }} {% include 'admin/partials/letzshop-orders-tab.html' %} {{ endtab_panel() }} - - {{ tab_panel('settings', tab_var='activeTab') }} - {% include 'admin/partials/letzshop-settings-tab.html' %} - {{ endtab_panel() }} + + {{ tab_panel('exceptions', tab_var='activeTab') }} {% include 'admin/partials/letzshop-exceptions-tab.html' %} {{ endtab_panel() }} - -
+ +
{% include 'admin/partials/letzshop-jobs-table.html' %}
@@ -361,7 +374,7 @@ ( items) -
+