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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
117
models/database/order_item_exception.py
Normal file
117
models/database/order_item_exception.py
Normal 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")
|
||||
Reference in New Issue
Block a user