refactor: migrate modules from re-exports to canonical implementations

Move actual code implementations into module directories:
- orders: 5 services, 4 models, order/invoice schemas
- inventory: 3 services, 2 models, 30+ schemas
- customers: 3 services, 2 models, customer schemas
- messaging: 3 services, 2 models, message/notification schemas
- monitoring: background_tasks_service
- marketplace: 5+ services including letzshop submodule
- dev_tools: code_quality_service, test_runner_service
- billing: billing_service
- contracts: definition.py

Legacy files in app/services/, models/database/, models/schema/
now re-export from canonical module locations for backwards
compatibility. Architecture validator passes with 0 errors.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-29 21:28:56 +01:00
parent b5a803cde8
commit de83875d0a
99 changed files with 19413 additions and 15357 deletions

View File

@@ -2,20 +2,21 @@
"""
Inventory module services.
Re-exports inventory-related services from their source locations.
This module contains the canonical implementations of inventory-related services.
"""
from app.services.inventory_service import (
from app.modules.inventory.services.inventory_service import (
inventory_service,
InventoryService,
)
from app.services.inventory_transaction_service import (
from app.modules.inventory.services.inventory_transaction_service import (
inventory_transaction_service,
InventoryTransactionService,
)
from app.services.inventory_import_service import (
from app.modules.inventory.services.inventory_import_service import (
inventory_import_service,
InventoryImportService,
ImportResult,
)
__all__ = [
@@ -25,4 +26,5 @@ __all__ = [
"InventoryTransactionService",
"inventory_import_service",
"InventoryImportService",
"ImportResult",
]

View File

@@ -0,0 +1,250 @@
# app/modules/inventory/services/inventory_import_service.py
"""
Inventory import service for bulk importing stock from TSV/CSV files.
Supports two formats:
1. One row per unit (quantity = count of rows):
BIN EAN PRODUCT
SA-10-02 0810050910101 Product Name
SA-10-02 0810050910101 Product Name (2nd unit)
2. With explicit quantity column:
BIN EAN PRODUCT QUANTITY
SA-10-02 0810050910101 Product Name 12
Products are matched by GTIN/EAN to existing vendor products.
"""
import csv
import io
import logging
from collections import defaultdict
from dataclasses import dataclass, field
from sqlalchemy.orm import Session
from app.modules.inventory.models.inventory import Inventory
from models.database.product import Product
logger = logging.getLogger(__name__)
@dataclass
class ImportResult:
"""Result of an inventory import operation."""
success: bool = True
total_rows: int = 0
entries_created: int = 0
entries_updated: int = 0
quantity_imported: int = 0
unmatched_gtins: list = field(default_factory=list)
errors: list = field(default_factory=list)
class InventoryImportService:
"""Service for importing inventory from TSV/CSV files."""
def import_from_text(
self,
db: Session,
content: str,
vendor_id: int,
warehouse: str = "strassen",
delimiter: str = "\t",
clear_existing: bool = False,
) -> ImportResult:
"""
Import inventory from TSV/CSV text content.
Args:
db: Database session
content: TSV/CSV content as string
vendor_id: Vendor ID for inventory
warehouse: Warehouse name (default: "strassen")
delimiter: Column delimiter (default: tab)
clear_existing: If True, clear existing inventory before import
Returns:
ImportResult with summary and errors
"""
result = ImportResult()
try:
# Parse CSV/TSV
reader = csv.DictReader(io.StringIO(content), delimiter=delimiter)
# Normalize headers (case-insensitive, strip whitespace)
if reader.fieldnames:
reader.fieldnames = [h.strip().upper() for h in reader.fieldnames]
# Validate required columns
required = {"BIN", "EAN"}
if not reader.fieldnames or not required.issubset(set(reader.fieldnames)):
result.success = False
result.errors.append(
f"Missing required columns. Found: {reader.fieldnames}, Required: {required}"
)
return result
has_quantity = "QUANTITY" in reader.fieldnames
# Group entries by (EAN, BIN)
# Key: (ean, bin) -> quantity
inventory_data: dict[tuple[str, str], int] = defaultdict(int)
product_names: dict[str, str] = {} # EAN -> product name (for logging)
for row in reader:
result.total_rows += 1
ean = row.get("EAN", "").strip()
bin_loc = row.get("BIN", "").strip()
product_name = row.get("PRODUCT", "").strip()
if not ean or not bin_loc:
result.errors.append(f"Row {result.total_rows}: Missing EAN or BIN")
continue
# Get quantity
if has_quantity:
try:
qty = int(row.get("QUANTITY", "1").strip())
except ValueError:
result.errors.append(
f"Row {result.total_rows}: Invalid quantity '{row.get('QUANTITY')}'"
)
continue
else:
qty = 1 # Each row = 1 unit
inventory_data[(ean, bin_loc)] += qty
if product_name:
product_names[ean] = product_name
# Clear existing inventory if requested
if clear_existing:
db.query(Inventory).filter(
Inventory.vendor_id == vendor_id,
Inventory.warehouse == warehouse,
).delete()
db.flush()
# Build EAN to Product mapping for this vendor
products = (
db.query(Product)
.filter(
Product.vendor_id == vendor_id,
Product.gtin.isnot(None),
)
.all()
)
ean_to_product: dict[str, Product] = {p.gtin: p for p in products if p.gtin}
# Track unmatched GTINs
unmatched: dict[str, int] = {} # EAN -> total quantity
# Process inventory entries
for (ean, bin_loc), quantity in inventory_data.items():
product = ean_to_product.get(ean)
if not product:
# Track unmatched
if ean not in unmatched:
unmatched[ean] = 0
unmatched[ean] += quantity
continue
# Upsert inventory entry
existing = (
db.query(Inventory)
.filter(
Inventory.product_id == product.id,
Inventory.warehouse == warehouse,
Inventory.bin_location == bin_loc,
)
.first()
)
if existing:
existing.quantity = quantity
existing.gtin = ean
result.entries_updated += 1
else:
inv = Inventory(
product_id=product.id,
vendor_id=vendor_id,
warehouse=warehouse,
bin_location=bin_loc,
location=bin_loc, # Legacy field
quantity=quantity,
gtin=ean,
)
db.add(inv)
result.entries_created += 1
result.quantity_imported += quantity
db.flush()
# Format unmatched GTINs for result
for ean, qty in unmatched.items():
product_name = product_names.get(ean, "Unknown")
result.unmatched_gtins.append(
{"gtin": ean, "quantity": qty, "product_name": product_name}
)
if result.unmatched_gtins:
logger.warning(
f"Import had {len(result.unmatched_gtins)} unmatched GTINs"
)
except Exception as e:
logger.exception("Inventory import failed")
result.success = False
result.errors.append(str(e))
return result
def import_from_file(
self,
db: Session,
file_path: str,
vendor_id: int,
warehouse: str = "strassen",
clear_existing: bool = False,
) -> ImportResult:
"""
Import inventory from a TSV/CSV file.
Args:
db: Database session
file_path: Path to TSV/CSV file
vendor_id: Vendor ID for inventory
warehouse: Warehouse name
clear_existing: If True, clear existing inventory before import
Returns:
ImportResult with summary and errors
"""
try:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
except Exception as e:
return ImportResult(success=False, errors=[f"Failed to read file: {e}"])
# Detect delimiter
first_line = content.split("\n")[0] if content else ""
delimiter = "\t" if "\t" in first_line else ","
return self.import_from_text(
db=db,
content=content,
vendor_id=vendor_id,
warehouse=warehouse,
delimiter=delimiter,
clear_existing=clear_existing,
)
# Singleton instance
inventory_import_service = InventoryImportService()

View File

@@ -0,0 +1,949 @@
# app/modules/inventory/services/inventory_service.py
import logging
from datetime import UTC, datetime
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.exceptions import (
InsufficientInventoryException,
InvalidQuantityException,
InventoryNotFoundException,
InventoryValidationException,
ProductNotFoundException,
ValidationException,
VendorNotFoundException,
)
from app.modules.inventory.models.inventory import Inventory
from app.modules.inventory.schemas.inventory import (
AdminInventoryItem,
AdminInventoryListResponse,
AdminInventoryLocationsResponse,
AdminInventoryStats,
AdminLowStockItem,
AdminVendorsWithInventoryResponse,
AdminVendorWithInventory,
InventoryAdjust,
InventoryCreate,
InventoryLocationResponse,
InventoryReserve,
InventoryUpdate,
ProductInventorySummary,
)
from models.database.product import Product
from models.database.vendor import Vendor
logger = logging.getLogger(__name__)
class InventoryService:
"""Service for inventory operations with vendor isolation."""
def set_inventory(
self, db: Session, vendor_id: int, inventory_data: InventoryCreate
) -> Inventory:
"""
Set exact inventory quantity for a product at a location (replaces existing).
Args:
db: Database session
vendor_id: Vendor ID (from middleware)
inventory_data: Inventory data
Returns:
Inventory object
"""
try:
# Validate product belongs to vendor
product = self._get_vendor_product(db, vendor_id, inventory_data.product_id)
# Validate location
location = self._validate_location(inventory_data.location)
# Validate quantity
self._validate_quantity(inventory_data.quantity, allow_zero=True)
# Check if inventory entry exists
existing = self._get_inventory_entry(
db, inventory_data.product_id, location
)
if existing:
old_qty = existing.quantity
existing.quantity = inventory_data.quantity
existing.updated_at = datetime.now(UTC)
db.flush()
db.refresh(existing)
logger.info(
f"Set inventory for product {inventory_data.product_id} at {location}: "
f"{old_qty}{inventory_data.quantity}"
)
return existing
# Create new inventory entry
new_inventory = Inventory(
product_id=inventory_data.product_id,
vendor_id=vendor_id,
warehouse="strassen", # Default warehouse
bin_location=location, # Use location as bin location
location=location, # Keep for backward compatibility
quantity=inventory_data.quantity,
gtin=product.marketplace_product.gtin, # Optional reference
)
db.add(new_inventory)
db.flush()
db.refresh(new_inventory)
logger.info(
f"Created inventory for product {inventory_data.product_id} at {location}: "
f"{inventory_data.quantity}"
)
return new_inventory
except (
ProductNotFoundException,
InvalidQuantityException,
InventoryValidationException,
):
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Error setting inventory: {str(e)}")
raise ValidationException("Failed to set inventory")
def adjust_inventory(
self, db: Session, vendor_id: int, inventory_data: InventoryAdjust
) -> Inventory:
"""
Adjust inventory by adding or removing quantity.
Positive quantity = add, negative = remove.
Args:
db: Database session
vendor_id: Vendor ID
inventory_data: Adjustment data
Returns:
Updated Inventory object
"""
try:
# Validate product belongs to vendor
product = self._get_vendor_product(db, vendor_id, inventory_data.product_id)
# Validate location
location = self._validate_location(inventory_data.location)
# Check if inventory exists
existing = self._get_inventory_entry(
db, inventory_data.product_id, location
)
if not existing:
# Create new if adding, error if removing
if inventory_data.quantity < 0:
raise InventoryNotFoundException(
f"No inventory found for product {inventory_data.product_id} at {location}"
)
# Create with positive quantity
new_inventory = Inventory(
product_id=inventory_data.product_id,
vendor_id=vendor_id,
warehouse="strassen", # Default warehouse
bin_location=location, # Use location as bin location
location=location, # Keep for backward compatibility
quantity=inventory_data.quantity,
gtin=product.marketplace_product.gtin,
)
db.add(new_inventory)
db.flush()
db.refresh(new_inventory)
logger.info(
f"Created inventory for product {inventory_data.product_id} at {location}: "
f"+{inventory_data.quantity}"
)
return new_inventory
# Adjust existing inventory
old_qty = existing.quantity
new_qty = old_qty + inventory_data.quantity
# Validate resulting quantity
if new_qty < 0:
raise InsufficientInventoryException(
f"Insufficient inventory. Available: {old_qty}, "
f"Requested removal: {abs(inventory_data.quantity)}"
)
existing.quantity = new_qty
existing.updated_at = datetime.now(UTC)
db.flush()
db.refresh(existing)
logger.info(
f"Adjusted inventory for product {inventory_data.product_id} at {location}: "
f"{old_qty} {'+' if inventory_data.quantity >= 0 else ''}{inventory_data.quantity} = {new_qty}"
)
return existing
except (
ProductNotFoundException,
InventoryNotFoundException,
InsufficientInventoryException,
InventoryValidationException,
):
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Error adjusting inventory: {str(e)}")
raise ValidationException("Failed to adjust inventory")
def reserve_inventory(
self, db: Session, vendor_id: int, reserve_data: InventoryReserve
) -> Inventory:
"""
Reserve inventory for an order (increases reserved_quantity).
Args:
db: Database session
vendor_id: Vendor ID
reserve_data: Reservation data
Returns:
Updated Inventory object
"""
try:
# Validate product
product = self._get_vendor_product(db, vendor_id, reserve_data.product_id)
# Validate location and quantity
location = self._validate_location(reserve_data.location)
self._validate_quantity(reserve_data.quantity, allow_zero=False)
# Get inventory entry
inventory = self._get_inventory_entry(db, reserve_data.product_id, location)
if not inventory:
raise InventoryNotFoundException(
f"No inventory found for product {reserve_data.product_id} at {location}"
)
# Check available quantity
available = inventory.quantity - inventory.reserved_quantity
if available < reserve_data.quantity:
raise InsufficientInventoryException(
f"Insufficient available inventory. Available: {available}, "
f"Requested: {reserve_data.quantity}"
)
# Reserve inventory
inventory.reserved_quantity += reserve_data.quantity
inventory.updated_at = datetime.now(UTC)
db.flush()
db.refresh(inventory)
logger.info(
f"Reserved {reserve_data.quantity} units for product {reserve_data.product_id} "
f"at {location}"
)
return inventory
except (
ProductNotFoundException,
InventoryNotFoundException,
InsufficientInventoryException,
InvalidQuantityException,
):
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Error reserving inventory: {str(e)}")
raise ValidationException("Failed to reserve inventory")
def release_reservation(
self, db: Session, vendor_id: int, reserve_data: InventoryReserve
) -> Inventory:
"""
Release reserved inventory (decreases reserved_quantity).
Args:
db: Database session
vendor_id: Vendor ID
reserve_data: Reservation data
Returns:
Updated Inventory object
"""
try:
# Validate product
product = self._get_vendor_product(db, vendor_id, reserve_data.product_id)
location = self._validate_location(reserve_data.location)
self._validate_quantity(reserve_data.quantity, allow_zero=False)
inventory = self._get_inventory_entry(db, reserve_data.product_id, location)
if not inventory:
raise InventoryNotFoundException(
f"No inventory found for product {reserve_data.product_id} at {location}"
)
# Validate reserved quantity
if inventory.reserved_quantity < reserve_data.quantity:
logger.warning(
f"Attempting to release more than reserved. Reserved: {inventory.reserved_quantity}, "
f"Requested: {reserve_data.quantity}"
)
inventory.reserved_quantity = 0
else:
inventory.reserved_quantity -= reserve_data.quantity
inventory.updated_at = datetime.now(UTC)
db.flush()
db.refresh(inventory)
logger.info(
f"Released {reserve_data.quantity} units for product {reserve_data.product_id} "
f"at {location}"
)
return inventory
except (
ProductNotFoundException,
InventoryNotFoundException,
InvalidQuantityException,
):
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Error releasing reservation: {str(e)}")
raise ValidationException("Failed to release reservation")
def fulfill_reservation(
self, db: Session, vendor_id: int, reserve_data: InventoryReserve
) -> Inventory:
"""
Fulfill a reservation (decreases both quantity and reserved_quantity).
Use when order is shipped/completed.
Args:
db: Database session
vendor_id: Vendor ID
reserve_data: Reservation data
Returns:
Updated Inventory object
"""
try:
product = self._get_vendor_product(db, vendor_id, reserve_data.product_id)
location = self._validate_location(reserve_data.location)
self._validate_quantity(reserve_data.quantity, allow_zero=False)
inventory = self._get_inventory_entry(db, reserve_data.product_id, location)
if not inventory:
raise InventoryNotFoundException(
f"No inventory found for product {reserve_data.product_id} at {location}"
)
# Validate quantities
if inventory.quantity < reserve_data.quantity:
raise InsufficientInventoryException(
f"Insufficient inventory. Available: {inventory.quantity}, "
f"Requested: {reserve_data.quantity}"
)
if inventory.reserved_quantity < reserve_data.quantity:
logger.warning(
f"Fulfilling more than reserved. Reserved: {inventory.reserved_quantity}, "
f"Fulfilling: {reserve_data.quantity}"
)
# Fulfill (remove from both quantity and reserved)
inventory.quantity -= reserve_data.quantity
inventory.reserved_quantity = max(
0, inventory.reserved_quantity - reserve_data.quantity
)
inventory.updated_at = datetime.now(UTC)
db.flush()
db.refresh(inventory)
logger.info(
f"Fulfilled {reserve_data.quantity} units for product {reserve_data.product_id} "
f"at {location}"
)
return inventory
except (
ProductNotFoundException,
InventoryNotFoundException,
InsufficientInventoryException,
InvalidQuantityException,
):
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Error fulfilling reservation: {str(e)}")
raise ValidationException("Failed to fulfill reservation")
def get_product_inventory(
self, db: Session, vendor_id: int, product_id: int
) -> ProductInventorySummary:
"""
Get inventory summary for a product across all locations.
Args:
db: Database session
vendor_id: Vendor ID
product_id: Product ID
Returns:
ProductInventorySummary
"""
try:
product = self._get_vendor_product(db, vendor_id, product_id)
inventory_entries = (
db.query(Inventory).filter(Inventory.product_id == product_id).all()
)
if not inventory_entries:
return ProductInventorySummary(
product_id=product_id,
vendor_id=vendor_id,
product_sku=product.vendor_sku,
product_title=product.marketplace_product.get_title() or "",
total_quantity=0,
total_reserved=0,
total_available=0,
locations=[],
)
total_qty = sum(inv.quantity for inv in inventory_entries)
total_reserved = sum(inv.reserved_quantity for inv in inventory_entries)
total_available = sum(inv.available_quantity for inv in inventory_entries)
locations = [
InventoryLocationResponse(
location=inv.location,
quantity=inv.quantity,
reserved_quantity=inv.reserved_quantity,
available_quantity=inv.available_quantity,
)
for inv in inventory_entries
]
return ProductInventorySummary(
product_id=product_id,
vendor_id=vendor_id,
product_sku=product.vendor_sku,
product_title=product.marketplace_product.get_title() or "",
total_quantity=total_qty,
total_reserved=total_reserved,
total_available=total_available,
locations=locations,
)
except ProductNotFoundException:
raise
except Exception as e:
logger.error(f"Error getting product inventory: {str(e)}")
raise ValidationException("Failed to retrieve product inventory")
def get_vendor_inventory(
self,
db: Session,
vendor_id: int,
skip: int = 0,
limit: int = 100,
location: str | None = None,
low_stock_threshold: int | None = None,
) -> list[Inventory]:
"""
Get all inventory for a vendor with filtering.
Args:
db: Database session
vendor_id: Vendor ID
skip: Pagination offset
limit: Pagination limit
location: Filter by location
low_stock_threshold: Filter items below threshold
Returns:
List of Inventory objects
"""
try:
query = db.query(Inventory).filter(Inventory.vendor_id == vendor_id)
if location:
query = query.filter(Inventory.location.ilike(f"%{location}%"))
if low_stock_threshold is not None:
query = query.filter(Inventory.quantity <= low_stock_threshold)
return query.offset(skip).limit(limit).all()
except Exception as e:
logger.error(f"Error getting vendor inventory: {str(e)}")
raise ValidationException("Failed to retrieve vendor inventory")
def update_inventory(
self,
db: Session,
vendor_id: int,
inventory_id: int,
inventory_update: InventoryUpdate,
) -> Inventory:
"""Update inventory entry."""
try:
inventory = self._get_inventory_by_id(db, inventory_id)
# Verify ownership
if inventory.vendor_id != vendor_id:
raise InventoryNotFoundException(f"Inventory {inventory_id} not found")
# Update fields
if inventory_update.quantity is not None:
self._validate_quantity(inventory_update.quantity, allow_zero=True)
inventory.quantity = inventory_update.quantity
if inventory_update.reserved_quantity is not None:
self._validate_quantity(
inventory_update.reserved_quantity, allow_zero=True
)
inventory.reserved_quantity = inventory_update.reserved_quantity
if inventory_update.location:
inventory.location = self._validate_location(inventory_update.location)
inventory.updated_at = datetime.now(UTC)
db.flush()
db.refresh(inventory)
logger.info(f"Updated inventory {inventory_id}")
return inventory
except (
InventoryNotFoundException,
InvalidQuantityException,
InventoryValidationException,
):
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Error updating inventory: {str(e)}")
raise ValidationException("Failed to update inventory")
def delete_inventory(self, db: Session, vendor_id: int, inventory_id: int) -> bool:
"""Delete inventory entry."""
try:
inventory = self._get_inventory_by_id(db, inventory_id)
# Verify ownership
if inventory.vendor_id != vendor_id:
raise InventoryNotFoundException(f"Inventory {inventory_id} not found")
db.delete(inventory)
db.flush()
logger.info(f"Deleted inventory {inventory_id}")
return True
except InventoryNotFoundException:
raise
except Exception as e:
db.rollback()
logger.error(f"Error deleting inventory: {str(e)}")
raise ValidationException("Failed to delete inventory")
# =========================================================================
# Admin Methods (cross-vendor operations)
# =========================================================================
def get_all_inventory_admin(
self,
db: Session,
skip: int = 0,
limit: int = 50,
vendor_id: int | None = None,
location: str | None = None,
low_stock: int | None = None,
search: str | None = None,
) -> AdminInventoryListResponse:
"""
Get inventory across all vendors with filtering (admin only).
Args:
db: Database session
skip: Pagination offset
limit: Pagination limit
vendor_id: Filter by vendor
location: Filter by location
low_stock: Filter items below threshold
search: Search by product title or SKU
Returns:
AdminInventoryListResponse
"""
query = db.query(Inventory).join(Product).join(Vendor)
# Apply filters
if vendor_id is not None:
query = query.filter(Inventory.vendor_id == vendor_id)
if location:
query = query.filter(Inventory.location.ilike(f"%{location}%"))
if low_stock is not None:
query = query.filter(Inventory.quantity <= low_stock)
if search:
from models.database.marketplace_product import MarketplaceProduct
from models.database.marketplace_product_translation import (
MarketplaceProductTranslation,
)
query = (
query.join(MarketplaceProduct)
.outerjoin(MarketplaceProductTranslation)
.filter(
(MarketplaceProductTranslation.title.ilike(f"%{search}%"))
| (Product.vendor_sku.ilike(f"%{search}%"))
)
)
# Get total count before pagination
total = query.count()
# Apply pagination
inventories = query.offset(skip).limit(limit).all()
# Build response with vendor/product info
items = []
for inv in inventories:
product = inv.product
vendor = inv.vendor
title = None
if product and product.marketplace_product:
title = product.marketplace_product.get_title()
items.append(
AdminInventoryItem(
id=inv.id,
product_id=inv.product_id,
vendor_id=inv.vendor_id,
vendor_name=vendor.name if vendor else None,
vendor_code=vendor.vendor_code if vendor else None,
product_title=title,
product_sku=product.vendor_sku if product else None,
location=inv.location,
quantity=inv.quantity,
reserved_quantity=inv.reserved_quantity,
available_quantity=inv.available_quantity,
gtin=inv.gtin,
created_at=inv.created_at,
updated_at=inv.updated_at,
)
)
return AdminInventoryListResponse(
inventories=items,
total=total,
skip=skip,
limit=limit,
vendor_filter=vendor_id,
location_filter=location,
)
def get_inventory_stats_admin(self, db: Session) -> AdminInventoryStats:
"""Get platform-wide inventory statistics (admin only)."""
# Total entries
total_entries = db.query(func.count(Inventory.id)).scalar() or 0
# Aggregate quantities
totals = db.query(
func.sum(Inventory.quantity).label("total_qty"),
func.sum(Inventory.reserved_quantity).label("total_reserved"),
).first()
total_quantity = totals.total_qty or 0
total_reserved = totals.total_reserved or 0
total_available = total_quantity - total_reserved
# Low stock count (default threshold: 10)
low_stock_count = (
db.query(func.count(Inventory.id))
.filter(Inventory.quantity <= 10)
.scalar()
or 0
)
# Vendors with inventory
vendors_with_inventory = (
db.query(func.count(func.distinct(Inventory.vendor_id))).scalar() or 0
)
# Unique locations
unique_locations = (
db.query(func.count(func.distinct(Inventory.location))).scalar() or 0
)
return AdminInventoryStats(
total_entries=total_entries,
total_quantity=total_quantity,
total_reserved=total_reserved,
total_available=total_available,
low_stock_count=low_stock_count,
vendors_with_inventory=vendors_with_inventory,
unique_locations=unique_locations,
)
def get_low_stock_items_admin(
self,
db: Session,
threshold: int = 10,
vendor_id: int | None = None,
limit: int = 50,
) -> list[AdminLowStockItem]:
"""Get items with low stock levels (admin only)."""
query = (
db.query(Inventory)
.join(Product)
.join(Vendor)
.filter(Inventory.quantity <= threshold)
)
if vendor_id is not None:
query = query.filter(Inventory.vendor_id == vendor_id)
# Order by quantity ascending (most critical first)
query = query.order_by(Inventory.quantity.asc())
inventories = query.limit(limit).all()
items = []
for inv in inventories:
product = inv.product
vendor = inv.vendor
title = None
if product and product.marketplace_product:
title = product.marketplace_product.get_title()
items.append(
AdminLowStockItem(
id=inv.id,
product_id=inv.product_id,
vendor_id=inv.vendor_id,
vendor_name=vendor.name if vendor else None,
product_title=title,
location=inv.location,
quantity=inv.quantity,
reserved_quantity=inv.reserved_quantity,
available_quantity=inv.available_quantity,
)
)
return items
def get_vendors_with_inventory_admin(
self, db: Session
) -> AdminVendorsWithInventoryResponse:
"""Get list of vendors that have inventory entries (admin only)."""
# noqa: SVC-005 - Admin function, intentionally cross-vendor
# Use subquery to avoid DISTINCT on JSON columns (PostgreSQL can't compare JSON)
vendor_ids_subquery = (
db.query(Inventory.vendor_id)
.distinct()
.subquery()
)
vendors = (
db.query(Vendor)
.filter(Vendor.id.in_(db.query(vendor_ids_subquery.c.vendor_id)))
.order_by(Vendor.name)
.all()
)
return AdminVendorsWithInventoryResponse(
vendors=[
AdminVendorWithInventory(
id=v.id, name=v.name, vendor_code=v.vendor_code
)
for v in vendors
]
)
def get_inventory_locations_admin(
self, db: Session, vendor_id: int | None = None
) -> AdminInventoryLocationsResponse:
"""Get list of unique inventory locations (admin only)."""
query = db.query(func.distinct(Inventory.location))
if vendor_id is not None:
query = query.filter(Inventory.vendor_id == vendor_id)
locations = [loc[0] for loc in query.all()]
return AdminInventoryLocationsResponse(locations=sorted(locations))
def get_vendor_inventory_admin(
self,
db: Session,
vendor_id: int,
skip: int = 0,
limit: int = 50,
location: str | None = None,
low_stock: int | None = None,
) -> AdminInventoryListResponse:
"""Get inventory for a specific vendor (admin only)."""
# Verify vendor exists
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
raise VendorNotFoundException(f"Vendor {vendor_id} not found")
# Use the existing method
inventories = self.get_vendor_inventory(
db=db,
vendor_id=vendor_id,
skip=skip,
limit=limit,
location=location,
low_stock_threshold=low_stock,
)
# Build response with product info
items = []
for inv in inventories:
product = inv.product
title = None
if product and product.marketplace_product:
title = product.marketplace_product.get_title()
items.append(
AdminInventoryItem(
id=inv.id,
product_id=inv.product_id,
vendor_id=inv.vendor_id,
vendor_name=vendor.name,
vendor_code=vendor.vendor_code,
product_title=title,
product_sku=product.vendor_sku if product else None,
location=inv.location,
quantity=inv.quantity,
reserved_quantity=inv.reserved_quantity,
available_quantity=inv.available_quantity,
gtin=inv.gtin,
created_at=inv.created_at,
updated_at=inv.updated_at,
)
)
# Get total count for pagination
total_query = db.query(func.count(Inventory.id)).filter(
Inventory.vendor_id == vendor_id
)
if location:
total_query = total_query.filter(Inventory.location.ilike(f"%{location}%"))
if low_stock is not None:
total_query = total_query.filter(Inventory.quantity <= low_stock)
total = total_query.scalar() or 0
return AdminInventoryListResponse(
inventories=items,
total=total,
skip=skip,
limit=limit,
vendor_filter=vendor_id,
location_filter=location,
)
def get_product_inventory_admin(
self, db: Session, product_id: int
) -> ProductInventorySummary:
"""Get inventory summary for a product (admin only - no vendor check)."""
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise ProductNotFoundException(f"Product {product_id} not found")
# Use existing method with the product's vendor_id
return self.get_product_inventory(db, product.vendor_id, product_id)
def verify_vendor_exists(self, db: Session, vendor_id: int) -> Vendor:
"""Verify vendor exists and return it."""
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
raise VendorNotFoundException(f"Vendor {vendor_id} not found")
return vendor
def get_inventory_by_id_admin(self, db: Session, inventory_id: int) -> Inventory:
"""Get inventory by ID (admin only - returns inventory with vendor_id)."""
inventory = db.query(Inventory).filter(Inventory.id == inventory_id).first()
if not inventory:
raise InventoryNotFoundException(f"Inventory {inventory_id} not found")
return inventory
# =========================================================================
# Private helper methods
# =========================================================================
def _get_vendor_product(
self, db: Session, vendor_id: int, product_id: int
) -> Product:
"""Get product and verify it belongs to vendor."""
product = (
db.query(Product)
.filter(Product.id == product_id, Product.vendor_id == vendor_id)
.first()
)
if not product:
raise ProductNotFoundException(
f"Product {product_id} not found in your catalog"
)
return product
def _get_inventory_entry(
self, db: Session, product_id: int, location: str
) -> Inventory | None:
"""Get inventory entry by product and location."""
return (
db.query(Inventory)
.filter(Inventory.product_id == product_id, Inventory.location == location)
.first()
)
def _get_inventory_by_id(self, db: Session, inventory_id: int) -> Inventory:
"""Get inventory by ID or raise exception."""
inventory = db.query(Inventory).filter(Inventory.id == inventory_id).first()
if not inventory:
raise InventoryNotFoundException(f"Inventory {inventory_id} not found")
return inventory
def _validate_location(self, location: str) -> str:
"""Validate and normalize location."""
if not location or not location.strip():
raise InventoryValidationException("Location is required")
return location.strip().upper()
def _validate_quantity(self, quantity: int, allow_zero: bool = True) -> None:
"""Validate quantity value."""
if quantity is None:
raise InvalidQuantityException("Quantity is required")
if not isinstance(quantity, int):
raise InvalidQuantityException("Quantity must be an integer")
if quantity < 0:
raise InvalidQuantityException("Quantity cannot be negative")
if not allow_zero and quantity == 0:
raise InvalidQuantityException("Quantity must be positive")
# Create service instance
inventory_service = InventoryService()

View File

@@ -0,0 +1,431 @@
# app/modules/inventory/services/inventory_transaction_service.py
"""
Inventory Transaction Service.
Provides query operations for inventory transaction history.
All transaction WRITES are handled by OrderInventoryService.
This service handles transaction READS for reporting and auditing.
"""
import logging
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.exceptions import OrderNotFoundException, ProductNotFoundException
from app.modules.inventory.models.inventory import Inventory
from app.modules.inventory.models.inventory_transaction import InventoryTransaction
from models.database.order import Order
from models.database.product import Product
logger = logging.getLogger(__name__)
class InventoryTransactionService:
"""Service for querying inventory transaction history."""
def get_vendor_transactions(
self,
db: Session,
vendor_id: int,
skip: int = 0,
limit: int = 50,
product_id: int | None = None,
transaction_type: str | None = None,
) -> tuple[list[dict], int]:
"""
Get inventory transactions for a vendor with optional filters.
Args:
db: Database session
vendor_id: Vendor ID
skip: Pagination offset
limit: Pagination limit
product_id: Optional product filter
transaction_type: Optional transaction type filter
Returns:
Tuple of (transactions with product details, total count)
"""
# Build query
query = db.query(InventoryTransaction).filter(
InventoryTransaction.vendor_id == vendor_id
)
# Apply filters
if product_id:
query = query.filter(InventoryTransaction.product_id == product_id)
if transaction_type:
query = query.filter(
InventoryTransaction.transaction_type == transaction_type
)
# Get total count
total = query.count()
# Get transactions with pagination (newest first)
transactions = (
query.order_by(InventoryTransaction.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
# Build result with product details
result = []
for tx in transactions:
product = db.query(Product).filter(Product.id == tx.product_id).first()
product_title = None
product_sku = None
if product:
product_sku = product.vendor_sku
if product.marketplace_product:
product_title = product.marketplace_product.get_title()
result.append(
{
"id": tx.id,
"vendor_id": tx.vendor_id,
"product_id": tx.product_id,
"inventory_id": tx.inventory_id,
"transaction_type": (
tx.transaction_type.value if tx.transaction_type else None
),
"quantity_change": tx.quantity_change,
"quantity_after": tx.quantity_after,
"reserved_after": tx.reserved_after,
"location": tx.location,
"warehouse": tx.warehouse,
"order_id": tx.order_id,
"order_number": tx.order_number,
"reason": tx.reason,
"created_by": tx.created_by,
"created_at": tx.created_at,
"product_title": product_title,
"product_sku": product_sku,
}
)
return result, total
def get_product_history(
self,
db: Session,
vendor_id: int,
product_id: int,
limit: int = 50,
) -> dict:
"""
Get transaction history for a specific product.
Args:
db: Database session
vendor_id: Vendor ID
product_id: Product ID
limit: Max transactions to return
Returns:
Dict with product info, current inventory, and transactions
Raises:
ProductNotFoundException: If product not found or doesn't belong to vendor
"""
# Get product details
product = (
db.query(Product)
.filter(Product.id == product_id, Product.vendor_id == vendor_id)
.first()
)
if not product:
raise ProductNotFoundException(
f"Product {product_id} not found in vendor catalog"
)
product_title = None
product_sku = product.vendor_sku
if product.marketplace_product:
product_title = product.marketplace_product.get_title()
# Get current inventory
inventory = (
db.query(Inventory)
.filter(Inventory.product_id == product_id, Inventory.vendor_id == vendor_id)
.first()
)
current_quantity = inventory.quantity if inventory else 0
current_reserved = inventory.reserved_quantity if inventory else 0
# Get transactions
transactions = (
db.query(InventoryTransaction)
.filter(
InventoryTransaction.vendor_id == vendor_id,
InventoryTransaction.product_id == product_id,
)
.order_by(InventoryTransaction.created_at.desc())
.limit(limit)
.all()
)
total = (
db.query(func.count(InventoryTransaction.id))
.filter(
InventoryTransaction.vendor_id == vendor_id,
InventoryTransaction.product_id == product_id,
)
.scalar()
or 0
)
return {
"product_id": product_id,
"product_title": product_title,
"product_sku": product_sku,
"current_quantity": current_quantity,
"current_reserved": current_reserved,
"transactions": [
{
"id": tx.id,
"vendor_id": tx.vendor_id,
"product_id": tx.product_id,
"inventory_id": tx.inventory_id,
"transaction_type": (
tx.transaction_type.value if tx.transaction_type else None
),
"quantity_change": tx.quantity_change,
"quantity_after": tx.quantity_after,
"reserved_after": tx.reserved_after,
"location": tx.location,
"warehouse": tx.warehouse,
"order_id": tx.order_id,
"order_number": tx.order_number,
"reason": tx.reason,
"created_by": tx.created_by,
"created_at": tx.created_at,
}
for tx in transactions
],
"total": total,
}
def get_order_history(
self,
db: Session,
vendor_id: int,
order_id: int,
) -> dict:
"""
Get all inventory transactions for a specific order.
Args:
db: Database session
vendor_id: Vendor ID
order_id: Order ID
Returns:
Dict with order info and transactions
Raises:
OrderNotFoundException: If order not found or doesn't belong to vendor
"""
# Verify order belongs to vendor
order = (
db.query(Order)
.filter(Order.id == order_id, Order.vendor_id == vendor_id)
.first()
)
if not order:
raise OrderNotFoundException(f"Order {order_id} not found")
# Get transactions for this order
transactions = (
db.query(InventoryTransaction)
.filter(InventoryTransaction.order_id == order_id)
.order_by(InventoryTransaction.created_at.asc())
.all()
)
# Build result with product details
result = []
for tx in transactions:
product = db.query(Product).filter(Product.id == tx.product_id).first()
product_title = None
product_sku = None
if product:
product_sku = product.vendor_sku
if product.marketplace_product:
product_title = product.marketplace_product.get_title()
result.append(
{
"id": tx.id,
"vendor_id": tx.vendor_id,
"product_id": tx.product_id,
"inventory_id": tx.inventory_id,
"transaction_type": (
tx.transaction_type.value if tx.transaction_type else None
),
"quantity_change": tx.quantity_change,
"quantity_after": tx.quantity_after,
"reserved_after": tx.reserved_after,
"location": tx.location,
"warehouse": tx.warehouse,
"order_id": tx.order_id,
"order_number": tx.order_number,
"reason": tx.reason,
"created_by": tx.created_by,
"created_at": tx.created_at,
"product_title": product_title,
"product_sku": product_sku,
}
)
return {
"order_id": order_id,
"order_number": order.order_number,
"transactions": result,
}
# =========================================================================
# Admin Methods (cross-vendor operations)
# =========================================================================
def get_all_transactions_admin(
self,
db: Session,
skip: int = 0,
limit: int = 50,
vendor_id: int | None = None,
product_id: int | None = None,
transaction_type: str | None = None,
order_id: int | None = None,
) -> tuple[list[dict], int]:
"""
Get inventory transactions across all vendors (admin only).
Args:
db: Database session
skip: Pagination offset
limit: Pagination limit
vendor_id: Optional vendor filter
product_id: Optional product filter
transaction_type: Optional transaction type filter
order_id: Optional order filter
Returns:
Tuple of (transactions with details, total count)
"""
from models.database.vendor import Vendor
# Build query
query = db.query(InventoryTransaction)
# Apply filters
if vendor_id:
query = query.filter(InventoryTransaction.vendor_id == vendor_id)
if product_id:
query = query.filter(InventoryTransaction.product_id == product_id)
if transaction_type:
query = query.filter(
InventoryTransaction.transaction_type == transaction_type
)
if order_id:
query = query.filter(InventoryTransaction.order_id == order_id)
# Get total count
total = query.count()
# Get transactions with pagination (newest first)
transactions = (
query.order_by(InventoryTransaction.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
# Build result with vendor and product details
result = []
for tx in transactions:
vendor = db.query(Vendor).filter(Vendor.id == tx.vendor_id).first()
product = db.query(Product).filter(Product.id == tx.product_id).first()
product_title = None
product_sku = None
if product:
product_sku = product.vendor_sku
if product.marketplace_product:
product_title = product.marketplace_product.get_title()
result.append(
{
"id": tx.id,
"vendor_id": tx.vendor_id,
"vendor_name": vendor.name if vendor else None,
"vendor_code": vendor.vendor_code if vendor else None,
"product_id": tx.product_id,
"inventory_id": tx.inventory_id,
"transaction_type": (
tx.transaction_type.value if tx.transaction_type else None
),
"quantity_change": tx.quantity_change,
"quantity_after": tx.quantity_after,
"reserved_after": tx.reserved_after,
"location": tx.location,
"warehouse": tx.warehouse,
"order_id": tx.order_id,
"order_number": tx.order_number,
"reason": tx.reason,
"created_by": tx.created_by,
"created_at": tx.created_at,
"product_title": product_title,
"product_sku": product_sku,
}
)
return result, total
def get_transaction_stats_admin(self, db: Session) -> dict:
"""
Get transaction statistics across the platform (admin only).
Returns:
Dict with transaction counts by type
"""
from sqlalchemy import func as sql_func
# Count by transaction type
type_counts = (
db.query(
InventoryTransaction.transaction_type,
sql_func.count(InventoryTransaction.id).label("count"),
)
.group_by(InventoryTransaction.transaction_type)
.all()
)
# Total transactions
total = db.query(sql_func.count(InventoryTransaction.id)).scalar() or 0
# Transactions today
from datetime import UTC, datetime, timedelta
today_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
today_count = (
db.query(sql_func.count(InventoryTransaction.id))
.filter(InventoryTransaction.created_at >= today_start)
.scalar()
or 0
)
return {
"total_transactions": total,
"transactions_today": today_count,
"by_type": {tc.transaction_type.value: tc.count for tc in type_counts},
}
# Create service instance
inventory_transaction_service = InventoryTransactionService()