feat: add order item exception system for graceful product matching

Replaces the "fail on missing product" behavior with graceful handling:
- Orders import even when products aren't found by GTIN
- Unmatched items link to a per-vendor placeholder product
- Exceptions tracked in order_item_exceptions table for QC resolution
- Order confirmation blocked until exceptions are resolved
- Auto-matching when products are imported via catalog sync

New files:
- OrderItemException model and migration
- OrderItemExceptionService with CRUD and resolution logic
- Admin and vendor API endpoints for exception management
- Domain exceptions for error handling

Modified:
- OrderItem: added needs_product_match flag and exception relationship
- OrderService: graceful handling with placeholder products
- MarketplaceProductService: auto-match on product import
- Letzshop confirm endpoints: blocking check for unresolved exceptions

🤖 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 13:11:47 +01:00
parent e0a0da85f8
commit d6d658dd85
19 changed files with 2215 additions and 16 deletions

View File

@@ -27,10 +27,17 @@ from app.exceptions import (
OrderNotFoundException,
ValidationException,
)
from app.services.order_item_exception_service import order_item_exception_service
from models.database.customer import Customer
from models.database.marketplace_product import MarketplaceProduct
from models.database.marketplace_product_translation import MarketplaceProductTranslation
from models.database.order import Order, OrderItem
from models.database.product import Product
from models.database.vendor import Vendor
# Placeholder product constants
PLACEHOLDER_GTIN = "0000000000000"
PLACEHOLDER_MARKETPLACE_ID = "PLACEHOLDER"
from models.schema.order import (
AddressSnapshot,
CustomerSnapshot,
@@ -71,6 +78,98 @@ class OrderService:
return order_number
# =========================================================================
# Placeholder Product Management
# =========================================================================
def _get_or_create_placeholder_product(
self,
db: Session,
vendor_id: int,
) -> Product:
"""
Get or create the vendor's placeholder product for unmatched items.
When a marketplace order contains a GTIN that doesn't match any product
in the vendor's catalog, we link the order item to this placeholder
and create an exception for resolution.
Args:
db: Database session
vendor_id: Vendor ID
Returns:
Placeholder Product for the vendor
"""
# Check for existing placeholder product for this vendor
placeholder = (
db.query(Product)
.filter(
and_(
Product.vendor_id == vendor_id,
Product.gtin == PLACEHOLDER_GTIN,
)
)
.first()
)
if placeholder:
return placeholder
# Get or create placeholder marketplace product (shared)
mp = (
db.query(MarketplaceProduct)
.filter(
MarketplaceProduct.marketplace_product_id == PLACEHOLDER_MARKETPLACE_ID
)
.first()
)
if not mp:
mp = MarketplaceProduct(
marketplace_product_id=PLACEHOLDER_MARKETPLACE_ID,
marketplace="internal",
vendor_name="system",
product_type_enum="physical",
is_active=False, # Not for sale
)
db.add(mp)
db.flush()
# Add translation for placeholder
translation = MarketplaceProductTranslation(
marketplace_product_id=mp.id,
language="en",
title="Unmatched Product (Pending Resolution)",
description=(
"This is a placeholder for products not found during order import. "
"Please resolve the exception to assign the correct product."
),
)
db.add(translation)
db.flush()
logger.info(
f"Created placeholder MarketplaceProduct {mp.id}"
)
# Create vendor-specific placeholder product
placeholder = Product(
vendor_id=vendor_id,
marketplace_product_id=mp.id,
gtin=PLACEHOLDER_GTIN,
gtin_type="placeholder",
is_active=False,
)
db.add(placeholder)
db.flush()
logger.info(
f"Created placeholder product {placeholder.id} for vendor {vendor_id}"
)
return placeholder
# =========================================================================
# Customer Management
# =========================================================================
@@ -405,17 +504,15 @@ class OrderService:
)
products_by_gtin = {p.gtin: p for p in products if p.gtin}
# Validate all products exist BEFORE creating anything
# Identify missing GTINs (graceful handling - no exception raised)
missing_gtins = gtins - set(products_by_gtin.keys())
placeholder = None
if missing_gtins:
missing_gtin = next(iter(missing_gtins))
logger.error(
f"Product not found for GTIN {missing_gtin} in vendor {vendor_id}. "
f"Order: {order_number}"
)
raise ValidationException(
f"Product not found for GTIN {missing_gtin}. "
f"Please ensure the product catalog is in sync."
# Get or create placeholder product for unmatched items
placeholder = self._get_or_create_placeholder_product(db, vendor_id)
logger.warning(
f"Order {order_number}: {len(missing_gtins)} product(s) not found. "
f"GTINs: {missing_gtins}. Using placeholder and creating exceptions."
)
# Parse address data
@@ -520,6 +617,7 @@ class OrderService:
db.flush()
# Create order items from inventory units
exceptions_created = 0
for unit in inventory_units:
variant = unit.get("variant", {}) or {}
product_info = variant.get("product", {}) or {}
@@ -529,10 +627,16 @@ class OrderService:
gtin = trade_id.get("number")
gtin_type = trade_id.get("parser")
# Get product from pre-validated map
# Get product from map, or use placeholder if not found
product = products_by_gtin.get(gtin)
needs_product_match = False
# Get product name
if not product:
# Use placeholder for unmatched items
product = placeholder
needs_product_match = True
# Get product name from marketplace data
product_name = (
product_name_dict.get("en")
or product_name_dict.get("fr")
@@ -564,12 +668,33 @@ class OrderService:
external_item_id=unit.get("id"),
external_variant_id=variant.get("id"),
item_state=item_state,
needs_product_match=needs_product_match,
)
db.add(order_item)
db.flush() # Get order_item.id for exception creation
# Create exception record for unmatched items
if needs_product_match:
order_item_exception_service.create_exception(
db=db,
order_item=order_item,
vendor_id=vendor_id,
original_gtin=gtin,
original_product_name=product_name,
original_sku=variant.get("sku"),
exception_type="product_not_found",
)
exceptions_created += 1
db.flush()
db.refresh(order)
if exceptions_created > 0:
logger.info(
f"Created {exceptions_created} order item exception(s) for "
f"order {order.order_number}"
)
logger.info(
f"Letzshop order {order.order_number} created for vendor {vendor_id}, "
f"status: {status}, items: {len(inventory_units)}"