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:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
11
app/api/v1/vendor/__init__.py
vendored
11
app/api/v1/vendor/__init__.py
vendored
@@ -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"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
55
app/api/v1/vendor/analytics.py
vendored
55
app/api/v1/vendor/analytics.py
vendored
@@ -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"]
|
||||
),
|
||||
)
|
||||
348
app/api/v1/vendor/features.py
vendored
348
app/api/v1/vendor/features.py
vendored
@@ -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)
|
||||
257
app/api/v1/vendor/inventory.py
vendored
257
app/api/v1/vendor/inventory.py
vendored
@@ -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"]
|
||||
],
|
||||
)
|
||||
788
app/api/v1/vendor/letzshop.py
vendored
788
app/api/v1/vendor/letzshop.py
vendored
@@ -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}"',
|
||||
},
|
||||
)
|
||||
133
app/api/v1/vendor/marketplace.py
vendored
133
app/api/v1/vendor/marketplace.py
vendored
@@ -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
|
||||
]
|
||||
289
app/api/v1/vendor/onboarding.py
vendored
289
app/api/v1/vendor/onboarding.py
vendored
@@ -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
|
||||
261
app/api/v1/vendor/order_item_exceptions.py
vendored
261
app/api/v1/vendor/order_item_exceptions.py
vendored
@@ -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,
|
||||
)
|
||||
269
app/api/v1/vendor/orders.py
vendored
269
app/api/v1/vendor/orders.py
vendored
@@ -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)
|
||||
177
app/api/v1/vendor/usage.py
vendored
177
app/api/v1/vendor/usage.py
vendored
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user