Refactoring code for modular approach
This commit is contained in:
@@ -7,8 +7,8 @@ from app.api.deps import get_current_user
|
|||||||
from app.tasks.background_tasks import process_marketplace_import
|
from app.tasks.background_tasks import process_marketplace_import
|
||||||
from middleware.decorators import rate_limit
|
from middleware.decorators import rate_limit
|
||||||
from models.api_models import MarketplaceImportJobResponse, MarketplaceImportRequest
|
from models.api_models import MarketplaceImportJobResponse, MarketplaceImportRequest
|
||||||
from models.database_models import User, MarketplaceImportJob, Shop
|
from models.database_models import User
|
||||||
from datetime import datetime
|
from marketplace_service import marketplace_service
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -25,50 +25,39 @@ async def import_products_from_marketplace(
|
|||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Import products from marketplace CSV with background processing (Protected)"""
|
"""Import products from marketplace CSV with background processing (Protected)"""
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
f"Starting marketplace import: {request.marketplace} -> {request.shop_code} by user {current_user.username}")
|
||||||
|
|
||||||
logger.info(
|
# Create import job through service
|
||||||
f"Starting marketplace import: {request.marketplace} -> {request.shop_code} by user {current_user.username}")
|
import_job = marketplace_service.create_import_job(db, request, current_user)
|
||||||
|
|
||||||
# Verify shop exists and user has access
|
# Process in background
|
||||||
shop = db.query(Shop).filter(Shop.shop_code == request.shop_code).first()
|
background_tasks.add_task(
|
||||||
if not shop:
|
process_marketplace_import,
|
||||||
raise HTTPException(status_code=404, detail="Shop not found")
|
import_job.id,
|
||||||
|
request.url,
|
||||||
|
request.marketplace,
|
||||||
|
request.shop_code,
|
||||||
|
request.batch_size or 1000
|
||||||
|
)
|
||||||
|
|
||||||
# Check permissions: admin can import for any shop, others only for their own
|
return MarketplaceImportJobResponse(
|
||||||
if current_user.role != "admin" and shop.owner_id != current_user.id:
|
job_id=import_job.id,
|
||||||
raise HTTPException(status_code=403, detail="Access denied to this shop")
|
status="pending",
|
||||||
|
marketplace=request.marketplace,
|
||||||
|
shop_code=request.shop_code,
|
||||||
|
message=f"Marketplace import started from {request.marketplace}. Check status with "
|
||||||
|
f"/marketplace-import-status/{import_job.id}"
|
||||||
|
)
|
||||||
|
|
||||||
# Create marketplace import job record
|
except ValueError as e:
|
||||||
import_job = MarketplaceImportJob(
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
status="pending",
|
except PermissionError as e:
|
||||||
source_url=request.url,
|
raise HTTPException(status_code=403, detail=str(e))
|
||||||
marketplace=request.marketplace,
|
except Exception as e:
|
||||||
shop_code=request.shop_code,
|
logger.error(f"Error starting marketplace import: {str(e)}")
|
||||||
user_id=current_user.id,
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
created_at=datetime.utcnow()
|
|
||||||
)
|
|
||||||
db.add(import_job)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(import_job)
|
|
||||||
|
|
||||||
# Process in background
|
|
||||||
background_tasks.add_task(
|
|
||||||
process_marketplace_import,
|
|
||||||
import_job.id,
|
|
||||||
request.url,
|
|
||||||
request.marketplace,
|
|
||||||
request.shop_code,
|
|
||||||
request.batch_size or 1000
|
|
||||||
)
|
|
||||||
|
|
||||||
return MarketplaceImportJobResponse(
|
|
||||||
job_id=import_job.id,
|
|
||||||
status="pending",
|
|
||||||
marketplace=request.marketplace,
|
|
||||||
shop_code=request.shop_code,
|
|
||||||
message=f"Marketplace import started from {request.marketplace}. Check status with "
|
|
||||||
f"/marketplace-import-status/{import_job.id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/marketplace-import-status/{job_id}", response_model=MarketplaceImportJobResponse)
|
@router.get("/marketplace-import-status/{job_id}", response_model=MarketplaceImportJobResponse)
|
||||||
@@ -78,28 +67,17 @@ def get_marketplace_import_status(
|
|||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Get status of marketplace import job (Protected)"""
|
"""Get status of marketplace import job (Protected)"""
|
||||||
job = db.query(MarketplaceImportJob).filter(MarketplaceImportJob.id == job_id).first()
|
try:
|
||||||
if not job:
|
job = marketplace_service.get_import_job_by_id(db, job_id, current_user)
|
||||||
raise HTTPException(status_code=404, detail="Marketplace import job not found")
|
return marketplace_service.convert_to_response_model(job)
|
||||||
|
|
||||||
# Users can only see their own jobs, admins can see all
|
except ValueError as e:
|
||||||
if current_user.role != "admin" and job.user_id != current_user.id:
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
raise HTTPException(status_code=403, detail="Access denied to this import job")
|
except PermissionError as e:
|
||||||
|
raise HTTPException(status_code=403, detail=str(e))
|
||||||
return MarketplaceImportJobResponse(
|
except Exception as e:
|
||||||
job_id=job.id,
|
logger.error(f"Error getting import job status {job_id}: {str(e)}")
|
||||||
status=job.status,
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
marketplace=job.marketplace,
|
|
||||||
shop_name=job.shop_name,
|
|
||||||
imported=job.imported_count or 0,
|
|
||||||
updated=job.updated_count or 0,
|
|
||||||
total_processed=job.total_processed or 0,
|
|
||||||
error_count=job.error_count or 0,
|
|
||||||
error_message=job.error_message,
|
|
||||||
created_at=job.created_at,
|
|
||||||
started_at=job.started_at,
|
|
||||||
completed_at=job.completed_at
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/marketplace-import-jobs", response_model=List[MarketplaceImportJobResponse])
|
@router.get("/marketplace-import-jobs", response_model=List[MarketplaceImportJobResponse])
|
||||||
@@ -112,35 +90,73 @@ def get_marketplace_import_jobs(
|
|||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Get marketplace import jobs with filtering (Protected)"""
|
"""Get marketplace import jobs with filtering (Protected)"""
|
||||||
|
try:
|
||||||
|
jobs = marketplace_service.get_import_jobs(
|
||||||
|
db=db,
|
||||||
|
user=current_user,
|
||||||
|
marketplace=marketplace,
|
||||||
|
shop_name=shop_name,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
query = db.query(MarketplaceImportJob)
|
return [marketplace_service.convert_to_response_model(job) for job in jobs]
|
||||||
|
|
||||||
# Users can only see their own jobs, admins can see all
|
except Exception as e:
|
||||||
if current_user.role != "admin":
|
logger.error(f"Error getting import jobs: {str(e)}")
|
||||||
query = query.filter(MarketplaceImportJob.user_id == current_user.id)
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
# Apply filters
|
|
||||||
if marketplace:
|
|
||||||
query = query.filter(MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%"))
|
|
||||||
if shop_name:
|
|
||||||
query = query.filter(MarketplaceImportJob.shop_name.ilike(f"%{shop_name}%"))
|
|
||||||
|
|
||||||
# Order by creation date (newest first) and apply pagination
|
@router.get("/marketplace-import-stats")
|
||||||
jobs = query.order_by(MarketplaceImportJob.created_at.desc()).offset(skip).limit(limit).all()
|
def get_marketplace_import_stats(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Get statistics about marketplace import jobs (Protected)"""
|
||||||
|
try:
|
||||||
|
stats = marketplace_service.get_job_stats(db, current_user)
|
||||||
|
return stats
|
||||||
|
|
||||||
return [
|
except Exception as e:
|
||||||
MarketplaceImportJobResponse(
|
logger.error(f"Error getting import stats: {str(e)}")
|
||||||
job_id=job.id,
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
status=job.status,
|
|
||||||
marketplace=job.marketplace,
|
|
||||||
shop_name=job.shop_name,
|
@router.put("/marketplace-import-jobs/{job_id}/cancel", response_model=MarketplaceImportJobResponse)
|
||||||
imported=job.imported_count or 0,
|
def cancel_marketplace_import_job(
|
||||||
updated=job.updated_count or 0,
|
job_id: int,
|
||||||
total_processed=job.total_processed or 0,
|
db: Session = Depends(get_db),
|
||||||
error_count=job.error_count or 0,
|
current_user: User = Depends(get_current_user)
|
||||||
error_message=job.error_message,
|
):
|
||||||
created_at=job.created_at,
|
"""Cancel a pending or running marketplace import job (Protected)"""
|
||||||
started_at=job.started_at,
|
try:
|
||||||
completed_at=job.completed_at
|
job = marketplace_service.cancel_import_job(db, job_id, current_user)
|
||||||
) for job in jobs
|
return marketplace_service.convert_to_response_model(job)
|
||||||
]
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(status_code=403, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error cancelling import job {job_id}: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/marketplace-import-jobs/{job_id}")
|
||||||
|
def delete_marketplace_import_job(
|
||||||
|
job_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Delete a completed marketplace import job (Protected)"""
|
||||||
|
try:
|
||||||
|
marketplace_service.delete_import_job(db, job_id, current_user)
|
||||||
|
return {"message": "Marketplace import job deleted successfully"}
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(status_code=403, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting import job {job_id}: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|||||||
@@ -11,15 +11,12 @@ from models.database_models import User, Product, Stock
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from utils.data_processing import GTINProcessor, PriceProcessor
|
from app.services.product_service import product_service
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Initialize processors
|
|
||||||
gtin_processor = GTINProcessor()
|
|
||||||
price_processor = PriceProcessor()
|
|
||||||
|
|
||||||
|
|
||||||
# Enhanced Product Routes with Marketplace Support
|
# Enhanced Product Routes with Marketplace Support
|
||||||
@router.get("/products", response_model=ProductListResponse)
|
@router.get("/products", response_model=ProductListResponse)
|
||||||
@@ -37,41 +34,28 @@ def get_products(
|
|||||||
):
|
):
|
||||||
"""Get products with advanced filtering including marketplace and shop (Protected)"""
|
"""Get products with advanced filtering including marketplace and shop (Protected)"""
|
||||||
|
|
||||||
query = db.query(Product)
|
try:
|
||||||
|
products, total = product_service.get_products_with_filters(
|
||||||
# Apply filters
|
db=db,
|
||||||
if brand:
|
skip=skip,
|
||||||
query = query.filter(Product.brand.ilike(f"%{brand}%"))
|
limit=limit,
|
||||||
if category:
|
brand=brand,
|
||||||
query = query.filter(Product.google_product_category.ilike(f"%{category}%"))
|
category=category,
|
||||||
if availability:
|
availability=availability,
|
||||||
query = query.filter(Product.availability == availability)
|
marketplace=marketplace,
|
||||||
if marketplace:
|
shop_name=shop_name,
|
||||||
query = query.filter(Product.marketplace.ilike(f"%{marketplace}%"))
|
search=search
|
||||||
if shop_name:
|
|
||||||
query = query.filter(Product.shop_name.ilike(f"%{shop_name}%"))
|
|
||||||
if search:
|
|
||||||
# Search in title, description, and marketplace
|
|
||||||
search_term = f"%{search}%"
|
|
||||||
query = query.filter(
|
|
||||||
(Product.title.ilike(search_term)) |
|
|
||||||
(Product.description.ilike(search_term)) |
|
|
||||||
(Product.marketplace.ilike(search_term)) |
|
|
||||||
(Product.shop_name.ilike(search_term))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get total count for pagination
|
return ProductListResponse(
|
||||||
total = query.count()
|
products=products,
|
||||||
|
total=total,
|
||||||
# Apply pagination
|
skip=skip,
|
||||||
products = query.offset(skip).limit(limit).all()
|
limit=limit
|
||||||
|
)
|
||||||
return ProductListResponse(
|
except Exception as e:
|
||||||
products=products,
|
logger.error(f"Error getting products: {str(e)}")
|
||||||
total=total,
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
skip=skip,
|
|
||||||
limit=limit
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/products", response_model=ProductResponse)
|
@router.post("/products", response_model=ProductResponse)
|
||||||
@@ -82,68 +66,54 @@ def create_product(
|
|||||||
):
|
):
|
||||||
"""Create a new product with validation and marketplace support (Protected)"""
|
"""Create a new product with validation and marketplace support (Protected)"""
|
||||||
|
|
||||||
# Check if product_id already exists
|
try:
|
||||||
existing = db.query(Product).filter(Product.product_id == product.product_id).first()
|
# Check if product_id already exists
|
||||||
if existing:
|
existing = product_service.get_product_by_id(db, product.product_id)
|
||||||
raise HTTPException(status_code=400, detail="Product with this ID already exists")
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Product with this ID already exists")
|
||||||
|
|
||||||
# Process and validate GTIN if provided
|
db_product = product_service.create_product(db, product)
|
||||||
if product.gtin:
|
|
||||||
normalized_gtin = gtin_processor.normalize(product.gtin)
|
|
||||||
if not normalized_gtin:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid GTIN format")
|
|
||||||
product.gtin = normalized_gtin
|
|
||||||
|
|
||||||
# Process price if provided
|
logger.info(
|
||||||
if product.price:
|
f"Created product {db_product.product_id} for marketplace {db_product.marketplace}, "
|
||||||
parsed_price, currency = price_processor.parse_price_currency(product.price)
|
f"shop {db_product.shop_name}")
|
||||||
if parsed_price:
|
return db_product
|
||||||
product.price = parsed_price
|
|
||||||
product.currency = currency
|
|
||||||
|
|
||||||
# Set default marketplace if not provided
|
except ValueError as e:
|
||||||
if not product.marketplace:
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
product.marketplace = "Letzshop"
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating product: {str(e)}")
|
||||||
db_product = Product(**product.dict())
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
db.add(db_product)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(db_product)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Created product {db_product.product_id} for marketplace {db_product.marketplace}, "
|
|
||||||
f"shop {db_product.shop_name}")
|
|
||||||
return db_product
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/products/{product_id}", response_model=ProductDetailResponse)
|
@router.get("/products/{product_id}", response_model=ProductDetailResponse)
|
||||||
def get_product(product_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
def get_product(
|
||||||
|
product_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
"""Get product with stock information (Protected)"""
|
"""Get product with stock information (Protected)"""
|
||||||
|
|
||||||
product = db.query(Product).filter(Product.product_id == product_id).first()
|
try:
|
||||||
if not product:
|
product = product_service.get_product_by_id(db, product_id)
|
||||||
raise HTTPException(status_code=404, detail="Product not found")
|
if not product:
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
|
||||||
# Get stock information if GTIN exists
|
# Get stock information if GTIN exists
|
||||||
stock_info = None
|
stock_info = None
|
||||||
if product.gtin:
|
if product.gtin:
|
||||||
stock_entries = db.query(Stock).filter(Stock.gtin == product.gtin).all()
|
stock_info = product_service.get_stock_info(db, product.gtin)
|
||||||
if stock_entries:
|
|
||||||
total_quantity = sum(entry.quantity for entry in stock_entries)
|
|
||||||
locations = [
|
|
||||||
StockLocationResponse(location=entry.location, quantity=entry.quantity)
|
|
||||||
for entry in stock_entries
|
|
||||||
]
|
|
||||||
stock_info = StockSummaryResponse(
|
|
||||||
gtin=product.gtin,
|
|
||||||
total_quantity=total_quantity,
|
|
||||||
locations=locations
|
|
||||||
)
|
|
||||||
|
|
||||||
return ProductDetailResponse(
|
return ProductDetailResponse(
|
||||||
product=product,
|
product=product,
|
||||||
stock_info=stock_info
|
stock_info=stock_info
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting product {product_id}: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.put("/products/{product_id}", response_model=ProductResponse)
|
@router.put("/products/{product_id}", response_model=ProductResponse)
|
||||||
@@ -155,35 +125,21 @@ def update_product(
|
|||||||
):
|
):
|
||||||
"""Update product with validation and marketplace support (Protected)"""
|
"""Update product with validation and marketplace support (Protected)"""
|
||||||
|
|
||||||
product = db.query(Product).filter(Product.product_id == product_id).first()
|
try:
|
||||||
if not product:
|
product = product_service.get_product_by_id(db, product_id)
|
||||||
raise HTTPException(status_code=404, detail="Product not found")
|
if not product:
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
|
||||||
# Update fields
|
updated_product = product_service.update_product(db, product_id, product_update)
|
||||||
update_data = product_update.dict(exclude_unset=True)
|
return updated_product
|
||||||
|
|
||||||
# Validate GTIN if being updated
|
except HTTPException:
|
||||||
if "gtin" in update_data and update_data["gtin"]:
|
raise
|
||||||
normalized_gtin = gtin_processor.normalize(update_data["gtin"])
|
except ValueError as e:
|
||||||
if not normalized_gtin:
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
raise HTTPException(status_code=400, detail="Invalid GTIN format")
|
except Exception as e:
|
||||||
update_data["gtin"] = normalized_gtin
|
logger.error(f"Error updating product {product_id}: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
# Process price if being updated
|
|
||||||
if "price" in update_data and update_data["price"]:
|
|
||||||
parsed_price, currency = price_processor.parse_price_currency(update_data["price"])
|
|
||||||
if parsed_price:
|
|
||||||
update_data["price"] = parsed_price
|
|
||||||
update_data["currency"] = currency
|
|
||||||
|
|
||||||
for key, value in update_data.items():
|
|
||||||
setattr(product, key, value)
|
|
||||||
|
|
||||||
product.updated_at = datetime.utcnow()
|
|
||||||
db.commit()
|
|
||||||
db.refresh(product)
|
|
||||||
|
|
||||||
return product
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/products/{product_id}")
|
@router.delete("/products/{product_id}")
|
||||||
@@ -194,18 +150,21 @@ def delete_product(
|
|||||||
):
|
):
|
||||||
"""Delete product and associated stock (Protected)"""
|
"""Delete product and associated stock (Protected)"""
|
||||||
|
|
||||||
product = db.query(Product).filter(Product.product_id == product_id).first()
|
try:
|
||||||
if not product:
|
product = product_service.get_product_by_id(db, product_id)
|
||||||
raise HTTPException(status_code=404, detail="Product not found")
|
if not product:
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
|
||||||
# Delete associated stock entries if GTIN exists
|
product_service.delete_product(db, product_id)
|
||||||
if product.gtin:
|
|
||||||
db.query(Stock).filter(Stock.gtin == product.gtin).delete()
|
|
||||||
|
|
||||||
db.delete(product)
|
return {"message": "Product and associated stock deleted successfully"}
|
||||||
db.commit()
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting product {product_id}: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
return {"message": "Product and associated stock deleted successfully"}
|
|
||||||
|
|
||||||
# Export with streaming for large datasets (Protected)
|
# Export with streaming for large datasets (Protected)
|
||||||
@router.get("/export-csv")
|
@router.get("/export-csv")
|
||||||
@@ -217,45 +176,27 @@ async def export_csv(
|
|||||||
):
|
):
|
||||||
"""Export products as CSV with streaming and marketplace filtering (Protected)"""
|
"""Export products as CSV with streaming and marketplace filtering (Protected)"""
|
||||||
|
|
||||||
def generate_csv():
|
try:
|
||||||
# Stream CSV generation for memory efficiency
|
def generate_csv():
|
||||||
yield "product_id,title,description,link,image_link,availability,price,currency,brand,gtin,marketplace,shop_name\n"
|
return product_service.generate_csv_export(
|
||||||
|
db=db,
|
||||||
|
marketplace=marketplace,
|
||||||
|
shop_name=shop_name
|
||||||
|
)
|
||||||
|
|
||||||
batch_size = 1000
|
filename = "products_export"
|
||||||
offset = 0
|
if marketplace:
|
||||||
|
filename += f"_{marketplace}"
|
||||||
|
if shop_name:
|
||||||
|
filename += f"_{shop_name}"
|
||||||
|
filename += ".csv"
|
||||||
|
|
||||||
while True:
|
return StreamingResponse(
|
||||||
query = db.query(Product)
|
generate_csv(),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||||
|
)
|
||||||
|
|
||||||
# Apply marketplace filters
|
except Exception as e:
|
||||||
if marketplace:
|
logger.error(f"Error exporting CSV: {str(e)}")
|
||||||
query = query.filter(Product.marketplace.ilike(f"%{marketplace}%"))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
if shop_name:
|
|
||||||
query = query.filter(Product.shop_name.ilike(f"%{shop_name}%"))
|
|
||||||
|
|
||||||
products = query.offset(offset).limit(batch_size).all()
|
|
||||||
if not products:
|
|
||||||
break
|
|
||||||
|
|
||||||
for product in products:
|
|
||||||
# Create CSV row with marketplace fields
|
|
||||||
row = (f'"{product.product_id}","{product.title or ""}","{product.description or ""}",'
|
|
||||||
f'"{product.link or ""}","{product.image_link or ""}","{product.availability or ""}",'
|
|
||||||
f'"{product.price or ""}","{product.currency or ""}","{product.brand or ""}",'
|
|
||||||
f'"{product.gtin or ""}","{product.marketplace or ""}","{product.shop_name or ""}"\n')
|
|
||||||
yield row
|
|
||||||
|
|
||||||
offset += batch_size
|
|
||||||
|
|
||||||
filename = "products_export"
|
|
||||||
if marketplace:
|
|
||||||
filename += f"_{marketplace}"
|
|
||||||
if shop_name:
|
|
||||||
filename += f"_{shop_name}"
|
|
||||||
filename += ".csv"
|
|
||||||
|
|
||||||
return StreamingResponse(
|
|
||||||
generate_csv(),
|
|
||||||
media_type="text/csv",
|
|
||||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ from app.core.database import get_db
|
|||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.tasks.background_tasks import process_marketplace_import
|
from app.tasks.background_tasks import process_marketplace_import
|
||||||
from middleware.decorators import rate_limit
|
from middleware.decorators import rate_limit
|
||||||
from models.api_models import MarketplaceImportJobResponse, MarketplaceImportRequest, StockResponse, \
|
from models.api_models import (MarketplaceImportJobResponse, MarketplaceImportRequest, StockResponse,
|
||||||
StockSummaryResponse
|
StockSummaryResponse, StockCreate, StockAdd, StockUpdate)
|
||||||
from models.database_models import User, MarketplaceImportJob, Shop
|
from models.database_models import User, MarketplaceImportJob, Shop
|
||||||
from datetime import datetime
|
from stock_service import stock_service
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -19,236 +19,88 @@ logger = logging.getLogger(__name__)
|
|||||||
# Stock Management Routes (Protected)
|
# Stock Management Routes (Protected)
|
||||||
|
|
||||||
@router.post("/stock", response_model=StockResponse)
|
@router.post("/stock", response_model=StockResponse)
|
||||||
def set_stock(stock: StockCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
def set_stock(
|
||||||
|
stock: StockCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
"""Set exact stock quantity for a GTIN at a specific location (replaces existing quantity)"""
|
"""Set exact stock quantity for a GTIN at a specific location (replaces existing quantity)"""
|
||||||
|
try:
|
||||||
# Normalize GTIN
|
result = stock_service.set_stock(db, stock)
|
||||||
def normalize_gtin(gtin_value):
|
return result
|
||||||
if not gtin_value:
|
except ValueError as e:
|
||||||
return None
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
gtin_str = str(gtin_value).strip()
|
except Exception as e:
|
||||||
if '.' in gtin_str:
|
logger.error(f"Error setting stock: {str(e)}")
|
||||||
gtin_str = gtin_str.split('.')[0]
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
gtin_clean = ''.join(filter(str.isdigit, gtin_str))
|
|
||||||
if len(gtin_clean) in [8, 12, 13, 14]:
|
|
||||||
return gtin_clean.zfill(13) if len(gtin_clean) == 13 else gtin_clean.zfill(12)
|
|
||||||
return gtin_clean if gtin_clean else None
|
|
||||||
|
|
||||||
normalized_gtin = normalize_gtin(stock.gtin)
|
|
||||||
if not normalized_gtin:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid GTIN format")
|
|
||||||
|
|
||||||
# Check if stock entry already exists for this GTIN and location
|
|
||||||
existing_stock = db.query(Stock).filter(
|
|
||||||
Stock.gtin == normalized_gtin,
|
|
||||||
Stock.location == stock.location.strip().upper()
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing_stock:
|
|
||||||
# Update existing stock (SET to exact quantity)
|
|
||||||
old_quantity = existing_stock.quantity
|
|
||||||
existing_stock.quantity = stock.quantity
|
|
||||||
existing_stock.updated_at = datetime.utcnow()
|
|
||||||
db.commit()
|
|
||||||
db.refresh(existing_stock)
|
|
||||||
logger.info(f"Updated stock for GTIN {normalized_gtin} at {stock.location}: {old_quantity} → {stock.quantity}")
|
|
||||||
return existing_stock
|
|
||||||
else:
|
|
||||||
# Create new stock entry
|
|
||||||
new_stock = Stock(
|
|
||||||
gtin=normalized_gtin,
|
|
||||||
location=stock.location.strip().upper(),
|
|
||||||
quantity=stock.quantity
|
|
||||||
)
|
|
||||||
db.add(new_stock)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(new_stock)
|
|
||||||
logger.info(f"Created new stock for GTIN {normalized_gtin} at {stock.location}: {stock.quantity}")
|
|
||||||
return new_stock
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/stock/add", response_model=StockResponse)
|
@router.post("/stock/add", response_model=StockResponse)
|
||||||
def add_stock(stock: StockAdd, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
def add_stock(
|
||||||
|
stock: StockAdd,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
"""Add quantity to existing stock for a GTIN at a specific location (adds to existing quantity)"""
|
"""Add quantity to existing stock for a GTIN at a specific location (adds to existing quantity)"""
|
||||||
|
try:
|
||||||
# Normalize GTIN
|
result = stock_service.add_stock(db, stock)
|
||||||
def normalize_gtin(gtin_value):
|
return result
|
||||||
if not gtin_value:
|
except ValueError as e:
|
||||||
return None
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
gtin_str = str(gtin_value).strip()
|
except Exception as e:
|
||||||
if '.' in gtin_str:
|
logger.error(f"Error adding stock: {str(e)}")
|
||||||
gtin_str = gtin_str.split('.')[0]
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
gtin_clean = ''.join(filter(str.isdigit, gtin_str))
|
|
||||||
if len(gtin_clean) in [8, 12, 13, 14]:
|
|
||||||
return gtin_clean.zfill(13) if len(gtin_clean) == 13 else gtin_clean.zfill(12)
|
|
||||||
return gtin_clean if gtin_clean else None
|
|
||||||
|
|
||||||
normalized_gtin = normalize_gtin(stock.gtin)
|
|
||||||
if not normalized_gtin:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid GTIN format")
|
|
||||||
|
|
||||||
# Check if stock entry already exists for this GTIN and location
|
|
||||||
existing_stock = db.query(Stock).filter(
|
|
||||||
Stock.gtin == normalized_gtin,
|
|
||||||
Stock.location == stock.location.strip().upper()
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing_stock:
|
|
||||||
# Add to existing stock
|
|
||||||
old_quantity = existing_stock.quantity
|
|
||||||
existing_stock.quantity += stock.quantity
|
|
||||||
existing_stock.updated_at = datetime.utcnow()
|
|
||||||
db.commit()
|
|
||||||
db.refresh(existing_stock)
|
|
||||||
logger.info(
|
|
||||||
f"Added stock for GTIN {normalized_gtin} at {stock.location}: {old_quantity} + {stock.quantity} = {existing_stock.quantity}")
|
|
||||||
return existing_stock
|
|
||||||
else:
|
|
||||||
# Create new stock entry with the quantity
|
|
||||||
new_stock = Stock(
|
|
||||||
gtin=normalized_gtin,
|
|
||||||
location=stock.location.strip().upper(),
|
|
||||||
quantity=stock.quantity
|
|
||||||
)
|
|
||||||
db.add(new_stock)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(new_stock)
|
|
||||||
logger.info(f"Created new stock for GTIN {normalized_gtin} at {stock.location}: {stock.quantity}")
|
|
||||||
return new_stock
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/stock/remove", response_model=StockResponse)
|
@router.post("/stock/remove", response_model=StockResponse)
|
||||||
def remove_stock(stock: StockAdd, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
def remove_stock(
|
||||||
|
stock: StockAdd,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
"""Remove quantity from existing stock for a GTIN at a specific location"""
|
"""Remove quantity from existing stock for a GTIN at a specific location"""
|
||||||
|
try:
|
||||||
# Normalize GTIN
|
result = stock_service.remove_stock(db, stock)
|
||||||
def normalize_gtin(gtin_value):
|
return result
|
||||||
if not gtin_value:
|
except ValueError as e:
|
||||||
return None
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
gtin_str = str(gtin_value).strip()
|
except Exception as e:
|
||||||
if '.' in gtin_str:
|
logger.error(f"Error removing stock: {str(e)}")
|
||||||
gtin_str = gtin_str.split('.')[0]
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
gtin_clean = ''.join(filter(str.isdigit, gtin_str))
|
|
||||||
if len(gtin_clean) in [8, 12, 13, 14]:
|
|
||||||
return gtin_clean.zfill(13) if len(gtin_clean) == 13 else gtin_clean.zfill(12)
|
|
||||||
return gtin_clean if gtin_clean else None
|
|
||||||
|
|
||||||
normalized_gtin = normalize_gtin(stock.gtin)
|
|
||||||
if not normalized_gtin:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid GTIN format")
|
|
||||||
|
|
||||||
# Find existing stock entry
|
|
||||||
existing_stock = db.query(Stock).filter(
|
|
||||||
Stock.gtin == normalized_gtin,
|
|
||||||
Stock.location == stock.location.strip().upper()
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not existing_stock:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail=f"No stock found for GTIN {normalized_gtin} at location {stock.location}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if we have enough stock to remove
|
|
||||||
if existing_stock.quantity < stock.quantity:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Insufficient stock. Available: {existing_stock.quantity}, Requested to remove: {stock.quantity}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Remove from existing stock
|
|
||||||
old_quantity = existing_stock.quantity
|
|
||||||
existing_stock.quantity -= stock.quantity
|
|
||||||
existing_stock.updated_at = datetime.utcnow()
|
|
||||||
db.commit()
|
|
||||||
db.refresh(existing_stock)
|
|
||||||
logger.info(
|
|
||||||
f"Removed stock for GTIN {normalized_gtin} at {stock.location}: {old_quantity} - {stock.quantity} = {existing_stock.quantity}")
|
|
||||||
return existing_stock
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{gtin}", response_model=StockSummaryResponse)
|
@router.get("/stock/{gtin}", response_model=StockSummaryResponse)
|
||||||
def get_stock_by_gtin(gtin: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
def get_stock_by_gtin(
|
||||||
|
gtin: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
"""Get all stock locations and total quantity for a specific GTIN"""
|
"""Get all stock locations and total quantity for a specific GTIN"""
|
||||||
|
try:
|
||||||
# Normalize GTIN
|
result = stock_service.get_stock_by_gtin(db, gtin)
|
||||||
def normalize_gtin(gtin_value):
|
return result
|
||||||
if not gtin_value:
|
except ValueError as e:
|
||||||
return None
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
gtin_str = str(gtin_value).strip()
|
except Exception as e:
|
||||||
if '.' in gtin_str:
|
logger.error(f"Error getting stock for GTIN {gtin}: {str(e)}")
|
||||||
gtin_str = gtin_str.split('.')[0]
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
gtin_clean = ''.join(filter(str.isdigit, gtin_str))
|
|
||||||
if len(gtin_clean) in [8, 12, 13, 14]:
|
|
||||||
return gtin_clean.zfill(13) if len(gtin_clean) == 13 else gtin_clean.zfill(12)
|
|
||||||
return gtin_clean if gtin_clean else None
|
|
||||||
|
|
||||||
normalized_gtin = normalize_gtin(gtin)
|
|
||||||
if not normalized_gtin:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid GTIN format")
|
|
||||||
|
|
||||||
# Get all stock entries for this GTIN
|
|
||||||
stock_entries = db.query(Stock).filter(Stock.gtin == normalized_gtin).all()
|
|
||||||
|
|
||||||
if not stock_entries:
|
|
||||||
raise HTTPException(status_code=404, detail=f"No stock found for GTIN: {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(Product).filter(Product.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
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stock/{gtin}/total")
|
@router.get("/stock/{gtin}/total")
|
||||||
def get_total_stock(gtin: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
def get_total_stock(
|
||||||
|
gtin: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
"""Get total quantity in stock for a specific GTIN"""
|
"""Get total quantity in stock for a specific GTIN"""
|
||||||
|
try:
|
||||||
# Normalize GTIN
|
result = stock_service.get_total_stock(db, gtin)
|
||||||
def normalize_gtin(gtin_value):
|
return result
|
||||||
if not gtin_value:
|
except ValueError as e:
|
||||||
return None
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
gtin_str = str(gtin_value).strip()
|
except Exception as e:
|
||||||
if '.' in gtin_str:
|
logger.error(f"Error getting total stock for GTIN {gtin}: {str(e)}")
|
||||||
gtin_str = gtin_str.split('.')[0]
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
gtin_clean = ''.join(filter(str.isdigit, gtin_str))
|
|
||||||
if len(gtin_clean) in [8, 12, 13, 14]:
|
|
||||||
return gtin_clean.zfill(13) if len(gtin_clean) == 13 else gtin_clean.zfill(12)
|
|
||||||
return gtin_clean if gtin_clean else None
|
|
||||||
|
|
||||||
normalized_gtin = normalize_gtin(gtin)
|
|
||||||
if not normalized_gtin:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid GTIN format")
|
|
||||||
|
|
||||||
# Calculate total stock
|
|
||||||
total_stock = db.query(Stock).filter(Stock.gtin == normalized_gtin).all()
|
|
||||||
total_quantity = sum(entry.quantity for entry in total_stock)
|
|
||||||
|
|
||||||
# Get product info for context
|
|
||||||
product = db.query(Product).filter(Product.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)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stock", response_model=List[StockResponse])
|
@router.get("/stock", response_model=List[StockResponse])
|
||||||
@@ -261,55 +113,50 @@ def get_all_stock(
|
|||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Get all stock entries with optional filtering"""
|
"""Get all stock entries with optional filtering"""
|
||||||
query = db.query(Stock)
|
try:
|
||||||
|
result = stock_service.get_all_stock(
|
||||||
if location:
|
db=db,
|
||||||
query = query.filter(Stock.location.ilike(f"%{location}%"))
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
if gtin:
|
location=location,
|
||||||
# Normalize GTIN for search
|
gtin=gtin
|
||||||
def normalize_gtin(gtin_value):
|
)
|
||||||
if not gtin_value:
|
return result
|
||||||
return None
|
except Exception as e:
|
||||||
gtin_str = str(gtin_value).strip()
|
logger.error(f"Error getting all stock: {str(e)}")
|
||||||
if '.' in gtin_str:
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
gtin_str = gtin_str.split('.')[0]
|
|
||||||
gtin_clean = ''.join(filter(str.isdigit, gtin_str))
|
|
||||||
if len(gtin_clean) in [8, 12, 13, 14]:
|
|
||||||
return gtin_clean.zfill(13) if len(gtin_clean) == 13 else gtin_clean.zfill(12)
|
|
||||||
return gtin_clean if gtin_clean else None
|
|
||||||
|
|
||||||
normalized_gtin = normalize_gtin(gtin)
|
|
||||||
if normalized_gtin:
|
|
||||||
query = query.filter(Stock.gtin == normalized_gtin)
|
|
||||||
|
|
||||||
stock_entries = query.offset(skip).limit(limit).all()
|
|
||||||
return stock_entries
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/stock/{stock_id}", response_model=StockResponse)
|
@router.put("/stock/{stock_id}", response_model=StockResponse)
|
||||||
def update_stock(stock_id: int, stock_update: StockUpdate, db: Session = Depends(get_db),
|
def update_stock(
|
||||||
current_user: User = Depends(get_current_user)):
|
stock_id: int,
|
||||||
|
stock_update: StockUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
"""Update stock quantity for a specific stock entry"""
|
"""Update stock quantity for a specific stock entry"""
|
||||||
stock_entry = db.query(Stock).filter(Stock.id == stock_id).first()
|
try:
|
||||||
if not stock_entry:
|
result = stock_service.update_stock(db, stock_id, stock_update)
|
||||||
raise HTTPException(status_code=404, detail="Stock entry not found")
|
return result
|
||||||
|
except ValueError as e:
|
||||||
stock_entry.quantity = stock_update.quantity
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
stock_entry.updated_at = datetime.utcnow()
|
except Exception as e:
|
||||||
db.commit()
|
logger.error(f"Error updating stock {stock_id}: {str(e)}")
|
||||||
db.refresh(stock_entry)
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
return stock_entry
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/stock/{stock_id}")
|
@router.delete("/stock/{stock_id}")
|
||||||
def delete_stock(stock_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
def delete_stock(
|
||||||
|
stock_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
"""Delete a stock entry"""
|
"""Delete a stock entry"""
|
||||||
stock_entry = db.query(Stock).filter(Stock.id == stock_id).first()
|
try:
|
||||||
if not stock_entry:
|
stock_service.delete_stock(db, stock_id)
|
||||||
raise HTTPException(status_code=404, detail="Stock entry not found")
|
return {"message": "Stock entry deleted successfully"}
|
||||||
|
except ValueError as e:
|
||||||
db.delete(stock_entry)
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
db.commit()
|
except Exception as e:
|
||||||
return {"message": "Stock entry deleted successfully"}
|
logger.error(f"Error deleting stock {stock_id}: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|||||||
202
app/services/marketplace_service.py
Normal file
202
app/services/marketplace_service.py
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from models.database_models import MarketplaceImportJob, Shop, User
|
||||||
|
from models.api_models import MarketplaceImportRequest, MarketplaceImportJobResponse
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MarketplaceService:
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def validate_shop_access(self, db: Session, shop_code: str, user: User) -> Shop:
|
||||||
|
"""Validate that the shop exists and user has access to it"""
|
||||||
|
shop = db.query(Shop).filter(Shop.shop_code == shop_code).first()
|
||||||
|
if not shop:
|
||||||
|
raise ValueError("Shop not found")
|
||||||
|
|
||||||
|
# Check permissions: admin can import for any shop, others only for their own
|
||||||
|
if user.role != "admin" and shop.owner_id != user.id:
|
||||||
|
raise PermissionError("Access denied to this shop")
|
||||||
|
|
||||||
|
return shop
|
||||||
|
|
||||||
|
def create_import_job(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
request: MarketplaceImportRequest,
|
||||||
|
user: User
|
||||||
|
) -> MarketplaceImportJob:
|
||||||
|
"""Create a new marketplace import job"""
|
||||||
|
# Validate shop access first
|
||||||
|
shop = self.validate_shop_access(db, request.shop_code, user)
|
||||||
|
|
||||||
|
# Create marketplace import job record
|
||||||
|
import_job = MarketplaceImportJob(
|
||||||
|
status="pending",
|
||||||
|
source_url=request.url,
|
||||||
|
marketplace=request.marketplace,
|
||||||
|
shop_code=request.shop_code,
|
||||||
|
user_id=user.id,
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(import_job)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(import_job)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Created marketplace import job {import_job.id}: {request.marketplace} -> {request.shop_code} by user {user.username}")
|
||||||
|
|
||||||
|
return import_job
|
||||||
|
|
||||||
|
def get_import_job_by_id(self, db: Session, job_id: int, user: User) -> MarketplaceImportJob:
|
||||||
|
"""Get a marketplace import job by ID with access control"""
|
||||||
|
job = db.query(MarketplaceImportJob).filter(MarketplaceImportJob.id == job_id).first()
|
||||||
|
if not job:
|
||||||
|
raise ValueError("Marketplace import job not found")
|
||||||
|
|
||||||
|
# Users can only see their own jobs, admins can see all
|
||||||
|
if user.role != "admin" and job.user_id != user.id:
|
||||||
|
raise PermissionError("Access denied to this import job")
|
||||||
|
|
||||||
|
return job
|
||||||
|
|
||||||
|
def get_import_jobs(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
user: User,
|
||||||
|
marketplace: Optional[str] = None,
|
||||||
|
shop_name: Optional[str] = None,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 50
|
||||||
|
) -> List[MarketplaceImportJob]:
|
||||||
|
"""Get marketplace import jobs with filtering and access control"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
if marketplace:
|
||||||
|
query = query.filter(MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%"))
|
||||||
|
if shop_name:
|
||||||
|
query = query.filter(MarketplaceImportJob.shop_name.ilike(f"%{shop_name}%"))
|
||||||
|
|
||||||
|
# Order by creation date (newest first) and apply pagination
|
||||||
|
jobs = query.order_by(MarketplaceImportJob.created_at.desc()).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
def update_job_status(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
job_id: int,
|
||||||
|
status: str,
|
||||||
|
**kwargs
|
||||||
|
) -> MarketplaceImportJob:
|
||||||
|
"""Update marketplace import job status and other fields"""
|
||||||
|
job = db.query(MarketplaceImportJob).filter(MarketplaceImportJob.id == job_id).first()
|
||||||
|
if not job:
|
||||||
|
raise ValueError("Marketplace import job not found")
|
||||||
|
|
||||||
|
job.status = status
|
||||||
|
|
||||||
|
# Update optional fields if provided
|
||||||
|
if 'imported_count' in kwargs:
|
||||||
|
job.imported_count = kwargs['imported_count']
|
||||||
|
if 'updated_count' in kwargs:
|
||||||
|
job.updated_count = kwargs['updated_count']
|
||||||
|
if 'total_processed' in kwargs:
|
||||||
|
job.total_processed = kwargs['total_processed']
|
||||||
|
if 'error_count' in kwargs:
|
||||||
|
job.error_count = kwargs['error_count']
|
||||||
|
if 'error_message' in kwargs:
|
||||||
|
job.error_message = kwargs['error_message']
|
||||||
|
if 'started_at' in kwargs:
|
||||||
|
job.started_at = kwargs['started_at']
|
||||||
|
if 'completed_at' in kwargs:
|
||||||
|
job.completed_at = kwargs['completed_at']
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(job)
|
||||||
|
|
||||||
|
logger.info(f"Updated marketplace import job {job_id} status to {status}")
|
||||||
|
return job
|
||||||
|
|
||||||
|
def get_job_stats(self, db: Session, user: User) -> dict:
|
||||||
|
"""Get statistics about marketplace import jobs for a user"""
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
def convert_to_response_model(self, job: MarketplaceImportJob) -> MarketplaceImportJobResponse:
|
||||||
|
"""Convert database model to API response model"""
|
||||||
|
return MarketplaceImportJobResponse(
|
||||||
|
job_id=job.id,
|
||||||
|
status=job.status,
|
||||||
|
marketplace=job.marketplace,
|
||||||
|
shop_name=job.shop_name,
|
||||||
|
imported=job.imported_count or 0,
|
||||||
|
updated=job.updated_count or 0,
|
||||||
|
total_processed=job.total_processed or 0,
|
||||||
|
error_count=job.error_count or 0,
|
||||||
|
error_message=job.error_message,
|
||||||
|
created_at=job.created_at,
|
||||||
|
started_at=job.started_at,
|
||||||
|
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"""
|
||||||
|
job = self.get_import_job_by_id(db, job_id, user)
|
||||||
|
|
||||||
|
if job.status not in ["pending", "running"]:
|
||||||
|
raise ValueError(f"Cannot cancel job with status: {job.status}")
|
||||||
|
|
||||||
|
job.status = "cancelled"
|
||||||
|
job.completed_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(job)
|
||||||
|
|
||||||
|
logger.info(f"Cancelled marketplace import job {job_id}")
|
||||||
|
return job
|
||||||
|
|
||||||
|
def delete_import_job(self, db: Session, job_id: int, user: User) -> bool:
|
||||||
|
"""Delete a marketplace import job"""
|
||||||
|
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 ValueError(f"Cannot delete job with status: {job.status}. Cancel it first.")
|
||||||
|
|
||||||
|
db.delete(job)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Deleted marketplace import job {job_id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# Create service instance
|
||||||
|
marketplace_service = MarketplaceService()
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from models.database_models import Product
|
from models.database_models import Product, Stock
|
||||||
from models.api_models import ProductCreate
|
from models.api_models import ProductCreate, ProductUpdate, StockLocationResponse, StockSummaryResponse
|
||||||
from utils.data_processing import GTINProcessor, PriceProcessor
|
from utils.data_processing import GTINProcessor, PriceProcessor
|
||||||
from typing import Optional, List
|
from typing import Optional, List, Generator
|
||||||
|
from datetime import datetime
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -41,6 +42,10 @@ class ProductService:
|
|||||||
logger.info(f"Created product {db_product.product_id}")
|
logger.info(f"Created product {db_product.product_id}")
|
||||||
return db_product
|
return db_product
|
||||||
|
|
||||||
|
def get_product_by_id(self, db: Session, product_id: str) -> Optional[Product]:
|
||||||
|
"""Get a product by its ID"""
|
||||||
|
return db.query(Product).filter(Product.product_id == product_id).first()
|
||||||
|
|
||||||
def get_products_with_filters(
|
def get_products_with_filters(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
@@ -48,7 +53,9 @@ class ProductService:
|
|||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
brand: Optional[str] = None,
|
brand: Optional[str] = None,
|
||||||
category: Optional[str] = None,
|
category: Optional[str] = None,
|
||||||
|
availability: Optional[str] = None,
|
||||||
marketplace: Optional[str] = None,
|
marketplace: Optional[str] = None,
|
||||||
|
shop_name: Optional[str] = None,
|
||||||
search: Optional[str] = None
|
search: Optional[str] = None
|
||||||
) -> tuple[List[Product], int]:
|
) -> tuple[List[Product], int]:
|
||||||
"""Get products with filtering and pagination"""
|
"""Get products with filtering and pagination"""
|
||||||
@@ -59,14 +66,20 @@ class ProductService:
|
|||||||
query = query.filter(Product.brand.ilike(f"%{brand}%"))
|
query = query.filter(Product.brand.ilike(f"%{brand}%"))
|
||||||
if category:
|
if category:
|
||||||
query = query.filter(Product.google_product_category.ilike(f"%{category}%"))
|
query = query.filter(Product.google_product_category.ilike(f"%{category}%"))
|
||||||
|
if availability:
|
||||||
|
query = query.filter(Product.availability == availability)
|
||||||
if marketplace:
|
if marketplace:
|
||||||
query = query.filter(Product.marketplace.ilike(f"%{marketplace}%"))
|
query = query.filter(Product.marketplace.ilike(f"%{marketplace}%"))
|
||||||
|
if shop_name:
|
||||||
|
query = query.filter(Product.shop_name.ilike(f"%{shop_name}%"))
|
||||||
if search:
|
if search:
|
||||||
|
# Search in title, description, marketplace, and shop_name
|
||||||
search_term = f"%{search}%"
|
search_term = f"%{search}%"
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
(Product.title.ilike(search_term)) |
|
(Product.title.ilike(search_term)) |
|
||||||
(Product.description.ilike(search_term)) |
|
(Product.description.ilike(search_term)) |
|
||||||
(Product.marketplace.ilike(search_term))
|
(Product.marketplace.ilike(search_term)) |
|
||||||
|
(Product.shop_name.ilike(search_term))
|
||||||
)
|
)
|
||||||
|
|
||||||
total = query.count()
|
total = query.count()
|
||||||
@@ -74,6 +87,114 @@ class ProductService:
|
|||||||
|
|
||||||
return products, total
|
return products, total
|
||||||
|
|
||||||
|
def update_product(self, db: Session, product_id: str, product_update: ProductUpdate) -> Product:
|
||||||
|
"""Update product with validation"""
|
||||||
|
product = db.query(Product).filter(Product.product_id == product_id).first()
|
||||||
|
if not product:
|
||||||
|
raise ValueError("Product not found")
|
||||||
|
|
||||||
|
# Update fields
|
||||||
|
update_data = product_update.dict(exclude_unset=True)
|
||||||
|
|
||||||
|
# Validate GTIN if being updated
|
||||||
|
if "gtin" in update_data and update_data["gtin"]:
|
||||||
|
normalized_gtin = self.gtin_processor.normalize(update_data["gtin"])
|
||||||
|
if not normalized_gtin:
|
||||||
|
raise ValueError("Invalid GTIN format")
|
||||||
|
update_data["gtin"] = normalized_gtin
|
||||||
|
|
||||||
|
# Process price if being updated
|
||||||
|
if "price" in update_data and update_data["price"]:
|
||||||
|
parsed_price, currency = self.price_processor.parse_price_currency(update_data["price"])
|
||||||
|
if parsed_price:
|
||||||
|
update_data["price"] = parsed_price
|
||||||
|
update_data["currency"] = currency
|
||||||
|
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(product, key, value)
|
||||||
|
|
||||||
|
product.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(product)
|
||||||
|
|
||||||
|
logger.info(f"Updated product {product_id}")
|
||||||
|
return product
|
||||||
|
|
||||||
|
def delete_product(self, db: Session, product_id: str) -> bool:
|
||||||
|
"""Delete product and associated stock"""
|
||||||
|
product = db.query(Product).filter(Product.product_id == product_id).first()
|
||||||
|
if not product:
|
||||||
|
raise ValueError("Product not found")
|
||||||
|
|
||||||
|
# Delete associated stock entries if GTIN exists
|
||||||
|
if product.gtin:
|
||||||
|
db.query(Stock).filter(Stock.gtin == product.gtin).delete()
|
||||||
|
|
||||||
|
db.delete(product)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Deleted product {product_id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_stock_info(self, db: Session, gtin: str) -> Optional[StockSummaryResponse]:
|
||||||
|
"""Get stock information for a product by GTIN"""
|
||||||
|
stock_entries = db.query(Stock).filter(Stock.gtin == gtin).all()
|
||||||
|
if not stock_entries:
|
||||||
|
return None
|
||||||
|
|
||||||
|
total_quantity = sum(entry.quantity for entry in stock_entries)
|
||||||
|
locations = [
|
||||||
|
StockLocationResponse(location=entry.location, quantity=entry.quantity)
|
||||||
|
for entry in stock_entries
|
||||||
|
]
|
||||||
|
|
||||||
|
return StockSummaryResponse(
|
||||||
|
gtin=gtin,
|
||||||
|
total_quantity=total_quantity,
|
||||||
|
locations=locations
|
||||||
|
)
|
||||||
|
|
||||||
|
def generate_csv_export(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
marketplace: Optional[str] = None,
|
||||||
|
shop_name: Optional[str] = None
|
||||||
|
) -> Generator[str, None, None]:
|
||||||
|
"""Generate CSV export with streaming for memory efficiency"""
|
||||||
|
# CSV header
|
||||||
|
yield ("product_id,title,description,link,image_link,availability,price,currency,brand,"
|
||||||
|
"gtin,marketplace,shop_name\n")
|
||||||
|
|
||||||
|
batch_size = 1000
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
query = db.query(Product)
|
||||||
|
|
||||||
|
# Apply marketplace filters
|
||||||
|
if marketplace:
|
||||||
|
query = query.filter(Product.marketplace.ilike(f"%{marketplace}%"))
|
||||||
|
if shop_name:
|
||||||
|
query = query.filter(Product.shop_name.ilike(f"%{shop_name}%"))
|
||||||
|
|
||||||
|
products = query.offset(offset).limit(batch_size).all()
|
||||||
|
if not products:
|
||||||
|
break
|
||||||
|
|
||||||
|
for product in products:
|
||||||
|
# Create CSV row with marketplace fields
|
||||||
|
row = (f'"{product.product_id}","{product.title or ""}","{product.description or ""}",'
|
||||||
|
f'"{product.link or ""}","{product.image_link or ""}","{product.availability or ""}",'
|
||||||
|
f'"{product.price or ""}","{product.currency or ""}","{product.brand or ""}",'
|
||||||
|
f'"{product.gtin or ""}","{product.marketplace or ""}","{product.shop_name or ""}"\n')
|
||||||
|
yield row
|
||||||
|
|
||||||
|
offset += batch_size
|
||||||
|
|
||||||
|
def product_exists(self, db: Session, product_id: str) -> bool:
|
||||||
|
"""Check if product exists by ID"""
|
||||||
|
return db.query(Product).filter(Product.product_id == product_id).first() is not None
|
||||||
|
|
||||||
|
|
||||||
# Create service instance
|
# Create service instance
|
||||||
product_service = ProductService()
|
product_service = ProductService()
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from models.database_models import Stock, Product
|
||||||
|
from models.api_models import StockCreate, StockAdd, StockUpdate, StockLocationResponse, StockSummaryResponse
|
||||||
|
from utils.data_processing import GTINProcessor
|
||||||
|
from typing import Optional, List, Tuple
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class StockService:
|
||||||
|
def __init__(self):
|
||||||
|
self.gtin_processor = GTINProcessor()
|
||||||
|
|
||||||
|
def normalize_gtin(self, gtin_value) -> Optional[str]:
|
||||||
|
"""Normalize GTIN format using the GTINProcessor"""
|
||||||
|
return self.gtin_processor.normalize(gtin_value)
|
||||||
|
|
||||||
|
def set_stock(self, db: Session, stock_data: StockCreate) -> Stock:
|
||||||
|
"""Set exact stock quantity for a GTIN at a specific location (replaces existing quantity)"""
|
||||||
|
normalized_gtin = self.normalize_gtin(stock_data.gtin)
|
||||||
|
if not normalized_gtin:
|
||||||
|
raise ValueError("Invalid GTIN format")
|
||||||
|
|
||||||
|
location = stock_data.location.strip().upper()
|
||||||
|
|
||||||
|
# Check if stock entry already exists for this GTIN and location
|
||||||
|
existing_stock = db.query(Stock).filter(
|
||||||
|
Stock.gtin == normalized_gtin,
|
||||||
|
Stock.location == location
|
||||||
|
).first()
|
||||||
|
|
||||||
|
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.utcnow()
|
||||||
|
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
|
||||||
|
|
||||||
|
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)"""
|
||||||
|
normalized_gtin = self.normalize_gtin(stock_data.gtin)
|
||||||
|
if not normalized_gtin:
|
||||||
|
raise ValueError("Invalid GTIN format")
|
||||||
|
|
||||||
|
location = stock_data.location.strip().upper()
|
||||||
|
|
||||||
|
# Check if stock entry already exists for this GTIN and location
|
||||||
|
existing_stock = db.query(Stock).filter(
|
||||||
|
Stock.gtin == normalized_gtin,
|
||||||
|
Stock.location == location
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_stock:
|
||||||
|
# Add to existing stock
|
||||||
|
old_quantity = existing_stock.quantity
|
||||||
|
existing_stock.quantity += stock_data.quantity
|
||||||
|
existing_stock.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(existing_stock)
|
||||||
|
logger.info(
|
||||||
|
f"Added stock for GTIN {normalized_gtin} at {location}: {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
|
||||||
|
|
||||||
|
def remove_stock(self, db: Session, stock_data: StockAdd) -> Stock:
|
||||||
|
"""Remove quantity from existing stock for a GTIN at a specific location"""
|
||||||
|
normalized_gtin = self.normalize_gtin(stock_data.gtin)
|
||||||
|
if not normalized_gtin:
|
||||||
|
raise ValueError("Invalid GTIN format")
|
||||||
|
|
||||||
|
location = stock_data.location.strip().upper()
|
||||||
|
|
||||||
|
# Find existing stock entry
|
||||||
|
existing_stock = db.query(Stock).filter(
|
||||||
|
Stock.gtin == normalized_gtin,
|
||||||
|
Stock.location == location
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not existing_stock:
|
||||||
|
raise ValueError(f"No stock found for GTIN {normalized_gtin} at location {location}")
|
||||||
|
|
||||||
|
# Check if we have enough stock to remove
|
||||||
|
if existing_stock.quantity < stock_data.quantity:
|
||||||
|
raise ValueError(
|
||||||
|
f"Insufficient stock. Available: {existing_stock.quantity}, Requested to remove: {stock_data.quantity}")
|
||||||
|
|
||||||
|
# Remove from existing stock
|
||||||
|
old_quantity = existing_stock.quantity
|
||||||
|
existing_stock.quantity -= stock_data.quantity
|
||||||
|
existing_stock.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(existing_stock)
|
||||||
|
logger.info(
|
||||||
|
f"Removed stock for GTIN {normalized_gtin} at {location}: {old_quantity} - {stock_data.quantity} = {existing_stock.quantity}")
|
||||||
|
return existing_stock
|
||||||
|
|
||||||
|
def get_stock_by_gtin(self, db: Session, gtin: str) -> StockSummaryResponse:
|
||||||
|
"""Get all stock locations and total quantity for a specific GTIN"""
|
||||||
|
normalized_gtin = self.normalize_gtin(gtin)
|
||||||
|
if not normalized_gtin:
|
||||||
|
raise ValueError("Invalid GTIN format")
|
||||||
|
|
||||||
|
# Get all stock entries for this GTIN
|
||||||
|
stock_entries = db.query(Stock).filter(Stock.gtin == normalized_gtin).all()
|
||||||
|
|
||||||
|
if not stock_entries:
|
||||||
|
raise ValueError(f"No stock found for GTIN: {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(Product).filter(Product.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
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_total_stock(self, db: Session, gtin: str) -> dict:
|
||||||
|
"""Get total quantity in stock for a specific GTIN"""
|
||||||
|
normalized_gtin = self.normalize_gtin(gtin)
|
||||||
|
if not normalized_gtin:
|
||||||
|
raise ValueError("Invalid GTIN format")
|
||||||
|
|
||||||
|
# Calculate total stock
|
||||||
|
total_stock = db.query(Stock).filter(Stock.gtin == normalized_gtin).all()
|
||||||
|
total_quantity = sum(entry.quantity for entry in total_stock)
|
||||||
|
|
||||||
|
# Get product info for context
|
||||||
|
product = db.query(Product).filter(Product.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"""
|
||||||
|
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()
|
||||||
|
|
||||||
|
def update_stock(self, db: Session, stock_id: int, stock_update: StockUpdate) -> Stock:
|
||||||
|
"""Update stock quantity for a specific stock entry"""
|
||||||
|
stock_entry = db.query(Stock).filter(Stock.id == stock_id).first()
|
||||||
|
if not stock_entry:
|
||||||
|
raise ValueError("Stock entry not found")
|
||||||
|
|
||||||
|
stock_entry.quantity = stock_update.quantity
|
||||||
|
stock_entry.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(stock_entry)
|
||||||
|
|
||||||
|
logger.info(f"Updated stock entry {stock_id} to quantity {stock_update.quantity}")
|
||||||
|
return stock_entry
|
||||||
|
|
||||||
|
def delete_stock(self, db: Session, stock_id: int) -> bool:
|
||||||
|
"""Delete a stock entry"""
|
||||||
|
stock_entry = db.query(Stock).filter(Stock.id == stock_id).first()
|
||||||
|
if not stock_entry:
|
||||||
|
raise ValueError("Stock entry not found")
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
def get_stock_by_id(self, db: Session, stock_id: int) -> Optional[Stock]:
|
||||||
|
"""Get a stock entry by its ID"""
|
||||||
|
return db.query(Stock).filter(Stock.id == stock_id).first()
|
||||||
|
|
||||||
|
|
||||||
|
# Create service instance
|
||||||
|
stock_service = StockService()
|
||||||
|
|||||||
313
tests/test_marketplace_service.py
Normal file
313
tests/test_marketplace_service.py
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
# tests/test_marketplace_service.py
|
||||||
|
import pytest
|
||||||
|
from app.services.marketplace_service import MarketplaceService
|
||||||
|
from models.api_models import MarketplaceImportRequest
|
||||||
|
from models.database_models import MarketplaceImportJob, Shop, User
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TestMarketplaceService:
|
||||||
|
def setup_method(self):
|
||||||
|
self.service = MarketplaceService()
|
||||||
|
|
||||||
|
def test_validate_shop_access_success(self, db, test_shop, test_user):
|
||||||
|
"""Test successful shop access validation"""
|
||||||
|
# Set the shop owner to the test user
|
||||||
|
test_shop.owner_id = test_user.id
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
result = self.service.validate_shop_access(db, test_shop.shop_code, test_user)
|
||||||
|
|
||||||
|
assert result.shop_code == test_shop.shop_code
|
||||||
|
assert result.owner_id == test_user.id
|
||||||
|
|
||||||
|
def test_validate_shop_access_admin_can_access_any_shop(self, db, test_shop, admin_user):
|
||||||
|
"""Test that admin users can access any shop"""
|
||||||
|
result = self.service.validate_shop_access(db, test_shop.shop_code, admin_user)
|
||||||
|
|
||||||
|
assert result.shop_code == test_shop.shop_code
|
||||||
|
|
||||||
|
def test_validate_shop_access_shop_not_found(self, db, test_user):
|
||||||
|
"""Test shop access validation when shop doesn't exist"""
|
||||||
|
with pytest.raises(ValueError, match="Shop not found"):
|
||||||
|
self.service.validate_shop_access(db, "NONEXISTENT", test_user)
|
||||||
|
|
||||||
|
def test_validate_shop_access_permission_denied(self, db, test_shop, test_user, other_user):
|
||||||
|
"""Test shop access validation when user doesn't own the shop"""
|
||||||
|
# Set the shop owner to a different user
|
||||||
|
test_shop.owner_id = other_user.id
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
with pytest.raises(PermissionError, match="Access denied to this shop"):
|
||||||
|
self.service.validate_shop_access(db, test_shop.shop_code, test_user)
|
||||||
|
|
||||||
|
def test_create_import_job_success(self, db, test_shop, test_user):
|
||||||
|
"""Test successful creation of import job"""
|
||||||
|
# Set the shop owner to the test user
|
||||||
|
test_shop.owner_id = test_user.id
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
request = MarketplaceImportRequest(
|
||||||
|
url="https://example.com/products.csv",
|
||||||
|
marketplace="Amazon",
|
||||||
|
shop_code=test_shop.shop_code,
|
||||||
|
batch_size=1000
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self.service.create_import_job(db, request, test_user)
|
||||||
|
|
||||||
|
assert result.marketplace == "Amazon"
|
||||||
|
assert result.shop_code == test_shop.shop_code
|
||||||
|
assert result.user_id == test_user.id
|
||||||
|
assert result.status == "pending"
|
||||||
|
assert result.source_url == "https://example.com/products.csv"
|
||||||
|
|
||||||
|
def test_create_import_job_invalid_shop(self, db, test_user):
|
||||||
|
"""Test import job creation with invalid shop"""
|
||||||
|
request = MarketplaceImportRequest(
|
||||||
|
url="https://example.com/products.csv",
|
||||||
|
marketplace="Amazon",
|
||||||
|
shop_code="INVALID_SHOP",
|
||||||
|
batch_size=1000
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Shop not found"):
|
||||||
|
self.service.create_import_job(db, request, test_user)
|
||||||
|
|
||||||
|
def test_get_import_job_by_id_success(self, db, test_import_job, test_user):
|
||||||
|
"""Test getting import job by ID for job owner"""
|
||||||
|
result = self.service.get_import_job_by_id(db, test_import_job.id, test_user)
|
||||||
|
|
||||||
|
assert result.id == test_import_job.id
|
||||||
|
assert result.user_id == test_user.id
|
||||||
|
|
||||||
|
def test_get_import_job_by_id_admin_access(self, db, test_import_job, admin_user):
|
||||||
|
"""Test that admin can access any import job"""
|
||||||
|
result = self.service.get_import_job_by_id(db, test_import_job.id, admin_user)
|
||||||
|
|
||||||
|
assert result.id == test_import_job.id
|
||||||
|
|
||||||
|
def test_get_import_job_by_id_not_found(self, db, test_user):
|
||||||
|
"""Test getting non-existent import job"""
|
||||||
|
with pytest.raises(ValueError, match="Marketplace import job not found"):
|
||||||
|
self.service.get_import_job_by_id(db, 99999, test_user)
|
||||||
|
|
||||||
|
def test_get_import_job_by_id_access_denied(self, db, test_import_job, other_user):
|
||||||
|
"""Test access denied when user doesn't own the job"""
|
||||||
|
with pytest.raises(PermissionError, match="Access denied to this import job"):
|
||||||
|
self.service.get_import_job_by_id(db, test_import_job.id, other_user)
|
||||||
|
|
||||||
|
def test_get_import_jobs_user_filter(self, db, test_import_job, test_user):
|
||||||
|
"""Test getting import jobs filtered by user"""
|
||||||
|
jobs = self.service.get_import_jobs(db, test_user)
|
||||||
|
|
||||||
|
assert len(jobs) == 1
|
||||||
|
assert jobs[0].id == test_import_job.id
|
||||||
|
assert jobs[0].user_id == test_user.id
|
||||||
|
|
||||||
|
def test_get_import_jobs_admin_sees_all(self, db, test_import_job, admin_user):
|
||||||
|
"""Test that admin sees all import jobs"""
|
||||||
|
jobs = self.service.get_import_jobs(db, admin_user)
|
||||||
|
|
||||||
|
assert len(jobs) >= 1
|
||||||
|
assert any(job.id == test_import_job.id for job in jobs)
|
||||||
|
|
||||||
|
def test_get_import_jobs_with_marketplace_filter(self, db, test_import_job, test_user):
|
||||||
|
"""Test getting import jobs with marketplace filter"""
|
||||||
|
jobs = self.service.get_import_jobs(
|
||||||
|
db, test_user, marketplace=test_import_job.marketplace
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(jobs) == 1
|
||||||
|
assert jobs[0].marketplace == test_import_job.marketplace
|
||||||
|
|
||||||
|
def test_get_import_jobs_with_pagination(self, db, test_user):
|
||||||
|
"""Test getting import jobs with pagination"""
|
||||||
|
# Create multiple import jobs
|
||||||
|
for i in range(5):
|
||||||
|
job = MarketplaceImportJob(
|
||||||
|
status="completed",
|
||||||
|
marketplace=f"Marketplace_{i}",
|
||||||
|
shop_code="TEST_SHOP",
|
||||||
|
user_id=test_user.id,
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
jobs = self.service.get_import_jobs(db, test_user, skip=2, limit=2)
|
||||||
|
|
||||||
|
assert len(jobs) == 2
|
||||||
|
|
||||||
|
def test_update_job_status_success(self, db, test_import_job):
|
||||||
|
"""Test updating job status"""
|
||||||
|
result = self.service.update_job_status(
|
||||||
|
db,
|
||||||
|
test_import_job.id,
|
||||||
|
"completed",
|
||||||
|
imported_count=100,
|
||||||
|
total_processed=100
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.status == "completed"
|
||||||
|
assert result.imported_count == 100
|
||||||
|
assert result.total_processed == 100
|
||||||
|
|
||||||
|
def test_update_job_status_not_found(self, db):
|
||||||
|
"""Test updating non-existent job status"""
|
||||||
|
with pytest.raises(ValueError, match="Marketplace import job not found"):
|
||||||
|
self.service.update_job_status(db, 99999, "completed")
|
||||||
|
|
||||||
|
def test_get_job_stats_user(self, db, test_import_job, test_user):
|
||||||
|
"""Test getting job statistics for user"""
|
||||||
|
stats = self.service.get_job_stats(db, test_user)
|
||||||
|
|
||||||
|
assert stats["total_jobs"] >= 1
|
||||||
|
assert "pending_jobs" in stats
|
||||||
|
assert "running_jobs" in stats
|
||||||
|
assert "completed_jobs" in stats
|
||||||
|
assert "failed_jobs" in stats
|
||||||
|
|
||||||
|
def test_get_job_stats_admin(self, db, test_import_job, admin_user):
|
||||||
|
"""Test getting job statistics for admin"""
|
||||||
|
stats = self.service.get_job_stats(db, admin_user)
|
||||||
|
|
||||||
|
assert stats["total_jobs"] >= 1
|
||||||
|
|
||||||
|
def test_convert_to_response_model(self, test_import_job):
|
||||||
|
"""Test converting database model to response model"""
|
||||||
|
response = self.service.convert_to_response_model(test_import_job)
|
||||||
|
|
||||||
|
assert response.job_id == test_import_job.id
|
||||||
|
assert response.status == test_import_job.status
|
||||||
|
assert response.marketplace == test_import_job.marketplace
|
||||||
|
assert response.imported == (test_import_job.imported_count or 0)
|
||||||
|
|
||||||
|
def test_cancel_import_job_success(self, db, test_user):
|
||||||
|
"""Test cancelling a pending import job"""
|
||||||
|
# Create a pending job
|
||||||
|
job = MarketplaceImportJob(
|
||||||
|
status="pending",
|
||||||
|
marketplace="Amazon",
|
||||||
|
shop_code="TEST_SHOP",
|
||||||
|
user_id=test_user.id,
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(job)
|
||||||
|
|
||||||
|
result = self.service.cancel_import_job(db, job.id, test_user)
|
||||||
|
|
||||||
|
assert result.status == "cancelled"
|
||||||
|
assert result.completed_at is not None
|
||||||
|
|
||||||
|
def test_cancel_import_job_invalid_status(self, db, test_import_job, test_user):
|
||||||
|
"""Test cancelling a job that can't be cancelled"""
|
||||||
|
# Set job status to completed
|
||||||
|
test_import_job.status = "completed"
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Cannot cancel job with status: completed"):
|
||||||
|
self.service.cancel_import_job(db, test_import_job.id, test_user)
|
||||||
|
|
||||||
|
def test_delete_import_job_success(self, db, test_user):
|
||||||
|
"""Test deleting a completed import job"""
|
||||||
|
# Create a completed job
|
||||||
|
job = MarketplaceImportJob(
|
||||||
|
status="completed",
|
||||||
|
marketplace="Amazon",
|
||||||
|
shop_code="TEST_SHOP",
|
||||||
|
user_id=test_user.id,
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(job)
|
||||||
|
job_id = job.id
|
||||||
|
|
||||||
|
result = self.service.delete_import_job(db, job_id, test_user)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
# Verify the job is actually deleted
|
||||||
|
deleted_job = db.query(MarketplaceImportJob).filter(MarketplaceImportJob.id == job_id).first()
|
||||||
|
assert deleted_job is None
|
||||||
|
|
||||||
|
def test_delete_import_job_invalid_status(self, db, test_user):
|
||||||
|
"""Test deleting a job that can't be deleted"""
|
||||||
|
# Create a pending job
|
||||||
|
job = MarketplaceImportJob(
|
||||||
|
status="pending",
|
||||||
|
marketplace="Amazon",
|
||||||
|
shop_code="TEST_SHOP",
|
||||||
|
user_id=test_user.id,
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(job)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Cannot delete job with status: pending"):
|
||||||
|
self.service.delete_import_job(db, job.id, test_user)
|
||||||
|
|
||||||
|
|
||||||
|
# Additional fixtures for marketplace tests
|
||||||
|
@pytest.fixture
|
||||||
|
def test_shop(db):
|
||||||
|
"""Create a test shop"""
|
||||||
|
shop = Shop(
|
||||||
|
shop_code="TEST_SHOP",
|
||||||
|
shop_name="Test Shop",
|
||||||
|
owner_id=1 # Will be updated in tests
|
||||||
|
)
|
||||||
|
db.add(shop)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(shop)
|
||||||
|
return shop
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_user(db):
|
||||||
|
"""Create a test admin user"""
|
||||||
|
user = User(
|
||||||
|
username="admin_user",
|
||||||
|
email="admin@test.com",
|
||||||
|
role="admin",
|
||||||
|
hashed_password="hashed_password"
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def other_user(db):
|
||||||
|
"""Create another test user"""
|
||||||
|
user = User(
|
||||||
|
username="other_user",
|
||||||
|
email="other@test.com",
|
||||||
|
role="user",
|
||||||
|
hashed_password="hashed_password"
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_import_job(db, test_user):
|
||||||
|
"""Create a test import job"""
|
||||||
|
job = MarketplaceImportJob(
|
||||||
|
status="pending",
|
||||||
|
marketplace="Amazon",
|
||||||
|
shop_code="TEST_SHOP",
|
||||||
|
user_id=test_user.id,
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(job)
|
||||||
|
return job
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# tests/test_services.py
|
# tests/test_product_service.py
|
||||||
import pytest
|
import pytest
|
||||||
from app.services.product_service import ProductService
|
from app.services.product_service import ProductService
|
||||||
from models.api_models import ProductCreate
|
from models.api_models import ProductCreate
|
||||||
322
tests/test_stock_service.py
Normal file
322
tests/test_stock_service.py
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
# tests/test_stock_service.py
|
||||||
|
import pytest
|
||||||
|
from app.services.stock_service import StockService
|
||||||
|
from models.api_models import StockCreate, StockAdd, StockUpdate
|
||||||
|
from models.database_models import Stock, Product
|
||||||
|
|
||||||
|
|
||||||
|
class TestStockService:
|
||||||
|
def setup_method(self):
|
||||||
|
self.service = StockService()
|
||||||
|
|
||||||
|
def test_normalize_gtin_valid(self):
|
||||||
|
"""Test GTIN normalization with valid GTINs"""
|
||||||
|
# Test various valid GTIN formats
|
||||||
|
assert self.service.normalize_gtin("1234567890123") == "1234567890123"
|
||||||
|
assert self.service.normalize_gtin("123456789012") == "123456789012"
|
||||||
|
assert self.service.normalize_gtin("12345678") == "12345678"
|
||||||
|
|
||||||
|
def test_normalize_gtin_invalid(self):
|
||||||
|
"""Test GTIN normalization with invalid GTINs"""
|
||||||
|
assert self.service.normalize_gtin("invalid") is None
|
||||||
|
assert self.service.normalize_gtin("123") is None
|
||||||
|
assert self.service.normalize_gtin("") is None
|
||||||
|
assert self.service.normalize_gtin(None) is None
|
||||||
|
|
||||||
|
def test_set_stock_new_entry(self, db):
|
||||||
|
"""Test setting stock for a new GTIN/location combination"""
|
||||||
|
stock_data = StockCreate(
|
||||||
|
gtin="1234567890123",
|
||||||
|
location="WAREHOUSE_A",
|
||||||
|
quantity=100
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self.service.set_stock(db, stock_data)
|
||||||
|
|
||||||
|
assert result.gtin == "1234567890123"
|
||||||
|
assert result.location == "WAREHOUSE_A"
|
||||||
|
assert result.quantity == 100
|
||||||
|
|
||||||
|
def test_set_stock_existing_entry(self, db, test_stock):
|
||||||
|
"""Test setting stock for an existing GTIN/location combination"""
|
||||||
|
stock_data = StockCreate(
|
||||||
|
gtin=test_stock.gtin,
|
||||||
|
location=test_stock.location,
|
||||||
|
quantity=200
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self.service.set_stock(db, stock_data)
|
||||||
|
|
||||||
|
assert result.gtin == test_stock.gtin
|
||||||
|
assert result.location == test_stock.location
|
||||||
|
assert result.quantity == 200 # Should replace the original quantity
|
||||||
|
|
||||||
|
def test_set_stock_invalid_gtin(self, db):
|
||||||
|
"""Test setting stock with invalid GTIN"""
|
||||||
|
stock_data = StockCreate(
|
||||||
|
gtin="invalid_gtin",
|
||||||
|
location="WAREHOUSE_A",
|
||||||
|
quantity=100
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Invalid GTIN format"):
|
||||||
|
self.service.set_stock(db, stock_data)
|
||||||
|
|
||||||
|
def test_add_stock_new_entry(self, db):
|
||||||
|
"""Test adding stock for a new GTIN/location combination"""
|
||||||
|
stock_data = StockAdd(
|
||||||
|
gtin="1234567890123",
|
||||||
|
location="WAREHOUSE_B",
|
||||||
|
quantity=50
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self.service.add_stock(db, stock_data)
|
||||||
|
|
||||||
|
assert result.gtin == "1234567890123"
|
||||||
|
assert result.location == "WAREHOUSE_B"
|
||||||
|
assert result.quantity == 50
|
||||||
|
|
||||||
|
def test_add_stock_existing_entry(self, db, test_stock):
|
||||||
|
"""Test adding stock to an existing GTIN/location combination"""
|
||||||
|
original_quantity = test_stock.quantity
|
||||||
|
stock_data = StockAdd(
|
||||||
|
gtin=test_stock.gtin,
|
||||||
|
location=test_stock.location,
|
||||||
|
quantity=25
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self.service.add_stock(db, stock_data)
|
||||||
|
|
||||||
|
assert result.gtin == test_stock.gtin
|
||||||
|
assert result.location == test_stock.location
|
||||||
|
assert result.quantity == original_quantity + 25
|
||||||
|
|
||||||
|
def test_add_stock_invalid_gtin(self, db):
|
||||||
|
"""Test adding stock with invalid GTIN"""
|
||||||
|
stock_data = StockAdd(
|
||||||
|
gtin="invalid_gtin",
|
||||||
|
location="WAREHOUSE_A",
|
||||||
|
quantity=50
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Invalid GTIN format"):
|
||||||
|
self.service.add_stock(db, stock_data)
|
||||||
|
|
||||||
|
def test_remove_stock_success(self, db, test_stock):
|
||||||
|
"""Test removing stock successfully"""
|
||||||
|
original_quantity = test_stock.quantity
|
||||||
|
remove_quantity = 10
|
||||||
|
|
||||||
|
stock_data = StockAdd(
|
||||||
|
gtin=test_stock.gtin,
|
||||||
|
location=test_stock.location,
|
||||||
|
quantity=remove_quantity
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self.service.remove_stock(db, stock_data)
|
||||||
|
|
||||||
|
assert result.gtin == test_stock.gtin
|
||||||
|
assert result.location == test_stock.location
|
||||||
|
assert result.quantity == original_quantity - remove_quantity
|
||||||
|
|
||||||
|
def test_remove_stock_insufficient_stock(self, db, test_stock):
|
||||||
|
"""Test removing more stock than available"""
|
||||||
|
stock_data = StockAdd(
|
||||||
|
gtin=test_stock.gtin,
|
||||||
|
location=test_stock.location,
|
||||||
|
quantity=test_stock.quantity + 10 # More than available
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Insufficient stock"):
|
||||||
|
self.service.remove_stock(db, stock_data)
|
||||||
|
|
||||||
|
def test_remove_stock_nonexistent_entry(self, db):
|
||||||
|
"""Test removing stock from non-existent GTIN/location"""
|
||||||
|
stock_data = StockAdd(
|
||||||
|
gtin="9999999999999",
|
||||||
|
location="NONEXISTENT",
|
||||||
|
quantity=10
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="No stock found"):
|
||||||
|
self.service.remove_stock(db, stock_data)
|
||||||
|
|
||||||
|
def test_remove_stock_invalid_gtin(self, db):
|
||||||
|
"""Test removing stock with invalid GTIN"""
|
||||||
|
stock_data = StockAdd(
|
||||||
|
gtin="invalid_gtin",
|
||||||
|
location="WAREHOUSE_A",
|
||||||
|
quantity=10
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Invalid GTIN format"):
|
||||||
|
self.service.remove_stock(db, stock_data)
|
||||||
|
|
||||||
|
def test_get_stock_by_gtin_success(self, db, test_stock, test_product):
|
||||||
|
"""Test getting stock summary by GTIN"""
|
||||||
|
result = self.service.get_stock_by_gtin(db, test_stock.gtin)
|
||||||
|
|
||||||
|
assert result.gtin == test_stock.gtin
|
||||||
|
assert result.total_quantity == test_stock.quantity
|
||||||
|
assert len(result.locations) == 1
|
||||||
|
assert result.locations[0].location == test_stock.location
|
||||||
|
assert result.locations[0].quantity == test_stock.quantity
|
||||||
|
assert result.product_title == test_product.title
|
||||||
|
|
||||||
|
def test_get_stock_by_gtin_multiple_locations(self, db):
|
||||||
|
"""Test getting stock summary with multiple locations"""
|
||||||
|
gtin = "1234567890123"
|
||||||
|
|
||||||
|
# Create multiple stock entries for the same GTIN
|
||||||
|
stock1 = Stock(gtin=gtin, location="WAREHOUSE_A", quantity=50)
|
||||||
|
stock2 = Stock(gtin=gtin, location="WAREHOUSE_B", quantity=30)
|
||||||
|
|
||||||
|
db.add(stock1)
|
||||||
|
db.add(stock2)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
result = self.service.get_stock_by_gtin(db, gtin)
|
||||||
|
|
||||||
|
assert result.gtin == gtin
|
||||||
|
assert result.total_quantity == 80
|
||||||
|
assert len(result.locations) == 2
|
||||||
|
|
||||||
|
def test_get_stock_by_gtin_not_found(self, db):
|
||||||
|
"""Test getting stock for non-existent GTIN"""
|
||||||
|
with pytest.raises(ValueError, match="No stock found"):
|
||||||
|
self.service.get_stock_by_gtin(db, "9999999999999")
|
||||||
|
|
||||||
|
def test_get_stock_by_gtin_invalid_gtin(self, db):
|
||||||
|
"""Test getting stock with invalid GTIN"""
|
||||||
|
with pytest.raises(ValueError, match="Invalid GTIN format"):
|
||||||
|
self.service.get_stock_by_gtin(db, "invalid_gtin")
|
||||||
|
|
||||||
|
def test_get_total_stock_success(self, db, test_stock, test_product):
|
||||||
|
"""Test getting total stock for a GTIN"""
|
||||||
|
result = self.service.get_total_stock(db, test_stock.gtin)
|
||||||
|
|
||||||
|
assert result["gtin"] == test_stock.gtin
|
||||||
|
assert result["total_quantity"] == test_stock.quantity
|
||||||
|
assert result["product_title"] == test_product.title
|
||||||
|
assert result["locations_count"] == 1
|
||||||
|
|
||||||
|
def test_get_total_stock_invalid_gtin(self, db):
|
||||||
|
"""Test getting total stock with invalid GTIN"""
|
||||||
|
with pytest.raises(ValueError, match="Invalid GTIN format"):
|
||||||
|
self.service.get_total_stock(db, "invalid_gtin")
|
||||||
|
|
||||||
|
def test_get_all_stock_no_filters(self, db, test_stock):
|
||||||
|
"""Test getting all stock without filters"""
|
||||||
|
result = self.service.get_all_stock(db)
|
||||||
|
|
||||||
|
assert len(result) >= 1
|
||||||
|
assert any(stock.gtin == test_stock.gtin for stock in result)
|
||||||
|
|
||||||
|
def test_get_all_stock_with_location_filter(self, db, test_stock):
|
||||||
|
"""Test getting all stock with location filter"""
|
||||||
|
result = self.service.get_all_stock(db, location=test_stock.location)
|
||||||
|
|
||||||
|
assert len(result) >= 1
|
||||||
|
assert all(stock.location.upper() == test_stock.location.upper() for stock in result)
|
||||||
|
|
||||||
|
def test_get_all_stock_with_gtin_filter(self, db, test_stock):
|
||||||
|
"""Test getting all stock with GTIN filter"""
|
||||||
|
result = self.service.get_all_stock(db, gtin=test_stock.gtin)
|
||||||
|
|
||||||
|
assert len(result) >= 1
|
||||||
|
assert all(stock.gtin == test_stock.gtin for stock in result)
|
||||||
|
|
||||||
|
def test_get_all_stock_with_pagination(self, db):
|
||||||
|
"""Test getting all stock with pagination"""
|
||||||
|
# Create multiple stock entries
|
||||||
|
for i in range(5):
|
||||||
|
stock = Stock(
|
||||||
|
gtin=f"123456789012{i}",
|
||||||
|
location=f"WAREHOUSE_{i}",
|
||||||
|
quantity=10
|
||||||
|
)
|
||||||
|
db.add(stock)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
result = self.service.get_all_stock(db, skip=2, limit=2)
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
|
|
||||||
|
def test_update_stock_success(self, db, test_stock):
|
||||||
|
"""Test updating stock quantity"""
|
||||||
|
stock_update = StockUpdate(quantity=150)
|
||||||
|
|
||||||
|
result = self.service.update_stock(db, test_stock.id, stock_update)
|
||||||
|
|
||||||
|
assert result.id == test_stock.id
|
||||||
|
assert result.quantity == 150
|
||||||
|
|
||||||
|
def test_update_stock_not_found(self, db):
|
||||||
|
"""Test updating non-existent stock entry"""
|
||||||
|
stock_update = StockUpdate(quantity=150)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Stock entry not found"):
|
||||||
|
self.service.update_stock(db, 99999, stock_update)
|
||||||
|
|
||||||
|
def test_delete_stock_success(self, db, test_stock):
|
||||||
|
"""Test deleting stock entry"""
|
||||||
|
stock_id = test_stock.id
|
||||||
|
|
||||||
|
result = self.service.delete_stock(db, stock_id)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
# Verify the stock is actually deleted
|
||||||
|
deleted_stock = db.query(Stock).filter(Stock.id == stock_id).first()
|
||||||
|
assert deleted_stock is None
|
||||||
|
|
||||||
|
def test_delete_stock_not_found(self, db):
|
||||||
|
"""Test deleting non-existent stock entry"""
|
||||||
|
with pytest.raises(ValueError, match="Stock entry not found"):
|
||||||
|
self.service.delete_stock(db, 99999)
|
||||||
|
|
||||||
|
def test_get_stock_by_id_success(self, db, test_stock):
|
||||||
|
"""Test getting stock entry by ID"""
|
||||||
|
result = self.service.get_stock_by_id(db, test_stock.id)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.id == test_stock.id
|
||||||
|
assert result.gtin == test_stock.gtin
|
||||||
|
|
||||||
|
def test_get_stock_by_id_not_found(self, db):
|
||||||
|
"""Test getting non-existent stock entry by ID"""
|
||||||
|
result = self.service.get_stock_by_id(db, 99999)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
# Additional fixtures that might be needed for stock tests
|
||||||
|
@pytest.fixture
|
||||||
|
def test_stock(db):
|
||||||
|
"""Create a test stock entry"""
|
||||||
|
stock = Stock(
|
||||||
|
gtin="1234567890123",
|
||||||
|
location="WAREHOUSE_MAIN",
|
||||||
|
quantity=50
|
||||||
|
)
|
||||||
|
db.add(stock)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(stock)
|
||||||
|
return stock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_product_with_stock(db, test_stock):
|
||||||
|
"""Create a test product that corresponds to the test stock"""
|
||||||
|
product = Product(
|
||||||
|
product_id="STOCK_TEST_001",
|
||||||
|
title="Stock Test Product",
|
||||||
|
gtin=test_stock.gtin,
|
||||||
|
price="29.99",
|
||||||
|
brand="TestBrand",
|
||||||
|
marketplace="Letzshop"
|
||||||
|
)
|
||||||
|
db.add(product)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(product)
|
||||||
|
return product
|
||||||
Reference in New Issue
Block a user