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>
118 lines
3.8 KiB
Python
118 lines
3.8 KiB
Python
# 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")
|