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

@@ -33,6 +33,7 @@ from .marketplace_product import (
)
from .marketplace_product_translation import MarketplaceProductTranslation
from .order import Order, OrderItem
from .order_item_exception import OrderItemException
from .product import Product
from .product_translation import ProductTranslation
from .test_run import TestCollection, TestResult, TestRun
@@ -89,6 +90,7 @@ __all__ = [
# Orders
"Order",
"OrderItem",
"OrderItemException",
# Letzshop Integration
"VendorLetzshopCredentials",
"LetzshopFulfillmentQueue",

View File

@@ -24,6 +24,10 @@ from sqlalchemy import (
String,
Text,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from models.database.order_item_exception import OrderItemException
from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy.orm import relationship
@@ -201,9 +205,19 @@ class OrderItem(Base, TimestampMixin):
inventory_reserved = Column(Boolean, default=False)
inventory_fulfilled = Column(Boolean, default=False)
# === Exception Tracking ===
# True if product was not found by GTIN during import (linked to placeholder)
needs_product_match = Column(Boolean, default=False, index=True)
# === Relationships ===
order = relationship("Order", back_populates="items")
product = relationship("Product")
exception = relationship(
"OrderItemException",
back_populates="order_item",
uselist=False,
cascade="all, delete-orphan",
)
def __repr__(self):
return f"<OrderItem(id={self.id}, order_id={self.order_id}, product_id={self.product_id}, gtin='{self.gtin}')>"
@@ -222,3 +236,10 @@ class OrderItem(Base, TimestampMixin):
def is_declined(self) -> bool:
"""Check if item was declined (unavailable)."""
return self.item_state == "confirmed_unavailable"
@property
def has_unresolved_exception(self) -> bool:
"""Check if item has an unresolved exception blocking confirmation."""
if not self.exception:
return False
return self.exception.blocks_confirmation

View File

@@ -0,0 +1,117 @@
# models/database/order_item_exception.py
"""
Order Item Exception model for tracking unmatched products during marketplace imports.
When a marketplace order contains a GTIN that doesn't match any product in the
vendor's catalog, the order is still imported but the item is linked to a
placeholder product and an exception is recorded here for resolution.
"""
from sqlalchemy import (
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
)
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class OrderItemException(Base, TimestampMixin):
"""
Tracks unmatched order items requiring admin/vendor resolution.
When a marketplace order is imported and a product cannot be found by GTIN,
the order item is linked to a placeholder product and this exception record
is created. The order cannot be confirmed until all exceptions are resolved.
"""
__tablename__ = "order_item_exceptions"
id = Column(Integer, primary_key=True, index=True)
# Link to the order item (one-to-one)
order_item_id = Column(
Integer,
ForeignKey("order_items.id", ondelete="CASCADE"),
nullable=False,
unique=True,
)
# Vendor ID for efficient querying (denormalized from order)
vendor_id = Column(
Integer, ForeignKey("vendors.id"), nullable=False, index=True
)
# Original data from marketplace (preserved for matching)
original_gtin = Column(String(50), nullable=True, index=True)
original_product_name = Column(String(500), nullable=True)
original_sku = Column(String(100), nullable=True)
# Exception classification
# product_not_found: GTIN not in vendor catalog
# gtin_mismatch: GTIN format issue
# duplicate_gtin: Multiple products with same GTIN
exception_type = Column(
String(50), nullable=False, default="product_not_found"
)
# Resolution status
# pending: Awaiting resolution
# resolved: Product has been assigned
# ignored: Marked as ignored (still blocks confirmation)
status = Column(String(50), nullable=False, default="pending", index=True)
# Resolution details (populated when resolved)
resolved_product_id = Column(
Integer, ForeignKey("products.id"), nullable=True
)
resolved_at = Column(DateTime(timezone=True), nullable=True)
resolved_by = Column(Integer, ForeignKey("users.id"), nullable=True)
resolution_notes = Column(Text, nullable=True)
# Relationships
order_item = relationship("OrderItem", back_populates="exception")
vendor = relationship("Vendor")
resolved_product = relationship("Product")
resolver = relationship("User")
# Composite indexes for common queries
__table_args__ = (
Index("idx_exception_vendor_status", "vendor_id", "status"),
Index("idx_exception_gtin", "vendor_id", "original_gtin"),
)
def __repr__(self):
return (
f"<OrderItemException(id={self.id}, "
f"order_item_id={self.order_item_id}, "
f"gtin='{self.original_gtin}', "
f"status='{self.status}')>"
)
@property
def is_pending(self) -> bool:
"""Check if exception is pending resolution."""
return self.status == "pending"
@property
def is_resolved(self) -> bool:
"""Check if exception has been resolved."""
return self.status == "resolved"
@property
def is_ignored(self) -> bool:
"""Check if exception has been ignored."""
return self.status == "ignored"
@property
def blocks_confirmation(self) -> bool:
"""Check if this exception blocks order confirmation."""
# Both pending and ignored exceptions block confirmation
return self.status in ("pending", "ignored")