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:
@@ -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)}"
|
||||
|
||||
Reference in New Issue
Block a user