Money Handling Architecture: - Store all monetary values as integer cents (€105.91 = 10591) - Add app/utils/money.py with Money class and conversion helpers - Add static/shared/js/money.js for frontend formatting - Update all database models to use _cents columns (Product, Order, etc.) - Update CSV processor to convert prices to cents on import - Add Alembic migration for Float to Integer conversion - Create .architecture-rules/money.yaml with 7 validation rules - Add docs/architecture/money-handling.md documentation Order Details Page Fixes: - Fix customer name showing 'undefined undefined' - use flat field names - Fix vendor info empty - add vendor_name/vendor_code to OrderDetailResponse - Fix shipping address using wrong nested object structure - Enrich order detail API response with vendor info Vendor Filter Persistence Fixes: - Fix orders.js: restoreSavedVendor now sets selectedVendor and filters - Fix orders.js: init() only loads orders if no saved vendor to restore - Fix marketplace-letzshop.js: restoreSavedVendor calls selectVendor() - Fix marketplace-letzshop.js: clearVendorSelection clears TomSelect dropdown - Align vendor selector placeholder text between pages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
633 lines
19 KiB
Python
633 lines
19 KiB
Python
# 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
|
|
if product.marketplace_product:
|
|
order_item.product_name = product.marketplace_product.get_title("en")
|
|
order_item.product_sku = product.vendor_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
|
|
if product.marketplace_product:
|
|
order_item.product_name = product.marketplace_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
|
|
if product.marketplace_product:
|
|
order_item.product_name = product.marketplace_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()
|