Templates Migration: - Migrate admin templates to modules (tenancy, billing, monitoring, marketplace, etc.) - Migrate vendor templates to modules (tenancy, billing, orders, messaging, etc.) - Migrate storefront templates to modules (catalog, customers, orders, cart, checkout, cms) - Migrate public templates to modules (billing, marketplace, cms) - Keep shared templates in app/templates/ (base.html, errors/, partials/, macros/) - Migrate letzshop partials to marketplace module Static Files Migration: - Migrate admin JS to modules: tenancy (23 files), core (5 files), monitoring (1 file) - Migrate vendor JS to modules: tenancy (4 files), core (2 files) - Migrate shared JS: vendor-selector.js to core, media-picker.js to cms - Migrate storefront JS: storefront-layout.js to core - Keep framework JS in static/ (api-client, utils, money, icons, log-config, lib/) - Update all template references to use module_static paths Naming Consistency: - Rename static/platform/ to static/public/ - Rename app/templates/platform/ to app/templates/public/ - Update all extends and static references Documentation: - Update module-system.md with shared templates documentation - Update frontend-structure.md with new module JS organization Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
437 lines
14 KiB
Python
437 lines
14 KiB
Python
# app/modules/inventory/routes/api/admin.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, require_module_access
|
|
from app.core.database import get_db
|
|
from app.modules.inventory.services.inventory_import_service import inventory_import_service
|
|
from app.modules.inventory.services.inventory_service import inventory_service
|
|
from app.modules.inventory.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,
|
|
)
|
|
|
|
admin_router = APIRouter(
|
|
prefix="/inventory",
|
|
dependencies=[Depends(require_module_access("inventory"))],
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ============================================================================
|
|
# List & Statistics Endpoints
|
|
# ============================================================================
|
|
|
|
|
|
@admin_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,
|
|
)
|
|
|
|
|
|
@admin_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)
|
|
|
|
|
|
@admin_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,
|
|
)
|
|
|
|
|
|
@admin_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)
|
|
|
|
|
|
@admin_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
|
|
# ============================================================================
|
|
|
|
|
|
@admin_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,
|
|
)
|
|
|
|
|
|
@admin_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
|
|
# ============================================================================
|
|
|
|
|
|
@admin_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
|
|
|
|
|
|
@admin_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
|
|
|
|
|
|
@admin_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
|
|
|
|
|
|
@admin_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]
|
|
|
|
|
|
@admin_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
|
|
# ============================================================================
|
|
|
|
|
|
@admin_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,
|
|
)
|
|
|
|
|
|
@admin_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)
|