refactor: migrate remaining routes to modules and enforce auto-discovery

MIGRATION:
- Delete app/api/v1/vendor/analytics.py (duplicate - analytics module already auto-discovered)
- Move usage routes from app/api/v1/vendor/usage.py to billing module
- Move onboarding routes from app/api/v1/vendor/onboarding.py to marketplace module
- Move features routes to billing module (admin + vendor)
- Move inventory routes to inventory module (admin + vendor)
- Move marketplace/letzshop routes to marketplace module
- Move orders routes to orders module
- Delete legacy letzshop service files (moved to marketplace module)

DOCUMENTATION:
- Add docs/development/migration/module-autodiscovery-migration.md with full migration history
- Update docs/architecture/module-system.md with Entity Auto-Discovery Reference section
- Add detailed sections for each entity type: routes, services, models, schemas, tasks,
  exceptions, templates, static files, locales, configuration

ARCHITECTURE VALIDATION:
- Add MOD-016: Routes must be in modules, not app/api/v1/
- Add MOD-017: Services must be in modules, not app/services/
- Add MOD-018: Tasks must be in modules, not app/tasks/
- Add MOD-019: Schemas must be in modules, not models/schema/
- Update scripts/validate_architecture.py with _validate_legacy_locations method
- Update .architecture-rules/module.yaml with legacy location rules

These rules enforce that all entities must be in self-contained modules.
Legacy locations now trigger ERROR severity violations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 14:25:59 +01:00
parent e2cecff014
commit 401db56258
52 changed files with 1160 additions and 4968 deletions

View File

@@ -47,7 +47,6 @@ from . import (
companies,
dashboard,
email_templates,
features,
images,
logs,
media,
@@ -60,7 +59,6 @@ from . import (
platform_health,
platforms,
settings,
subscriptions, # Legacy - will be replaced by billing module router
tests,
users,
vendor_domains,
@@ -179,9 +177,6 @@ router.include_router(
# Include test runner endpoints
router.include_router(tests.router, prefix="/tests", tags=["admin-tests"])
# Include feature management endpoints
router.include_router(features.router, tags=["admin-features"])
# ============================================================================
# Auto-discovered Module Routes

View File

@@ -1,308 +0,0 @@
# app/api/v1/admin/features.py
"""
Admin feature management endpoints.
Provides endpoints for:
- Listing all features with their tier assignments
- Updating tier feature assignments
- Managing feature metadata
- Viewing feature usage statistics
"""
import logging
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.services.feature_service import feature_service
from models.schema.auth import UserContext
router = APIRouter(prefix="/features")
logger = logging.getLogger(__name__)
# ============================================================================
# Response Schemas
# ============================================================================
class FeatureResponse(BaseModel):
"""Feature information for admin."""
id: int
code: str
name: str
description: str | None = None
category: str
ui_location: str | None = None
ui_icon: str | None = None
ui_route: str | None = None
ui_badge_text: str | None = None
minimum_tier_id: int | None = None
minimum_tier_code: str | None = None
minimum_tier_name: str | None = None
is_active: bool
is_visible: bool
display_order: int
class FeatureListResponse(BaseModel):
"""List of features."""
features: list[FeatureResponse]
total: int
class TierFeaturesResponse(BaseModel):
"""Tier with its features."""
id: int
code: str
name: str
description: str | None = None
features: list[str]
feature_count: int
class TierListWithFeaturesResponse(BaseModel):
"""All tiers with their features."""
tiers: list[TierFeaturesResponse]
class UpdateTierFeaturesRequest(BaseModel):
"""Request to update tier features."""
feature_codes: list[str]
class UpdateFeatureRequest(BaseModel):
"""Request to update feature metadata."""
name: str | None = None
description: str | None = None
category: str | None = None
ui_location: str | None = None
ui_icon: str | None = None
ui_route: str | None = None
ui_badge_text: str | None = None
minimum_tier_code: str | None = None
is_active: bool | None = None
is_visible: bool | None = None
display_order: int | None = None
class CategoryListResponse(BaseModel):
"""List of feature categories."""
categories: list[str]
class TierFeatureDetailResponse(BaseModel):
"""Tier features with full details."""
tier_code: str
tier_name: str
features: list[dict]
feature_count: int
# ============================================================================
# Helper Functions
# ============================================================================
def _feature_to_response(feature) -> FeatureResponse:
"""Convert Feature model to response."""
return FeatureResponse(
id=feature.id,
code=feature.code,
name=feature.name,
description=feature.description,
category=feature.category,
ui_location=feature.ui_location,
ui_icon=feature.ui_icon,
ui_route=feature.ui_route,
ui_badge_text=feature.ui_badge_text,
minimum_tier_id=feature.minimum_tier_id,
minimum_tier_code=feature.minimum_tier.code if feature.minimum_tier else None,
minimum_tier_name=feature.minimum_tier.name if feature.minimum_tier else None,
is_active=feature.is_active,
is_visible=feature.is_visible,
display_order=feature.display_order,
)
# ============================================================================
# Endpoints
# ============================================================================
@router.get("", response_model=FeatureListResponse)
def list_features(
category: str | None = Query(None, description="Filter by category"),
active_only: bool = Query(False, description="Only active features"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""List all features with their tier assignments."""
features = feature_service.get_all_features(
db, category=category, active_only=active_only
)
return FeatureListResponse(
features=[_feature_to_response(f) for f in features],
total=len(features),
)
@router.get("/categories", response_model=CategoryListResponse)
def list_categories(
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""List all feature categories."""
categories = feature_service.get_categories(db)
return CategoryListResponse(categories=categories)
@router.get("/tiers", response_model=TierListWithFeaturesResponse)
def list_tiers_with_features(
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""List all tiers with their feature assignments."""
tiers = feature_service.get_all_tiers_with_features(db)
return TierListWithFeaturesResponse(
tiers=[
TierFeaturesResponse(
id=t.id,
code=t.code,
name=t.name,
description=t.description,
features=t.features or [],
feature_count=len(t.features or []),
)
for t in tiers
]
)
@router.get("/{feature_code}", response_model=FeatureResponse)
def get_feature(
feature_code: str,
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Get a single feature by code.
Raises 404 if feature not found.
"""
feature = feature_service.get_feature_by_code(db, feature_code)
if not feature:
from app.exceptions import FeatureNotFoundError
raise FeatureNotFoundError(feature_code)
return _feature_to_response(feature)
@router.put("/{feature_code}", response_model=FeatureResponse)
def update_feature(
feature_code: str,
request: UpdateFeatureRequest,
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Update feature metadata.
Raises 404 if feature not found, 400 if tier code is invalid.
"""
feature = feature_service.update_feature(
db,
feature_code,
name=request.name,
description=request.description,
category=request.category,
ui_location=request.ui_location,
ui_icon=request.ui_icon,
ui_route=request.ui_route,
ui_badge_text=request.ui_badge_text,
minimum_tier_code=request.minimum_tier_code,
is_active=request.is_active,
is_visible=request.is_visible,
display_order=request.display_order,
)
db.commit()
db.refresh(feature)
logger.info(f"Updated feature {feature_code} by admin {current_user.id}")
return _feature_to_response(feature)
@router.put("/tiers/{tier_code}/features", response_model=TierFeaturesResponse)
def update_tier_features(
tier_code: str,
request: UpdateTierFeaturesRequest,
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Update features assigned to a tier.
Raises 404 if tier not found, 422 if any feature codes are invalid.
"""
tier = feature_service.update_tier_features(db, tier_code, request.feature_codes)
db.commit()
logger.info(
f"Updated tier {tier_code} features to {len(request.feature_codes)} features "
f"by admin {current_user.id}"
)
return TierFeaturesResponse(
id=tier.id,
code=tier.code,
name=tier.name,
description=tier.description,
features=tier.features or [],
feature_count=len(tier.features or []),
)
@router.get("/tiers/{tier_code}/features", response_model=TierFeatureDetailResponse)
def get_tier_features(
tier_code: str,
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Get features assigned to a specific tier with full details.
Raises 404 if tier not found.
"""
tier, features = feature_service.get_tier_features_with_details(db, tier_code)
return TierFeatureDetailResponse(
tier_code=tier.code,
tier_name=tier.name,
features=[
{
"code": f.code,
"name": f.name,
"category": f.category,
"description": f.description,
}
for f in features
],
feature_count=len(features),
)

View File

@@ -1,433 +0,0 @@
# app/api/v1/admin/inventory.py
"""
Admin inventory management endpoints.
Provides inventory management capabilities for administrators:
- View inventory across all vendors
- View vendor-specific inventory
- Set/adjust inventory on behalf of vendors
- Low stock alerts and reporting
Admin Context: Uses admin JWT authentication.
Vendor selection is passed as a request parameter.
"""
import logging
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.services.inventory_import_service import inventory_import_service
from app.services.inventory_service import inventory_service
from app.services.inventory_transaction_service import inventory_transaction_service
from models.schema.auth import UserContext
from app.modules.inventory.schemas import (
AdminInventoryAdjust,
AdminInventoryCreate,
AdminInventoryListResponse,
AdminInventoryLocationsResponse,
AdminInventoryStats,
AdminInventoryTransactionItem,
AdminInventoryTransactionListResponse,
AdminLowStockItem,
AdminTransactionStatsResponse,
AdminVendorsWithInventoryResponse,
InventoryAdjust,
InventoryCreate,
InventoryMessageResponse,
InventoryResponse,
InventoryUpdate,
ProductInventorySummary,
)
router = APIRouter(prefix="/inventory")
logger = logging.getLogger(__name__)
# ============================================================================
# List & Statistics Endpoints
# ============================================================================
@router.get("", response_model=AdminInventoryListResponse)
def get_all_inventory(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=500),
vendor_id: int | None = Query(None, description="Filter by vendor"),
location: str | None = Query(None, description="Filter by location"),
low_stock: int | None = Query(None, ge=0, description="Filter items below threshold"),
search: str | None = Query(None, description="Search by product title or SKU"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Get inventory across all vendors with filtering.
Allows admins to view and filter inventory across the platform.
"""
return inventory_service.get_all_inventory_admin(
db=db,
skip=skip,
limit=limit,
vendor_id=vendor_id,
location=location,
low_stock=low_stock,
search=search,
)
@router.get("/stats", response_model=AdminInventoryStats)
def get_inventory_stats(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get platform-wide inventory statistics."""
return inventory_service.get_inventory_stats_admin(db)
@router.get("/low-stock", response_model=list[AdminLowStockItem])
def get_low_stock_items(
threshold: int = Query(10, ge=0, description="Stock threshold"),
vendor_id: int | None = Query(None, description="Filter by vendor"),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get items with low stock levels."""
return inventory_service.get_low_stock_items_admin(
db=db,
threshold=threshold,
vendor_id=vendor_id,
limit=limit,
)
@router.get("/vendors", response_model=AdminVendorsWithInventoryResponse)
def get_vendors_with_inventory(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get list of vendors that have inventory entries."""
return inventory_service.get_vendors_with_inventory_admin(db)
@router.get("/locations", response_model=AdminInventoryLocationsResponse)
def get_inventory_locations(
vendor_id: int | None = Query(None, description="Filter by vendor"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get list of unique inventory locations."""
return inventory_service.get_inventory_locations_admin(db, vendor_id)
# ============================================================================
# Vendor-Specific Endpoints
# ============================================================================
@router.get("/vendors/{vendor_id}", response_model=AdminInventoryListResponse)
def get_vendor_inventory(
vendor_id: int,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=500),
location: str | None = Query(None, description="Filter by location"),
low_stock: int | None = Query(None, ge=0, description="Filter items below threshold"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get inventory for a specific vendor."""
return inventory_service.get_vendor_inventory_admin(
db=db,
vendor_id=vendor_id,
skip=skip,
limit=limit,
location=location,
low_stock=low_stock,
)
@router.get("/products/{product_id}", response_model=ProductInventorySummary)
def get_product_inventory(
product_id: int,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get inventory summary for a specific product across all locations."""
return inventory_service.get_product_inventory_admin(db, product_id)
# ============================================================================
# Inventory Modification Endpoints
# ============================================================================
@router.post("/set", response_model=InventoryResponse)
def set_inventory(
inventory_data: AdminInventoryCreate,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Set exact inventory quantity for a product at a location.
Admin version - requires explicit vendor_id in request body.
"""
# Verify vendor exists
inventory_service.verify_vendor_exists(db, inventory_data.vendor_id)
# Convert to standard schema for service
service_data = InventoryCreate(
product_id=inventory_data.product_id,
location=inventory_data.location,
quantity=inventory_data.quantity,
)
result = inventory_service.set_inventory(
db=db,
vendor_id=inventory_data.vendor_id,
inventory_data=service_data,
)
logger.info(
f"Admin {current_admin.email} set inventory for product {inventory_data.product_id} "
f"at {inventory_data.location}: {inventory_data.quantity} units"
)
db.commit()
return result
@router.post("/adjust", response_model=InventoryResponse)
def adjust_inventory(
adjustment: AdminInventoryAdjust,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Adjust inventory by adding or removing quantity.
Positive quantity = add stock, negative = remove stock.
Admin version - requires explicit vendor_id in request body.
"""
# Verify vendor exists
inventory_service.verify_vendor_exists(db, adjustment.vendor_id)
# Convert to standard schema for service
service_data = InventoryAdjust(
product_id=adjustment.product_id,
location=adjustment.location,
quantity=adjustment.quantity,
)
result = inventory_service.adjust_inventory(
db=db,
vendor_id=adjustment.vendor_id,
inventory_data=service_data,
)
sign = "+" if adjustment.quantity >= 0 else ""
logger.info(
f"Admin {current_admin.email} adjusted inventory for product {adjustment.product_id} "
f"at {adjustment.location}: {sign}{adjustment.quantity} units"
f"{f' (reason: {adjustment.reason})' if adjustment.reason else ''}"
)
db.commit()
return result
@router.put("/{inventory_id}", response_model=InventoryResponse)
def update_inventory(
inventory_id: int,
inventory_update: InventoryUpdate,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Update inventory entry fields."""
# Get inventory to find vendor_id
inventory = inventory_service.get_inventory_by_id_admin(db, inventory_id)
result = inventory_service.update_inventory(
db=db,
vendor_id=inventory.vendor_id,
inventory_id=inventory_id,
inventory_update=inventory_update,
)
logger.info(f"Admin {current_admin.email} updated inventory {inventory_id}")
db.commit()
return result
@router.delete("/{inventory_id}", response_model=InventoryMessageResponse)
def delete_inventory(
inventory_id: int,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Delete inventory entry."""
# Get inventory to find vendor_id and log details
inventory = inventory_service.get_inventory_by_id_admin(db, inventory_id)
vendor_id = inventory.vendor_id
product_id = inventory.product_id
location = inventory.location
inventory_service.delete_inventory(
db=db,
vendor_id=vendor_id,
inventory_id=inventory_id,
)
logger.info(
f"Admin {current_admin.email} deleted inventory {inventory_id} "
f"(product {product_id} at {location})"
)
db.commit()
return InventoryMessageResponse(message="Inventory deleted successfully")
# ============================================================================
# Import Endpoints
# ============================================================================
class UnmatchedGtin(BaseModel):
"""GTIN that couldn't be matched to a product."""
gtin: str
quantity: int
product_name: str
class InventoryImportResponse(BaseModel):
"""Response from inventory import."""
success: bool
total_rows: int
entries_created: int
entries_updated: int
quantity_imported: int
unmatched_gtins: list[UnmatchedGtin]
errors: list[str]
@router.post("/import", response_model=InventoryImportResponse)
async def import_inventory(
file: UploadFile = File(..., description="TSV/CSV file with BIN, EAN, PRODUCT, QUANTITY columns"),
vendor_id: int = Form(..., description="Vendor ID"),
warehouse: str = Form("strassen", description="Warehouse name"),
clear_existing: bool = Form(False, description="Clear existing inventory before import"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Import inventory from a TSV/CSV file.
File format (TSV recommended):
- Required columns: BIN, EAN
- Optional columns: PRODUCT (for display), QUANTITY (defaults to 1 per row)
If QUANTITY column is present, each row represents the quantity specified.
If QUANTITY is absent, each row counts as 1 unit (rows with same EAN+BIN are summed).
Products are matched by GTIN/EAN. Unmatched GTINs are reported in the response.
"""
# Verify vendor exists
inventory_service.verify_vendor_exists(db, vendor_id)
# Read file content
content = await file.read()
try:
content_str = content.decode("utf-8")
except UnicodeDecodeError:
content_str = content.decode("latin-1")
# Detect delimiter
first_line = content_str.split("\n")[0] if content_str else ""
delimiter = "\t" if "\t" in first_line else ","
# Run import
result = inventory_import_service.import_from_text(
db=db,
content=content_str,
vendor_id=vendor_id,
warehouse=warehouse,
delimiter=delimiter,
clear_existing=clear_existing,
)
if result.success:
db.commit()
logger.info(
f"Admin {current_admin.email} imported inventory: "
f"{result.entries_created} created, {result.entries_updated} updated, "
f"{result.quantity_imported} total units"
)
else:
db.rollback()
logger.error(f"Inventory import failed: {result.errors}")
return InventoryImportResponse(
success=result.success,
total_rows=result.total_rows,
entries_created=result.entries_created,
entries_updated=result.entries_updated,
quantity_imported=result.quantity_imported,
unmatched_gtins=[UnmatchedGtin(**g) for g in result.unmatched_gtins],
errors=result.errors,
)
# ============================================================================
# Transaction History Endpoints
# ============================================================================
@router.get("/transactions", response_model=AdminInventoryTransactionListResponse)
def get_all_transactions(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
vendor_id: int | None = Query(None, description="Filter by vendor"),
product_id: int | None = Query(None, description="Filter by product"),
transaction_type: str | None = Query(None, description="Filter by type"),
order_id: int | None = Query(None, description="Filter by order"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Get inventory transaction history across all vendors.
Returns a paginated list of all stock movements with vendor and product details.
"""
transactions, total = inventory_transaction_service.get_all_transactions_admin(
db=db,
skip=skip,
limit=limit,
vendor_id=vendor_id,
product_id=product_id,
transaction_type=transaction_type,
order_id=order_id,
)
return AdminInventoryTransactionListResponse(
transactions=[AdminInventoryTransactionItem(**tx) for tx in transactions],
total=total,
skip=skip,
limit=limit,
)
@router.get("/transactions/stats", response_model=AdminTransactionStatsResponse)
def get_transaction_stats(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get transaction statistics for the platform."""
stats = inventory_transaction_service.get_transaction_stats_admin(db)
return AdminTransactionStatsResponse(**stats)

File diff suppressed because it is too large Load Diff

View File

@@ -1,173 +0,0 @@
# app/api/v1/admin/marketplace.py
"""
Marketplace import job monitoring endpoints for admin.
"""
import logging
from fastapi import APIRouter, BackgroundTasks, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.services.marketplace_import_job_service import marketplace_import_job_service
from app.services.stats_service import stats_service
from app.services.vendor_service import vendor_service
from app.tasks.background_tasks import process_marketplace_import
from models.schema.auth import UserContext
from app.modules.marketplace.schemas import (
AdminMarketplaceImportJobListResponse,
AdminMarketplaceImportJobRequest,
AdminMarketplaceImportJobResponse,
MarketplaceImportErrorListResponse,
MarketplaceImportErrorResponse,
MarketplaceImportJobRequest,
MarketplaceImportJobResponse,
)
from app.modules.analytics.schemas import ImportStatsResponse
router = APIRouter(prefix="/marketplace-import-jobs")
logger = logging.getLogger(__name__)
@router.get("", response_model=AdminMarketplaceImportJobListResponse)
def get_all_marketplace_import_jobs(
marketplace: str | None = Query(None),
status: str | None = Query(None),
page: int = Query(1, ge=1),
limit: int = Query(100, ge=1, le=100),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get all marketplace import jobs with pagination (Admin only)."""
jobs, total = marketplace_import_job_service.get_all_import_jobs_paginated(
db=db,
marketplace=marketplace,
status=status,
page=page,
limit=limit,
)
return AdminMarketplaceImportJobListResponse(
items=[
marketplace_import_job_service.convert_to_admin_response_model(job)
for job in jobs
],
total=total,
page=page,
limit=limit,
)
@router.post("", response_model=MarketplaceImportJobResponse)
async def create_marketplace_import_job(
request: AdminMarketplaceImportJobRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Create a new marketplace import job (Admin only).
Admins can trigger imports for any vendor by specifying vendor_id.
The import is processed asynchronously in the background.
The `language` parameter specifies the language code for product
translations (e.g., 'en', 'fr', 'de'). Default is 'en'.
"""
vendor = vendor_service.get_vendor_by_id(db, request.vendor_id)
job_request = MarketplaceImportJobRequest(
source_url=request.source_url,
marketplace=request.marketplace,
batch_size=request.batch_size,
language=request.language,
)
job = marketplace_import_job_service.create_import_job(
db=db,
request=job_request,
vendor=vendor,
user=current_admin,
)
db.commit()
logger.info(
f"Admin {current_admin.username} created import job {job.id} "
f"for vendor {vendor.vendor_code} (language={request.language})"
)
# Dispatch via task dispatcher (supports Celery or BackgroundTasks)
from app.tasks.dispatcher import task_dispatcher
celery_task_id = task_dispatcher.dispatch_marketplace_import(
background_tasks=background_tasks,
job_id=job.id,
url=request.source_url,
marketplace=request.marketplace,
vendor_id=vendor.id,
batch_size=request.batch_size or 1000,
language=request.language,
)
# Store Celery task ID if using Celery
if celery_task_id:
job.celery_task_id = celery_task_id
db.commit()
return marketplace_import_job_service.convert_to_response_model(job)
# NOTE: /stats must be defined BEFORE /{job_id} to avoid route conflicts
@router.get("/stats", response_model=ImportStatsResponse)
def get_import_statistics(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get marketplace import statistics (Admin only)."""
stats = stats_service.get_import_statistics(db)
return ImportStatsResponse(**stats)
@router.get("/{job_id}", response_model=AdminMarketplaceImportJobResponse)
def get_marketplace_import_job(
job_id: int,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get a single marketplace import job by ID (Admin only)."""
job = marketplace_import_job_service.get_import_job_by_id_admin(db, job_id)
return marketplace_import_job_service.convert_to_admin_response_model(job)
@router.get("/{job_id}/errors", response_model=MarketplaceImportErrorListResponse)
def get_import_job_errors(
job_id: int,
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=100),
error_type: str | None = Query(None, description="Filter by error type"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get import errors for a specific job (Admin only).
Returns detailed error information including row number, identifier,
error type, error message, and raw row data for review.
"""
# Verify job exists
marketplace_import_job_service.get_import_job_by_id_admin(db, job_id)
# Get errors from service
errors, total = marketplace_import_job_service.get_import_job_errors(
db=db,
job_id=job_id,
error_type=error_type,
page=page,
limit=limit,
)
return MarketplaceImportErrorListResponse(
errors=[MarketplaceImportErrorResponse.model_validate(e) for e in errors],
total=total,
import_job_id=job_id,
)

View File

@@ -1,252 +0,0 @@
# app/api/v1/admin/order_item_exceptions.py
"""
Admin API endpoints for order item exception management.
Provides admin-level management of:
- Listing exceptions across all vendors
- Resolving exceptions by assigning products
- Bulk resolution by GTIN
- Exception statistics
"""
import logging
from fastapi import APIRouter, 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.services.order_item_exception_service import order_item_exception_service
from models.schema.auth import UserContext
from app.modules.orders.schemas import (
BulkResolveRequest,
BulkResolveResponse,
IgnoreExceptionRequest,
OrderItemExceptionListResponse,
OrderItemExceptionResponse,
OrderItemExceptionStats,
ResolveExceptionRequest,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/order-exceptions", tags=["Order Item Exceptions"])
# ============================================================================
# Exception Listing and Stats
# ============================================================================
@router.get("", response_model=OrderItemExceptionListResponse)
def list_exceptions(
vendor_id: int | None = Query(None, description="Filter by vendor"),
status: str | None = Query(
None,
pattern="^(pending|resolved|ignored)$",
description="Filter by status"
),
search: str | None = Query(
None,
description="Search in GTIN, product name, SKU, or order number"
),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
List order item exceptions with filtering and pagination.
Returns exceptions for unmatched products during marketplace order imports.
"""
exceptions, total = order_item_exception_service.get_pending_exceptions(
db=db,
vendor_id=vendor_id,
status=status,
search=search,
skip=skip,
limit=limit,
)
# Enrich with order and vendor info
response_items = []
for exc in exceptions:
item = OrderItemExceptionResponse.model_validate(exc)
if exc.order_item and exc.order_item.order:
order = exc.order_item.order
item.order_number = order.order_number
item.order_id = order.id
item.order_date = order.order_date
item.order_status = order.status
# Add vendor name for cross-vendor view
if order.vendor:
item.vendor_name = order.vendor.name
response_items.append(item)
return OrderItemExceptionListResponse(
exceptions=response_items,
total=total,
skip=skip,
limit=limit,
)
@router.get("/stats", response_model=OrderItemExceptionStats)
def get_exception_stats(
vendor_id: int | None = Query(None, description="Filter by vendor"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Get exception statistics.
Returns counts of pending, resolved, and ignored exceptions.
"""
stats = order_item_exception_service.get_exception_stats(db, vendor_id)
return OrderItemExceptionStats(**stats)
# ============================================================================
# Exception Details
# ============================================================================
@router.get("/{exception_id}", response_model=OrderItemExceptionResponse)
def get_exception(
exception_id: int = Path(..., description="Exception ID"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Get details of a single exception.
"""
exception = order_item_exception_service.get_exception_by_id(db, exception_id)
response = OrderItemExceptionResponse.model_validate(exception)
if exception.order_item and exception.order_item.order:
order = exception.order_item.order
response.order_number = order.order_number
response.order_id = order.id
response.order_date = order.order_date
response.order_status = order.status
return response
# ============================================================================
# Exception Resolution
# ============================================================================
@router.post("/{exception_id}/resolve", response_model=OrderItemExceptionResponse)
def resolve_exception(
exception_id: int = Path(..., description="Exception ID"),
request: ResolveExceptionRequest = ...,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Resolve an exception by assigning a product.
This updates the order item's product_id and marks the exception as resolved.
"""
exception = order_item_exception_service.resolve_exception(
db=db,
exception_id=exception_id,
product_id=request.product_id,
resolved_by=current_admin.id,
notes=request.notes,
)
db.commit()
response = OrderItemExceptionResponse.model_validate(exception)
if exception.order_item and exception.order_item.order:
order = exception.order_item.order
response.order_number = order.order_number
response.order_id = order.id
response.order_date = order.order_date
response.order_status = order.status
logger.info(
f"Admin {current_admin.id} resolved exception {exception_id} "
f"with product {request.product_id}"
)
return response
@router.post("/{exception_id}/ignore", response_model=OrderItemExceptionResponse)
def ignore_exception(
exception_id: int = Path(..., description="Exception ID"),
request: IgnoreExceptionRequest = ...,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Mark an exception as ignored.
Note: Ignored exceptions still block order confirmation.
Use this when a product will never be matched (e.g., discontinued).
"""
exception = order_item_exception_service.ignore_exception(
db=db,
exception_id=exception_id,
resolved_by=current_admin.id,
notes=request.notes,
)
db.commit()
response = OrderItemExceptionResponse.model_validate(exception)
if exception.order_item and exception.order_item.order:
order = exception.order_item.order
response.order_number = order.order_number
response.order_id = order.id
response.order_date = order.order_date
response.order_status = order.status
logger.info(
f"Admin {current_admin.id} ignored exception {exception_id}: {request.notes}"
)
return response
# ============================================================================
# Bulk Operations
# ============================================================================
@router.post("/bulk-resolve", response_model=BulkResolveResponse)
def bulk_resolve_by_gtin(
request: BulkResolveRequest,
vendor_id: int = Query(..., description="Vendor ID"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Bulk resolve all pending exceptions for a GTIN.
Useful when a new product is imported and multiple orders have
items with the same unmatched GTIN.
"""
resolved_count = order_item_exception_service.bulk_resolve_by_gtin(
db=db,
vendor_id=vendor_id,
gtin=request.gtin,
product_id=request.product_id,
resolved_by=current_admin.id,
notes=request.notes,
)
db.commit()
logger.info(
f"Admin {current_admin.id} bulk resolved {resolved_count} exceptions "
f"for GTIN {request.gtin} with product {request.product_id}"
)
return BulkResolveResponse(
resolved_count=resolved_count,
gtin=request.gtin,
product_id=request.product_id,
)

View File

@@ -1,193 +0,0 @@
# app/api/v1/admin/orders.py
"""
Admin order management endpoints.
Provides order management capabilities for administrators:
- View orders across all vendors
- View vendor-specific orders
- Update order status on behalf of vendors
- Order statistics and reporting
Admin Context: Uses admin JWT authentication.
Vendor selection is passed as a request parameter.
"""
import logging
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.services.order_service import order_service
from models.schema.auth import UserContext
from app.modules.orders.schemas import (
AdminOrderItem,
AdminOrderListResponse,
AdminOrderStats,
AdminOrderStatusUpdate,
AdminVendorsWithOrdersResponse,
MarkAsShippedRequest,
OrderDetailResponse,
ShippingLabelInfo,
)
router = APIRouter(prefix="/orders")
logger = logging.getLogger(__name__)
# ============================================================================
# List & Statistics Endpoints
# ============================================================================
@router.get("", response_model=AdminOrderListResponse)
def get_all_orders(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=500),
vendor_id: int | None = Query(None, description="Filter by vendor"),
status: str | None = Query(None, description="Filter by status"),
channel: str | None = Query(None, description="Filter by channel"),
search: str | None = Query(None, description="Search by order number or customer"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Get orders across all vendors with filtering.
Allows admins to view and filter orders across the platform.
"""
orders, total = order_service.get_all_orders_admin(
db=db,
skip=skip,
limit=limit,
vendor_id=vendor_id,
status=status,
channel=channel,
search=search,
)
return AdminOrderListResponse(
orders=[AdminOrderItem(**order) for order in orders],
total=total,
skip=skip,
limit=limit,
)
@router.get("/stats", response_model=AdminOrderStats)
def get_order_stats(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get platform-wide order statistics."""
return order_service.get_order_stats_admin(db)
@router.get("/vendors", response_model=AdminVendorsWithOrdersResponse)
def get_vendors_with_orders(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get list of vendors that have orders."""
vendors = order_service.get_vendors_with_orders_admin(db)
return AdminVendorsWithOrdersResponse(vendors=vendors)
# ============================================================================
# Order Detail & Update Endpoints
# ============================================================================
@router.get("/{order_id}", response_model=OrderDetailResponse)
def get_order_detail(
order_id: int,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get order details including items and addresses."""
order = order_service.get_order_by_id_admin(db, order_id)
# Enrich with vendor info
response = OrderDetailResponse.model_validate(order)
if order.vendor:
response.vendor_name = order.vendor.name
response.vendor_code = order.vendor.vendor_code
return response
@router.patch("/{order_id}/status", response_model=OrderDetailResponse)
def update_order_status(
order_id: int,
status_update: AdminOrderStatusUpdate,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Update order status.
Admin can update status and add tracking number.
Status changes are logged with optional reason.
"""
order = order_service.update_order_status_admin(
db=db,
order_id=order_id,
status=status_update.status,
tracking_number=status_update.tracking_number,
reason=status_update.reason,
)
logger.info(
f"Admin {current_admin.email} updated order {order.order_number} "
f"status to {status_update.status}"
)
db.commit()
return order
@router.post("/{order_id}/ship", response_model=OrderDetailResponse)
def mark_order_as_shipped(
order_id: int,
ship_request: MarkAsShippedRequest,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Mark an order as shipped with optional tracking information.
This endpoint:
- Sets order status to 'shipped'
- Sets shipped_at timestamp
- Optionally stores tracking number, URL, and carrier
"""
order = order_service.mark_as_shipped_admin(
db=db,
order_id=order_id,
tracking_number=ship_request.tracking_number,
tracking_url=ship_request.tracking_url,
shipping_carrier=ship_request.shipping_carrier,
)
logger.info(
f"Admin {current_admin.email} marked order {order.order_number} as shipped"
)
db.commit()
return order
@router.get("/{order_id}/shipping-label", response_model=ShippingLabelInfo)
def get_shipping_label_info(
order_id: int,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Get shipping label information for an order.
Returns the shipment number, carrier, and generated label URL
based on carrier settings.
"""
return order_service.get_shipping_label_info_admin(db, order_id)

View File

@@ -1,331 +0,0 @@
# app/api/v1/admin/subscriptions.py
"""
Admin Subscription Management API.
Provides endpoints for platform administrators to manage:
- Subscription tiers (CRUD)
- Vendor subscriptions (view, update, override limits)
- Billing history across all vendors
- Subscription analytics
"""
import logging
from fastapi import APIRouter, 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.services.admin_subscription_service import admin_subscription_service
from app.services.subscription_service import subscription_service
from models.schema.auth import UserContext
from app.modules.billing.schemas import (
BillingHistoryListResponse,
BillingHistoryWithVendor,
SubscriptionStatsResponse,
SubscriptionTierCreate,
SubscriptionTierListResponse,
SubscriptionTierResponse,
SubscriptionTierUpdate,
VendorSubscriptionCreate,
VendorSubscriptionListResponse,
VendorSubscriptionResponse,
VendorSubscriptionUpdate,
VendorSubscriptionWithVendor,
)
router = APIRouter(prefix="/subscriptions")
logger = logging.getLogger(__name__)
# ============================================================================
# Subscription Tier Endpoints
# ============================================================================
@router.get("/tiers", response_model=SubscriptionTierListResponse)
def list_subscription_tiers(
include_inactive: bool = Query(False, description="Include inactive tiers"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
List all subscription tiers.
Returns all tiers with their limits, features, and Stripe configuration.
"""
tiers = admin_subscription_service.get_tiers(db, include_inactive=include_inactive)
return SubscriptionTierListResponse(
tiers=[SubscriptionTierResponse.model_validate(t) for t in tiers],
total=len(tiers),
)
@router.get("/tiers/{tier_code}", response_model=SubscriptionTierResponse)
def get_subscription_tier(
tier_code: str = Path(..., description="Tier code"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get a specific subscription tier by code."""
tier = admin_subscription_service.get_tier_by_code(db, tier_code)
return SubscriptionTierResponse.model_validate(tier)
@router.post("/tiers", response_model=SubscriptionTierResponse, status_code=201)
def create_subscription_tier(
tier_data: SubscriptionTierCreate,
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Create a new subscription tier."""
tier = admin_subscription_service.create_tier(db, tier_data.model_dump())
db.commit()
db.refresh(tier)
return SubscriptionTierResponse.model_validate(tier)
@router.patch("/tiers/{tier_code}", response_model=SubscriptionTierResponse)
def update_subscription_tier(
tier_data: SubscriptionTierUpdate,
tier_code: str = Path(..., description="Tier code"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Update a subscription tier."""
update_data = tier_data.model_dump(exclude_unset=True)
tier = admin_subscription_service.update_tier(db, tier_code, update_data)
db.commit()
db.refresh(tier)
return SubscriptionTierResponse.model_validate(tier)
@router.delete("/tiers/{tier_code}", status_code=204)
def delete_subscription_tier(
tier_code: str = Path(..., description="Tier code"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Soft-delete a subscription tier.
Sets is_active=False rather than deleting to preserve history.
"""
admin_subscription_service.deactivate_tier(db, tier_code)
db.commit()
# ============================================================================
# Vendor Subscription Endpoints
# ============================================================================
@router.get("", response_model=VendorSubscriptionListResponse)
def list_vendor_subscriptions(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
status: str | None = Query(None, description="Filter by status"),
tier: str | None = Query(None, description="Filter by tier"),
search: str | None = Query(None, description="Search vendor name"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
List all vendor subscriptions with filtering.
Includes vendor information for each subscription.
"""
data = admin_subscription_service.list_subscriptions(
db, page=page, per_page=per_page, status=status, tier=tier, search=search
)
subscriptions = []
for sub, vendor in data["results"]:
sub_dict = {
**VendorSubscriptionResponse.model_validate(sub).model_dump(),
"vendor_name": vendor.name,
"vendor_code": vendor.subdomain,
}
subscriptions.append(VendorSubscriptionWithVendor(**sub_dict))
return VendorSubscriptionListResponse(
subscriptions=subscriptions,
total=data["total"],
page=data["page"],
per_page=data["per_page"],
pages=data["pages"],
)
# ============================================================================
# Statistics Endpoints
# ============================================================================
@router.get("/stats", response_model=SubscriptionStatsResponse)
def get_subscription_stats(
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get subscription statistics for admin dashboard."""
stats = admin_subscription_service.get_stats(db)
return SubscriptionStatsResponse(**stats)
# ============================================================================
# Billing History Endpoints
# ============================================================================
@router.get("/billing/history", response_model=BillingHistoryListResponse)
def list_billing_history(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
vendor_id: int | None = Query(None, description="Filter by vendor"),
status: str | None = Query(None, description="Filter by status"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""List billing history (invoices) across all vendors."""
data = admin_subscription_service.list_billing_history(
db, page=page, per_page=per_page, vendor_id=vendor_id, status=status
)
invoices = []
for invoice, vendor in data["results"]:
invoice_dict = {
"id": invoice.id,
"vendor_id": invoice.vendor_id,
"stripe_invoice_id": invoice.stripe_invoice_id,
"invoice_number": invoice.invoice_number,
"invoice_date": invoice.invoice_date,
"due_date": invoice.due_date,
"subtotal_cents": invoice.subtotal_cents,
"tax_cents": invoice.tax_cents,
"total_cents": invoice.total_cents,
"amount_paid_cents": invoice.amount_paid_cents,
"currency": invoice.currency,
"status": invoice.status,
"invoice_pdf_url": invoice.invoice_pdf_url,
"hosted_invoice_url": invoice.hosted_invoice_url,
"description": invoice.description,
"created_at": invoice.created_at,
"vendor_name": vendor.name,
"vendor_code": vendor.subdomain,
}
invoices.append(BillingHistoryWithVendor(**invoice_dict))
return BillingHistoryListResponse(
invoices=invoices,
total=data["total"],
page=data["page"],
per_page=data["per_page"],
pages=data["pages"],
)
# ============================================================================
# Vendor Subscription Detail Endpoints
# ============================================================================
@router.post("/{vendor_id}", response_model=VendorSubscriptionWithVendor, status_code=201)
def create_vendor_subscription(
create_data: VendorSubscriptionCreate,
vendor_id: int = Path(..., description="Vendor ID"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Create a subscription for a vendor.
Creates a new subscription with the specified tier and status.
Defaults to Essential tier with trial status.
"""
# Verify vendor exists
vendor = admin_subscription_service.get_vendor(db, vendor_id)
# Create subscription using the subscription service
sub = subscription_service.get_or_create_subscription(
db,
vendor_id=vendor_id,
tier=create_data.tier,
trial_days=create_data.trial_days,
)
# Update status if not trial
if create_data.status != "trial":
sub.status = create_data.status
sub.is_annual = create_data.is_annual
db.commit()
db.refresh(sub)
# Get usage counts
usage = admin_subscription_service.get_vendor_usage_counts(db, vendor_id)
logger.info(f"Admin created subscription for vendor {vendor_id}: tier={create_data.tier}")
return VendorSubscriptionWithVendor(
**VendorSubscriptionResponse.model_validate(sub).model_dump(),
vendor_name=vendor.name,
vendor_code=vendor.subdomain,
products_count=usage["products_count"],
team_count=usage["team_count"],
)
@router.get("/{vendor_id}", response_model=VendorSubscriptionWithVendor)
def get_vendor_subscription(
vendor_id: int = Path(..., description="Vendor ID"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get subscription details for a specific vendor."""
sub, vendor = admin_subscription_service.get_subscription(db, vendor_id)
# Get usage counts
usage = admin_subscription_service.get_vendor_usage_counts(db, vendor_id)
return VendorSubscriptionWithVendor(
**VendorSubscriptionResponse.model_validate(sub).model_dump(),
vendor_name=vendor.name,
vendor_code=vendor.subdomain,
products_count=usage["products_count"],
team_count=usage["team_count"],
)
@router.patch("/{vendor_id}", response_model=VendorSubscriptionWithVendor)
def update_vendor_subscription(
update_data: VendorSubscriptionUpdate,
vendor_id: int = Path(..., description="Vendor ID"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Update a vendor's subscription.
Allows admins to:
- Change tier
- Update status
- Set custom limit overrides
- Extend trial period
"""
data = update_data.model_dump(exclude_unset=True)
sub, vendor = admin_subscription_service.update_subscription(db, vendor_id, data)
db.commit()
db.refresh(sub)
# Get usage counts
usage = admin_subscription_service.get_vendor_usage_counts(db, vendor_id)
return VendorSubscriptionWithVendor(
**VendorSubscriptionResponse.model_validate(sub).model_dump(),
vendor_name=vendor.name,
vendor_code=vendor.subdomain,
products_count=usage["products_count"],
team_count=usage["team_count"],
)

View File

@@ -345,7 +345,7 @@ def export_vendor_products_letzshop(
"""
from fastapi.responses import Response
from app.services.letzshop_export_service import letzshop_export_service
from app.modules.marketplace.services.letzshop_export_service import letzshop_export_service
vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier)
@@ -396,7 +396,7 @@ def export_vendor_products_letzshop_to_folder(
from pathlib import Path as FilePath
from app.core.config import settings
from app.services.letzshop_export_service import letzshop_export_service
from app.modules.marketplace.services.letzshop_export_service import letzshop_export_service
vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier)
include_inactive = request.include_inactive if request else False

View File

@@ -19,7 +19,7 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.services.letzshop.vendor_sync_service import LetzshopVendorSyncService
from app.modules.marketplace.services.letzshop import LetzshopVendorSyncService
from app.services.platform_signup_service import platform_signup_service
from app.modules.marketplace.models import LetzshopVendorCache

View File

@@ -15,10 +15,11 @@ For multi-tenant apps, module enablement is checked at request time
based on platform context (not at route registration time).
Self-contained modules (auto-discovered from app/modules/{module}/routes/api/vendor.py):
- billing: Subscription tiers, vendor billing, checkout, add-ons, features
- analytics: Vendor analytics and reporting
- billing: Subscription tiers, vendor billing, checkout, add-ons, features, usage
- inventory: Stock management, inventory tracking
- orders: Order management, fulfillment, exceptions, invoices
- marketplace: Letzshop integration, product sync
- marketplace: Letzshop integration, product sync, onboarding
- catalog: Vendor product catalog management
- cms: Content pages management
- customers: Customer management
@@ -29,7 +30,6 @@ from fastapi import APIRouter
# Import all sub-routers (legacy routes that haven't been migrated to modules)
from . import (
analytics,
auth,
dashboard,
email_settings,
@@ -38,11 +38,9 @@ from . import (
media,
messages,
notifications,
onboarding,
profile,
settings,
team,
usage,
)
# Create vendor router
@@ -66,7 +64,6 @@ router.include_router(profile.router, tags=["vendor-profile"])
router.include_router(settings.router, tags=["vendor-settings"])
router.include_router(email_templates.router, tags=["vendor-email-templates"])
router.include_router(email_settings.router, tags=["vendor-email-settings"])
router.include_router(onboarding.router, tags=["vendor-onboarding"])
# Business operations (with prefixes: /team/*)
router.include_router(team.router, tags=["vendor-team"])
@@ -75,8 +72,6 @@ router.include_router(team.router, tags=["vendor-team"])
router.include_router(media.router, tags=["vendor-media"])
router.include_router(notifications.router, tags=["vendor-notifications"])
router.include_router(messages.router, tags=["vendor-messages"])
router.include_router(analytics.router, tags=["vendor-analytics"])
router.include_router(usage.router, tags=["vendor-usage"])
# ============================================================================

View File

@@ -1,55 +0,0 @@
# app/api/v1/vendor/analytics.py
"""
Vendor analytics and reporting endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
Feature Requirements:
- basic_reports: Basic analytics (Essential tier)
- analytics_dashboard: Advanced analytics (Business tier)
"""
import logging
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.core.feature_gate import RequireFeature
from app.services.stats_service import stats_service
from app.modules.billing.models import FeatureCode
from models.schema.auth import UserContext
from app.modules.analytics.schemas import (
VendorAnalyticsCatalog,
VendorAnalyticsImports,
VendorAnalyticsInventory,
VendorAnalyticsResponse,
)
router = APIRouter(prefix="/analytics")
logger = logging.getLogger(__name__)
@router.get("", response_model=VendorAnalyticsResponse)
def get_vendor_analytics(
period: str = Query("30d", description="Time period: 7d, 30d, 90d, 1y"),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
_: None = Depends(RequireFeature(FeatureCode.BASIC_REPORTS, FeatureCode.ANALYTICS_DASHBOARD)),
):
"""Get vendor analytics data for specified time period."""
data = stats_service.get_vendor_analytics(db, current_user.token_vendor_id, period)
return VendorAnalyticsResponse(
period=data["period"],
start_date=data["start_date"],
imports=VendorAnalyticsImports(count=data["imports"]["count"]),
catalog=VendorAnalyticsCatalog(
products_added=data["catalog"]["products_added"]
),
inventory=VendorAnalyticsInventory(
total_locations=data["inventory"]["total_locations"]
),
)

View File

@@ -1,348 +0,0 @@
# app/api/v1/vendor/features.py
"""
Vendor features API endpoints.
Provides feature availability information for the frontend to:
- Show/hide UI elements based on tier
- Display upgrade prompts for unavailable features
- Load feature metadata for dynamic rendering
Endpoints:
- GET /features/available - List of feature codes (for quick checks)
- GET /features - Full feature list with availability and metadata
- GET /features/{code} - Single feature details with upgrade info
- GET /features/categories - List feature categories
"""
import logging
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import FeatureNotFoundError
from app.services.feature_service import feature_service
from models.schema.auth import UserContext
router = APIRouter(prefix="/features")
logger = logging.getLogger(__name__)
# ============================================================================
# Response Schemas
# ============================================================================
class FeatureCodeListResponse(BaseModel):
"""Simple list of available feature codes for quick checks."""
features: list[str]
tier_code: str
tier_name: str
class FeatureResponse(BaseModel):
"""Full feature information."""
code: str
name: str
description: str | None = None
category: str
ui_location: str | None = None
ui_icon: str | None = None
ui_route: str | None = None
ui_badge_text: str | None = None
is_available: bool
minimum_tier_code: str | None = None
minimum_tier_name: str | None = None
class FeatureListResponse(BaseModel):
"""List of features with metadata."""
features: list[FeatureResponse]
available_count: int
total_count: int
tier_code: str
tier_name: str
class FeatureDetailResponse(BaseModel):
"""Single feature detail with upgrade info."""
code: str
name: str
description: str | None = None
category: str
ui_location: str | None = None
ui_icon: str | None = None
ui_route: str | None = None
is_available: bool
# Upgrade info (only if not available)
upgrade_tier_code: str | None = None
upgrade_tier_name: str | None = None
upgrade_tier_price_monthly_cents: int | None = None
class CategoryListResponse(BaseModel):
"""List of feature categories."""
categories: list[str]
class FeatureGroupedResponse(BaseModel):
"""Features grouped by category."""
categories: dict[str, list[FeatureResponse]]
available_count: int
total_count: int
class FeatureCheckResponse(BaseModel):
"""Quick feature availability check response."""
has_feature: bool
feature_code: str
# ============================================================================
# Endpoints
# ============================================================================
@router.get("/available", response_model=FeatureCodeListResponse)
def get_available_features(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get list of feature codes available to vendor.
This is a lightweight endpoint for quick feature checks.
Use this to populate a frontend feature store on app init.
Returns:
List of feature codes the vendor has access to
"""
vendor_id = current_user.token_vendor_id
# Get subscription for tier info
from app.services.subscription_service import subscription_service
subscription = subscription_service.get_or_create_subscription(db, vendor_id)
tier = subscription.tier_obj
# Get available features
feature_codes = feature_service.get_available_feature_codes(db, vendor_id)
return FeatureCodeListResponse(
features=feature_codes,
tier_code=subscription.tier,
tier_name=tier.name if tier else subscription.tier.title(),
)
@router.get("", response_model=FeatureListResponse)
def get_features(
category: str | None = Query(None, description="Filter by category"),
include_unavailable: bool = Query(True, description="Include features not available to vendor"),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get all features with availability status and metadata.
This is a comprehensive endpoint for building feature-gated UIs.
Each feature includes:
- Availability status
- UI metadata (icon, route, location)
- Minimum tier required
Args:
category: Filter to specific category (orders, inventory, etc.)
include_unavailable: Whether to include locked features
Returns:
List of features with metadata and availability
"""
vendor_id = current_user.token_vendor_id
# Get subscription for tier info
from app.services.subscription_service import subscription_service
subscription = subscription_service.get_or_create_subscription(db, vendor_id)
tier = subscription.tier_obj
# Get features
features = feature_service.get_vendor_features(
db,
vendor_id,
category=category,
include_unavailable=include_unavailable,
)
available_count = sum(1 for f in features if f.is_available)
return FeatureListResponse(
features=[
FeatureResponse(
code=f.code,
name=f.name,
description=f.description,
category=f.category,
ui_location=f.ui_location,
ui_icon=f.ui_icon,
ui_route=f.ui_route,
ui_badge_text=f.ui_badge_text,
is_available=f.is_available,
minimum_tier_code=f.minimum_tier_code,
minimum_tier_name=f.minimum_tier_name,
)
for f in features
],
available_count=available_count,
total_count=len(features),
tier_code=subscription.tier,
tier_name=tier.name if tier else subscription.tier.title(),
)
@router.get("/categories", response_model=CategoryListResponse)
def get_feature_categories(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get list of feature categories.
Returns:
List of category names
"""
categories = feature_service.get_categories(db)
return CategoryListResponse(categories=categories)
@router.get("/grouped", response_model=FeatureGroupedResponse)
def get_features_grouped(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get features grouped by category.
Useful for rendering feature comparison tables or settings pages.
"""
vendor_id = current_user.token_vendor_id
grouped = feature_service.get_features_grouped_by_category(db, vendor_id)
# Convert to response format
categories_response = {}
total = 0
available = 0
for category, features in grouped.items():
categories_response[category] = [
FeatureResponse(
code=f.code,
name=f.name,
description=f.description,
category=f.category,
ui_location=f.ui_location,
ui_icon=f.ui_icon,
ui_route=f.ui_route,
ui_badge_text=f.ui_badge_text,
is_available=f.is_available,
minimum_tier_code=f.minimum_tier_code,
minimum_tier_name=f.minimum_tier_name,
)
for f in features
]
total += len(features)
available += sum(1 for f in features if f.is_available)
return FeatureGroupedResponse(
categories=categories_response,
available_count=available,
total_count=total,
)
@router.get("/{feature_code}", response_model=FeatureDetailResponse)
def get_feature_detail(
feature_code: str,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get detailed information about a specific feature.
Includes upgrade information if the feature is not available.
Use this for upgrade prompts and feature explanation modals.
Args:
feature_code: The feature code
Returns:
Feature details with upgrade info if locked
"""
vendor_id = current_user.token_vendor_id
# Get feature
feature = feature_service.get_feature_by_code(db, feature_code)
if not feature:
raise FeatureNotFoundError(feature_code)
# Check availability
is_available = feature_service.has_feature(db, vendor_id, feature_code)
# Get upgrade info if not available
upgrade_tier_code = None
upgrade_tier_name = None
upgrade_tier_price = None
if not is_available:
upgrade_info = feature_service.get_feature_upgrade_info(db, feature_code)
if upgrade_info:
upgrade_tier_code = upgrade_info.required_tier_code
upgrade_tier_name = upgrade_info.required_tier_name
upgrade_tier_price = upgrade_info.required_tier_price_monthly_cents
return FeatureDetailResponse(
code=feature.code,
name=feature.name,
description=feature.description,
category=feature.category,
ui_location=feature.ui_location,
ui_icon=feature.ui_icon,
ui_route=feature.ui_route,
is_available=is_available,
upgrade_tier_code=upgrade_tier_code,
upgrade_tier_name=upgrade_tier_name,
upgrade_tier_price_monthly_cents=upgrade_tier_price,
)
@router.get("/check/{feature_code}", response_model=FeatureCheckResponse)
def check_feature(
feature_code: str,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Quick check if vendor has access to a feature.
Returns simple boolean response for inline checks.
Args:
feature_code: The feature code
Returns:
has_feature and feature_code
"""
vendor_id = current_user.token_vendor_id
has_feature = feature_service.has_feature(db, vendor_id, feature_code)
return FeatureCheckResponse(has_feature=has_feature, feature_code=feature_code)

View File

@@ -1,257 +0,0 @@
# app/api/v1/vendor/inventory.py
"""
Vendor inventory management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
"""
import logging
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.inventory_service import inventory_service
from app.services.inventory_transaction_service import inventory_transaction_service
from models.schema.auth import UserContext
from app.modules.inventory.schemas import (
InventoryAdjust,
InventoryCreate,
InventoryListResponse,
InventoryMessageResponse,
InventoryReserve,
InventoryResponse,
InventoryTransactionListResponse,
InventoryTransactionWithProduct,
InventoryUpdate,
OrderTransactionHistoryResponse,
ProductInventorySummary,
ProductTransactionHistoryResponse,
)
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/inventory/set", response_model=InventoryResponse)
def set_inventory(
inventory: InventoryCreate,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Set exact inventory quantity (replaces existing)."""
result = inventory_service.set_inventory(
db, current_user.token_vendor_id, inventory
)
db.commit()
return result
@router.post("/inventory/adjust", response_model=InventoryResponse)
def adjust_inventory(
adjustment: InventoryAdjust,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Adjust inventory (positive to add, negative to remove)."""
result = inventory_service.adjust_inventory(
db, current_user.token_vendor_id, adjustment
)
db.commit()
return result
@router.post("/inventory/reserve", response_model=InventoryResponse)
def reserve_inventory(
reservation: InventoryReserve,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Reserve inventory for an order."""
result = inventory_service.reserve_inventory(
db, current_user.token_vendor_id, reservation
)
db.commit()
return result
@router.post("/inventory/release", response_model=InventoryResponse)
def release_reservation(
reservation: InventoryReserve,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Release reserved inventory (cancel order)."""
result = inventory_service.release_reservation(
db, current_user.token_vendor_id, reservation
)
db.commit()
return result
@router.post("/inventory/fulfill", response_model=InventoryResponse)
def fulfill_reservation(
reservation: InventoryReserve,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Fulfill reservation (complete order, remove from stock)."""
result = inventory_service.fulfill_reservation(
db, current_user.token_vendor_id, reservation
)
db.commit()
return result
@router.get("/inventory/product/{product_id}", response_model=ProductInventorySummary)
def get_product_inventory(
product_id: int,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get inventory summary for a product."""
return inventory_service.get_product_inventory(
db, current_user.token_vendor_id, product_id
)
@router.get("/inventory", response_model=InventoryListResponse)
def get_vendor_inventory(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
location: str | None = Query(None),
low_stock: int | None = Query(None, ge=0),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get all inventory for vendor."""
inventories = inventory_service.get_vendor_inventory(
db, current_user.token_vendor_id, skip, limit, location, low_stock
)
# Get total count
total = len(inventories) # You might want a separate count query for large datasets
return InventoryListResponse(
inventories=inventories, total=total, skip=skip, limit=limit
)
@router.put("/inventory/{inventory_id}", response_model=InventoryResponse)
def update_inventory(
inventory_id: int,
inventory_update: InventoryUpdate,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Update inventory entry."""
result = inventory_service.update_inventory(
db, current_user.token_vendor_id, inventory_id, inventory_update
)
db.commit()
return result
@router.delete("/inventory/{inventory_id}", response_model=InventoryMessageResponse)
def delete_inventory(
inventory_id: int,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Delete inventory entry."""
inventory_service.delete_inventory(db, current_user.token_vendor_id, inventory_id)
db.commit()
return InventoryMessageResponse(message="Inventory deleted successfully")
# ============================================================================
# Inventory Transaction History Endpoints
# ============================================================================
@router.get("/inventory/transactions", response_model=InventoryTransactionListResponse)
def get_inventory_transactions(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
product_id: int | None = Query(None, description="Filter by product"),
transaction_type: str | None = Query(None, description="Filter by type"),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get inventory transaction history for the vendor.
Returns a paginated list of all stock movements with product details.
Use filters to narrow down by product or transaction type.
"""
transactions, total = inventory_transaction_service.get_vendor_transactions(
db=db,
vendor_id=current_user.token_vendor_id,
skip=skip,
limit=limit,
product_id=product_id,
transaction_type=transaction_type,
)
return InventoryTransactionListResponse(
transactions=[InventoryTransactionWithProduct(**tx) for tx in transactions],
total=total,
skip=skip,
limit=limit,
)
@router.get(
"/inventory/transactions/product/{product_id}",
response_model=ProductTransactionHistoryResponse,
)
def get_product_transaction_history(
product_id: int,
limit: int = Query(50, ge=1, le=200),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get transaction history for a specific product.
Returns recent stock movements with current inventory status.
"""
result = inventory_transaction_service.get_product_history(
db=db,
vendor_id=current_user.token_vendor_id,
product_id=product_id,
limit=limit,
)
return ProductTransactionHistoryResponse(**result)
@router.get(
"/inventory/transactions/order/{order_id}",
response_model=OrderTransactionHistoryResponse,
)
def get_order_transaction_history(
order_id: int,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get all inventory transactions for a specific order.
Shows all stock movements (reserve, fulfill, release) related to an order.
"""
result = inventory_transaction_service.get_order_history(
db=db,
vendor_id=current_user.token_vendor_id,
order_id=order_id,
)
return OrderTransactionHistoryResponse(
order_id=result["order_id"],
order_number=result["order_number"],
transactions=[
InventoryTransactionWithProduct(**tx) for tx in result["transactions"]
],
)

View File

@@ -1,788 +0,0 @@
# app/api/v1/vendor/letzshop.py
"""
Vendor API endpoints for Letzshop marketplace integration.
Provides vendor-level management of:
- Letzshop credentials
- Connection testing
- Order import and sync
- Fulfillment operations (confirm, reject, tracking)
Vendor Context: Uses token_vendor_id from JWT token.
"""
import logging
from fastapi import APIRouter, Depends, Path, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_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,
)
from models.schema.auth import UserContext
from app.modules.marketplace.schemas import (
FulfillmentConfirmRequest,
FulfillmentOperationResponse,
FulfillmentQueueItemResponse,
FulfillmentQueueListResponse,
FulfillmentRejectRequest,
FulfillmentTrackingRequest,
LetzshopConnectionTestRequest,
LetzshopConnectionTestResponse,
LetzshopCredentialsCreate,
LetzshopCredentialsResponse,
LetzshopCredentialsStatus,
LetzshopCredentialsUpdate,
LetzshopOrderDetailResponse,
LetzshopOrderListResponse,
LetzshopOrderResponse,
LetzshopSuccessResponse,
LetzshopSyncLogListResponse,
LetzshopSyncLogResponse,
LetzshopSyncTriggerRequest,
LetzshopSyncTriggerResponse,
)
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)
# ============================================================================
# Status & Configuration
# ============================================================================
@router.get("/status", response_model=LetzshopCredentialsStatus)
def get_letzshop_status(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get Letzshop integration status for the current vendor."""
creds_service = get_credentials_service(db)
status = creds_service.get_status(current_user.token_vendor_id)
return LetzshopCredentialsStatus(**status)
@router.get("/credentials", response_model=LetzshopCredentialsResponse)
def get_credentials(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get Letzshop credentials for the current vendor (API key is masked)."""
creds_service = get_credentials_service(db)
vendor_id = current_user.token_vendor_id
try:
credentials = creds_service.get_credentials_or_raise(vendor_id)
except CredentialsNotFoundError:
raise ResourceNotFoundException("LetzshopCredentials", str(vendor_id))
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("/credentials", response_model=LetzshopCredentialsResponse)
def save_credentials(
credentials_data: LetzshopCredentialsCreate,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Create or update Letzshop credentials for the current vendor."""
creds_service = get_credentials_service(db)
vendor_id = current_user.token_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"Vendor user {current_user.email} updated Letzshop credentials")
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("/credentials", response_model=LetzshopCredentialsResponse)
def update_credentials(
credentials_data: LetzshopCredentialsUpdate,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Partially update Letzshop credentials for the current vendor."""
creds_service = get_credentials_service(db)
vendor_id = current_user.token_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))
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("/credentials", response_model=LetzshopSuccessResponse)
def delete_credentials(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Delete Letzshop credentials for the current vendor."""
creds_service = get_credentials_service(db)
deleted = creds_service.delete_credentials(current_user.token_vendor_id)
if not deleted:
raise ResourceNotFoundException(
"LetzshopCredentials", str(current_user.token_vendor_id)
)
db.commit()
logger.info(f"Vendor user {current_user.email} deleted Letzshop credentials")
return LetzshopSuccessResponse(success=True, message="Letzshop credentials deleted")
# ============================================================================
# Connection Testing
# ============================================================================
@router.post("/test", response_model=LetzshopConnectionTestResponse)
def test_connection(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Test the Letzshop connection using stored credentials."""
creds_service = get_credentials_service(db)
success, response_time_ms, error = creds_service.test_connection(
current_user.token_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-key", response_model=LetzshopConnectionTestResponse)
def test_api_key(
test_request: LetzshopConnectionTestRequest,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""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_orders(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
status: str | None = Query(None, description="Filter by order status"),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""List Letzshop orders for the current vendor."""
order_service = get_order_service(db)
vendor_id = current_user.token_vendor_id
orders, total = order_service.list_orders(
vendor_id=vendor_id,
skip=skip,
limit=limit,
status=status,
)
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=f"{order.customer_first_name} {order.customer_last_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,
)
for order in orders
],
total=total,
skip=skip,
limit=limit,
)
@router.get("/orders/{order_id}", response_model=LetzshopOrderDetailResponse)
def get_order(
order_id: int = Path(..., description="Order ID"),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get a specific Letzshop order with full details."""
order_service = get_order_service(db)
try:
order = order_service.get_order_or_raise(current_user.token_vendor_id, order_id)
except OrderNotFoundError:
raise ResourceNotFoundException("LetzshopOrder", str(order_id))
return LetzshopOrderDetailResponse(
# Base fields from 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=f"{order.customer_first_name} {order.customer_last_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,
# Detail fields from LetzshopOrderDetailResponse
customer_first_name=order.customer_first_name,
customer_last_name=order.customer_last_name,
customer_phone=order.customer_phone,
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_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,
external_data=order.external_data,
customer_notes=order.customer_notes,
internal_notes=order.internal_notes,
)
@router.post("/orders/import", response_model=LetzshopSyncTriggerResponse)
def import_orders(
sync_request: LetzshopSyncTriggerRequest = LetzshopSyncTriggerRequest(),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Import new orders from Letzshop."""
vendor_id = current_user.token_vendor_id
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
# Verify credentials exist
try:
creds_service.get_credentials_or_raise(vendor_id)
except CredentialsNotFoundError:
raise ValidationException("Letzshop credentials not configured")
# Import orders
try:
with creds_service.create_client(vendor_id) as client:
shipments = client.get_unconfirmed_shipments()
orders_imported = 0
orders_updated = 0
errors = []
for shipment in shipments:
try:
existing = order_service.get_order_by_shipment_id(
vendor_id, shipment["id"]
)
if existing:
order_service.update_order_from_shipment(existing, shipment)
orders_updated += 1
else:
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()
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"Import 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"Import failed: {e}",
errors=[str(e)],
)
# ============================================================================
# Fulfillment Operations
# ============================================================================
@router.post("/orders/{order_id}/confirm", response_model=FulfillmentOperationResponse)
def confirm_order(
order_id: int = Path(..., description="Order ID"),
confirm_request: FulfillmentConfirmRequest | None = None,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Confirm inventory units for a Letzshop order.
Raises:
OrderHasUnresolvedExceptionsException: If order has unresolved product exceptions
"""
vendor_id = current_user.token_vendor_id
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("LetzshopOrder", 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 request or order items
if confirm_request and confirm_request.inventory_unit_ids:
inventory_unit_ids = confirm_request.inventory_unit_ids
else:
# Get inventory unit IDs from order items' external_item_id
inventory_unit_ids = [
item.external_item_id for item in order.items if item.external_item_id
]
if not inventory_unit_ids:
raise ValidationException("No inventory units to confirm")
try:
with creds_service.create_client(vendor_id) as client:
result = client.confirm_inventory_units(inventory_unit_ids)
# Check for errors
if result.get("errors"):
error_messages = [
f"{e.get('id', 'unknown')}: {e.get('message', 'Unknown error')}"
for e in result["errors"]
]
return FulfillmentOperationResponse(
success=False,
message="Some inventory units could not be confirmed",
errors=error_messages,
)
# Update order status
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("/orders/{order_id}/reject", response_model=FulfillmentOperationResponse)
def reject_order(
order_id: int = Path(..., description="Order ID"),
reject_request: FulfillmentRejectRequest | None = None,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Reject inventory units for a Letzshop order."""
vendor_id = current_user.token_vendor_id
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("LetzshopOrder", str(order_id))
# Get inventory unit IDs from request or order items
if reject_request and reject_request.inventory_unit_ids:
inventory_unit_ids = reject_request.inventory_unit_ids
else:
# Get inventory unit IDs from order items' external_item_id
inventory_unit_ids = [
item.external_item_id for item in order.items if item.external_item_id
]
if not inventory_unit_ids:
raise ValidationException("No inventory units to reject")
try:
with creds_service.create_client(vendor_id) as client:
result = client.reject_inventory_units(inventory_unit_ids)
if result.get("errors"):
error_messages = [
f"{e.get('id', 'unknown')}: {e.get('message', 'Unknown error')}"
for e in result["errors"]
]
return FulfillmentOperationResponse(
success=False,
message="Some inventory units could not be rejected",
errors=error_messages,
)
order_service.mark_order_rejected(order)
db.commit()
return FulfillmentOperationResponse(
success=True,
message=f"Rejected {len(inventory_unit_ids)} inventory units",
)
except LetzshopClientError as e:
return FulfillmentOperationResponse(success=False, message=str(e))
@router.post("/orders/{order_id}/tracking", response_model=FulfillmentOperationResponse)
def set_order_tracking(
order_id: int = Path(..., description="Order ID"),
tracking_request: FulfillmentTrackingRequest = ...,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Set tracking information for a Letzshop order."""
vendor_id = current_user.token_vendor_id
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("LetzshopOrder", str(order_id))
if not order.external_shipment_id:
raise ValidationException("Order does not have a shipment ID")
try:
with creds_service.create_client(vendor_id) as client:
result = client.set_shipment_tracking(
shipment_id=order.external_shipment_id,
tracking_code=tracking_request.tracking_number,
tracking_provider=tracking_request.tracking_carrier,
)
if result.get("errors"):
error_messages = [
f"{e.get('code', 'unknown')}: {e.get('message', 'Unknown error')}"
for e in result["errors"]
]
return FulfillmentOperationResponse(
success=False,
message="Failed to set tracking",
errors=error_messages,
)
order_service.set_order_tracking(
order,
tracking_request.tracking_number,
tracking_request.tracking_carrier,
)
db.commit()
return FulfillmentOperationResponse(
success=True,
message="Tracking information set",
tracking_number=tracking_request.tracking_number,
tracking_carrier=tracking_request.tracking_carrier,
)
except LetzshopClientError as e:
return FulfillmentOperationResponse(success=False, message=str(e))
# ============================================================================
# Sync Logs
# ============================================================================
@router.get("/logs", response_model=LetzshopSyncLogListResponse)
def list_sync_logs(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""List Letzshop sync logs for the current vendor."""
order_service = get_order_service(db)
vendor_id = current_user.token_vendor_id
logs, total = order_service.list_sync_logs(
vendor_id=vendor_id,
skip=skip,
limit=limit,
)
return LetzshopSyncLogListResponse(
logs=[
LetzshopSyncLogResponse(
id=log.id,
vendor_id=log.vendor_id,
operation_type=log.operation_type,
direction=log.direction,
status=log.status,
records_processed=log.records_processed,
records_succeeded=log.records_succeeded,
records_failed=log.records_failed,
error_details=log.error_details,
started_at=log.started_at,
completed_at=log.completed_at,
duration_seconds=log.duration_seconds,
triggered_by=log.triggered_by,
created_at=log.created_at,
)
for log in logs
],
total=total,
skip=skip,
limit=limit,
)
# ============================================================================
# Fulfillment Queue
# ============================================================================
@router.get("/queue", response_model=FulfillmentQueueListResponse)
def list_fulfillment_queue(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
status: str | None = Query(None, description="Filter by status"),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""List fulfillment queue items for the current vendor."""
order_service = get_order_service(db)
vendor_id = current_user.token_vendor_id
items, total = order_service.list_fulfillment_queue(
vendor_id=vendor_id,
skip=skip,
limit=limit,
status=status,
)
return FulfillmentQueueListResponse(
items=[
FulfillmentQueueItemResponse(
id=item.id,
vendor_id=item.vendor_id,
letzshop_order_id=item.letzshop_order_id,
operation=item.operation,
payload=item.payload,
status=item.status,
attempts=item.attempts,
max_attempts=item.max_attempts,
last_attempt_at=item.last_attempt_at,
next_retry_at=item.next_retry_at,
error_message=item.error_message,
completed_at=item.completed_at,
response_data=item.response_data,
created_at=item.created_at,
updated_at=item.updated_at,
)
for item in items
],
total=total,
skip=skip,
limit=limit,
)
# ============================================================================
# Product Export
# ============================================================================
@router.get("/export")
def export_products_letzshop(
language: str = Query(
"en", description="Language for title/description (en, fr, de)"
),
include_inactive: bool = Query(False, description="Include inactive products"),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Export vendor products in Letzshop CSV format.
Generates a Google Shopping compatible CSV file for Letzshop marketplace.
The file uses tab-separated values and includes all required Letzshop fields.
**Supported languages:** en, fr, de
**CSV Format:**
- Delimiter: Tab (\\t)
- Encoding: UTF-8
- Fields: id, title, description, price, availability, image_link, etc.
Returns:
CSV file as attachment (vendor_code_letzshop_export.csv)
"""
from fastapi.responses import Response
from app.services.letzshop_export_service import letzshop_export_service
from app.services.vendor_service import vendor_service
vendor_id = current_user.token_vendor_id
vendor = vendor_service.get_vendor_by_id(db, vendor_id)
csv_content = letzshop_export_service.export_vendor_products(
db=db,
vendor_id=vendor_id,
language=language,
include_inactive=include_inactive,
)
filename = f"{vendor.vendor_code.lower()}_letzshop_export.csv"
return Response(
content=csv_content,
media_type="text/csv; charset=utf-8",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
},
)

View File

@@ -1,133 +0,0 @@
# app/api/v1/vendor/marketplace.py
"""
Marketplace import endpoints for vendors.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
"""
import logging
from fastapi import APIRouter, BackgroundTasks, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.marketplace_import_job_service import marketplace_import_job_service
from app.services.vendor_service import vendor_service
from app.tasks.background_tasks import process_marketplace_import
from middleware.decorators import rate_limit
from models.schema.auth import UserContext
from app.modules.marketplace.schemas import (
MarketplaceImportJobRequest,
MarketplaceImportJobResponse,
)
router = APIRouter(prefix="/marketplace")
logger = logging.getLogger(__name__)
@router.post("/import", response_model=MarketplaceImportJobResponse)
@rate_limit(max_requests=10, window_seconds=3600)
async def import_products_from_marketplace(
request: MarketplaceImportJobRequest,
background_tasks: BackgroundTasks,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Import products from marketplace CSV with background processing (Protected).
The `language` parameter specifies the language code for product
translations (e.g., 'en', 'fr', 'de'). Default is 'en'.
For multi-language imports, call this endpoint multiple times with
different language codes and CSV files containing translations.
"""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
logger.info(
f"Starting marketplace import: {request.marketplace} for vendor {vendor.vendor_code} "
f"by user {current_user.username} (language={request.language})"
)
# Create import job (vendor comes from token)
import_job = marketplace_import_job_service.create_import_job(
db, request, vendor, current_user
)
db.commit()
# Dispatch via task dispatcher (supports Celery or BackgroundTasks)
from app.tasks.dispatcher import task_dispatcher
celery_task_id = task_dispatcher.dispatch_marketplace_import(
background_tasks=background_tasks,
job_id=import_job.id,
url=request.source_url,
marketplace=request.marketplace,
vendor_id=vendor.id,
batch_size=request.batch_size or 1000,
language=request.language,
)
# Store Celery task ID if using Celery
if celery_task_id:
import_job.celery_task_id = celery_task_id
db.commit()
return MarketplaceImportJobResponse(
job_id=import_job.id,
status="pending",
marketplace=request.marketplace,
vendor_id=import_job.vendor_id,
vendor_code=vendor.vendor_code,
vendor_name=vendor.name,
source_url=request.source_url,
language=request.language,
message=f"Marketplace import started from {request.marketplace}. "
f"Check status with /import-status/{import_job.id}",
imported=0,
updated=0,
total_processed=0,
error_count=0,
created_at=import_job.created_at,
)
@router.get("/imports/{job_id}", response_model=MarketplaceImportJobResponse)
def get_marketplace_import_status(
job_id: int,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get status of marketplace import job (Protected)."""
# Service validates that job belongs to vendor and raises UnauthorizedVendorAccessException if not
job = marketplace_import_job_service.get_import_job_for_vendor(
db, job_id, current_user.token_vendor_id
)
return marketplace_import_job_service.convert_to_response_model(job)
@router.get("/imports", response_model=list[MarketplaceImportJobResponse])
def get_marketplace_import_jobs(
marketplace: str | None = Query(None, description="Filter by marketplace"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get marketplace import jobs for current vendor (Protected)."""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
jobs = marketplace_import_job_service.get_import_jobs(
db=db,
vendor=vendor,
user=current_user,
marketplace=marketplace,
skip=skip,
limit=limit,
)
return [
marketplace_import_job_service.convert_to_response_model(job) for job in jobs
]

View File

@@ -1,289 +0,0 @@
# app/api/v1/vendor/onboarding.py
"""
Vendor onboarding API endpoints.
Provides endpoints for the 4-step mandatory onboarding wizard:
1. Company Profile Setup
2. Letzshop API Configuration
3. Product & Order Import Configuration
4. Order Sync (historical import)
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
"""
import logging
from fastapi import APIRouter, BackgroundTasks, Depends
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.onboarding_service import OnboardingService
from app.tasks.letzshop_tasks import process_historical_import
from models.schema.auth import UserContext
from app.modules.marketplace.schemas import (
CompanyProfileRequest,
CompanyProfileResponse,
LetzshopApiConfigRequest,
LetzshopApiConfigResponse,
LetzshopApiTestRequest,
LetzshopApiTestResponse,
OnboardingStatusResponse,
OrderSyncCompleteRequest,
OrderSyncCompleteResponse,
OrderSyncProgressResponse,
OrderSyncTriggerRequest,
OrderSyncTriggerResponse,
ProductImportConfigRequest,
ProductImportConfigResponse,
)
router = APIRouter(prefix="/onboarding")
logger = logging.getLogger(__name__)
# =============================================================================
# Status Endpoint
# =============================================================================
@router.get("/status", response_model=OnboardingStatusResponse)
def get_onboarding_status(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get current onboarding status.
Returns full status including all step completion states and progress.
"""
service = OnboardingService(db)
status = service.get_status_response(current_user.token_vendor_id)
return status
# =============================================================================
# Step 1: Company Profile
# =============================================================================
@router.get("/step/company-profile")
def get_company_profile(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get current company profile data for editing.
Returns pre-filled data from vendor and company records.
"""
service = OnboardingService(db)
return service.get_company_profile_data(current_user.token_vendor_id)
@router.post("/step/company-profile", response_model=CompanyProfileResponse)
def save_company_profile(
request: CompanyProfileRequest,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Save company profile and complete Step 1.
Updates vendor and company records with provided data.
"""
service = OnboardingService(db)
result = service.complete_company_profile(
vendor_id=current_user.token_vendor_id,
company_name=request.company_name,
brand_name=request.brand_name,
description=request.description,
contact_email=request.contact_email,
contact_phone=request.contact_phone,
website=request.website,
business_address=request.business_address,
tax_number=request.tax_number,
default_language=request.default_language,
dashboard_language=request.dashboard_language,
)
db.commit() # Commit at API level for transaction control
return result
# =============================================================================
# Step 2: Letzshop API Configuration
# =============================================================================
@router.post("/step/letzshop-api/test", response_model=LetzshopApiTestResponse)
def test_letzshop_api(
request: LetzshopApiTestRequest,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Test Letzshop API connection without saving.
Use this to validate API key before saving credentials.
"""
service = OnboardingService(db)
return service.test_letzshop_api(
api_key=request.api_key,
shop_slug=request.shop_slug,
)
@router.post("/step/letzshop-api", response_model=LetzshopApiConfigResponse)
def save_letzshop_api(
request: LetzshopApiConfigRequest,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Save Letzshop API credentials and complete Step 2.
Tests connection first, only saves if successful.
"""
service = OnboardingService(db)
result = service.complete_letzshop_api(
vendor_id=current_user.token_vendor_id,
api_key=request.api_key,
shop_slug=request.shop_slug,
letzshop_vendor_id=request.vendor_id,
)
db.commit() # Commit at API level for transaction control
return result
# =============================================================================
# Step 3: Product & Order Import Configuration
# =============================================================================
@router.get("/step/product-import")
def get_product_import_config(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get current product import configuration.
Returns pre-filled CSV URLs and Letzshop feed settings.
"""
service = OnboardingService(db)
return service.get_product_import_config(current_user.token_vendor_id)
@router.post("/step/product-import", response_model=ProductImportConfigResponse)
def save_product_import_config(
request: ProductImportConfigRequest,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Save product import configuration and complete Step 3.
At least one CSV URL must be provided.
"""
service = OnboardingService(db)
result = service.complete_product_import(
vendor_id=current_user.token_vendor_id,
csv_url_fr=request.csv_url_fr,
csv_url_en=request.csv_url_en,
csv_url_de=request.csv_url_de,
default_tax_rate=request.default_tax_rate,
delivery_method=request.delivery_method,
preorder_days=request.preorder_days,
)
db.commit() # Commit at API level for transaction control
return result
# =============================================================================
# Step 4: Order Sync
# =============================================================================
@router.post("/step/order-sync/trigger", response_model=OrderSyncTriggerResponse)
def trigger_order_sync(
request: OrderSyncTriggerRequest,
background_tasks: BackgroundTasks,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Trigger historical order import.
Creates a background job that imports orders from Letzshop.
"""
service = OnboardingService(db)
result = service.trigger_order_sync(
vendor_id=current_user.token_vendor_id,
user_id=current_user.id,
days_back=request.days_back,
include_products=request.include_products,
)
db.commit() # Commit at API level for transaction control
# Queue background task to process the import
if result.get("success") and result.get("job_id"):
from app.tasks.dispatcher import task_dispatcher
celery_task_id = task_dispatcher.dispatch_historical_import(
background_tasks=background_tasks,
job_id=result["job_id"],
vendor_id=current_user.token_vendor_id,
)
# Store Celery task ID if using Celery
if celery_task_id:
from app.services.letzshop import LetzshopOrderService
order_service = LetzshopOrderService(db)
order_service.update_job_celery_task_id(result["job_id"], celery_task_id)
logger.info(f"Queued historical import task for job {result['job_id']}")
return result
@router.get(
"/step/order-sync/progress/{job_id}",
response_model=OrderSyncProgressResponse,
)
def get_order_sync_progress(
job_id: int,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get order sync job progress.
Poll this endpoint to show progress bar during import.
"""
service = OnboardingService(db)
return service.get_order_sync_progress(
vendor_id=current_user.token_vendor_id,
job_id=job_id,
)
@router.post("/step/order-sync/complete", response_model=OrderSyncCompleteResponse)
def complete_order_sync(
request: OrderSyncCompleteRequest,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Mark order sync step as complete.
Called after the import job finishes (success or failure).
This also marks the entire onboarding as complete.
"""
service = OnboardingService(db)
result = service.complete_order_sync(
vendor_id=current_user.token_vendor_id,
job_id=request.job_id,
)
db.commit() # Commit at API level for transaction control
return result

View File

@@ -1,261 +0,0 @@
# app/api/v1/vendor/order_item_exceptions.py
"""
Vendor API endpoints for order item exception management.
Provides vendor-level management of:
- Listing vendor's own exceptions
- Resolving exceptions by assigning products
- Exception statistics for vendor dashboard
"""
import logging
from fastapi import APIRouter, Depends, Path, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.order_item_exception_service import order_item_exception_service
from models.schema.auth import UserContext
from app.modules.orders.schemas import (
BulkResolveRequest,
BulkResolveResponse,
IgnoreExceptionRequest,
OrderItemExceptionListResponse,
OrderItemExceptionResponse,
OrderItemExceptionStats,
ResolveExceptionRequest,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/order-exceptions", tags=["Vendor Order Item Exceptions"])
# ============================================================================
# Exception Listing and Stats
# ============================================================================
@router.get("", response_model=OrderItemExceptionListResponse)
def list_vendor_exceptions(
status: str | None = Query(
None,
pattern="^(pending|resolved|ignored)$",
description="Filter by status"
),
search: str | None = Query(
None,
description="Search in GTIN, product name, SKU, or order number"
),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
List order item exceptions for the authenticated vendor.
Returns exceptions for unmatched products during marketplace order imports.
"""
vendor_id = current_user.token_vendor_id
exceptions, total = order_item_exception_service.get_pending_exceptions(
db=db,
vendor_id=vendor_id,
status=status,
search=search,
skip=skip,
limit=limit,
)
# Enrich with order info
response_items = []
for exc in exceptions:
item = OrderItemExceptionResponse.model_validate(exc)
if exc.order_item and exc.order_item.order:
order = exc.order_item.order
item.order_number = order.order_number
item.order_id = order.id
item.order_date = order.order_date
item.order_status = order.status
response_items.append(item)
return OrderItemExceptionListResponse(
exceptions=response_items,
total=total,
skip=skip,
limit=limit,
)
@router.get("/stats", response_model=OrderItemExceptionStats)
def get_vendor_exception_stats(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get exception statistics for the authenticated vendor.
Returns counts of pending, resolved, and ignored exceptions.
"""
vendor_id = current_user.token_vendor_id
stats = order_item_exception_service.get_exception_stats(db, vendor_id)
return OrderItemExceptionStats(**stats)
# ============================================================================
# Exception Details
# ============================================================================
@router.get("/{exception_id}", response_model=OrderItemExceptionResponse)
def get_vendor_exception(
exception_id: int = Path(..., description="Exception ID"),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get details of a single exception (vendor-scoped).
"""
vendor_id = current_user.token_vendor_id
# Pass vendor_id for scoped access
exception = order_item_exception_service.get_exception_by_id(
db, exception_id, vendor_id
)
response = OrderItemExceptionResponse.model_validate(exception)
if exception.order_item and exception.order_item.order:
order = exception.order_item.order
response.order_number = order.order_number
response.order_id = order.id
response.order_date = order.order_date
response.order_status = order.status
return response
# ============================================================================
# Exception Resolution
# ============================================================================
@router.post("/{exception_id}/resolve", response_model=OrderItemExceptionResponse)
def resolve_vendor_exception(
exception_id: int = Path(..., description="Exception ID"),
request: ResolveExceptionRequest = ...,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Resolve an exception by assigning a product (vendor-scoped).
This updates the order item's product_id and marks the exception as resolved.
"""
vendor_id = current_user.token_vendor_id
exception = order_item_exception_service.resolve_exception(
db=db,
exception_id=exception_id,
product_id=request.product_id,
resolved_by=current_user.id,
notes=request.notes,
vendor_id=vendor_id, # Vendor-scoped access
)
db.commit()
response = OrderItemExceptionResponse.model_validate(exception)
if exception.order_item and exception.order_item.order:
order = exception.order_item.order
response.order_number = order.order_number
response.order_id = order.id
response.order_date = order.order_date
response.order_status = order.status
logger.info(
f"Vendor user {current_user.id} resolved exception {exception_id} "
f"with product {request.product_id}"
)
return response
@router.post("/{exception_id}/ignore", response_model=OrderItemExceptionResponse)
def ignore_vendor_exception(
exception_id: int = Path(..., description="Exception ID"),
request: IgnoreExceptionRequest = ...,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Mark an exception as ignored (vendor-scoped).
Note: Ignored exceptions still block order confirmation.
Use this when a product will never be matched (e.g., discontinued).
"""
vendor_id = current_user.token_vendor_id
exception = order_item_exception_service.ignore_exception(
db=db,
exception_id=exception_id,
resolved_by=current_user.id,
notes=request.notes,
vendor_id=vendor_id, # Vendor-scoped access
)
db.commit()
response = OrderItemExceptionResponse.model_validate(exception)
if exception.order_item and exception.order_item.order:
order = exception.order_item.order
response.order_number = order.order_number
response.order_id = order.id
response.order_date = order.order_date
response.order_status = order.status
logger.info(
f"Vendor user {current_user.id} ignored exception {exception_id}: {request.notes}"
)
return response
# ============================================================================
# Bulk Operations
# ============================================================================
@router.post("/bulk-resolve", response_model=BulkResolveResponse)
def bulk_resolve_vendor_exceptions(
request: BulkResolveRequest,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Bulk resolve all pending exceptions for a GTIN (vendor-scoped).
Useful when a new product is imported and multiple orders have
items with the same unmatched GTIN.
"""
vendor_id = current_user.token_vendor_id
resolved_count = order_item_exception_service.bulk_resolve_by_gtin(
db=db,
vendor_id=vendor_id,
gtin=request.gtin,
product_id=request.product_id,
resolved_by=current_user.id,
notes=request.notes,
)
db.commit()
logger.info(
f"Vendor user {current_user.id} bulk resolved {resolved_count} exceptions "
f"for GTIN {request.gtin} with product {request.product_id}"
)
return BulkResolveResponse(
resolved_count=resolved_count,
gtin=request.gtin,
product_id=request.product_id,
)

View File

@@ -1,269 +0,0 @@
# app/api/v1/vendor/orders.py
"""
Vendor order management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
"""
import logging
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.order_inventory_service import order_inventory_service
from app.services.order_service import order_service
from models.schema.auth import UserContext
from app.modules.orders.schemas import (
OrderDetailResponse,
OrderListResponse,
OrderResponse,
OrderUpdate,
)
router = APIRouter(prefix="/orders")
logger = logging.getLogger(__name__)
@router.get("", response_model=OrderListResponse)
def get_vendor_orders(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
status: str | None = Query(None, description="Filter by order status"),
customer_id: int | None = Query(None, description="Filter by customer"),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get all orders for vendor.
Supports filtering by:
- status: Order status (pending, processing, shipped, delivered, cancelled)
- customer_id: Filter orders from specific customer
Vendor is determined from JWT token (vendor_id claim).
Requires Authorization header (API endpoint).
"""
orders, total = order_service.get_vendor_orders(
db=db,
vendor_id=current_user.token_vendor_id,
skip=skip,
limit=limit,
status=status,
customer_id=customer_id,
)
return OrderListResponse(
orders=[OrderResponse.model_validate(o) for o in orders],
total=total,
skip=skip,
limit=limit,
)
@router.get("/{order_id}", response_model=OrderDetailResponse)
def get_order_details(
order_id: int,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get detailed order information including items and addresses.
Requires Authorization header (API endpoint).
"""
order = order_service.get_order(
db=db, vendor_id=current_user.token_vendor_id, order_id=order_id
)
return OrderDetailResponse.model_validate(order)
@router.put("/{order_id}/status", response_model=OrderResponse)
def update_order_status(
order_id: int,
order_update: OrderUpdate,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Update order status and tracking information.
Valid statuses:
- pending: Order placed, awaiting processing
- processing: Order being prepared
- shipped: Order shipped to customer
- delivered: Order delivered
- cancelled: Order cancelled
- refunded: Order refunded
Requires Authorization header (API endpoint).
"""
order = order_service.update_order_status(
db=db,
vendor_id=current_user.token_vendor_id,
order_id=order_id,
order_update=order_update,
)
db.commit()
logger.info(
f"Order {order.order_number} status updated to {order.status} "
f"by user {current_user.username}"
)
return OrderResponse.model_validate(order)
# ============================================================================
# Partial Shipment Endpoints
# ============================================================================
class ShipItemRequest(BaseModel):
"""Request to ship specific quantity of an order item."""
quantity: int | None = Field(
None, ge=1, description="Quantity to ship (default: remaining quantity)"
)
class ShipItemResponse(BaseModel):
"""Response from shipping an item."""
order_id: int
item_id: int
fulfilled_quantity: int
shipped_quantity: int | None = None
remaining_quantity: int | None = None
is_fully_shipped: bool | None = None
message: str | None = None
class ShipmentStatusItemResponse(BaseModel):
"""Item-level shipment status."""
item_id: int
product_id: int
product_name: str
quantity: int
shipped_quantity: int
remaining_quantity: int
is_fully_shipped: bool
is_partially_shipped: bool
class ShipmentStatusResponse(BaseModel):
"""Order shipment status response."""
order_id: int
order_number: str
order_status: str
is_fully_shipped: bool
is_partially_shipped: bool
shipped_item_count: int
total_item_count: int
total_shipped_units: int
total_ordered_units: int
items: list[ShipmentStatusItemResponse]
@router.get("/{order_id}/shipment-status", response_model=ShipmentStatusResponse)
def get_shipment_status(
order_id: int,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get detailed shipment status for an order.
Returns item-level shipment status showing what has been shipped
and what remains. Useful for partial shipment tracking.
Requires Authorization header (API endpoint).
"""
result = order_inventory_service.get_shipment_status(
db=db,
vendor_id=current_user.token_vendor_id,
order_id=order_id,
)
return ShipmentStatusResponse(
order_id=result["order_id"],
order_number=result["order_number"],
order_status=result["order_status"],
is_fully_shipped=result["is_fully_shipped"],
is_partially_shipped=result["is_partially_shipped"],
shipped_item_count=result["shipped_item_count"],
total_item_count=result["total_item_count"],
total_shipped_units=result["total_shipped_units"],
total_ordered_units=result["total_ordered_units"],
items=[ShipmentStatusItemResponse(**item) for item in result["items"]],
)
@router.post("/{order_id}/items/{item_id}/ship", response_model=ShipItemResponse)
def ship_order_item(
order_id: int,
item_id: int,
request: ShipItemRequest | None = None,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Ship a specific order item (supports partial shipment).
Fulfills inventory and updates the item's shipped quantity.
If quantity is not specified, ships the remaining quantity.
Example use cases:
- Ship all of an item: POST /orders/{id}/items/{item_id}/ship
- Ship partial: POST /orders/{id}/items/{item_id}/ship with {"quantity": 2}
Requires Authorization header (API endpoint).
"""
quantity = request.quantity if request else None
result = order_inventory_service.fulfill_item(
db=db,
vendor_id=current_user.token_vendor_id,
order_id=order_id,
item_id=item_id,
quantity=quantity,
skip_missing=True,
)
# Update order status based on shipment state
order = order_service.get_order(db, current_user.token_vendor_id, order_id)
if order.is_fully_shipped and order.status != "shipped":
order_service.update_order_status(
db=db,
vendor_id=current_user.token_vendor_id,
order_id=order_id,
order_update=OrderUpdate(status="shipped"),
)
logger.info(f"Order {order.order_number} fully shipped")
elif order.is_partially_shipped and order.status not in (
"partially_shipped",
"shipped",
):
order_service.update_order_status(
db=db,
vendor_id=current_user.token_vendor_id,
order_id=order_id,
order_update=OrderUpdate(status="partially_shipped"),
)
logger.info(f"Order {order.order_number} partially shipped")
db.commit()
logger.info(
f"Shipped item {item_id} of order {order_id}: "
f"{result.get('fulfilled_quantity', 0)} units"
)
return ShipItemResponse(**result)

View File

@@ -1,177 +0,0 @@
# app/api/v1/vendor/usage.py
"""
Vendor usage and limits API endpoints.
Provides endpoints for:
- Current usage vs limits
- Upgrade recommendations
- Approaching limit warnings
"""
import logging
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.usage_service import usage_service
from models.schema.auth import UserContext
router = APIRouter(prefix="/usage")
logger = logging.getLogger(__name__)
# ============================================================================
# Response Schemas
# ============================================================================
class UsageMetric(BaseModel):
"""Single usage metric."""
name: str
current: int
limit: int | None # None = unlimited
percentage: float # 0-100, or 0 if unlimited
is_unlimited: bool
is_at_limit: bool
is_approaching_limit: bool # >= 80%
class TierInfo(BaseModel):
"""Current tier information."""
code: str
name: str
price_monthly_cents: int
is_highest_tier: bool
class UpgradeTierInfo(BaseModel):
"""Next tier upgrade information."""
code: str
name: str
price_monthly_cents: int
price_increase_cents: int
benefits: list[str]
class UsageResponse(BaseModel):
"""Full usage response with limits and upgrade info."""
tier: TierInfo
usage: list[UsageMetric]
has_limits_approaching: bool
has_limits_reached: bool
upgrade_available: bool
upgrade_tier: UpgradeTierInfo | None = None
upgrade_reasons: list[str]
class LimitCheckResponse(BaseModel):
"""Response for checking a specific limit."""
limit_type: str
can_proceed: bool
current: int
limit: int | None
percentage: float
message: str | None = None
upgrade_tier_code: str | None = None
upgrade_tier_name: str | None = None
# ============================================================================
# Endpoints
# ============================================================================
@router.get("", response_model=UsageResponse)
def get_usage(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get current usage, limits, and upgrade recommendations.
Returns comprehensive usage info for displaying in dashboard
and determining when to show upgrade prompts.
"""
vendor_id = current_user.token_vendor_id
# Get usage data from service
usage_data = usage_service.get_vendor_usage(db, vendor_id)
# Convert to response
return UsageResponse(
tier=TierInfo(
code=usage_data.tier.code,
name=usage_data.tier.name,
price_monthly_cents=usage_data.tier.price_monthly_cents,
is_highest_tier=usage_data.tier.is_highest_tier,
),
usage=[
UsageMetric(
name=m.name,
current=m.current,
limit=m.limit,
percentage=m.percentage,
is_unlimited=m.is_unlimited,
is_at_limit=m.is_at_limit,
is_approaching_limit=m.is_approaching_limit,
)
for m in usage_data.usage
],
has_limits_approaching=usage_data.has_limits_approaching,
has_limits_reached=usage_data.has_limits_reached,
upgrade_available=usage_data.upgrade_available,
upgrade_tier=(
UpgradeTierInfo(
code=usage_data.upgrade_tier.code,
name=usage_data.upgrade_tier.name,
price_monthly_cents=usage_data.upgrade_tier.price_monthly_cents,
price_increase_cents=usage_data.upgrade_tier.price_increase_cents,
benefits=usage_data.upgrade_tier.benefits,
)
if usage_data.upgrade_tier
else None
),
upgrade_reasons=usage_data.upgrade_reasons,
)
@router.get("/check/{limit_type}", response_model=LimitCheckResponse)
def check_limit(
limit_type: str,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Check a specific limit before performing an action.
Use this before creating orders, products, or inviting team members.
Args:
limit_type: One of "orders", "products", "team_members"
Returns:
Whether the action can proceed and upgrade info if not
"""
vendor_id = current_user.token_vendor_id
# Check limit using service
check_data = usage_service.check_limit(db, vendor_id, limit_type)
return LimitCheckResponse(
limit_type=check_data.limit_type,
can_proceed=check_data.can_proceed,
current=check_data.current,
limit=check_data.limit,
percentage=check_data.percentage,
message=check_data.message,
upgrade_tier_code=check_data.upgrade_tier_code,
upgrade_tier_name=check_data.upgrade_tier_name,
)