Files
orion/app/api/v1/admin/letzshop.py
Samir Boulahtit bedc979b12 feat: show Jobs tab for all vendors when no filter selected
- Add /admin/letzshop/jobs API endpoint for all jobs across vendors
- Update list_letzshop_jobs service to support optional vendor_id
- Remove x-if condition from Jobs tab button and panel
- Update JS to use global or vendor-specific endpoint based on selection
- Update jobs table subtitle to show context

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 00:30:33 +01:00

1268 lines
43 KiB
Python

# app/api/v1/admin/letzshop.py
"""
Admin API endpoints for Letzshop marketplace integration.
Provides admin-level management of:
- Per-vendor Letzshop credentials
- Connection testing
- Sync triggers and status
- Order overview
"""
import logging
from fastapi import APIRouter, BackgroundTasks, Depends, Path, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.exceptions import (
OrderHasUnresolvedExceptionsException,
ResourceNotFoundException,
ValidationException,
)
from app.services.order_item_exception_service import order_item_exception_service
from app.services.letzshop import (
CredentialsNotFoundError,
LetzshopClientError,
LetzshopCredentialsService,
LetzshopOrderService,
OrderNotFoundError,
VendorNotFoundError,
)
from app.tasks.letzshop_tasks import process_historical_import
from models.database.user import User
from models.schema.letzshop import (
FulfillmentOperationResponse,
LetzshopConnectionTestRequest,
LetzshopConnectionTestResponse,
LetzshopCredentialsCreate,
LetzshopCredentialsResponse,
LetzshopCredentialsUpdate,
LetzshopHistoricalImportJobResponse,
LetzshopHistoricalImportStartResponse,
LetzshopJobItem,
LetzshopJobsListResponse,
LetzshopOrderDetailResponse,
LetzshopOrderItemResponse,
LetzshopOrderListResponse,
LetzshopOrderResponse,
LetzshopOrderStats,
LetzshopSuccessResponse,
LetzshopSyncTriggerRequest,
LetzshopSyncTriggerResponse,
LetzshopVendorListResponse,
LetzshopVendorOverview,
)
router = APIRouter(prefix="/letzshop")
logger = logging.getLogger(__name__)
# ============================================================================
# Helper Functions
# ============================================================================
def get_order_service(db: Session) -> LetzshopOrderService:
"""Get order service instance."""
return LetzshopOrderService(db)
def get_credentials_service(db: Session) -> LetzshopCredentialsService:
"""Get credentials service instance."""
return LetzshopCredentialsService(db)
# ============================================================================
# Vendor Overview
# ============================================================================
@router.get("/vendors", response_model=LetzshopVendorListResponse)
def list_vendors_letzshop_status(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
configured_only: bool = Query(
False, description="Only show vendors with Letzshop configured"
),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
List all vendors with their Letzshop integration status.
Shows which vendors have Letzshop configured, sync status, and pending orders.
"""
order_service = get_order_service(db)
vendor_overviews, total = order_service.list_vendors_with_letzshop_status(
skip=skip,
limit=limit,
configured_only=configured_only,
)
return LetzshopVendorListResponse(
vendors=[LetzshopVendorOverview(**v) for v in vendor_overviews],
total=total,
skip=skip,
limit=limit,
)
# ============================================================================
# Credentials Management
# ============================================================================
@router.get(
"/vendors/{vendor_id}/credentials",
response_model=LetzshopCredentialsResponse,
)
def get_vendor_credentials(
vendor_id: int = Path(..., description="Vendor ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Get Letzshop credentials for a vendor (API key is masked)."""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
vendor = order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
try:
credentials = creds_service.get_credentials_or_raise(vendor_id)
except CredentialsNotFoundError:
raise ResourceNotFoundException(
"LetzshopCredentials",
str(vendor_id),
message=f"Letzshop credentials not configured for vendor {vendor.name}",
)
return LetzshopCredentialsResponse(
id=credentials.id,
vendor_id=credentials.vendor_id,
api_key_masked=creds_service.get_masked_api_key(vendor_id),
api_endpoint=credentials.api_endpoint,
auto_sync_enabled=credentials.auto_sync_enabled,
sync_interval_minutes=credentials.sync_interval_minutes,
last_sync_at=credentials.last_sync_at,
last_sync_status=credentials.last_sync_status,
last_sync_error=credentials.last_sync_error,
created_at=credentials.created_at,
updated_at=credentials.updated_at,
)
@router.post(
"/vendors/{vendor_id}/credentials",
response_model=LetzshopCredentialsResponse,
)
def create_or_update_vendor_credentials(
vendor_id: int = Path(..., description="Vendor ID"),
credentials_data: LetzshopCredentialsCreate = ...,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Create or update Letzshop credentials for a vendor."""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
vendor = order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
credentials = creds_service.upsert_credentials(
vendor_id=vendor_id,
api_key=credentials_data.api_key,
api_endpoint=credentials_data.api_endpoint,
auto_sync_enabled=credentials_data.auto_sync_enabled,
sync_interval_minutes=credentials_data.sync_interval_minutes,
)
db.commit()
logger.info(
f"Admin {current_admin.email} updated Letzshop credentials for vendor {vendor.name}"
)
return LetzshopCredentialsResponse(
id=credentials.id,
vendor_id=credentials.vendor_id,
api_key_masked=creds_service.get_masked_api_key(vendor_id),
api_endpoint=credentials.api_endpoint,
auto_sync_enabled=credentials.auto_sync_enabled,
sync_interval_minutes=credentials.sync_interval_minutes,
last_sync_at=credentials.last_sync_at,
last_sync_status=credentials.last_sync_status,
last_sync_error=credentials.last_sync_error,
created_at=credentials.created_at,
updated_at=credentials.updated_at,
)
@router.patch(
"/vendors/{vendor_id}/credentials",
response_model=LetzshopCredentialsResponse,
)
def update_vendor_credentials(
vendor_id: int = Path(..., description="Vendor ID"),
credentials_data: LetzshopCredentialsUpdate = ...,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Partially update Letzshop credentials for a vendor."""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
vendor = order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
try:
credentials = creds_service.update_credentials(
vendor_id=vendor_id,
api_key=credentials_data.api_key,
api_endpoint=credentials_data.api_endpoint,
auto_sync_enabled=credentials_data.auto_sync_enabled,
sync_interval_minutes=credentials_data.sync_interval_minutes,
)
db.commit()
except CredentialsNotFoundError:
raise ResourceNotFoundException(
"LetzshopCredentials",
str(vendor_id),
message=f"Letzshop credentials not configured for vendor {vendor.name}",
)
return LetzshopCredentialsResponse(
id=credentials.id,
vendor_id=credentials.vendor_id,
api_key_masked=creds_service.get_masked_api_key(vendor_id),
api_endpoint=credentials.api_endpoint,
auto_sync_enabled=credentials.auto_sync_enabled,
sync_interval_minutes=credentials.sync_interval_minutes,
last_sync_at=credentials.last_sync_at,
last_sync_status=credentials.last_sync_status,
last_sync_error=credentials.last_sync_error,
created_at=credentials.created_at,
updated_at=credentials.updated_at,
)
@router.delete(
"/vendors/{vendor_id}/credentials",
response_model=LetzshopSuccessResponse,
)
def delete_vendor_credentials(
vendor_id: int = Path(..., description="Vendor ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Delete Letzshop credentials for a vendor."""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
vendor = order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
deleted = creds_service.delete_credentials(vendor_id)
if not deleted:
raise ResourceNotFoundException(
"LetzshopCredentials",
str(vendor_id),
message=f"Letzshop credentials not configured for vendor {vendor.name}",
)
db.commit()
logger.info(
f"Admin {current_admin.email} deleted Letzshop credentials for vendor {vendor.name}"
)
return LetzshopSuccessResponse(success=True, message="Letzshop credentials deleted")
# ============================================================================
# Connection Testing
# ============================================================================
@router.post(
"/vendors/{vendor_id}/test",
response_model=LetzshopConnectionTestResponse,
)
def test_vendor_connection(
vendor_id: int = Path(..., description="Vendor ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Test the Letzshop connection for a vendor using stored credentials."""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
success, response_time_ms, error = creds_service.test_connection(vendor_id)
return LetzshopConnectionTestResponse(
success=success,
message="Connection successful" if success else "Connection failed",
response_time_ms=response_time_ms,
error_details=error,
)
@router.post("/test", response_model=LetzshopConnectionTestResponse)
def test_api_key(
test_request: LetzshopConnectionTestRequest,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Test a Letzshop API key without saving it."""
creds_service = get_credentials_service(db)
success, response_time_ms, error = creds_service.test_api_key(
api_key=test_request.api_key,
api_endpoint=test_request.api_endpoint,
)
return LetzshopConnectionTestResponse(
success=success,
message="Connection successful" if success else "Connection failed",
response_time_ms=response_time_ms,
error_details=error,
)
# ============================================================================
# Order Management
# ============================================================================
@router.get(
"/orders",
response_model=LetzshopOrderListResponse,
)
def list_all_letzshop_orders(
vendor_id: int | None = Query(None, description="Filter by vendor"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
status: str | None = Query(None, description="Filter by order status"),
has_declined_items: bool | None = Query(
None, description="Filter orders with declined/unavailable items"
),
search: str | None = Query(
None, description="Search by order number, customer name, or email"
),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
List Letzshop orders across all vendors (or for a specific vendor).
When vendor_id is not provided, returns orders from all vendors.
"""
order_service = get_order_service(db)
orders, total = order_service.list_orders(
vendor_id=vendor_id,
skip=skip,
limit=limit,
status=status,
has_declined_items=has_declined_items,
search=search,
)
# Get order stats (cross-vendor or vendor-specific)
stats = order_service.get_order_stats(vendor_id)
return LetzshopOrderListResponse(
orders=[
LetzshopOrderResponse(
id=order.id,
vendor_id=order.vendor_id,
vendor_name=order.vendor.name if order.vendor else None,
order_number=order.order_number,
external_order_id=order.external_order_id,
external_shipment_id=order.external_shipment_id,
external_order_number=order.external_order_number,
status=order.status,
customer_email=order.customer_email,
customer_name=order.customer_full_name,
customer_locale=order.customer_locale,
ship_country_iso=order.ship_country_iso,
bill_country_iso=order.bill_country_iso,
total_amount=order.total_amount,
currency=order.currency,
tracking_number=order.tracking_number,
tracking_provider=order.tracking_provider,
order_date=order.order_date,
confirmed_at=order.confirmed_at,
shipped_at=order.shipped_at,
cancelled_at=order.cancelled_at,
created_at=order.created_at,
updated_at=order.updated_at,
items=[
LetzshopOrderItemResponse(
id=item.id,
product_id=item.product_id,
product_name=item.product_name,
product_sku=item.product_sku,
gtin=item.gtin,
gtin_type=item.gtin_type,
quantity=item.quantity,
unit_price=item.unit_price,
total_price=item.total_price,
external_item_id=item.external_item_id,
external_variant_id=item.external_variant_id,
item_state=item.item_state,
)
for item in order.items
],
)
for order in orders
],
total=total,
skip=skip,
limit=limit,
stats=LetzshopOrderStats(**stats),
)
@router.get(
"/vendors/{vendor_id}/orders",
response_model=LetzshopOrderListResponse,
)
def list_vendor_letzshop_orders(
vendor_id: int = Path(..., description="Vendor ID"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
status: str | None = Query(None, description="Filter by order status"),
has_declined_items: bool | None = Query(
None, description="Filter orders with declined/unavailable items"
),
search: str | None = Query(
None, description="Search by order number, customer name, or email"
),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""List Letzshop orders for a vendor."""
order_service = get_order_service(db)
try:
order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
orders, total = order_service.list_orders(
vendor_id=vendor_id,
skip=skip,
limit=limit,
status=status,
has_declined_items=has_declined_items,
search=search,
)
# Get order stats for all statuses
stats = order_service.get_order_stats(vendor_id)
return LetzshopOrderListResponse(
orders=[
LetzshopOrderResponse(
id=order.id,
vendor_id=order.vendor_id,
order_number=order.order_number,
external_order_id=order.external_order_id,
external_shipment_id=order.external_shipment_id,
external_order_number=order.external_order_number,
status=order.status,
customer_email=order.customer_email,
customer_name=order.customer_full_name,
customer_locale=order.customer_locale,
ship_country_iso=order.ship_country_iso,
bill_country_iso=order.bill_country_iso,
total_amount=order.total_amount,
currency=order.currency,
tracking_number=order.tracking_number,
tracking_provider=order.tracking_provider,
order_date=order.order_date,
confirmed_at=order.confirmed_at,
shipped_at=order.shipped_at,
cancelled_at=order.cancelled_at,
created_at=order.created_at,
updated_at=order.updated_at,
items=[
LetzshopOrderItemResponse(
id=item.id,
product_id=item.product_id,
product_name=item.product_name,
product_sku=item.product_sku,
gtin=item.gtin,
gtin_type=item.gtin_type,
quantity=item.quantity,
unit_price=item.unit_price,
total_price=item.total_price,
external_item_id=item.external_item_id,
external_variant_id=item.external_variant_id,
item_state=item.item_state,
)
for item in order.items
],
)
for order in orders
],
total=total,
skip=skip,
limit=limit,
stats=LetzshopOrderStats(**stats),
)
@router.get(
"/orders/{order_id}",
response_model=LetzshopOrderDetailResponse,
)
def get_letzshop_order_detail(
order_id: int = Path(..., description="Letzshop order ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Get detailed information for a single Letzshop order."""
order_service = get_order_service(db)
order = order_service.get_order_by_id(order_id)
if not order:
raise ResourceNotFoundException("Order", str(order_id))
return LetzshopOrderDetailResponse(
id=order.id,
vendor_id=order.vendor_id,
order_number=order.order_number,
external_order_id=order.external_order_id,
external_shipment_id=order.external_shipment_id,
external_order_number=order.external_order_number,
status=order.status,
customer_email=order.customer_email,
customer_name=order.customer_full_name,
customer_locale=order.customer_locale,
customer_first_name=order.customer_first_name,
customer_last_name=order.customer_last_name,
customer_phone=order.customer_phone,
ship_country_iso=order.ship_country_iso,
ship_first_name=order.ship_first_name,
ship_last_name=order.ship_last_name,
ship_company=order.ship_company,
ship_address_line_1=order.ship_address_line_1,
ship_address_line_2=order.ship_address_line_2,
ship_city=order.ship_city,
ship_postal_code=order.ship_postal_code,
bill_country_iso=order.bill_country_iso,
bill_first_name=order.bill_first_name,
bill_last_name=order.bill_last_name,
bill_company=order.bill_company,
bill_address_line_1=order.bill_address_line_1,
bill_address_line_2=order.bill_address_line_2,
bill_city=order.bill_city,
bill_postal_code=order.bill_postal_code,
total_amount=order.total_amount,
currency=order.currency,
tracking_number=order.tracking_number,
tracking_provider=order.tracking_provider,
order_date=order.order_date,
confirmed_at=order.confirmed_at,
shipped_at=order.shipped_at,
cancelled_at=order.cancelled_at,
created_at=order.created_at,
updated_at=order.updated_at,
external_data=order.external_data,
customer_notes=order.customer_notes,
internal_notes=order.internal_notes,
items=[
LetzshopOrderItemResponse(
id=item.id,
product_id=item.product_id,
product_name=item.product_name,
product_sku=item.product_sku,
gtin=item.gtin,
gtin_type=item.gtin_type,
quantity=item.quantity,
unit_price=item.unit_price,
total_price=item.total_price,
external_item_id=item.external_item_id,
external_variant_id=item.external_variant_id,
item_state=item.item_state,
)
for item in order.items
],
)
@router.post(
"/vendors/{vendor_id}/sync",
response_model=LetzshopSyncTriggerResponse,
)
def trigger_vendor_sync(
vendor_id: int = Path(..., description="Vendor ID"),
sync_request: LetzshopSyncTriggerRequest = LetzshopSyncTriggerRequest(),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Trigger a sync operation for a vendor.
This imports new orders from Letzshop.
"""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
vendor = order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
# Verify credentials exist
try:
creds_service.get_credentials_or_raise(vendor_id)
except CredentialsNotFoundError:
raise ValidationException(
f"Letzshop credentials not configured for vendor {vendor.name}"
)
# Import orders using the client
try:
with creds_service.create_client(vendor_id) as client:
shipments = client.get_unconfirmed_shipments()
logger.info(
f"Letzshop sync for vendor {vendor_id}: "
f"fetched {len(shipments)} unconfirmed shipments from API"
)
orders_imported = 0
orders_updated = 0
errors = []
for shipment in shipments:
try:
# Check if order already exists
existing = order_service.get_order_by_shipment_id(
vendor_id, shipment["id"]
)
if existing:
# Update existing order
order_service.update_order_from_shipment(existing, shipment)
orders_updated += 1
else:
# Create new order
order_service.create_order(vendor_id, shipment)
orders_imported += 1
except Exception as e:
errors.append(
f"Error processing shipment {shipment.get('id')}: {e}"
)
db.commit()
# Update sync status
creds_service.update_sync_status(
vendor_id,
"success" if not errors else "partial",
"; ".join(errors) if errors else None,
)
return LetzshopSyncTriggerResponse(
success=True,
message=f"Sync completed: {orders_imported} imported, {orders_updated} updated",
orders_imported=orders_imported,
orders_updated=orders_updated,
errors=errors,
)
except LetzshopClientError as e:
creds_service.update_sync_status(vendor_id, "failed", str(e))
return LetzshopSyncTriggerResponse(
success=False,
message=f"Sync failed: {e}",
errors=[str(e)],
)
# ============================================================================
# Jobs (Unified view of imports, exports, and syncs)
# ============================================================================
@router.get(
"/jobs",
response_model=LetzshopJobsListResponse,
)
def list_all_letzshop_jobs(
job_type: str | None = Query(None, description="Filter: import, export, order_sync, historical_import"),
status: str | None = Query(None, description="Filter by status"),
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Get unified list of all Letzshop-related jobs across all vendors.
Combines product imports, exports, and order syncs.
"""
order_service = get_order_service(db)
jobs_data, total = order_service.list_letzshop_jobs(
vendor_id=None,
job_type=job_type,
status=status,
skip=skip,
limit=limit,
)
jobs = [LetzshopJobItem(**job) for job in jobs_data]
return LetzshopJobsListResponse(jobs=jobs, total=total)
@router.get(
"/vendors/{vendor_id}/jobs",
response_model=LetzshopJobsListResponse,
)
def list_vendor_letzshop_jobs(
vendor_id: int = Path(..., description="Vendor ID"),
job_type: str | None = Query(None, description="Filter: import, export, order_sync"),
status: str | None = Query(None, description="Filter by status"),
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Get unified list of Letzshop-related jobs for a vendor.
Combines product imports, exports, and order syncs.
"""
order_service = get_order_service(db)
try:
order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
# Use service layer for database queries
jobs_data, total = order_service.list_letzshop_jobs(
vendor_id=vendor_id,
job_type=job_type,
status=status,
skip=skip,
limit=limit,
)
# Convert dict data to Pydantic models
jobs = [LetzshopJobItem(**job) for job in jobs_data]
return LetzshopJobsListResponse(jobs=jobs, total=total)
# ============================================================================
# Historical Import
# ============================================================================
@router.post(
"/vendors/{vendor_id}/import-history",
response_model=LetzshopHistoricalImportStartResponse,
)
def start_historical_import(
vendor_id: int = Path(..., description="Vendor ID"),
background_tasks: BackgroundTasks = None,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Start historical order import from Letzshop as a background job.
Creates a job that imports both confirmed and declined orders with
real-time progress tracking. Poll the status endpoint to track progress.
Returns a job_id for polling the status endpoint.
"""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
vendor = order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
# Verify credentials exist
try:
creds_service.get_credentials_or_raise(vendor_id)
except CredentialsNotFoundError:
raise ValidationException(
f"Letzshop credentials not configured for vendor {vendor.name}"
)
# Check if there's already a running import for this vendor
existing_job = order_service.get_running_historical_import_job(vendor_id)
if existing_job:
raise ValidationException(
f"Historical import already in progress (job_id={existing_job.id})"
)
# Create job record
job = order_service.create_historical_import_job(vendor_id, current_admin.id)
logger.info(f"Created historical import job {job.id} for vendor {vendor_id}")
# Queue background task
background_tasks.add_task(
process_historical_import,
job.id,
vendor_id,
)
return LetzshopHistoricalImportStartResponse(
job_id=job.id,
status="pending",
message="Historical import job started",
)
@router.get(
"/vendors/{vendor_id}/import-history/{job_id}/status",
response_model=LetzshopHistoricalImportJobResponse,
)
def get_historical_import_status(
vendor_id: int = Path(..., description="Vendor ID"),
job_id: int = Path(..., description="Import job ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Get status of a historical import job.
Poll this endpoint to track import progress. Returns current phase,
page being fetched, and counts of processed/imported/updated orders.
"""
order_service = get_order_service(db)
job = order_service.get_historical_import_job_by_id(vendor_id, job_id)
if not job:
raise ResourceNotFoundException("HistoricalImportJob", str(job_id))
return LetzshopHistoricalImportJobResponse.model_validate(job)
@router.get(
"/vendors/{vendor_id}/import-summary",
)
def get_import_summary(
vendor_id: int = Path(..., description="Vendor ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Get summary statistics for imported Letzshop orders.
Returns total orders, unique customers, and breakdowns by state/locale/country.
"""
order_service = get_order_service(db)
try:
order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
summary = order_service.get_historical_import_summary(vendor_id)
return {
"success": True,
"summary": summary,
}
# ============================================================================
# Fulfillment Operations (Admin)
# ============================================================================
@router.post(
"/vendors/{vendor_id}/orders/{order_id}/confirm",
response_model=FulfillmentOperationResponse,
)
def confirm_order(
vendor_id: int = Path(..., description="Vendor ID"),
order_id: int = Path(..., description="Order ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Confirm all inventory units for a Letzshop order.
Sends confirmInventoryUnits mutation with isAvailable=true for all items.
Raises:
OrderHasUnresolvedExceptionsException: If order has unresolved product exceptions
"""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
order = order_service.get_order_or_raise(vendor_id, order_id)
except OrderNotFoundError:
raise ResourceNotFoundException("Order", str(order_id))
# Check for unresolved exceptions (blocks confirmation)
unresolved_count = order_item_exception_service.get_unresolved_exception_count(
db, order_id
)
if unresolved_count > 0:
raise OrderHasUnresolvedExceptionsException(order_id, unresolved_count)
# Get inventory unit IDs from order items
items = order_service.get_order_items(order)
if not items:
return FulfillmentOperationResponse(
success=False,
message="No items found in order",
)
inventory_unit_ids = [
item.external_item_id for item in items if item.external_item_id
]
if not inventory_unit_ids:
return FulfillmentOperationResponse(
success=False,
message="No inventory unit IDs found in order items",
)
try:
with creds_service.create_client(vendor_id) as client:
result = client.confirm_inventory_units(inventory_unit_ids)
if not result.get("inventoryUnits"):
error_messages = [
e.get("message", "Unknown error")
for e in result.get("errors", [])
]
return FulfillmentOperationResponse(
success=False,
message="Some inventory units could not be confirmed",
errors=error_messages,
)
# Update order status and item states
for item in items:
if item.external_item_id:
order_service.update_inventory_unit_state(
order, item.external_item_id, "confirmed_available"
)
order_service.mark_order_confirmed(order)
db.commit()
return FulfillmentOperationResponse(
success=True,
message=f"Confirmed {len(inventory_unit_ids)} inventory units",
confirmed_units=[u.get("id") for u in result.get("inventoryUnits", [])],
)
except LetzshopClientError as e:
return FulfillmentOperationResponse(success=False, message=str(e))
@router.post(
"/vendors/{vendor_id}/orders/{order_id}/reject",
response_model=FulfillmentOperationResponse,
)
def reject_order(
vendor_id: int = Path(..., description="Vendor ID"),
order_id: int = Path(..., description="Order ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Decline all inventory units for a Letzshop order.
Sends confirmInventoryUnits mutation with isAvailable=false for all items.
"""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
order = order_service.get_order_or_raise(vendor_id, order_id)
except OrderNotFoundError:
raise ResourceNotFoundException("Order", str(order_id))
# Get inventory unit IDs from order items
items = order_service.get_order_items(order)
if not items:
return FulfillmentOperationResponse(
success=False,
message="No items found in order",
)
inventory_unit_ids = [
item.external_item_id for item in items if item.external_item_id
]
if not inventory_unit_ids:
return FulfillmentOperationResponse(
success=False,
message="No inventory unit IDs found in order items",
)
try:
with creds_service.create_client(vendor_id) as client:
result = client.reject_inventory_units(inventory_unit_ids)
if not result.get("inventoryUnits"):
error_messages = [
e.get("message", "Unknown error")
for e in result.get("errors", [])
]
return FulfillmentOperationResponse(
success=False,
message="Some inventory units could not be declined",
errors=error_messages,
)
# Update item states and order status
for item in items:
if item.external_item_id:
order_service.update_inventory_unit_state(
order, item.external_item_id, "confirmed_unavailable"
)
order_service.mark_order_rejected(order)
db.commit()
return FulfillmentOperationResponse(
success=True,
message=f"Declined {len(inventory_unit_ids)} inventory units",
)
except LetzshopClientError as e:
return FulfillmentOperationResponse(success=False, message=str(e))
@router.post(
"/vendors/{vendor_id}/orders/{order_id}/items/{item_id}/confirm",
response_model=FulfillmentOperationResponse,
)
def confirm_single_item(
vendor_id: int = Path(..., description="Vendor ID"),
order_id: int = Path(..., description="Order ID"),
item_id: str = Path(..., description="External Item ID (Letzshop inventory unit ID)"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Confirm a single inventory unit in an order.
Sends confirmInventoryUnits mutation with isAvailable=true for one item.
Raises:
OrderHasUnresolvedExceptionsException: If the specific item has an unresolved exception
"""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
order = order_service.get_order_or_raise(vendor_id, order_id)
except OrderNotFoundError:
raise ResourceNotFoundException("Order", str(order_id))
# Check if this specific item has an unresolved exception
# Find the order item by external_item_id
item = next(
(i for i in order.items if i.external_item_id == item_id),
None
)
if item and item.needs_product_match:
raise OrderHasUnresolvedExceptionsException(order_id, 1)
try:
with creds_service.create_client(vendor_id) as client:
result = client.confirm_inventory_units([item_id])
if not result.get("inventoryUnits"):
error_messages = [
e.get("message", "Unknown error")
for e in result.get("errors", [])
]
return FulfillmentOperationResponse(
success=False,
message="Failed to confirm item",
errors=error_messages,
)
# Update local order item state
order_service.update_inventory_unit_state(order, item_id, "confirmed_available")
db.commit()
return FulfillmentOperationResponse(
success=True,
message="Item confirmed",
confirmed_units=[item_id],
)
except LetzshopClientError as e:
return FulfillmentOperationResponse(success=False, message=str(e))
@router.post(
"/vendors/{vendor_id}/orders/{order_id}/items/{item_id}/decline",
response_model=FulfillmentOperationResponse,
)
def decline_single_item(
vendor_id: int = Path(..., description="Vendor ID"),
order_id: int = Path(..., description="Order ID"),
item_id: str = Path(..., description="External Item ID (Letzshop inventory unit ID)"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Decline a single inventory unit in an order.
Sends confirmInventoryUnits mutation with isAvailable=false for one item.
"""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
order = order_service.get_order_or_raise(vendor_id, order_id)
except OrderNotFoundError:
raise ResourceNotFoundException("Order", str(order_id))
try:
with creds_service.create_client(vendor_id) as client:
result = client.reject_inventory_units([item_id])
if not result.get("inventoryUnits"):
error_messages = [
e.get("message", "Unknown error")
for e in result.get("errors", [])
]
return FulfillmentOperationResponse(
success=False,
message="Failed to decline item",
errors=error_messages,
)
# Update local order item state
order_service.update_inventory_unit_state(order, item_id, "confirmed_unavailable")
db.commit()
return FulfillmentOperationResponse(
success=True,
message="Item declined",
)
except LetzshopClientError as e:
return FulfillmentOperationResponse(success=False, message=str(e))
# ============================================================================
# Tracking Sync
# ============================================================================
@router.post(
"/vendors/{vendor_id}/sync-tracking",
response_model=LetzshopSyncTriggerResponse,
)
def sync_tracking_for_vendor(
vendor_id: int = Path(..., description="Vendor ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Sync tracking information from Letzshop for confirmed orders.
Fetches tracking data from Letzshop API for orders that:
- Are in "processing" status (confirmed)
- Don't have tracking info yet
- Have an external shipment ID
This is useful when tracking is added by Letzshop after order confirmation.
"""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
# Verify credentials exist
try:
creds_service.get_credentials_or_raise(vendor_id)
except CredentialsNotFoundError:
raise ValidationException(
f"Letzshop credentials not configured for vendor {vendor_id}"
)
# Get orders that need tracking
orders_without_tracking = order_service.get_orders_without_tracking(vendor_id)
if not orders_without_tracking:
return LetzshopSyncTriggerResponse(
success=True,
message="No orders need tracking updates",
orders_imported=0,
orders_updated=0,
)
logger.info(
f"Syncing tracking for {len(orders_without_tracking)} orders (vendor {vendor_id})"
)
orders_updated = 0
errors = []
try:
with creds_service.create_client(vendor_id) as client:
for order in orders_without_tracking:
try:
# Fetch shipment by ID
shipment_data = client.get_shipment_by_id(order.external_shipment_id)
if shipment_data:
updated = order_service.update_tracking_from_shipment_data(
order, shipment_data
)
if updated:
orders_updated += 1
except Exception as e:
errors.append(
f"Error syncing tracking for order {order.order_number}: {e}"
)
db.commit()
message = f"Tracking sync completed: {orders_updated} orders updated"
if errors:
message += f" ({len(errors)} errors)"
return LetzshopSyncTriggerResponse(
success=True,
message=message,
orders_imported=0,
orders_updated=orders_updated,
errors=errors,
)
except LetzshopClientError as e:
return LetzshopSyncTriggerResponse(
success=False,
message=f"Tracking sync failed: {e}",
errors=[str(e)],
)