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")
|
||||
@@ -57,6 +57,19 @@ class OrderItemCreate(BaseModel):
|
||||
quantity: int = Field(..., ge=1)
|
||||
|
||||
|
||||
class OrderItemExceptionBrief(BaseModel):
|
||||
"""Brief exception info for embedding in order item responses."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
original_gtin: str | None
|
||||
original_product_name: str | None
|
||||
exception_type: str
|
||||
status: str
|
||||
resolved_product_id: int | None
|
||||
|
||||
|
||||
class OrderItemResponse(BaseModel):
|
||||
"""Schema for order item response."""
|
||||
|
||||
@@ -84,6 +97,10 @@ class OrderItemResponse(BaseModel):
|
||||
inventory_reserved: bool
|
||||
inventory_fulfilled: bool
|
||||
|
||||
# Exception tracking
|
||||
needs_product_match: bool = False
|
||||
exception: OrderItemExceptionBrief | None = None
|
||||
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -102,6 +119,13 @@ class OrderItemResponse(BaseModel):
|
||||
"""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.status in ("pending", "ignored")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Customer Snapshot Schemas
|
||||
|
||||
172
models/schema/order_item_exception.py
Normal file
172
models/schema/order_item_exception.py
Normal file
@@ -0,0 +1,172 @@
|
||||
# models/schema/order_item_exception.py
|
||||
"""
|
||||
Pydantic schemas for order item exception management.
|
||||
|
||||
Handles unmatched products during marketplace order imports.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Exception Response Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class OrderItemExceptionResponse(BaseModel):
|
||||
"""Schema for order item exception response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
order_item_id: int
|
||||
vendor_id: int
|
||||
|
||||
# Original data from marketplace
|
||||
original_gtin: str | None
|
||||
original_product_name: str | None
|
||||
original_sku: str | None
|
||||
|
||||
# Exception classification
|
||||
exception_type: str # product_not_found, gtin_mismatch, duplicate_gtin
|
||||
|
||||
# Resolution status
|
||||
status: str # pending, resolved, ignored
|
||||
|
||||
# Resolution details
|
||||
resolved_product_id: int | None
|
||||
resolved_at: datetime | None
|
||||
resolved_by: int | None
|
||||
resolution_notes: str | None
|
||||
|
||||
# Timestamps
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
# Nested order info (populated by service)
|
||||
order_number: str | None = None
|
||||
order_id: int | None = None
|
||||
order_date: datetime | None = None
|
||||
order_status: str | None = None
|
||||
|
||||
@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"
|
||||
|
||||
|
||||
class OrderItemExceptionBriefResponse(BaseModel):
|
||||
"""Brief exception info for embedding in order item responses."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
original_gtin: str | None
|
||||
original_product_name: str | None
|
||||
exception_type: str
|
||||
status: str
|
||||
resolved_product_id: int | None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# List/Stats Response Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class OrderItemExceptionListResponse(BaseModel):
|
||||
"""Paginated list of exceptions."""
|
||||
|
||||
exceptions: list[OrderItemExceptionResponse]
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
|
||||
class OrderItemExceptionStats(BaseModel):
|
||||
"""Exception statistics for a vendor."""
|
||||
|
||||
pending: int = 0
|
||||
resolved: int = 0
|
||||
ignored: int = 0
|
||||
total: int = 0
|
||||
|
||||
# Additional breakdown
|
||||
orders_with_exceptions: int = 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Request Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ResolveExceptionRequest(BaseModel):
|
||||
"""Request to resolve an exception by assigning a product."""
|
||||
|
||||
product_id: int = Field(..., description="Product ID to assign to this order item")
|
||||
notes: str | None = Field(
|
||||
None,
|
||||
max_length=1000,
|
||||
description="Optional notes about the resolution"
|
||||
)
|
||||
|
||||
|
||||
class IgnoreExceptionRequest(BaseModel):
|
||||
"""Request to ignore an exception (still blocks confirmation)."""
|
||||
|
||||
notes: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=1000,
|
||||
description="Reason for ignoring (required)"
|
||||
)
|
||||
|
||||
|
||||
class BulkResolveRequest(BaseModel):
|
||||
"""Request to bulk resolve all pending exceptions for a GTIN."""
|
||||
|
||||
gtin: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=50,
|
||||
description="GTIN to match pending exceptions"
|
||||
)
|
||||
product_id: int = Field(..., description="Product ID to assign")
|
||||
notes: str | None = Field(
|
||||
None,
|
||||
max_length=1000,
|
||||
description="Optional notes about the resolution"
|
||||
)
|
||||
|
||||
|
||||
class BulkResolveResponse(BaseModel):
|
||||
"""Response from bulk resolve operation."""
|
||||
|
||||
resolved_count: int
|
||||
gtin: str
|
||||
product_id: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Auto-Match Response Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class AutoMatchResult(BaseModel):
|
||||
"""Result of auto-matching after product import."""
|
||||
|
||||
gtin: str
|
||||
product_id: int
|
||||
resolved_count: int
|
||||
resolved_exception_ids: list[int]
|
||||
Reference in New Issue
Block a user