Files
orion/app/services/order_item_exception_service.py
Samir Boulahtit a19c84ea4e feat: integer cents money handling, order page fixes, and vendor filter persistence
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>
2025-12-20 20:33:48 +01:00

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()