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:
@@ -865,11 +865,14 @@ class MarketplaceProductService:
|
||||
marketplace_product_id=mp.id,
|
||||
is_active=True,
|
||||
is_featured=False,
|
||||
# Copy GTIN for order matching
|
||||
gtin=mp.gtin,
|
||||
gtin_type=mp.gtin_type if hasattr(mp, "gtin_type") else None,
|
||||
)
|
||||
|
||||
db.add(product)
|
||||
copied += 1
|
||||
details.append({"id": mp.id, "status": "copied"})
|
||||
details.append({"id": mp.id, "status": "copied", "gtin": mp.gtin})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to copy product {mp.id}: {str(e)}")
|
||||
@@ -878,15 +881,48 @@ class MarketplaceProductService:
|
||||
|
||||
db.flush()
|
||||
|
||||
# Auto-match pending order item exceptions
|
||||
# Collect GTINs and their product IDs from newly copied products
|
||||
from app.services.order_item_exception_service import (
|
||||
order_item_exception_service,
|
||||
)
|
||||
|
||||
gtin_to_product: dict[str, int] = {}
|
||||
for detail in details:
|
||||
if detail.get("status") == "copied" and detail.get("gtin"):
|
||||
# Find the product we just created
|
||||
product = (
|
||||
db.query(Product)
|
||||
.filter(
|
||||
Product.vendor_id == vendor_id,
|
||||
Product.gtin == detail["gtin"],
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if product:
|
||||
gtin_to_product[detail["gtin"]] = product.id
|
||||
|
||||
auto_matched = 0
|
||||
if gtin_to_product:
|
||||
auto_matched = order_item_exception_service.auto_match_batch(
|
||||
db, vendor_id, gtin_to_product
|
||||
)
|
||||
if auto_matched:
|
||||
logger.info(
|
||||
f"Auto-matched {auto_matched} order item exceptions "
|
||||
f"during product copy to vendor {vendor_id}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Copied {copied} products to vendor {vendor.name} "
|
||||
f"(skipped: {skipped}, failed: {failed})"
|
||||
f"(skipped: {skipped}, failed: {failed}, auto_matched: {auto_matched})"
|
||||
)
|
||||
|
||||
return {
|
||||
"copied": copied,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"auto_matched": auto_matched,
|
||||
"details": details if len(details) <= 100 else None,
|
||||
}
|
||||
|
||||
|
||||
629
app/services/order_item_exception_service.py
Normal file
629
app/services/order_item_exception_service.py
Normal file
@@ -0,0 +1,629 @@
|
||||
# app/services/order_item_exception_service.py
|
||||
"""
|
||||
Service for managing order item exceptions (unmatched products).
|
||||
|
||||
This service handles:
|
||||
- Creating exceptions when products are not found during order import
|
||||
- Resolving exceptions by assigning products
|
||||
- Auto-matching when new products are imported
|
||||
- Querying and statistics for exceptions
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.exceptions import (
|
||||
ExceptionAlreadyResolvedException,
|
||||
InvalidProductForExceptionException,
|
||||
OrderItemExceptionNotFoundException,
|
||||
ProductNotFoundException,
|
||||
)
|
||||
from models.database.order import Order, OrderItem
|
||||
from models.database.order_item_exception import OrderItemException
|
||||
from models.database.product import Product
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OrderItemExceptionService:
|
||||
"""Service for order item exception CRUD and resolution workflow."""
|
||||
|
||||
# =========================================================================
|
||||
# Exception Creation
|
||||
# =========================================================================
|
||||
|
||||
def create_exception(
|
||||
self,
|
||||
db: Session,
|
||||
order_item: OrderItem,
|
||||
vendor_id: int,
|
||||
original_gtin: str | None,
|
||||
original_product_name: str | None,
|
||||
original_sku: str | None,
|
||||
exception_type: str = "product_not_found",
|
||||
) -> OrderItemException:
|
||||
"""
|
||||
Create an exception record for an unmatched order item.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
order_item: The order item that couldn't be matched
|
||||
vendor_id: Vendor ID (denormalized for efficient queries)
|
||||
original_gtin: Original GTIN from marketplace
|
||||
original_product_name: Original product name from marketplace
|
||||
original_sku: Original SKU from marketplace
|
||||
exception_type: Type of exception (product_not_found, gtin_mismatch, etc.)
|
||||
|
||||
Returns:
|
||||
Created OrderItemException
|
||||
"""
|
||||
exception = OrderItemException(
|
||||
order_item_id=order_item.id,
|
||||
vendor_id=vendor_id,
|
||||
original_gtin=original_gtin,
|
||||
original_product_name=original_product_name,
|
||||
original_sku=original_sku,
|
||||
exception_type=exception_type,
|
||||
status="pending",
|
||||
)
|
||||
db.add(exception)
|
||||
db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Created order item exception {exception.id} for order item "
|
||||
f"{order_item.id}, GTIN: {original_gtin}"
|
||||
)
|
||||
|
||||
return exception
|
||||
|
||||
# =========================================================================
|
||||
# Exception Retrieval
|
||||
# =========================================================================
|
||||
|
||||
def get_exception_by_id(
|
||||
self,
|
||||
db: Session,
|
||||
exception_id: int,
|
||||
vendor_id: int | None = None,
|
||||
) -> OrderItemException:
|
||||
"""
|
||||
Get an exception by ID, optionally filtered by vendor.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
exception_id: Exception ID
|
||||
vendor_id: Optional vendor ID filter (for vendor-scoped access)
|
||||
|
||||
Returns:
|
||||
OrderItemException
|
||||
|
||||
Raises:
|
||||
OrderItemExceptionNotFoundException: If not found
|
||||
"""
|
||||
query = db.query(OrderItemException).filter(
|
||||
OrderItemException.id == exception_id
|
||||
)
|
||||
|
||||
if vendor_id is not None:
|
||||
query = query.filter(OrderItemException.vendor_id == vendor_id)
|
||||
|
||||
exception = query.first()
|
||||
|
||||
if not exception:
|
||||
raise OrderItemExceptionNotFoundException(exception_id)
|
||||
|
||||
return exception
|
||||
|
||||
def get_pending_exceptions(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int | None = None,
|
||||
status: str | None = None,
|
||||
search: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> tuple[list[OrderItemException], int]:
|
||||
"""
|
||||
Get exceptions with pagination and filtering.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Optional vendor filter
|
||||
status: Optional status filter (pending, resolved, ignored)
|
||||
search: Optional search in GTIN, product name, or order number
|
||||
skip: Pagination offset
|
||||
limit: Pagination limit
|
||||
|
||||
Returns:
|
||||
Tuple of (list of exceptions, total count)
|
||||
"""
|
||||
query = (
|
||||
db.query(OrderItemException)
|
||||
.join(OrderItem)
|
||||
.join(Order)
|
||||
.options(
|
||||
joinedload(OrderItemException.order_item).joinedload(OrderItem.order)
|
||||
)
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if vendor_id is not None:
|
||||
query = query.filter(OrderItemException.vendor_id == vendor_id)
|
||||
|
||||
if status:
|
||||
query = query.filter(OrderItemException.status == status)
|
||||
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
OrderItemException.original_gtin.ilike(search_pattern),
|
||||
OrderItemException.original_product_name.ilike(search_pattern),
|
||||
OrderItemException.original_sku.ilike(search_pattern),
|
||||
Order.order_number.ilike(search_pattern),
|
||||
)
|
||||
)
|
||||
|
||||
# Get total count
|
||||
total = query.count()
|
||||
|
||||
# Apply pagination and ordering
|
||||
exceptions = (
|
||||
query.order_by(OrderItemException.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return exceptions, total
|
||||
|
||||
def get_exceptions_for_order(
|
||||
self,
|
||||
db: Session,
|
||||
order_id: int,
|
||||
) -> list[OrderItemException]:
|
||||
"""
|
||||
Get all exceptions for items in an order.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
order_id: Order ID
|
||||
|
||||
Returns:
|
||||
List of exceptions for the order
|
||||
"""
|
||||
return (
|
||||
db.query(OrderItemException)
|
||||
.join(OrderItem)
|
||||
.filter(OrderItem.order_id == order_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Exception Statistics
|
||||
# =========================================================================
|
||||
|
||||
def get_exception_stats(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int | None = None,
|
||||
) -> dict[str, int]:
|
||||
"""
|
||||
Get exception counts by status.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Optional vendor filter
|
||||
|
||||
Returns:
|
||||
Dict with pending, resolved, ignored, total counts
|
||||
"""
|
||||
query = db.query(
|
||||
OrderItemException.status,
|
||||
func.count(OrderItemException.id).label("count"),
|
||||
)
|
||||
|
||||
if vendor_id is not None:
|
||||
query = query.filter(OrderItemException.vendor_id == vendor_id)
|
||||
|
||||
results = query.group_by(OrderItemException.status).all()
|
||||
|
||||
stats = {
|
||||
"pending": 0,
|
||||
"resolved": 0,
|
||||
"ignored": 0,
|
||||
"total": 0,
|
||||
}
|
||||
|
||||
for status, count in results:
|
||||
if status in stats:
|
||||
stats[status] = count
|
||||
stats["total"] += count
|
||||
|
||||
# Count orders with pending exceptions
|
||||
orders_query = (
|
||||
db.query(func.count(func.distinct(OrderItem.order_id)))
|
||||
.join(OrderItemException)
|
||||
.filter(OrderItemException.status == "pending")
|
||||
)
|
||||
|
||||
if vendor_id is not None:
|
||||
orders_query = orders_query.filter(
|
||||
OrderItemException.vendor_id == vendor_id
|
||||
)
|
||||
|
||||
stats["orders_with_exceptions"] = orders_query.scalar() or 0
|
||||
|
||||
return stats
|
||||
|
||||
# =========================================================================
|
||||
# Exception Resolution
|
||||
# =========================================================================
|
||||
|
||||
def resolve_exception(
|
||||
self,
|
||||
db: Session,
|
||||
exception_id: int,
|
||||
product_id: int,
|
||||
resolved_by: int,
|
||||
notes: str | None = None,
|
||||
vendor_id: int | None = None,
|
||||
) -> OrderItemException:
|
||||
"""
|
||||
Resolve an exception by assigning a product.
|
||||
|
||||
This updates:
|
||||
- The exception record (status, resolved_product_id, etc.)
|
||||
- The order item (product_id, needs_product_match)
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
exception_id: Exception ID to resolve
|
||||
product_id: Product ID to assign
|
||||
resolved_by: User ID who resolved
|
||||
notes: Optional resolution notes
|
||||
vendor_id: Optional vendor filter (for scoped access)
|
||||
|
||||
Returns:
|
||||
Updated OrderItemException
|
||||
|
||||
Raises:
|
||||
OrderItemExceptionNotFoundException: If exception not found
|
||||
ExceptionAlreadyResolvedException: If already resolved
|
||||
InvalidProductForExceptionException: If product is invalid
|
||||
"""
|
||||
exception = self.get_exception_by_id(db, exception_id, vendor_id)
|
||||
|
||||
if exception.status == "resolved":
|
||||
raise ExceptionAlreadyResolvedException(exception_id)
|
||||
|
||||
# Validate product exists and belongs to vendor
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise ProductNotFoundException(product_id)
|
||||
|
||||
if product.vendor_id != exception.vendor_id:
|
||||
raise InvalidProductForExceptionException(
|
||||
product_id, "Product belongs to a different vendor"
|
||||
)
|
||||
|
||||
if not product.is_active:
|
||||
raise InvalidProductForExceptionException(
|
||||
product_id, "Product is not active"
|
||||
)
|
||||
|
||||
# Update exception
|
||||
exception.status = "resolved"
|
||||
exception.resolved_product_id = product_id
|
||||
exception.resolved_at = datetime.now(UTC)
|
||||
exception.resolved_by = resolved_by
|
||||
exception.resolution_notes = notes
|
||||
|
||||
# Update order item
|
||||
order_item = exception.order_item
|
||||
order_item.product_id = product_id
|
||||
order_item.needs_product_match = False
|
||||
|
||||
# Update product snapshot on order item
|
||||
order_item.product_name = product.get_title("en") # Default to English
|
||||
order_item.product_sku = product.sku or order_item.product_sku
|
||||
|
||||
db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Resolved exception {exception_id} with product {product_id} "
|
||||
f"by user {resolved_by}"
|
||||
)
|
||||
|
||||
return exception
|
||||
|
||||
def ignore_exception(
|
||||
self,
|
||||
db: Session,
|
||||
exception_id: int,
|
||||
resolved_by: int,
|
||||
notes: str,
|
||||
vendor_id: int | None = None,
|
||||
) -> OrderItemException:
|
||||
"""
|
||||
Mark an exception as ignored.
|
||||
|
||||
Note: Ignored exceptions still block order confirmation.
|
||||
This is for tracking purposes (e.g., product will never be matched).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
exception_id: Exception ID
|
||||
resolved_by: User ID who ignored
|
||||
notes: Reason for ignoring (required)
|
||||
vendor_id: Optional vendor filter
|
||||
|
||||
Returns:
|
||||
Updated OrderItemException
|
||||
"""
|
||||
exception = self.get_exception_by_id(db, exception_id, vendor_id)
|
||||
|
||||
if exception.status == "resolved":
|
||||
raise ExceptionAlreadyResolvedException(exception_id)
|
||||
|
||||
exception.status = "ignored"
|
||||
exception.resolved_at = datetime.now(UTC)
|
||||
exception.resolved_by = resolved_by
|
||||
exception.resolution_notes = notes
|
||||
|
||||
db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Ignored exception {exception_id} by user {resolved_by}: {notes}"
|
||||
)
|
||||
|
||||
return exception
|
||||
|
||||
# =========================================================================
|
||||
# Auto-Matching
|
||||
# =========================================================================
|
||||
|
||||
def auto_match_by_gtin(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
gtin: str,
|
||||
product_id: int,
|
||||
) -> list[OrderItemException]:
|
||||
"""
|
||||
Auto-resolve pending exceptions matching a GTIN.
|
||||
|
||||
Called after a product is imported with a GTIN.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
gtin: GTIN to match
|
||||
product_id: Product ID to assign
|
||||
|
||||
Returns:
|
||||
List of resolved exceptions
|
||||
"""
|
||||
if not gtin:
|
||||
return []
|
||||
|
||||
# Find pending exceptions for this GTIN
|
||||
pending = (
|
||||
db.query(OrderItemException)
|
||||
.filter(
|
||||
and_(
|
||||
OrderItemException.vendor_id == vendor_id,
|
||||
OrderItemException.original_gtin == gtin,
|
||||
OrderItemException.status == "pending",
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not pending:
|
||||
return []
|
||||
|
||||
# Get product for snapshot update
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
logger.warning(f"Product {product_id} not found for auto-match")
|
||||
return []
|
||||
|
||||
resolved = []
|
||||
now = datetime.now(UTC)
|
||||
|
||||
for exception in pending:
|
||||
exception.status = "resolved"
|
||||
exception.resolved_product_id = product_id
|
||||
exception.resolved_at = now
|
||||
exception.resolution_notes = "Auto-matched during product import"
|
||||
|
||||
# Update order item
|
||||
order_item = exception.order_item
|
||||
order_item.product_id = product_id
|
||||
order_item.needs_product_match = False
|
||||
order_item.product_name = product.get_title("en")
|
||||
|
||||
resolved.append(exception)
|
||||
|
||||
if resolved:
|
||||
db.flush()
|
||||
logger.info(
|
||||
f"Auto-matched {len(resolved)} exceptions for GTIN {gtin} "
|
||||
f"with product {product_id}"
|
||||
)
|
||||
|
||||
return resolved
|
||||
|
||||
def auto_match_batch(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
gtin_to_product: dict[str, int],
|
||||
) -> int:
|
||||
"""
|
||||
Batch auto-match multiple GTINs after bulk import.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
gtin_to_product: Dict mapping GTIN to product ID
|
||||
|
||||
Returns:
|
||||
Total number of resolved exceptions
|
||||
"""
|
||||
if not gtin_to_product:
|
||||
return 0
|
||||
|
||||
total_resolved = 0
|
||||
|
||||
for gtin, product_id in gtin_to_product.items():
|
||||
resolved = self.auto_match_by_gtin(db, vendor_id, gtin, product_id)
|
||||
total_resolved += len(resolved)
|
||||
|
||||
return total_resolved
|
||||
|
||||
# =========================================================================
|
||||
# Confirmation Checks
|
||||
# =========================================================================
|
||||
|
||||
def order_has_unresolved_exceptions(
|
||||
self,
|
||||
db: Session,
|
||||
order_id: int,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if order has any unresolved exceptions.
|
||||
|
||||
An order cannot be confirmed if it has pending or ignored exceptions.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
order_id: Order ID
|
||||
|
||||
Returns:
|
||||
True if order has unresolved exceptions
|
||||
"""
|
||||
count = (
|
||||
db.query(func.count(OrderItemException.id))
|
||||
.join(OrderItem)
|
||||
.filter(
|
||||
and_(
|
||||
OrderItem.order_id == order_id,
|
||||
OrderItemException.status.in_(["pending", "ignored"]),
|
||||
)
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
|
||||
return count > 0
|
||||
|
||||
def get_unresolved_exception_count(
|
||||
self,
|
||||
db: Session,
|
||||
order_id: int,
|
||||
) -> int:
|
||||
"""
|
||||
Get count of unresolved exceptions for an order.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
order_id: Order ID
|
||||
|
||||
Returns:
|
||||
Count of unresolved exceptions
|
||||
"""
|
||||
return (
|
||||
db.query(func.count(OrderItemException.id))
|
||||
.join(OrderItem)
|
||||
.filter(
|
||||
and_(
|
||||
OrderItem.order_id == order_id,
|
||||
OrderItemException.status.in_(["pending", "ignored"]),
|
||||
)
|
||||
)
|
||||
.scalar()
|
||||
) or 0
|
||||
|
||||
# =========================================================================
|
||||
# Bulk Operations
|
||||
# =========================================================================
|
||||
|
||||
def bulk_resolve_by_gtin(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
gtin: str,
|
||||
product_id: int,
|
||||
resolved_by: int,
|
||||
notes: str | None = None,
|
||||
) -> int:
|
||||
"""
|
||||
Bulk resolve all pending exceptions for a GTIN.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
gtin: GTIN to match
|
||||
product_id: Product ID to assign
|
||||
resolved_by: User ID who resolved
|
||||
notes: Optional notes
|
||||
|
||||
Returns:
|
||||
Number of resolved exceptions
|
||||
"""
|
||||
# Validate product
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise ProductNotFoundException(product_id)
|
||||
|
||||
if product.vendor_id != vendor_id:
|
||||
raise InvalidProductForExceptionException(
|
||||
product_id, "Product belongs to a different vendor"
|
||||
)
|
||||
|
||||
# Find and resolve all pending exceptions for this GTIN
|
||||
pending = (
|
||||
db.query(OrderItemException)
|
||||
.filter(
|
||||
and_(
|
||||
OrderItemException.vendor_id == vendor_id,
|
||||
OrderItemException.original_gtin == gtin,
|
||||
OrderItemException.status == "pending",
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
resolution_notes = notes or f"Bulk resolved for GTIN {gtin}"
|
||||
|
||||
for exception in pending:
|
||||
exception.status = "resolved"
|
||||
exception.resolved_product_id = product_id
|
||||
exception.resolved_at = now
|
||||
exception.resolved_by = resolved_by
|
||||
exception.resolution_notes = resolution_notes
|
||||
|
||||
# Update order item
|
||||
order_item = exception.order_item
|
||||
order_item.product_id = product_id
|
||||
order_item.needs_product_match = False
|
||||
order_item.product_name = product.get_title("en")
|
||||
|
||||
db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Bulk resolved {len(pending)} exceptions for GTIN {gtin} "
|
||||
f"with product {product_id} by user {resolved_by}"
|
||||
)
|
||||
|
||||
return len(pending)
|
||||
|
||||
|
||||
# Global service instance
|
||||
order_item_exception_service = OrderItemExceptionService()
|
||||
@@ -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