major refactoring adding vendor and customer features

This commit is contained in:
2025-10-11 09:09:25 +02:00
parent f569995883
commit dd16198276
126 changed files with 15109 additions and 3747 deletions

View File

@@ -4,27 +4,35 @@ Admin service for managing users, vendors, and import jobs.
This module provides classes and functions for:
- User management and status control
- Vendor creation with owner user generation
- Vendor verification and activation
- Marketplace import job monitoring
- Platform statistics
"""
import logging
import secrets
import string
from datetime import datetime, timezone
from typing import List, Optional, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import func, or_
from app.exceptions import (
UserNotFoundException,
UserStatusChangeException,
CannotModifySelfException,
VendorNotFoundException,
VendorAlreadyExistsException,
VendorVerificationException,
AdminOperationException,
ValidationException,
)
from models.schemas.marketplace_import_job import MarketplaceImportJobResponse
from models.schemas.vendor import VendorCreate
from models.database.marketplace_import_job import MarketplaceImportJob
from models.database.vendor import Vendor
from models.database.vendor import Vendor, Role
from models.database.user import User
logger = logging.getLogger(__name__)
@@ -33,6 +41,10 @@ logger = logging.getLogger(__name__)
class AdminService:
"""Service class for admin operations following the application's service pattern."""
# ============================================================================
# USER MANAGEMENT
# ============================================================================
def get_all_users(self, db: Session, skip: int = 0, limit: int = 100) -> List[User]:
"""Get paginated list of all users."""
try:
@@ -47,29 +59,14 @@ class AdminService:
def toggle_user_status(
self, db: Session, user_id: int, current_admin_id: int
) -> Tuple[User, str]:
"""
Toggle user active status.
Args:
db: Database session
user_id: ID of user to toggle
current_admin_id: ID of the admin performing the action
Returns:
Tuple of (updated_user, status_message)
Raises:
UserNotFoundException: If user not found
CannotModifySelfException: If trying to modify own account
UserStatusChangeException: If status change is not allowed
"""
"""Toggle user active status."""
user = self._get_user_by_id_or_raise(db, user_id)
# Prevent self-modification
if user.id == current_admin_id:
raise CannotModifySelfException(user_id, "deactivate account")
# Check if user is another admin - FIXED LOGIC
# Check if user is another admin
if user.role == "admin" and user.id != current_admin_id:
raise UserStatusChangeException(
user_id=user_id,
@@ -101,23 +98,150 @@ class AdminService:
reason="Database update failed"
)
def get_all_vendors(
self, db: Session, skip: int = 0, limit: int = 100
) -> Tuple[List[Vendor], int]:
# ============================================================================
# VENDOR MANAGEMENT
# ============================================================================
def create_vendor_with_owner(
self, db: Session, vendor_data: VendorCreate
) -> Tuple[Vendor, User, str]:
"""
Get paginated list of all vendors with total count.
Create vendor with owner user account.
Args:
db: Database session
skip: Number of records to skip
limit: Maximum number of records to return
Returns:
Tuple of (vendors_list, total_count)
Returns: (vendor, owner_user, temporary_password)
"""
try:
total = db.query(Vendor).count()
vendors =db.query(Vendor).offset(skip).limit(limit).all()
# Check if vendor code already exists
existing_vendor = db.query(Vendor).filter(
func.upper(Vendor.vendor_code) == vendor_data.vendor_code.upper()
).first()
if existing_vendor:
raise VendorAlreadyExistsException(vendor_data.vendor_code)
# Check if subdomain already exists
existing_subdomain = db.query(Vendor).filter(
func.lower(Vendor.subdomain) == vendor_data.subdomain.lower()
).first()
if existing_subdomain:
raise ValidationException(
f"Subdomain '{vendor_data.subdomain}' is already taken"
)
# Generate temporary password for owner
temp_password = self._generate_temp_password()
# Create owner user
from middleware.auth import AuthManager
auth_manager = AuthManager()
owner_username = f"{vendor_data.vendor_code.lower()}_owner"
owner_email = vendor_data.owner_email if hasattr(vendor_data,
'owner_email') else f"{owner_username}@{vendor_data.subdomain}.com"
# Check if user with this email already exists
existing_user = db.query(User).filter(
User.email == owner_email
).first()
if existing_user:
# Use existing user as owner
owner_user = existing_user
else:
# Create new owner user
owner_user = User(
email=owner_email,
username=owner_username,
hashed_password=auth_manager.hash_password(temp_password),
role="user", # Will be vendor owner through relationship
is_active=True,
)
db.add(owner_user)
db.flush() # Get owner_user.id
# Create vendor
vendor = Vendor(
vendor_code=vendor_data.vendor_code.upper(),
subdomain=vendor_data.subdomain.lower(),
name=vendor_data.name,
description=getattr(vendor_data, 'description', None),
owner_user_id=owner_user.id,
contact_email=owner_email,
contact_phone=getattr(vendor_data, 'contact_phone', None),
website=getattr(vendor_data, 'website', None),
business_address=getattr(vendor_data, 'business_address', None),
tax_number=getattr(vendor_data, 'tax_number', None),
letzshop_csv_url_fr=getattr(vendor_data, 'letzshop_csv_url_fr', None),
letzshop_csv_url_en=getattr(vendor_data, 'letzshop_csv_url_en', None),
letzshop_csv_url_de=getattr(vendor_data, 'letzshop_csv_url_de', None),
theme_config=getattr(vendor_data, 'theme_config', {}),
is_active=True,
is_verified=True,
)
db.add(vendor)
db.flush() # Get vendor.id
# Create default roles for vendor
self._create_default_roles(db, vendor.id)
db.commit()
db.refresh(vendor)
db.refresh(owner_user)
logger.info(
f"Vendor {vendor.vendor_code} created with owner {owner_user.username}"
)
# TODO: Send welcome email to owner with credentials
# self._send_vendor_welcome_email(owner_user, vendor, temp_password)
return vendor, owner_user, temp_password
except (VendorAlreadyExistsException, ValidationException):
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Failed to create vendor: {str(e)}")
raise AdminOperationException(
operation="create_vendor_with_owner",
reason=f"Failed to create vendor: {str(e)}"
)
def get_all_vendors(
self,
db: Session,
skip: int = 0,
limit: int = 100,
search: Optional[str] = None,
is_active: Optional[bool] = None,
is_verified: Optional[bool] = None
) -> Tuple[List[Vendor], int]:
"""Get paginated list of all vendors with filtering."""
try:
query = db.query(Vendor)
# Apply search filter
if search:
search_term = f"%{search}%"
query = query.filter(
or_(
Vendor.name.ilike(search_term),
Vendor.vendor_code.ilike(search_term),
Vendor.subdomain.ilike(search_term)
)
)
# Apply status filters
if is_active is not None:
query = query.filter(Vendor.is_active == is_active)
if is_verified is not None:
query = query.filter(Vendor.is_verified == is_verified)
total = query.count()
vendors = query.offset(skip).limit(limit).all()
return vendors, total
except Exception as e:
logger.error(f"Failed to retrieve vendors: {str(e)}")
@@ -126,21 +250,12 @@ class AdminService:
reason="Database query failed"
)
def get_vendor_by_id(self, db: Session, vendor_id: int) -> Vendor:
"""Get vendor by ID."""
return self._get_vendor_by_id_or_raise(db, vendor_id)
def verify_vendor(self, db: Session, vendor_id: int) -> Tuple[Vendor, str]:
"""
Toggle vendor verification status.
Args:
db: Database session
vendor_id: ID of vendor to verify/unverify
Returns:
Tuple of (updated_vendor, status_message)
Raises:
VendorNotFoundException: If vendor not found
VendorVerificationException: If verification fails
"""
"""Toggle vendor verification status."""
vendor = self._get_vendor_by_id_or_raise(db, vendor_id)
try:
@@ -148,7 +263,6 @@ class AdminService:
vendor.is_verified = not vendor.is_verified
vendor.updated_at = datetime.now(timezone.utc)
# Add verification timestamp if implementing audit trail
if vendor.is_verified:
vendor.verified_at = datetime.now(timezone.utc)
@@ -171,20 +285,7 @@ class AdminService:
)
def toggle_vendor_status(self, db: Session, vendor_id: int) -> Tuple[Vendor, str]:
"""
Toggle vendor active status.
Args:
db: Database session
vendor_id: ID of vendor to activate/deactivate
Returns:
Tuple of (updated_vendor, status_message)
Raises:
VendorNotFoundException: If vendor not found
AdminOperationException: If status change fails
"""
"""Toggle vendor active status."""
vendor = self._get_vendor_by_id_or_raise(db, vendor_id)
try:
@@ -198,7 +299,7 @@ class AdminService:
message = f"Vendor {vendor.vendor_code} has been {status_action}"
logger.info(message)
return vendor , message
return vendor, message
except Exception as e:
db.rollback()
@@ -210,6 +311,39 @@ class AdminService:
target_id=str(vendor_id)
)
def delete_vendor(self, db: Session, vendor_id: int) -> str:
"""Delete vendor and all associated data."""
vendor = self._get_vendor_by_id_or_raise(db, vendor_id)
try:
vendor_code = vendor.vendor_code
# TODO: Delete associated data in correct order
# - Delete orders
# - Delete customers
# - Delete products
# - Delete team members
# - Delete roles
# - Delete import jobs
db.delete(vendor)
db.commit()
logger.warning(f"Vendor {vendor_code} and all associated data deleted")
return f"Vendor {vendor_code} successfully deleted"
except Exception as e:
db.rollback()
logger.error(f"Failed to delete vendor {vendor_id}: {str(e)}")
raise AdminOperationException(
operation="delete_vendor",
reason="Database deletion failed"
)
# ============================================================================
# MARKETPLACE IMPORT JOBS
# ============================================================================
def get_marketplace_import_jobs(
self,
db: Session,
@@ -219,24 +353,10 @@ class AdminService:
skip: int = 0,
limit: int = 100,
) -> List[MarketplaceImportJobResponse]:
"""
Get filtered and paginated marketplace import jobs.
Args:
db: Database session
marketplace: Filter by marketplace name (case-insensitive partial match)
vendor_name: Filter by vendor name (case-insensitive partial match)
status: Filter by exact status
skip: Number of records to skip
limit: Maximum number of records to return
Returns:
List of MarketplaceImportJobResponse objects
"""
"""Get filtered and paginated marketplace import jobs."""
try:
query = db.query(MarketplaceImportJob)
# Apply filters
if marketplace:
query = query.filter(
MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%")
@@ -246,7 +366,6 @@ class AdminService:
if status:
query = query.filter(MarketplaceImportJob.status == status)
# Order by creation date and apply pagination
jobs = (
query.order_by(MarketplaceImportJob.created_at.desc())
.offset(skip)
@@ -263,17 +382,23 @@ class AdminService:
reason="Database query failed"
)
# ============================================================================
# STATISTICS
# ============================================================================
def get_user_statistics(self, db: Session) -> dict:
"""Get user statistics for admin dashboard."""
try:
total_users = db.query(User).count()
active_users = db.query(User).filter(User.is_active == True).count()
inactive_users = total_users - active_users
admin_users = db.query(User).filter(User.role == "admin").count()
return {
"total_users": total_users,
"active_users": active_users,
"inactive_users": inactive_users,
"admin_users": admin_users,
"activation_rate": (active_users / total_users * 100) if total_users > 0 else 0
}
except Exception as e:
@@ -289,10 +414,12 @@ class AdminService:
total_vendors = db.query(Vendor).count()
active_vendors = db.query(Vendor).filter(Vendor.is_active == True).count()
verified_vendors = db.query(Vendor).filter(Vendor.is_verified == True).count()
inactive_vendors = total_vendors - active_vendors
return {
"total_vendors": total_vendors,
"active_vendors": active_vendors,
"inactive_vendors": inactive_vendors,
"verified_vendors": verified_vendors,
"verification_rate": (verified_vendors / total_vendors * 100) if total_vendors > 0 else 0
}
@@ -303,7 +430,100 @@ class AdminService:
reason="Database query failed"
)
# Private helper methods
def get_recent_vendors(self, db: Session, limit: int = 5) -> List[dict]:
"""Get recently created vendors."""
try:
vendors = (
db.query(Vendor)
.order_by(Vendor.created_at.desc())
.limit(limit)
.all()
)
return [
{
"id": v.id,
"vendor_code": v.vendor_code,
"name": v.name,
"subdomain": v.subdomain,
"is_active": v.is_active,
"is_verified": v.is_verified,
"created_at": v.created_at
}
for v in vendors
]
except Exception as e:
logger.error(f"Failed to get recent vendors: {str(e)}")
return []
def get_recent_import_jobs(self, db: Session, limit: int = 10) -> List[dict]:
"""Get recent marketplace import jobs."""
try:
jobs = (
db.query(MarketplaceImportJob)
.order_by(MarketplaceImportJob.created_at.desc())
.limit(limit)
.all()
)
return [
{
"id": j.id,
"marketplace": j.marketplace,
"vendor_name": j.vendor_name,
"status": j.status,
"total_processed": j.total_processed or 0,
"created_at": j.created_at
}
for j in jobs
]
except Exception as e:
logger.error(f"Failed to get recent import jobs: {str(e)}")
return []
def get_product_statistics(self, db: Session) -> dict:
"""Get product statistics."""
# TODO: Implement when Product model is available
return {
"total_products": 0,
"active_products": 0,
"out_of_stock": 0
}
def get_order_statistics(self, db: Session) -> dict:
"""Get order statistics."""
# TODO: Implement when Order model is available
return {
"total_orders": 0,
"pending_orders": 0,
"completed_orders": 0
}
def get_import_statistics(self, db: Session) -> dict:
"""Get import job statistics."""
try:
total = db.query(MarketplaceImportJob).count()
completed = db.query(MarketplaceImportJob).filter(
MarketplaceImportJob.status == "completed"
).count()
failed = db.query(MarketplaceImportJob).filter(
MarketplaceImportJob.status == "failed"
).count()
return {
"total_imports": total,
"completed_imports": completed,
"failed_imports": failed,
"success_rate": (completed / total * 100) if total > 0 else 0
}
except Exception as e:
logger.error(f"Failed to get import statistics: {str(e)}")
return {"total_imports": 0, "completed_imports": 0, "failed_imports": 0, "success_rate": 0}
# ============================================================================
# PRIVATE HELPER METHODS
# ============================================================================
def _get_user_by_id_or_raise(self, db: Session, user_id: int) -> User:
"""Get user by ID or raise UserNotFoundException."""
user = db.query(User).filter(User.id == user_id).first()
@@ -314,9 +534,52 @@ class AdminService:
def _get_vendor_by_id_or_raise(self, db: Session, vendor_id: int) -> Vendor:
"""Get vendor by ID or raise VendorNotFoundException."""
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor :
if not vendor:
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
return vendor
return vendor
def _generate_temp_password(self, length: int = 12) -> str:
"""Generate secure temporary password."""
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
return ''.join(secrets.choice(alphabet) for _ in range(length))
def _create_default_roles(self, db: Session, vendor_id: int):
"""Create default roles for a new vendor."""
default_roles = [
{
"name": "Owner",
"permissions": ["*"] # Full access
},
{
"name": "Manager",
"permissions": [
"products.*", "orders.*", "customers.view",
"inventory.*", "team.view"
]
},
{
"name": "Editor",
"permissions": [
"products.view", "products.edit",
"orders.view", "inventory.view"
]
},
{
"name": "Viewer",
"permissions": [
"products.view", "orders.view",
"customers.view", "inventory.view"
]
}
]
for role_data in default_roles:
role = Role(
vendor_id=vendor_id,
name=role_data["name"],
permissions=role_data["permissions"]
)
db.add(role)
def _convert_job_to_response(self, job: MarketplaceImportJob) -> MarketplaceImportJobResponse:
"""Convert database model to response schema."""
@@ -338,5 +601,5 @@ class AdminService:
)
# Create service instance following the same pattern as marketplace_product_service
# Create service instance
admin_service = AdminService()

View File

@@ -0,0 +1,184 @@
# app/services/cart_service.py
"""
Shopping cart service.
This module provides:
- Session-based cart management
- Cart item operations (add, update, remove)
- Cart total calculations
"""
import logging
from typing import Dict, List, Optional
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from sqlalchemy import and_
from models.database.product import Product
from models.database.vendor import Vendor
from app.exceptions import (
ProductNotFoundException,
ValidationException,
InsufficientInventoryException
)
logger = logging.getLogger(__name__)
class CartService:
"""Service for managing shopping carts."""
def get_cart(
self,
db: Session,
vendor_id: int,
session_id: str
) -> Dict:
"""
Get cart contents for a session.
Note: This is a simple in-memory implementation.
In production, you'd store carts in Redis or database.
"""
# For now, return empty cart structure
# TODO: Implement persistent cart storage
return {
"vendor_id": vendor_id,
"session_id": session_id,
"items": [],
"subtotal": 0.0,
"total": 0.0
}
def add_to_cart(
self,
db: Session,
vendor_id: int,
session_id: str,
product_id: int,
quantity: int = 1
) -> Dict:
"""
Add product to cart.
Args:
db: Database session
vendor_id: Vendor ID
session_id: Session ID
product_id: Product ID
quantity: Quantity to add
Returns:
Updated cart
Raises:
ProductNotFoundException: If product not found
InsufficientInventoryException: If not enough inventory
"""
# Verify product exists and belongs to vendor
product = db.query(Product).filter(
and_(
Product.id == product_id,
Product.vendor_id == vendor_id,
Product.is_active == True
)
).first()
if not product:
raise ProductNotFoundException(str(product_id))
# Check inventory
if product.available_inventory < quantity:
raise InsufficientInventoryException(
product_id=product_id,
requested=quantity,
available=product.available_inventory
)
# TODO: Add to persistent cart storage
# For now, return success response
logger.info(
f"Added product {product_id} (qty: {quantity}) to cart "
f"for session {session_id}"
)
return {
"message": "Product added to cart",
"product_id": product_id,
"quantity": quantity
}
def update_cart_item(
self,
db: Session,
vendor_id: int,
session_id: str,
product_id: int,
quantity: int
) -> Dict:
"""Update quantity of item in cart."""
if quantity < 1:
raise ValidationException("Quantity must be at least 1")
# Verify product
product = db.query(Product).filter(
and_(
Product.id == product_id,
Product.vendor_id == vendor_id
)
).first()
if not product:
raise ProductNotFoundException(str(product_id))
# Check inventory
if product.available_inventory < quantity:
raise InsufficientInventoryException(
product_id=product_id,
requested=quantity,
available=product.available_inventory
)
# TODO: Update persistent cart
logger.info(f"Updated cart item {product_id} quantity to {quantity}")
return {
"message": "Cart updated",
"product_id": product_id,
"quantity": quantity
}
def remove_from_cart(
self,
db: Session,
vendor_id: int,
session_id: str,
product_id: int
) -> Dict:
"""Remove item from cart."""
# TODO: Remove from persistent cart
logger.info(f"Removed product {product_id} from cart {session_id}")
return {
"message": "Item removed from cart",
"product_id": product_id
}
def clear_cart(
self,
db: Session,
vendor_id: int,
session_id: str
) -> Dict:
"""Clear all items from cart."""
# TODO: Clear persistent cart
logger.info(f"Cleared cart for session {session_id}")
return {
"message": "Cart cleared"
}
# Create service instance
cart_service = CartService()

View File

@@ -0,0 +1,407 @@
# app/services/customer_service.py
"""
Customer management service.
Handles customer registration, authentication, and profile management
with complete vendor isolation.
"""
import logging
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import and_
from models.database.customer import Customer, CustomerAddress
from models.database.vendor import Vendor
from models.schemas.customer import CustomerRegister, CustomerUpdate
from models.schemas.auth import UserLogin
from app.exceptions.customer import (
CustomerNotFoundException,
CustomerAlreadyExistsException,
CustomerNotActiveException,
InvalidCustomerCredentialsException,
CustomerValidationException,
DuplicateCustomerEmailException
)
from app.exceptions.vendor import VendorNotFoundException, VendorNotActiveException
from app.services.auth_service import AuthService
logger = logging.getLogger(__name__)
class CustomerService:
"""Service for managing vendor-scoped customers."""
def __init__(self):
self.auth_service = AuthService()
def register_customer(
self,
db: Session,
vendor_id: int,
customer_data: CustomerRegister
) -> Customer:
"""
Register a new customer for a specific vendor.
Args:
db: Database session
vendor_id: Vendor ID
customer_data: Customer registration data
Returns:
Customer: Created customer object
Raises:
VendorNotFoundException: If vendor doesn't exist
VendorNotActiveException: If vendor is not active
DuplicateCustomerEmailException: If email already exists for this vendor
CustomerValidationException: If customer data is invalid
"""
# Verify vendor exists and is active
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
if not vendor.is_active:
raise VendorNotActiveException(vendor.vendor_code)
# Check if email already exists for this vendor
existing_customer = db.query(Customer).filter(
and_(
Customer.vendor_id == vendor_id,
Customer.email == customer_data.email.lower()
)
).first()
if existing_customer:
raise DuplicateCustomerEmailException(customer_data.email, vendor.vendor_code)
# Generate unique customer number for this vendor
customer_number = self._generate_customer_number(db, vendor_id, vendor.vendor_code)
# Hash password
hashed_password = self.auth_service.hash_password(customer_data.password)
# Create customer
customer = Customer(
vendor_id=vendor_id,
email=customer_data.email.lower(),
hashed_password=hashed_password,
first_name=customer_data.first_name,
last_name=customer_data.last_name,
phone=customer_data.phone,
customer_number=customer_number,
marketing_consent=customer_data.marketing_consent if hasattr(customer_data, 'marketing_consent') else False,
is_active=True
)
try:
db.add(customer)
db.commit()
db.refresh(customer)
logger.info(
f"Customer registered successfully: {customer.email} "
f"(ID: {customer.id}, Number: {customer.customer_number}) "
f"for vendor {vendor.vendor_code}"
)
return customer
except Exception as e:
db.rollback()
logger.error(f"Error registering customer: {str(e)}")
raise CustomerValidationException(
message="Failed to register customer",
details={"error": str(e)}
)
def login_customer(
self,
db: Session,
vendor_id: int,
credentials: UserLogin
) -> Dict[str, Any]:
"""
Authenticate customer and generate JWT token.
Args:
db: Database session
vendor_id: Vendor ID
credentials: Login credentials
Returns:
Dict containing customer and token data
Raises:
VendorNotFoundException: If vendor doesn't exist
InvalidCustomerCredentialsException: If credentials are invalid
CustomerNotActiveException: If customer account is inactive
"""
# Verify vendor exists
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
# Find customer by email (vendor-scoped)
customer = db.query(Customer).filter(
and_(
Customer.vendor_id == vendor_id,
Customer.email == credentials.username.lower()
)
).first()
if not customer:
raise InvalidCustomerCredentialsException()
# Verify password
if not self.auth_service.verify_password(
credentials.password,
customer.hashed_password
):
raise InvalidCustomerCredentialsException()
# Check if customer is active
if not customer.is_active:
raise CustomerNotActiveException(customer.email)
# Generate JWT token with customer context
token_data = self.auth_service.create_access_token(
data={
"sub": str(customer.id),
"email": customer.email,
"vendor_id": vendor_id,
"type": "customer"
}
)
logger.info(
f"Customer login successful: {customer.email} "
f"for vendor {vendor.vendor_code}"
)
return {
"customer": customer,
"token_data": token_data
}
def get_customer(
self,
db: Session,
vendor_id: int,
customer_id: int
) -> Customer:
"""
Get customer by ID with vendor isolation.
Args:
db: Database session
vendor_id: Vendor ID
customer_id: Customer ID
Returns:
Customer: Customer object
Raises:
CustomerNotFoundException: If customer not found
"""
customer = db.query(Customer).filter(
and_(
Customer.id == customer_id,
Customer.vendor_id == vendor_id
)
).first()
if not customer:
raise CustomerNotFoundException(str(customer_id))
return customer
def get_customer_by_email(
self,
db: Session,
vendor_id: int,
email: str
) -> Optional[Customer]:
"""
Get customer by email (vendor-scoped).
Args:
db: Database session
vendor_id: Vendor ID
email: Customer email
Returns:
Optional[Customer]: Customer object or None
"""
return db.query(Customer).filter(
and_(
Customer.vendor_id == vendor_id,
Customer.email == email.lower()
)
).first()
def update_customer(
self,
db: Session,
vendor_id: int,
customer_id: int,
customer_data: CustomerUpdate
) -> Customer:
"""
Update customer profile.
Args:
db: Database session
vendor_id: Vendor ID
customer_id: Customer ID
customer_data: Updated customer data
Returns:
Customer: Updated customer object
Raises:
CustomerNotFoundException: If customer not found
CustomerValidationException: If update data is invalid
"""
customer = self.get_customer(db, vendor_id, customer_id)
# Update fields
update_data = customer_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
if field == "email" and value:
# Check if new email already exists for this vendor
existing = db.query(Customer).filter(
and_(
Customer.vendor_id == vendor_id,
Customer.email == value.lower(),
Customer.id != customer_id
)
).first()
if existing:
raise DuplicateCustomerEmailException(value, "vendor")
setattr(customer, field, value.lower())
elif hasattr(customer, field):
setattr(customer, field, value)
try:
db.commit()
db.refresh(customer)
logger.info(f"Customer updated: {customer.email} (ID: {customer.id})")
return customer
except Exception as e:
db.rollback()
logger.error(f"Error updating customer: {str(e)}")
raise CustomerValidationException(
message="Failed to update customer",
details={"error": str(e)}
)
def deactivate_customer(
self,
db: Session,
vendor_id: int,
customer_id: int
) -> Customer:
"""
Deactivate customer account.
Args:
db: Database session
vendor_id: Vendor ID
customer_id: Customer ID
Returns:
Customer: Deactivated customer object
Raises:
CustomerNotFoundException: If customer not found
"""
customer = self.get_customer(db, vendor_id, customer_id)
customer.is_active = False
db.commit()
db.refresh(customer)
logger.info(f"Customer deactivated: {customer.email} (ID: {customer.id})")
return customer
def update_customer_stats(
self,
db: Session,
customer_id: int,
order_total: float
) -> None:
"""
Update customer statistics after order.
Args:
db: Database session
customer_id: Customer ID
order_total: Order total amount
"""
customer = db.query(Customer).filter(Customer.id == customer_id).first()
if customer:
customer.total_orders += 1
customer.total_spent += order_total
customer.last_order_date = datetime.utcnow()
db.commit()
logger.debug(f"Updated stats for customer {customer.email}")
def _generate_customer_number(
self,
db: Session,
vendor_id: int,
vendor_code: str
) -> str:
"""
Generate unique customer number for vendor.
Format: {VENDOR_CODE}-CUST-{SEQUENCE}
Example: VENDORA-CUST-00001
Args:
db: Database session
vendor_id: Vendor ID
vendor_code: Vendor code
Returns:
str: Unique customer number
"""
# Get count of customers for this vendor
count = db.query(Customer).filter(
Customer.vendor_id == vendor_id
).count()
# Generate number with padding
sequence = str(count + 1).zfill(5)
customer_number = f"{vendor_code.upper()}-CUST-{sequence}"
# Ensure uniqueness (in case of deletions)
while db.query(Customer).filter(
and_(
Customer.vendor_id == vendor_id,
Customer.customer_number == customer_number
)
).first():
count += 1
sequence = str(count + 1).zfill(5)
customer_number = f"{vendor_code.upper()}-CUST-{sequence}"
return customer_number
# Singleton instance
customer_service = CustomerService()

View File

@@ -0,0 +1,578 @@
# app/services/inventory_service.py
import logging
from datetime import datetime, timezone
from typing import List, Optional
from sqlalchemy.orm import Session
from app.exceptions import (
InventoryNotFoundException,
InsufficientInventoryException,
InvalidInventoryOperationException,
InventoryValidationException,
NegativeInventoryException,
InvalidQuantityException,
ValidationException,
ProductNotFoundException,
)
from models.schemas.inventory import (
InventoryCreate,
InventoryAdjust,
InventoryUpdate,
InventoryReserve,
InventoryLocationResponse,
ProductInventorySummary
)
from models.database.inventory import Inventory
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(timezone.utc)
db.commit()
db.refresh(existing)
logger.info(
f"Set inventory for product {inventory_data.product_id} at {location}: "
f"{old_qty}{inventory_data.quantity}"
)
return existing
else:
# Create new inventory entry
new_inventory = Inventory(
product_id=inventory_data.product_id,
vendor_id=vendor_id,
location=location,
quantity=inventory_data.quantity,
gtin=product.marketplace_product.gtin, # Optional reference
)
db.add(new_inventory)
db.commit()
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,
location=location,
quantity=inventory_data.quantity,
gtin=product.marketplace_product.gtin,
)
db.add(new_inventory)
db.commit()
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(timezone.utc)
db.commit()
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(timezone.utc)
db.commit()
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(timezone.utc)
db.commit()
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(timezone.utc)
db.commit()
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.product_id,
product_title=product.marketplace_product.title,
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.product_id,
product_title=product.marketplace_product.title,
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: Optional[str] = None, low_stock_threshold: Optional[int] = 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(timezone.utc)
db.commit()
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.commit()
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")
# 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
) -> Optional[Inventory]:
"""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

@@ -1,31 +1,21 @@
# app/services/marketplace_import_job_service.py
"""
Marketplace service for managing import jobs and marketplace integrations.
This module provides classes and functions for:
- Import job creation and management
- Vendor access validation
- Import job status tracking and updates
"""
import logging
from datetime import datetime, timezone
from typing import List, Optional
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.exceptions import (
VendorNotFoundException,
UnauthorizedVendorAccessException,
ImportJobNotFoundException,
ImportJobNotOwnedException,
ImportJobCannotBeCancelledException,
ImportJobCannotBeDeletedException,
ValidationException,
)
from models.schemas.marketplace_import_job import (MarketplaceImportJobResponse,
MarketplaceImportJobRequest)
from models.schemas.marketplace_import_job import (
MarketplaceImportJobResponse,
MarketplaceImportJobRequest
)
from models.database.marketplace_import_job import MarketplaceImportJob
from models.database.vendor import Vendor
from models.database.user import User
@@ -34,49 +24,14 @@ logger = logging.getLogger(__name__)
class MarketplaceImportJobService:
"""Service class for Marketplace operations following the application's service pattern."""
def validate_vendor_access(self, db: Session, vendor_code: str, user: User) -> Vendor:
"""
Validate that the vendor exists and user has access to it.
Args:
db: Database session
vendor_code: Vendor code to validate
user: User requesting access
Returns:
Vendor object if access is valid
Raises:
VendorNotFoundException: If vendor doesn't exist
UnauthorizedVendorAccessException: If user lacks access
"""
try:
# Use case-insensitive query to handle both uppercase and lowercase codes
vendor = (
db.query(Vendor)
.filter(func.upper(Vendor.vendor_code) == vendor_code.upper())
.first()
)
if not vendor :
raise VendorNotFoundException(vendor_code)
# Check permissions: admin can import for any vendor, others only for their own
if user.role != "admin" and vendor.owner_id != user.id:
raise UnauthorizedVendorAccessException(vendor_code, user.id)
return vendor
except (VendorNotFoundException, UnauthorizedVendorAccessException):
raise # Re-raise custom exceptions
except Exception as e:
logger.error(f"Error validating vendor access: {str(e)}")
raise ValidationException("Failed to validate vendor access")
"""Service class for Marketplace operations."""
def create_import_job(
self, db: Session, request: MarketplaceImportJobRequest, user: User
self,
db: Session,
request: MarketplaceImportJobRequest,
vendor: Vendor, # CHANGED: Vendor object from middleware
user: User
) -> MarketplaceImportJob:
"""
Create a new marketplace import job.
@@ -84,29 +39,20 @@ class MarketplaceImportJobService:
Args:
db: Database session
request: Import request data
vendor: Vendor object (from middleware)
user: User creating the job
Returns:
Created MarketplaceImportJob object
Raises:
VendorNotFoundException: If vendor doesn't exist
UnauthorizedVendorAccessException: If user lacks vendor access
ValidationException: If job creation fails
"""
try:
# Validate vendor access first
vendor = self.validate_vendor_access(db, request.vendor_code, user)
# Create marketplace import job record
import_job = MarketplaceImportJob(
status="pending",
source_url=request.url,
source_url=request.source_url,
marketplace=request.marketplace,
vendor_id=vendor.id, # Foreign key to vendors table
vendor_name=vendor.vendor_name, # Use vendor.vendor_name (the display name)
vendor_id=vendor.id,
user_id=user.id,
created_at=datetime.now(timezone.utc),
)
db.add(import_job)
@@ -115,36 +61,21 @@ class MarketplaceImportJobService:
logger.info(
f"Created marketplace import job {import_job.id}: "
f"{request.marketplace} -> {vendor.vendor_name} (vendor_code: {vendor.vendor_code}) by user {user.username}"
f"{request.marketplace} -> {vendor.name} (code: {vendor.vendor_code}) "
f"by user {user.username}"
)
return import_job
except (VendorNotFoundException, UnauthorizedVendorAccessException):
raise # Re-raise custom exceptions
except Exception as e:
db.rollback()
logger.error(f"Error creating import job: {str(e)}")
raise ValidationException("Failed to create import job")
def get_import_job_by_id(
self, db: Session, job_id: int, user: User
self, db: Session, job_id: int, user: User
) -> MarketplaceImportJob:
"""
Get a marketplace import job by ID with access control.
Args:
db: Database session
job_id: Import job ID
user: User requesting the job
Returns:
MarketplaceImportJob object
Raises:
ImportJobNotFoundException: If job doesn't exist
ImportJobNotOwnedException: If user lacks access to job
"""
"""Get a marketplace import job by ID with access control."""
try:
job = (
db.query(MarketplaceImportJob)
@@ -162,48 +93,35 @@ class MarketplaceImportJobService:
return job
except (ImportJobNotFoundException, ImportJobNotOwnedException):
raise # Re-raise custom exceptions
raise
except Exception as e:
logger.error(f"Error getting import job {job_id}: {str(e)}")
raise ValidationException("Failed to retrieve import job")
def get_import_jobs(
self,
db: Session,
user: User,
marketplace: Optional[str] = None,
vendor_name: Optional[str] = None,
skip: int = 0,
limit: int = 50,
self,
db: Session,
vendor: Vendor, # ADDED: Vendor filter
user: User,
marketplace: Optional[str] = None,
skip: int = 0,
limit: int = 50,
) -> List[MarketplaceImportJob]:
"""
Get marketplace import jobs with filtering and access control.
Args:
db: Database session
user: User requesting jobs
marketplace: Optional marketplace filter
vendor_name: Optional vendor name filter
skip: Number of records to skip
limit: Maximum records to return
Returns:
List of MarketplaceImportJob objects
"""
"""Get marketplace import jobs for a specific vendor."""
try:
query = db.query(MarketplaceImportJob)
query = db.query(MarketplaceImportJob).filter(
MarketplaceImportJob.vendor_id == vendor.id
)
# Users can only see their own jobs, admins can see all
# Users can only see their own jobs, admins can see all vendor jobs
if user.role != "admin":
query = query.filter(MarketplaceImportJob.user_id == user.id)
# Apply filters
# Apply marketplace filter
if marketplace:
query = query.filter(
MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%")
)
if vendor_name:
query = query.filter(MarketplaceImportJob.vendor_name.ilike(f"%{vendor_name}%"))
# Order by creation date (newest first) and apply pagination
jobs = (
@@ -219,100 +137,8 @@ class MarketplaceImportJobService:
logger.error(f"Error getting import jobs: {str(e)}")
raise ValidationException("Failed to retrieve import jobs")
def update_job_status(
self, db: Session, job_id: int, status: str, **kwargs
) -> MarketplaceImportJob:
"""
Update marketplace import job status and other fields.
Args:
db: Database session
job_id: Import job ID
status: New status
**kwargs: Additional fields to update
Returns:
Updated MarketplaceImportJob object
Raises:
ImportJobNotFoundException: If job doesn't exist
ValidationException: If update fails
"""
try:
job = (
db.query(MarketplaceImportJob)
.filter(MarketplaceImportJob.id == job_id)
.first()
)
if not job:
raise ImportJobNotFoundException(job_id)
job.status = status
# Update optional fields if provided
allowed_fields = [
'imported_count', 'updated_count', 'total_processed',
'error_count', 'error_message', 'started_at', 'completed_at'
]
for field in allowed_fields:
if field in kwargs:
setattr(job, field, kwargs[field])
db.commit()
db.refresh(job)
logger.info(f"Updated marketplace import job {job_id} status to {status}")
return job
except ImportJobNotFoundException:
raise # Re-raise custom exceptions
except Exception as e:
db.rollback()
logger.error(f"Error updating job {job_id} status: {str(e)}")
raise ValidationException("Failed to update job status")
def get_job_stats(self, db: Session, user: User) -> dict:
"""
Get statistics about marketplace import jobs for a user.
Args:
db: Database session
user: User to get stats for
Returns:
Dictionary containing job statistics
"""
try:
query = db.query(MarketplaceImportJob)
# Users can only see their own jobs, admins can see all
if user.role != "admin":
query = query.filter(MarketplaceImportJob.user_id == user.id)
total_jobs = query.count()
pending_jobs = query.filter(MarketplaceImportJob.status == "pending").count()
running_jobs = query.filter(MarketplaceImportJob.status == "running").count()
completed_jobs = query.filter(
MarketplaceImportJob.status == "completed"
).count()
failed_jobs = query.filter(MarketplaceImportJob.status == "failed").count()
return {
"total_jobs": total_jobs,
"pending_jobs": pending_jobs,
"running_jobs": running_jobs,
"completed_jobs": completed_jobs,
"failed_jobs": failed_jobs,
}
except Exception as e:
logger.error(f"Error getting job stats: {str(e)}")
raise ValidationException("Failed to retrieve job statistics")
def convert_to_response_model(
self, job: MarketplaceImportJob
self, job: MarketplaceImportJob
) -> MarketplaceImportJobResponse:
"""Convert database model to API response model."""
return MarketplaceImportJobResponse(
@@ -320,10 +146,9 @@ class MarketplaceImportJobService:
status=job.status,
marketplace=job.marketplace,
vendor_id=job.vendor_id,
vendor_code=(
job.vendor.vendor_code if job.vendor else None
), # Add this optional field via relationship
vendor_name=job.vendor_name,
vendor_code=job.vendor.vendor_code if job.vendor else None, # FIXED
vendor_name=job.vendor.name if job.vendor else None, # FIXED: from relationship
source_url=job.source_url,
imported=job.imported_count or 0,
updated=job.updated_count or 0,
total_processed=job.total_processed or 0,
@@ -334,84 +159,7 @@ class MarketplaceImportJobService:
completed_at=job.completed_at,
)
def cancel_import_job(
self, db: Session, job_id: int, user: User
) -> MarketplaceImportJob:
"""
Cancel a pending or running import job.
Args:
db: Database session
job_id: Import job ID
user: User requesting cancellation
Returns:
Updated MarketplaceImportJob object
Raises:
ImportJobNotFoundException: If job doesn't exist
ImportJobNotOwnedException: If user lacks access
ImportJobCannotBeCancelledException: If job can't be cancelled
"""
try:
job = self.get_import_job_by_id(db, job_id, user)
if job.status not in ["pending", "running"]:
raise ImportJobCannotBeCancelledException(job_id, job.status)
job.status = "cancelled"
job.completed_at = datetime.now(timezone.utc)
db.commit()
db.refresh(job)
logger.info(f"Cancelled marketplace import job {job_id}")
return job
except (ImportJobNotFoundException, ImportJobNotOwnedException, ImportJobCannotBeCancelledException):
raise # Re-raise custom exceptions
except Exception as e:
db.rollback()
logger.error(f"Error cancelling job {job_id}: {str(e)}")
raise ValidationException("Failed to cancel import job")
def delete_import_job(self, db: Session, job_id: int, user: User) -> bool:
"""
Delete a marketplace import job.
Args:
db: Database session
job_id: Import job ID
user: User requesting deletion
Returns:
True if deletion successful
Raises:
ImportJobNotFoundException: If job doesn't exist
ImportJobNotOwnedException: If user lacks access
ImportJobCannotBeDeletedException: If job can't be deleted
"""
try:
job = self.get_import_job_by_id(db, job_id, user)
# Only allow deletion of completed, failed, or cancelled jobs
if job.status in ["pending", "running"]:
raise ImportJobCannotBeDeletedException(job_id, job.status)
db.delete(job)
db.commit()
logger.info(f"Deleted marketplace import job {job_id}")
return True
except (ImportJobNotFoundException, ImportJobNotOwnedException, ImportJobCannotBeDeletedException):
raise # Re-raise custom exceptions
except Exception as e:
db.rollback()
logger.error(f"Error deleting job {job_id}: {str(e)}")
raise ValidationException("Failed to delete import job")
# ... other methods (cancel, delete, etc.) remain similar ...
# Create service instance
marketplace_import_job_service = MarketplaceImportJobService()

View File

@@ -5,7 +5,7 @@ MarketplaceProduct service for managing product operations and data processing.
This module provides classes and functions for:
- MarketplaceProduct CRUD operations with validation
- Advanced product filtering and search
- Stock information integration
- Inventory information integration
- CSV export functionality
"""
import csv
@@ -26,9 +26,9 @@ from app.exceptions import (
)
from app.services.marketplace_import_job_service import marketplace_import_job_service
from models.schemas.marketplace_product import MarketplaceProductCreate, MarketplaceProductUpdate
from models.schemas.stock import StockLocationResponse, StockSummaryResponse
from models.schemas.inventory import InventoryLocationResponse, InventorySummaryResponse
from models.database.marketplace_product import MarketplaceProduct
from models.database.stock import Stock
from models.database.inventory import Inventory
from app.utils.data_processing import GTINProcessor, PriceProcessor
logger = logging.getLogger(__name__)
@@ -170,7 +170,7 @@ class MarketplaceProductService:
if vendor_name:
query = query.filter(MarketplaceProduct.vendor_name.ilike(f"%{vendor_name}%"))
if search:
# Search in title, description, marketplace, and vendor_name
# Search in title, description, marketplace, and name
search_term = f"%{search}%"
query = query.filter(
(MarketplaceProduct.title.ilike(search_term))
@@ -240,7 +240,7 @@ class MarketplaceProductService:
def delete_product(self, db: Session, marketplace_product_id: str) -> bool:
"""
Delete product and associated stock.
Delete product and associated inventory.
Args:
db: Database session
@@ -255,9 +255,9 @@ class MarketplaceProductService:
try:
product = self.get_product_by_id_or_raise(db, marketplace_product_id)
# Delete associated stock entries if GTIN exists
# Delete associated inventory entries if GTIN exists
if product.gtin:
db.query(Stock).filter(Stock.gtin == product.gtin).delete()
db.query(Inventory).filter(Inventory.gtin == product.gtin).delete()
db.delete(product)
db.commit()
@@ -272,34 +272,34 @@ class MarketplaceProductService:
logger.error(f"Error deleting product {marketplace_product_id}: {str(e)}")
raise ValidationException("Failed to delete product")
def get_stock_info(self, db: Session, gtin: str) -> Optional[StockSummaryResponse]:
def get_inventory_info(self, db: Session, gtin: str) -> Optional[InventorySummaryResponse]:
"""
Get stock information for a product by GTIN.
Get inventory information for a product by GTIN.
Args:
db: Database session
gtin: GTIN to look up stock for
gtin: GTIN to look up inventory for
Returns:
StockSummaryResponse if stock found, None otherwise
InventorySummaryResponse if inventory found, None otherwise
"""
try:
stock_entries = db.query(Stock).filter(Stock.gtin == gtin).all()
if not stock_entries:
inventory_entries = db.query(Inventory).filter(Inventory.gtin == gtin).all()
if not inventory_entries:
return None
total_quantity = sum(entry.quantity for entry in stock_entries)
total_quantity = sum(entry.quantity for entry in inventory_entries)
locations = [
StockLocationResponse(location=entry.location, quantity=entry.quantity)
for entry in stock_entries
InventoryLocationResponse(location=entry.location, quantity=entry.quantity)
for entry in inventory_entries
]
return StockSummaryResponse(
return InventorySummaryResponse(
gtin=gtin, total_quantity=total_quantity, locations=locations
)
except Exception as e:
logger.error(f"Error getting stock info for GTIN {gtin}: {str(e)}")
logger.error(f"Error getting inventory info for GTIN {gtin}: {str(e)}")
return None
import csv
@@ -333,7 +333,7 @@ class MarketplaceProductService:
headers = [
"marketplace_product_id", "title", "description", "link", "image_link",
"availability", "price", "currency", "brand", "gtin",
"marketplace", "vendor_name"
"marketplace", "name"
]
writer.writerow(headers)
yield output.getvalue()
@@ -413,7 +413,7 @@ class MarketplaceProductService:
normalized = product_data.copy()
# Trim whitespace from string fields
string_fields = ['marketplace_product_id', 'title', 'description', 'brand', 'marketplace', 'vendor_name']
string_fields = ['marketplace_product_id', 'title', 'description', 'brand', 'marketplace', 'name']
for field in string_fields:
if field in normalized and normalized[field]:
normalized[field] = normalized[field].strip()

View File

@@ -0,0 +1,376 @@
# app/services/order_service.py
"""
Order service for order management.
This module provides:
- Order creation from cart
- Order status management
- Order retrieval and filtering
"""
import logging
from datetime import datetime, timezone
from typing import List, Optional, Tuple
import random
import string
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from models.database.order import Order, OrderItem
from models.database.customer import Customer, CustomerAddress
from models.database.product import Product
from models.schemas.order import OrderCreate, OrderUpdate, OrderAddressCreate
from app.exceptions import (
OrderNotFoundException,
ValidationException,
InsufficientInventoryException,
CustomerNotFoundException
)
logger = logging.getLogger(__name__)
class OrderService:
"""Service for order operations."""
def _generate_order_number(self, db: Session, vendor_id: int) -> str:
"""
Generate unique order number.
Format: ORD-{VENDOR_ID}-{TIMESTAMP}-{RANDOM}
Example: ORD-1-20250110-A1B2C3
"""
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d")
random_suffix = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
order_number = f"ORD-{vendor_id}-{timestamp}-{random_suffix}"
# Ensure uniqueness
while db.query(Order).filter(Order.order_number == order_number).first():
random_suffix = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
order_number = f"ORD-{vendor_id}-{timestamp}-{random_suffix}"
return order_number
def _create_customer_address(
self,
db: Session,
vendor_id: int,
customer_id: int,
address_data: OrderAddressCreate,
address_type: str
) -> CustomerAddress:
"""Create a customer address for order."""
address = CustomerAddress(
vendor_id=vendor_id,
customer_id=customer_id,
address_type=address_type,
first_name=address_data.first_name,
last_name=address_data.last_name,
company=address_data.company,
address_line_1=address_data.address_line_1,
address_line_2=address_data.address_line_2,
city=address_data.city,
postal_code=address_data.postal_code,
country=address_data.country,
is_default=False
)
db.add(address)
db.flush() # Get ID without committing
return address
def create_order(
self,
db: Session,
vendor_id: int,
order_data: OrderCreate
) -> Order:
"""
Create a new order.
Args:
db: Database session
vendor_id: Vendor ID
order_data: Order creation data
Returns:
Created Order object
Raises:
ValidationException: If order data is invalid
InsufficientInventoryException: If not enough inventory
"""
try:
# Validate customer exists if provided
customer_id = order_data.customer_id
if customer_id:
customer = db.query(Customer).filter(
and_(
Customer.id == customer_id,
Customer.vendor_id == vendor_id
)
).first()
if not customer:
raise CustomerNotFoundException(str(customer_id))
else:
# Guest checkout - create guest customer
# TODO: Implement guest customer creation
raise ValidationException("Guest checkout not yet implemented")
# Create shipping address
shipping_address = self._create_customer_address(
db=db,
vendor_id=vendor_id,
customer_id=customer_id,
address_data=order_data.shipping_address,
address_type="shipping"
)
# Create billing address (use shipping if not provided)
if order_data.billing_address:
billing_address = self._create_customer_address(
db=db,
vendor_id=vendor_id,
customer_id=customer_id,
address_data=order_data.billing_address,
address_type="billing"
)
else:
billing_address = shipping_address
# Calculate order totals
subtotal = 0.0
order_items_data = []
for item_data in order_data.items:
# Get product
product = db.query(Product).filter(
and_(
Product.id == item_data.product_id,
Product.vendor_id == vendor_id,
Product.is_active == True
)
).first()
if not product:
raise ValidationException(f"Product {item_data.product_id} not found")
# Check inventory
if product.available_inventory < item_data.quantity:
raise InsufficientInventoryException(
product_id=product.id,
requested=item_data.quantity,
available=product.available_inventory
)
# Calculate item total
unit_price = product.sale_price if product.sale_price else product.price
if not unit_price:
raise ValidationException(f"Product {product.id} has no price")
item_total = unit_price * item_data.quantity
subtotal += item_total
order_items_data.append({
"product_id": product.id,
"product_name": product.marketplace_product.title,
"product_sku": product.product_id,
"quantity": item_data.quantity,
"unit_price": unit_price,
"total_price": item_total
})
# Calculate tax and shipping (simple implementation)
tax_amount = 0.0 # TODO: Implement tax calculation
shipping_amount = 5.99 if subtotal < 50 else 0.0 # Free shipping over €50
discount_amount = 0.0 # TODO: Implement discounts
total_amount = subtotal + tax_amount + shipping_amount - discount_amount
# Generate order number
order_number = self._generate_order_number(db, vendor_id)
# Create order
order = Order(
vendor_id=vendor_id,
customer_id=customer_id,
order_number=order_number,
status="pending",
subtotal=subtotal,
tax_amount=tax_amount,
shipping_amount=shipping_amount,
discount_amount=discount_amount,
total_amount=total_amount,
currency="EUR",
shipping_address_id=shipping_address.id,
billing_address_id=billing_address.id,
shipping_method=order_data.shipping_method,
customer_notes=order_data.customer_notes
)
db.add(order)
db.flush() # Get order ID
# Create order items
for item_data in order_items_data:
order_item = OrderItem(
order_id=order.id,
**item_data
)
db.add(order_item)
db.commit()
db.refresh(order)
logger.info(
f"Order {order.order_number} created for vendor {vendor_id}, "
f"total: €{total_amount:.2f}"
)
return order
except (ValidationException, InsufficientInventoryException, CustomerNotFoundException):
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Error creating order: {str(e)}")
raise ValidationException(f"Failed to create order: {str(e)}")
def get_order(
self,
db: Session,
vendor_id: int,
order_id: int
) -> Order:
"""Get order by ID."""
order = db.query(Order).filter(
and_(
Order.id == order_id,
Order.vendor_id == vendor_id
)
).first()
if not order:
raise OrderNotFoundException(str(order_id))
return order
def get_vendor_orders(
self,
db: Session,
vendor_id: int,
skip: int = 0,
limit: int = 100,
status: Optional[str] = None,
customer_id: Optional[int] = None
) -> Tuple[List[Order], int]:
"""
Get orders for vendor with filtering.
Args:
db: Database session
vendor_id: Vendor ID
skip: Pagination offset
limit: Pagination limit
status: Filter by status
customer_id: Filter by customer
Returns:
Tuple of (orders, total_count)
"""
query = db.query(Order).filter(Order.vendor_id == vendor_id)
if status:
query = query.filter(Order.status == status)
if customer_id:
query = query.filter(Order.customer_id == customer_id)
# Order by most recent first
query = query.order_by(Order.created_at.desc())
total = query.count()
orders = query.offset(skip).limit(limit).all()
return orders, total
def get_customer_orders(
self,
db: Session,
vendor_id: int,
customer_id: int,
skip: int = 0,
limit: int = 100
) -> Tuple[List[Order], int]:
"""Get orders for a specific customer."""
return self.get_vendor_orders(
db=db,
vendor_id=vendor_id,
skip=skip,
limit=limit,
customer_id=customer_id
)
def update_order_status(
self,
db: Session,
vendor_id: int,
order_id: int,
order_update: OrderUpdate
) -> Order:
"""
Update order status and tracking information.
Args:
db: Database session
vendor_id: Vendor ID
order_id: Order ID
order_update: Update data
Returns:
Updated Order object
"""
try:
order = self.get_order(db, vendor_id, order_id)
# Update status with timestamps
if order_update.status:
order.status = order_update.status
# Update timestamp based on status
now = datetime.now(timezone.utc)
if order_update.status == "shipped" and not order.shipped_at:
order.shipped_at = now
elif order_update.status == "delivered" and not order.delivered_at:
order.delivered_at = now
elif order_update.status == "cancelled" and not order.cancelled_at:
order.cancelled_at = now
# Update tracking number
if order_update.tracking_number:
order.tracking_number = order_update.tracking_number
# Update internal notes
if order_update.internal_notes:
order.internal_notes = order_update.internal_notes
order.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(order)
logger.info(f"Order {order.order_number} updated: status={order.status}")
return order
except OrderNotFoundException:
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Error updating order: {str(e)}")
raise ValidationException(f"Failed to update order: {str(e)}")
# Create service instance
order_service = OrderService()

View File

@@ -0,0 +1,247 @@
# app/services/product_service.py
"""
Product service for vendor catalog management.
This module provides:
- Product catalog CRUD operations
- Product publishing from marketplace staging
- Product search and filtering
"""
import logging
from datetime import datetime, timezone
from typing import List, Optional, Tuple
from sqlalchemy.orm import Session
from app.exceptions import (
ProductNotFoundException,
ProductAlreadyExistsException,
ValidationException,
)
from models.schemas.product import ProductCreate, ProductUpdate
from models.database.product import Product
from models.database.marketplace_product import MarketplaceProduct
logger = logging.getLogger(__name__)
class ProductService:
"""Service for vendor catalog product operations."""
def get_product(self, db: Session, vendor_id: int, product_id: int) -> Product:
"""
Get a product from vendor catalog.
Args:
db: Database session
vendor_id: Vendor ID
product_id: Product ID
Returns:
Product object
Raises:
ProductNotFoundException: If product not found
"""
try:
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")
return product
except ProductNotFoundException:
raise
except Exception as e:
logger.error(f"Error getting product: {str(e)}")
raise ValidationException("Failed to retrieve product")
def create_product(
self, db: Session, vendor_id: int, product_data: ProductCreate
) -> Product:
"""
Add a product from marketplace to vendor catalog.
Args:
db: Database session
vendor_id: Vendor ID
product_data: Product creation data
Returns:
Created Product object
Raises:
ProductAlreadyExistsException: If product already in catalog
ValidationException: If marketplace product not found
"""
try:
# Verify marketplace product exists and belongs to vendor
marketplace_product = db.query(MarketplaceProduct).filter(
MarketplaceProduct.id == product_data.marketplace_product_id,
MarketplaceProduct.vendor_id == vendor_id
).first()
if not marketplace_product:
raise ValidationException(
f"Marketplace product {product_data.marketplace_product_id} not found"
)
# Check if already in catalog
existing = db.query(Product).filter(
Product.vendor_id == vendor_id,
Product.marketplace_product_id == product_data.marketplace_product_id
).first()
if existing:
raise ProductAlreadyExistsException(
f"Product already exists in catalog"
)
# Create product
product = Product(
vendor_id=vendor_id,
marketplace_product_id=product_data.marketplace_product_id,
product_id=product_data.product_id,
price=product_data.price,
sale_price=product_data.sale_price,
currency=product_data.currency,
availability=product_data.availability,
condition=product_data.condition,
is_featured=product_data.is_featured,
is_active=True,
min_quantity=product_data.min_quantity,
max_quantity=product_data.max_quantity,
)
db.add(product)
db.commit()
db.refresh(product)
logger.info(
f"Added product {product.id} to vendor {vendor_id} catalog"
)
return product
except (ProductAlreadyExistsException, ValidationException):
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Error creating product: {str(e)}")
raise ValidationException("Failed to create product")
def update_product(
self, db: Session, vendor_id: int, product_id: int, product_update: ProductUpdate
) -> Product:
"""
Update product in vendor catalog.
Args:
db: Database session
vendor_id: Vendor ID
product_id: Product ID
product_update: Update data
Returns:
Updated Product object
"""
try:
product = self.get_product(db, vendor_id, product_id)
# Update fields
update_data = product_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(product, key, value)
product.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(product)
logger.info(f"Updated product {product_id} in vendor {vendor_id} catalog")
return product
except ProductNotFoundException:
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Error updating product: {str(e)}")
raise ValidationException("Failed to update product")
def delete_product(self, db: Session, vendor_id: int, product_id: int) -> bool:
"""
Remove product from vendor catalog.
Args:
db: Database session
vendor_id: Vendor ID
product_id: Product ID
Returns:
True if deleted
"""
try:
product = self.get_product(db, vendor_id, product_id)
db.delete(product)
db.commit()
logger.info(f"Deleted product {product_id} from vendor {vendor_id} catalog")
return True
except ProductNotFoundException:
raise
except Exception as e:
db.rollback()
logger.error(f"Error deleting product: {str(e)}")
raise ValidationException("Failed to delete product")
def get_vendor_products(
self,
db: Session,
vendor_id: int,
skip: int = 0,
limit: int = 100,
is_active: Optional[bool] = None,
is_featured: Optional[bool] = None,
) -> Tuple[List[Product], int]:
"""
Get products in vendor catalog with filtering.
Args:
db: Database session
vendor_id: Vendor ID
skip: Pagination offset
limit: Pagination limit
is_active: Filter by active status
is_featured: Filter by featured status
Returns:
Tuple of (products, total_count)
"""
try:
query = db.query(Product).filter(Product.vendor_id == vendor_id)
if is_active is not None:
query = query.filter(Product.is_active == is_active)
if is_featured is not None:
query = query.filter(Product.is_featured == is_featured)
total = query.count()
products = query.offset(skip).limit(limit).all()
return products, total
except Exception as e:
logger.error(f"Error getting vendor products: {str(e)}")
raise ValidationException("Failed to retrieve products")
# Create service instance
product_service = ProductService()

View File

@@ -2,71 +2,281 @@
"""
Statistics service for generating system analytics and metrics.
This module provides classes and functions for:
- Comprehensive system statistics
- Marketplace-specific analytics
- Performance metrics and data insights
- Cached statistics for performance
This module provides:
- System-wide statistics (admin)
- Vendor-specific statistics
- Marketplace analytics
- Performance metrics
"""
import logging
from typing import Any, Dict, List
from datetime import datetime, timedelta
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.exceptions import ValidationException
from app.exceptions import (
VendorNotFoundException,
AdminOperationException,
)
from models.database.marketplace_product import MarketplaceProduct
from models.database.stock import Stock
from models.database.product import Product
from models.database.inventory import Inventory
from models.database.vendor import Vendor
from models.database.order import Order
from models.database.customer import Customer
from models.database.marketplace_import_job import MarketplaceImportJob
logger = logging.getLogger(__name__)
class StatsService:
"""Service class for statistics operations following the application's service pattern."""
"""Service for statistics operations."""
# ========================================================================
# VENDOR-SPECIFIC STATISTICS
# ========================================================================
def get_vendor_stats(self, db: Session, vendor_id: int) -> Dict[str, Any]:
"""
Get statistics for a specific vendor.
Args:
db: Database session
vendor_id: Vendor ID
Returns:
Dictionary with vendor statistics
Raises:
VendorNotFoundException: If vendor doesn't exist
AdminOperationException: If database query fails
"""
# Verify vendor exists
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
try:
# Catalog statistics
total_catalog_products = db.query(Product).filter(
Product.vendor_id == vendor_id,
Product.is_active == True
).count()
featured_products = db.query(Product).filter(
Product.vendor_id == vendor_id,
Product.is_featured == True,
Product.is_active == True
).count()
# Staging statistics
staging_products = db.query(MarketplaceProduct).filter(
MarketplaceProduct.vendor_id == vendor_id
).count()
# Inventory statistics
total_inventory = db.query(
func.sum(Inventory.quantity)
).filter(
Inventory.vendor_id == vendor_id
).scalar() or 0
reserved_inventory = db.query(
func.sum(Inventory.reserved_quantity)
).filter(
Inventory.vendor_id == vendor_id
).scalar() or 0
inventory_locations = db.query(
func.count(func.distinct(Inventory.location))
).filter(
Inventory.vendor_id == vendor_id
).scalar() or 0
# Import statistics
total_imports = db.query(MarketplaceImportJob).filter(
MarketplaceImportJob.vendor_id == vendor_id
).count()
successful_imports = db.query(MarketplaceImportJob).filter(
MarketplaceImportJob.vendor_id == vendor_id,
MarketplaceImportJob.status == "completed"
).count()
# Orders
total_orders = db.query(Order).filter(
Order.vendor_id == vendor_id
).count()
# Customers
total_customers = db.query(Customer).filter(
Customer.vendor_id == vendor_id
).count()
return {
"catalog": {
"total_products": total_catalog_products,
"featured_products": featured_products,
"active_products": total_catalog_products,
},
"staging": {
"imported_products": staging_products,
},
"inventory": {
"total_quantity": int(total_inventory),
"reserved_quantity": int(reserved_inventory),
"available_quantity": int(total_inventory - reserved_inventory),
"locations_count": inventory_locations,
},
"imports": {
"total_imports": total_imports,
"successful_imports": successful_imports,
"success_rate": (successful_imports / total_imports * 100) if total_imports > 0 else 0,
},
"orders": {
"total_orders": total_orders,
},
"customers": {
"total_customers": total_customers,
},
}
except VendorNotFoundException:
raise
except Exception as e:
logger.error(f"Failed to retrieve vendor statistics for vendor {vendor_id}: {str(e)}")
raise AdminOperationException(
operation="get_vendor_stats",
reason=f"Database query failed: {str(e)}",
target_type="vendor",
target_id=str(vendor_id)
)
def get_vendor_analytics(
self, db: Session, vendor_id: int, period: str = "30d"
) -> Dict[str, Any]:
"""
Get vendor analytics for a time period.
Args:
db: Database session
vendor_id: Vendor ID
period: Time period (7d, 30d, 90d, 1y)
Returns:
Analytics data
Raises:
VendorNotFoundException: If vendor doesn't exist
AdminOperationException: If database query fails
"""
# Verify vendor exists
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
try:
# Parse period
days = self._parse_period(period)
start_date = datetime.utcnow() - timedelta(days=days)
# Import activity
recent_imports = db.query(MarketplaceImportJob).filter(
MarketplaceImportJob.vendor_id == vendor_id,
MarketplaceImportJob.created_at >= start_date
).count()
# Products added to catalog
products_added = db.query(Product).filter(
Product.vendor_id == vendor_id,
Product.created_at >= start_date
).count()
# Inventory changes
inventory_entries = db.query(Inventory).filter(
Inventory.vendor_id == vendor_id
).count()
return {
"period": period,
"start_date": start_date.isoformat(),
"imports": {
"count": recent_imports,
},
"catalog": {
"products_added": products_added,
},
"inventory": {
"total_locations": inventory_entries,
},
}
except VendorNotFoundException:
raise
except Exception as e:
logger.error(f"Failed to retrieve vendor analytics for vendor {vendor_id}: {str(e)}")
raise AdminOperationException(
operation="get_vendor_analytics",
reason=f"Database query failed: {str(e)}",
target_type="vendor",
target_id=str(vendor_id)
)
# ========================================================================
# SYSTEM-WIDE STATISTICS (ADMIN)
# ========================================================================
def get_comprehensive_stats(self, db: Session) -> Dict[str, Any]:
"""
Get comprehensive statistics with marketplace data.
Get comprehensive system statistics for admin dashboard.
Args:
db: Database session
Returns:
Dictionary containing all statistics data
Dictionary with comprehensive statistics
Raises:
ValidationException: If statistics generation fails
AdminOperationException: If database query fails
"""
try:
# Use more efficient queries with proper indexes
total_products = self._get_product_count(db)
# Vendors
total_vendors = db.query(Vendor).filter(Vendor.is_active == True).count()
# Products
total_catalog_products = db.query(Product).count()
unique_brands = self._get_unique_brands_count(db)
unique_categories = self._get_unique_categories_count(db)
unique_marketplaces = self._get_unique_marketplaces_count(db)
unique_vendors = self._get_unique_vendors_count(db)
# Stock statistics
stock_stats = self._get_stock_statistics(db)
# Marketplaces
unique_marketplaces = (
db.query(MarketplaceProduct.marketplace)
.filter(MarketplaceProduct.marketplace.isnot(None))
.distinct()
.count()
)
stats_data = {
"total_products": total_products,
# Inventory
inventory_stats = self._get_inventory_statistics(db)
return {
"total_products": total_catalog_products,
"unique_brands": unique_brands,
"unique_categories": unique_categories,
"unique_marketplaces": unique_marketplaces,
"unique_vendors": unique_vendors,
"total_stock_entries": stock_stats["total_stock_entries"],
"total_inventory_quantity": stock_stats["total_inventory_quantity"],
"unique_vendors": total_vendors,
"total_inventory_entries": inventory_stats.get("total_entries", 0),
"total_inventory_quantity": inventory_stats.get("total_quantity", 0),
}
logger.info(
f"Generated comprehensive stats: {total_products} products, {unique_marketplaces} marketplaces"
)
return stats_data
except Exception as e:
logger.error(f"Error getting comprehensive stats: {str(e)}")
raise ValidationException("Failed to retrieve system statistics")
logger.error(f"Failed to retrieve comprehensive statistics: {str(e)}")
raise AdminOperationException(
operation="get_comprehensive_stats",
reason=f"Database query failed: {str(e)}"
)
def get_marketplace_breakdown_stats(self, db: Session) -> List[Dict[str, Any]]:
"""
@@ -76,13 +286,12 @@ class StatsService:
db: Database session
Returns:
List of dictionaries containing marketplace statistics
List of marketplace statistics
Raises:
ValidationException: If marketplace statistics generation fails
AdminOperationException: If database query fails
"""
try:
# Query to get stats per marketplace
marketplace_stats = (
db.query(
MarketplaceProduct.marketplace,
@@ -95,7 +304,7 @@ class StatsService:
.all()
)
stats_list = [
return [
{
"marketplace": stat.marketplace,
"total_products": stat.total_products,
@@ -105,103 +314,35 @@ class StatsService:
for stat in marketplace_stats
]
logger.info(
f"Generated marketplace breakdown stats for {len(stats_list)} marketplaces"
except Exception as e:
logger.error(f"Failed to retrieve marketplace breakdown statistics: {str(e)}")
raise AdminOperationException(
operation="get_marketplace_breakdown_stats",
reason=f"Database query failed: {str(e)}"
)
return stats_list
except Exception as e:
logger.error(f"Error getting marketplace breakdown stats: {str(e)}")
raise ValidationException("Failed to retrieve marketplace statistics")
# ========================================================================
# PRIVATE HELPER METHODS
# ========================================================================
def get_product_statistics(self, db: Session) -> Dict[str, Any]:
"""
Get detailed product statistics.
Args:
db: Database session
Returns:
Dictionary containing product statistics
"""
try:
stats = {
"total_products": self._get_product_count(db),
"unique_brands": self._get_unique_brands_count(db),
"unique_categories": self._get_unique_categories_count(db),
"unique_marketplaces": self._get_unique_marketplaces_count(db),
"unique_vendors": self._get_unique_vendors_count(db),
"products_with_gtin": self._get_products_with_gtin_count(db),
"products_with_images": self._get_products_with_images_count(db),
}
return stats
except Exception as e:
logger.error(f"Error getting product statistics: {str(e)}")
raise ValidationException("Failed to retrieve product statistics")
def get_stock_statistics(self, db: Session) -> Dict[str, Any]:
"""
Get stock-related statistics.
Args:
db: Database session
Returns:
Dictionary containing stock statistics
"""
try:
return self._get_stock_statistics(db)
except Exception as e:
logger.error(f"Error getting stock statistics: {str(e)}")
raise ValidationException("Failed to retrieve stock statistics")
def get_marketplace_details(self, db: Session, marketplace: str) -> Dict[str, Any]:
"""
Get detailed statistics for a specific marketplace.
Args:
db: Database session
marketplace: Marketplace name
Returns:
Dictionary containing marketplace details
"""
try:
if not marketplace or not marketplace.strip():
raise ValidationException("Marketplace name is required")
product_count = self._get_products_by_marketplace_count(db, marketplace)
brands = self._get_brands_by_marketplace(db, marketplace)
vendors =self._get_vendors_by_marketplace(db, marketplace)
return {
"marketplace": marketplace,
"total_products": product_count,
"unique_brands": len(brands),
"unique_vendors": len(vendors),
"brands": brands,
"vendors": vendors,
}
except ValidationException:
raise # Re-raise custom exceptions
except Exception as e:
logger.error(f"Error getting marketplace details for {marketplace}: {str(e)}")
raise ValidationException("Failed to retrieve marketplace details")
# Private helper methods
def _get_product_count(self, db: Session) -> int:
"""Get total product count."""
return db.query(MarketplaceProduct).count()
def _parse_period(self, period: str) -> int:
"""Parse period string to days."""
period_map = {
"7d": 7,
"30d": 30,
"90d": 90,
"1y": 365,
}
return period_map.get(period, 30)
def _get_unique_brands_count(self, db: Session) -> int:
"""Get count of unique brands."""
return (
db.query(MarketplaceProduct.brand)
.filter(MarketplaceProduct.brand.isnot(None), MarketplaceProduct.brand != "")
.filter(
MarketplaceProduct.brand.isnot(None),
MarketplaceProduct.brand != ""
)
.distinct()
.count()
)
@@ -218,81 +359,19 @@ class StatsService:
.count()
)
def _get_unique_marketplaces_count(self, db: Session) -> int:
"""Get count of unique marketplaces."""
return (
db.query(MarketplaceProduct.marketplace)
.filter(MarketplaceProduct.marketplace.isnot(None), MarketplaceProduct.marketplace != "")
.distinct()
.count()
)
def _get_unique_vendors_count(self, db: Session) -> int:
"""Get count of unique vendors."""
return (
db.query(MarketplaceProduct.vendor_name)
.filter(MarketplaceProduct.vendor_name.isnot(None), MarketplaceProduct.vendor_name != "")
.distinct()
.count()
)
def _get_products_with_gtin_count(self, db: Session) -> int:
"""Get count of products with GTIN."""
return (
db.query(MarketplaceProduct)
.filter(MarketplaceProduct.gtin.isnot(None), MarketplaceProduct.gtin != "")
.count()
)
def _get_products_with_images_count(self, db: Session) -> int:
"""Get count of products with images."""
return (
db.query(MarketplaceProduct)
.filter(MarketplaceProduct.image_link.isnot(None), MarketplaceProduct.image_link != "")
.count()
)
def _get_stock_statistics(self, db: Session) -> Dict[str, int]:
"""Get stock-related statistics."""
total_stock_entries = db.query(Stock).count()
total_inventory = db.query(func.sum(Stock.quantity)).scalar() or 0
def _get_inventory_statistics(self, db: Session) -> Dict[str, int]:
"""Get inventory-related statistics."""
total_entries = db.query(Inventory).count()
total_quantity = db.query(func.sum(Inventory.quantity)).scalar() or 0
total_reserved = db.query(func.sum(Inventory.reserved_quantity)).scalar() or 0
return {
"total_stock_entries": total_stock_entries,
"total_inventory_quantity": total_inventory,
"total_entries": total_entries,
"total_quantity": int(total_quantity),
"total_reserved": int(total_reserved),
"total_available": int(total_quantity - total_reserved),
}
def _get_brands_by_marketplace(self, db: Session, marketplace: str) -> List[str]:
"""Get unique brands for a specific marketplace."""
brands = (
db.query(MarketplaceProduct.brand)
.filter(
MarketplaceProduct.marketplace == marketplace,
MarketplaceProduct.brand.isnot(None),
MarketplaceProduct.brand != "",
)
.distinct()
.all()
)
return [brand[0] for brand in brands]
def _get_vendors_by_marketplace(self, db: Session, marketplace: str) -> List[str]:
"""Get unique vendors for a specific marketplace."""
vendors =(
db.query(MarketplaceProduct.vendor_name)
.filter(
MarketplaceProduct.marketplace == marketplace,
MarketplaceProduct.vendor_name.isnot(None),
MarketplaceProduct.vendor_name != "",
)
.distinct()
.all()
)
return [vendor [0] for vendor in vendors]
def _get_products_by_marketplace_count(self, db: Session, marketplace: str) -> int:
"""Get product count for a specific marketplace."""
return db.query(MarketplaceProduct).filter(MarketplaceProduct.marketplace == marketplace).count()
# Create service instance following the same pattern as other services
# Create service instance
stats_service = StatsService()

View File

@@ -1,570 +0,0 @@
# app/services/stock_service.py
"""
Stock service for managing inventory operations.
This module provides classes and functions for:
- Stock quantity management (set, add, remove)
- Stock information retrieval and validation
- Location-based inventory tracking
- GTIN normalization and validation
"""
import logging
from datetime import datetime, timezone
from typing import List, Optional
from sqlalchemy.orm import Session
from app.exceptions import (
StockNotFoundException,
InsufficientStockException,
InvalidStockOperationException,
StockValidationException,
NegativeStockException,
InvalidQuantityException,
ValidationException,
)
from models.schemas.stock import (StockAdd, StockCreate, StockLocationResponse,
StockSummaryResponse, StockUpdate)
from models.database.marketplace_product import MarketplaceProduct
from models.database.stock import Stock
from app.utils.data_processing import GTINProcessor
logger = logging.getLogger(__name__)
class StockService:
"""Service class for stock operations following the application's service pattern."""
def __init__(self):
"""Class constructor."""
self.gtin_processor = GTINProcessor()
def set_stock(self, db: Session, stock_data: StockCreate) -> Stock:
"""
Set exact stock quantity for a GTIN at a specific location (replaces existing quantity).
Args:
db: Database session
stock_data: Stock creation data
Returns:
Stock object with updated quantity
Raises:
InvalidQuantityException: If quantity is negative
StockValidationException: If GTIN or location is invalid
"""
try:
# Validate and normalize input
normalized_gtin = self._validate_and_normalize_gtin(stock_data.gtin)
location = self._validate_and_normalize_location(stock_data.location)
self._validate_quantity(stock_data.quantity, allow_zero=True)
# Check if stock entry already exists for this GTIN and location
existing_stock = self._get_stock_entry(db, normalized_gtin, location)
if existing_stock:
# Update existing stock (SET to exact quantity)
old_quantity = existing_stock.quantity
existing_stock.quantity = stock_data.quantity
existing_stock.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(existing_stock)
logger.info(
f"Updated stock for GTIN {normalized_gtin} at {location}: {old_quantity}{stock_data.quantity}"
)
return existing_stock
else:
# Create new stock entry
new_stock = Stock(
gtin=normalized_gtin,
location=location,
quantity=stock_data.quantity
)
db.add(new_stock)
db.commit()
db.refresh(new_stock)
logger.info(
f"Created new stock for GTIN {normalized_gtin} at {location}: {stock_data.quantity}"
)
return new_stock
except (InvalidQuantityException, StockValidationException):
db.rollback()
raise # Re-raise custom exceptions
except Exception as e:
db.rollback()
logger.error(f"Error setting stock: {str(e)}")
raise ValidationException("Failed to set stock")
def add_stock(self, db: Session, stock_data: StockAdd) -> Stock:
"""
Add quantity to existing stock for a GTIN at a specific location (adds to existing quantity).
Args:
db: Database session
stock_data: Stock addition data
Returns:
Stock object with updated quantity
Raises:
InvalidQuantityException: If quantity is not positive
StockValidationException: If GTIN or location is invalid
"""
try:
# Validate and normalize input
normalized_gtin = self._validate_and_normalize_gtin(stock_data.gtin)
location = self._validate_and_normalize_location(stock_data.location)
self._validate_quantity(stock_data.quantity, allow_zero=False)
# Check if stock entry already exists for this GTIN and location
existing_stock = self._get_stock_entry(db, normalized_gtin, location)
if existing_stock:
# Add to existing stock
old_quantity = existing_stock.quantity
existing_stock.quantity += stock_data.quantity
existing_stock.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(existing_stock)
logger.info(
f"Added stock for GTIN {normalized_gtin} at {location}: "
f"{old_quantity} + {stock_data.quantity} = {existing_stock.quantity}"
)
return existing_stock
else:
# Create new stock entry with the quantity
new_stock = Stock(
gtin=normalized_gtin,
location=location,
quantity=stock_data.quantity
)
db.add(new_stock)
db.commit()
db.refresh(new_stock)
logger.info(
f"Created new stock for GTIN {normalized_gtin} at {location}: {stock_data.quantity}"
)
return new_stock
except (InvalidQuantityException, StockValidationException):
db.rollback()
raise # Re-raise custom exceptions
except Exception as e:
db.rollback()
logger.error(f"Error adding stock: {str(e)}")
raise ValidationException("Failed to add stock")
def remove_stock(self, db: Session, stock_data: StockAdd) -> Stock:
"""
Remove quantity from existing stock for a GTIN at a specific location.
Args:
db: Database session
stock_data: Stock removal data
Returns:
Stock object with updated quantity
Raises:
StockNotFoundException: If no stock found for GTIN/location
InsufficientStockException: If not enough stock available
InvalidQuantityException: If quantity is not positive
NegativeStockException: If operation would result in negative stock
"""
try:
# Validate and normalize input
normalized_gtin = self._validate_and_normalize_gtin(stock_data.gtin)
location = self._validate_and_normalize_location(stock_data.location)
self._validate_quantity(stock_data.quantity, allow_zero=False)
# Find existing stock entry
existing_stock = self._get_stock_entry(db, normalized_gtin, location)
if not existing_stock:
raise StockNotFoundException(normalized_gtin, identifier_type="gtin")
# Check if we have enough stock to remove
if existing_stock.quantity < stock_data.quantity:
raise InsufficientStockException(
gtin=normalized_gtin,
location=location,
requested=stock_data.quantity,
available=existing_stock.quantity
)
# Remove from existing stock
old_quantity = existing_stock.quantity
new_quantity = existing_stock.quantity - stock_data.quantity
# Validate resulting quantity
if new_quantity < 0:
raise NegativeStockException(normalized_gtin, location, new_quantity)
existing_stock.quantity = new_quantity
existing_stock.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(existing_stock)
logger.info(
f"Removed stock for GTIN {normalized_gtin} at {location}: "
f"{old_quantity} - {stock_data.quantity} = {existing_stock.quantity}"
)
return existing_stock
except (StockValidationException, StockNotFoundException, InsufficientStockException, InvalidQuantityException,
NegativeStockException):
db.rollback()
raise # Re-raise custom exceptions
except Exception as e:
db.rollback()
logger.error(f"Error removing stock: {str(e)}")
raise ValidationException("Failed to remove stock")
def get_stock_by_gtin(self, db: Session, gtin: str) -> StockSummaryResponse:
"""
Get all stock locations and total quantity for a specific GTIN.
Args:
db: Database session
gtin: GTIN to look up
Returns:
StockSummaryResponse with locations and totals
Raises:
StockNotFoundException: If no stock found for GTIN
StockValidationException: If GTIN is invalid
"""
try:
normalized_gtin = self._validate_and_normalize_gtin(gtin)
# Get all stock entries for this GTIN
stock_entries = db.query(Stock).filter(Stock.gtin == normalized_gtin).all()
if not stock_entries:
raise StockNotFoundException(normalized_gtin, identifier_type="gtin")
# Calculate total quantity and build locations list
total_quantity = 0
locations = []
for entry in stock_entries:
total_quantity += entry.quantity
locations.append(
StockLocationResponse(location=entry.location, quantity=entry.quantity)
)
# Try to get product title for reference
product = db.query(MarketplaceProduct).filter(MarketplaceProduct.gtin == normalized_gtin).first()
product_title = product.title if product else None
return StockSummaryResponse(
gtin=normalized_gtin,
total_quantity=total_quantity,
locations=locations,
product_title=product_title,
)
except (StockNotFoundException, StockValidationException):
raise # Re-raise custom exceptions
except Exception as e:
logger.error(f"Error getting stock by GTIN {gtin}: {str(e)}")
raise ValidationException("Failed to retrieve stock information")
def get_total_stock(self, db: Session, gtin: str) -> dict:
"""
Get total quantity in stock for a specific GTIN.
Args:
db: Database session
gtin: GTIN to look up
Returns:
Dictionary with total stock information
Raises:
StockNotFoundException: If no stock found for GTIN
StockValidationException: If GTIN is invalid
"""
try:
normalized_gtin = self._validate_and_normalize_gtin(gtin)
# Calculate total stock
total_stock = db.query(Stock).filter(Stock.gtin == normalized_gtin).all()
if not total_stock:
raise StockNotFoundException(normalized_gtin, identifier_type="gtin")
total_quantity = sum(entry.quantity for entry in total_stock)
# Get product info for context
product = db.query(MarketplaceProduct).filter(MarketplaceProduct.gtin == normalized_gtin).first()
return {
"gtin": normalized_gtin,
"total_quantity": total_quantity,
"product_title": product.title if product else None,
"locations_count": len(total_stock),
}
except (StockNotFoundException, StockValidationException):
raise # Re-raise custom exceptions
except Exception as e:
logger.error(f"Error getting total stock for GTIN {gtin}: {str(e)}")
raise ValidationException("Failed to retrieve total stock")
def get_all_stock(
self,
db: Session,
skip: int = 0,
limit: int = 100,
location: Optional[str] = None,
gtin: Optional[str] = None,
) -> List[Stock]:
"""
Get all stock entries with optional filtering.
Args:
db: Database session
skip: Number of records to skip
limit: Maximum records to return
location: Optional location filter
gtin: Optional GTIN filter
Returns:
List of Stock objects
"""
try:
query = db.query(Stock)
if location:
query = query.filter(Stock.location.ilike(f"%{location}%"))
if gtin:
normalized_gtin = self._normalize_gtin(gtin)
if normalized_gtin:
query = query.filter(Stock.gtin == normalized_gtin)
return query.offset(skip).limit(limit).all()
except Exception as e:
logger.error(f"Error getting all stock: {str(e)}")
raise ValidationException("Failed to retrieve stock entries")
def update_stock(
self, db: Session, stock_id: int, stock_update: StockUpdate
) -> Stock:
"""
Update stock quantity for a specific stock entry.
Args:
db: Database session
stock_id: Stock entry ID
stock_update: Update data
Returns:
Updated Stock object
Raises:
StockNotFoundException: If stock entry not found
InvalidQuantityException: If quantity is invalid
"""
try:
stock_entry = self._get_stock_by_id_or_raise(db, stock_id)
# Validate new quantity
self._validate_quantity(stock_update.quantity, allow_zero=True)
stock_entry.quantity = stock_update.quantity
stock_entry.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(stock_entry)
logger.info(
f"Updated stock entry {stock_id} to quantity {stock_update.quantity}"
)
return stock_entry
except (StockNotFoundException, InvalidQuantityException):
db.rollback()
raise # Re-raise custom exceptions
except Exception as e:
db.rollback()
logger.error(f"Error updating stock {stock_id}: {str(e)}")
raise ValidationException("Failed to update stock")
def delete_stock(self, db: Session, stock_id: int) -> bool:
"""
Delete a stock entry.
Args:
db: Database session
stock_id: Stock entry ID
Returns:
True if deletion successful
Raises:
StockNotFoundException: If stock entry not found
"""
try:
stock_entry = self._get_stock_by_id_or_raise(db, stock_id)
gtin = stock_entry.gtin
location = stock_entry.location
db.delete(stock_entry)
db.commit()
logger.info(f"Deleted stock entry {stock_id} for GTIN {gtin} at {location}")
return True
except StockNotFoundException:
raise # Re-raise custom exceptions
except Exception as e:
db.rollback()
logger.error(f"Error deleting stock {stock_id}: {str(e)}")
raise ValidationException("Failed to delete stock entry")
def get_stock_summary_by_location(self, db: Session, location: str) -> dict:
"""
Get stock summary for a specific location.
Args:
db: Database session
location: Location to summarize
Returns:
Dictionary with location stock summary
"""
try:
normalized_location = self._validate_and_normalize_location(location)
stock_entries = db.query(Stock).filter(Stock.location == normalized_location).all()
if not stock_entries:
return {
"location": normalized_location,
"total_items": 0,
"total_quantity": 0,
"unique_gtins": 0,
}
total_quantity = sum(entry.quantity for entry in stock_entries)
unique_gtins = len(set(entry.gtin for entry in stock_entries))
return {
"location": normalized_location,
"total_items": len(stock_entries),
"total_quantity": total_quantity,
"unique_gtins": unique_gtins,
}
except StockValidationException:
raise # Re-raise custom exceptions
except Exception as e:
logger.error(f"Error getting stock summary for location {location}: {str(e)}")
raise ValidationException("Failed to retrieve location stock summary")
def get_low_stock_items(self, db: Session, threshold: int = 10) -> List[dict]:
"""
Get items with stock below threshold.
Args:
db: Database session
threshold: Stock threshold to consider "low"
Returns:
List of low stock items with details
"""
try:
if threshold < 0:
raise InvalidQuantityException(threshold, "Threshold must be non-negative")
low_stock_entries = db.query(Stock).filter(Stock.quantity <= threshold).all()
low_stock_items = []
for entry in low_stock_entries:
# Get product info if available
product = db.query(MarketplaceProduct).filter(MarketplaceProduct.gtin == entry.gtin).first()
low_stock_items.append({
"gtin": entry.gtin,
"location": entry.location,
"current_quantity": entry.quantity,
"product_title": product.title if product else None,
"marketplace_product_id": product.marketplace_product_id if product else None,
})
return low_stock_items
except InvalidQuantityException:
raise # Re-raise custom exceptions
except Exception as e:
logger.error(f"Error getting low stock items: {str(e)}")
raise ValidationException("Failed to retrieve low stock items")
# Private helper methods
def _validate_and_normalize_gtin(self, gtin: str) -> str:
"""Validate and normalize GTIN format."""
if not gtin or not gtin.strip():
raise StockValidationException("GTIN is required", field="gtin")
normalized_gtin = self._normalize_gtin(gtin)
if not normalized_gtin:
raise StockValidationException("Invalid GTIN format", field="gtin")
return normalized_gtin
def _validate_and_normalize_location(self, location: str) -> str:
"""Validate and normalize location."""
if not location or not location.strip():
raise StockValidationException("Location is required", field="location")
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, "Quantity is required")
if not isinstance(quantity, int):
raise InvalidQuantityException(quantity, "Quantity must be an integer")
if quantity < 0:
raise InvalidQuantityException(quantity, "Quantity cannot be negative")
if not allow_zero and quantity == 0:
raise InvalidQuantityException(quantity, "Quantity must be positive")
def _normalize_gtin(self, gtin_value) -> Optional[str]:
"""Normalize GTIN format using the GTINProcessor."""
try:
return self.gtin_processor.normalize(gtin_value)
except Exception as e:
logger.error(f"Error normalizing GTIN {gtin_value}: {str(e)}")
return None
def _get_stock_entry(self, db: Session, gtin: str, location: str) -> Optional[Stock]:
"""Get stock entry by GTIN and location."""
return (
db.query(Stock)
.filter(Stock.gtin == gtin, Stock.location == location)
.first()
)
def _get_stock_by_id_or_raise(self, db: Session, stock_id: int) -> Stock:
"""Get stock by ID or raise exception."""
stock_entry = db.query(Stock).filter(Stock.id == stock_id).first()
if not stock_entry:
raise StockNotFoundException(str(stock_id))
return stock_entry
# Create service instance
stock_service = StockService()

View File

@@ -0,0 +1,214 @@
# app/services/team_service.py
"""
Team service for vendor team management.
This module provides:
- Team member invitation
- Role management
- Team member CRUD operations
"""
import logging
from typing import List, Dict, Any
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.exceptions import (
ValidationException,
UnauthorizedVendorAccessException,
)
from models.database.vendor import VendorUser, Role
from models.database.user import User
logger = logging.getLogger(__name__)
class TeamService:
"""Service for team management operations."""
def get_team_members(
self, db: Session, vendor_id: int, current_user: User
) -> List[Dict[str, Any]]:
"""
Get all team members for vendor.
Args:
db: Database session
vendor_id: Vendor ID
current_user: Current user
Returns:
List of team members
"""
try:
vendor_users = db.query(VendorUser).filter(
VendorUser.vendor_id == vendor_id,
VendorUser.is_active == True
).all()
members = []
for vu in vendor_users:
members.append({
"id": vu.user_id,
"email": vu.user.email,
"first_name": vu.user.first_name,
"last_name": vu.user.last_name,
"role": vu.role.name,
"role_id": vu.role_id,
"is_active": vu.is_active,
"joined_at": vu.created_at,
})
return members
except Exception as e:
logger.error(f"Error getting team members: {str(e)}")
raise ValidationException("Failed to retrieve team members")
def invite_team_member(
self, db: Session, vendor_id: int, invitation_data: dict, current_user: User
) -> Dict[str, Any]:
"""
Invite a new team member.
Args:
db: Database session
vendor_id: Vendor ID
invitation_data: Invitation details
current_user: Current user
Returns:
Invitation result
"""
try:
# TODO: Implement full invitation flow with email
# For now, return placeholder
return {
"message": "Team invitation feature coming soon",
"email": invitation_data.get("email"),
"role": invitation_data.get("role"),
}
except Exception as e:
logger.error(f"Error inviting team member: {str(e)}")
raise ValidationException("Failed to invite team member")
def update_team_member(
self,
db: Session,
vendor_id: int,
user_id: int,
update_data: dict,
current_user: User
) -> Dict[str, Any]:
"""
Update team member role or status.
Args:
db: Database session
vendor_id: Vendor ID
user_id: User ID to update
update_data: Update data
current_user: Current user
Returns:
Updated member info
"""
try:
vendor_user = db.query(VendorUser).filter(
VendorUser.vendor_id == vendor_id,
VendorUser.user_id == user_id
).first()
if not vendor_user:
raise ValidationException("Team member not found")
# Update fields
if "role_id" in update_data:
vendor_user.role_id = update_data["role_id"]
if "is_active" in update_data:
vendor_user.is_active = update_data["is_active"]
vendor_user.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(vendor_user)
return {
"message": "Team member updated successfully",
"user_id": user_id,
}
except Exception as e:
db.rollback()
logger.error(f"Error updating team member: {str(e)}")
raise ValidationException("Failed to update team member")
def remove_team_member(
self, db: Session, vendor_id: int, user_id: int, current_user: User
) -> bool:
"""
Remove team member from vendor.
Args:
db: Database session
vendor_id: Vendor ID
user_id: User ID to remove
current_user: Current user
Returns:
True if removed
"""
try:
vendor_user = db.query(VendorUser).filter(
VendorUser.vendor_id == vendor_id,
VendorUser.user_id == user_id
).first()
if not vendor_user:
raise ValidationException("Team member not found")
# Soft delete
vendor_user.is_active = False
vendor_user.updated_at = datetime.now(timezone.utc)
db.commit()
logger.info(f"Removed user {user_id} from vendor {vendor_id}")
return True
except Exception as e:
db.rollback()
logger.error(f"Error removing team member: {str(e)}")
raise ValidationException("Failed to remove team member")
def get_vendor_roles(self, db: Session, vendor_id: int) -> List[Dict[str, Any]]:
"""
Get available roles for vendor.
Args:
db: Database session
vendor_id: Vendor ID
Returns:
List of roles
"""
try:
roles = db.query(Role).filter(Role.vendor_id == vendor_id).all()
return [
{
"id": role.id,
"name": role.name,
"permissions": role.permissions,
}
for role in roles
]
except Exception as e:
logger.error(f"Error getting vendor roles: {str(e)}")
raise ValidationException("Failed to retrieve roles")
# Create service instance
team_service = TeamService()

View File

@@ -77,7 +77,7 @@ class VendorService:
new_vendor = Vendor(
**vendor_dict,
owner_id=current_user.id,
owner_user_id=current_user.id,
is_active=True,
is_verified=(current_user.role == "admin"),
)
@@ -129,7 +129,7 @@ class VendorService:
if current_user.role != "admin":
query = query.filter(
(Vendor.is_active == True)
& ((Vendor.is_verified == True) | (Vendor.owner_id == current_user.id))
& ((Vendor.is_verified == True) | (Vendor.owner_user_id == current_user.id))
)
else:
# Admin can apply filters
@@ -295,7 +295,7 @@ class VendorService:
raise InvalidVendorDataException("Vendor code is required", field="vendor_code")
if not vendor_data.vendor_name or not vendor_data.vendor_name.strip():
raise InvalidVendorDataException("Vendor name is required", field="vendor_name")
raise InvalidVendorDataException("Vendor name is required", field="name")
# Validate vendor code format (alphanumeric, underscores, hyphens)
import re
@@ -310,7 +310,7 @@ class VendorService:
if user.role == "admin":
return # Admins have no limit
user_vendor_count = db.query(Vendor).filter(Vendor.owner_id == user.id).count()
user_vendor_count = db.query(Vendor).filter(Vendor.owner_user_id == user.id).count()
max_vendors = 5 # Configure this as needed
if user_vendor_count >= max_vendors:
@@ -345,7 +345,7 @@ class VendorService:
def _can_access_vendor(self, vendor : Vendor, user: User) -> bool:
"""Check if user can access vendor."""
# Admins and owners can always access
if user.role == "admin" or vendor.owner_id == user.id:
if user.role == "admin" or vendor.owner_user_id == user.id:
return True
# Others can only access active and verified vendors
@@ -353,7 +353,7 @@ class VendorService:
def _is_vendor_owner(self, vendor : Vendor, user: User) -> bool:
"""Check if user is vendor owner."""
return vendor.owner_id == user.id
return vendor.owner_user_id == user.id
# Create service instance following the same pattern as other services
vendor_service = VendorService()