REFACTOR data service

This commit is contained in:
Urtzi Alfaro
2025-08-12 18:17:30 +02:00
parent 7c237c0acc
commit fbe7470ad9
149 changed files with 8528 additions and 7393 deletions

View File

@@ -0,0 +1 @@
# services/sales/app/api/__init__.py

View File

@@ -0,0 +1,397 @@
# services/sales/app/api/import_data.py
"""
Sales Data Import API Endpoints
"""
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Path
from typing import Dict, Any, Optional
from uuid import UUID
import structlog
import json
from app.services.data_import_service import DataImportService
from shared.auth.decorators import get_current_user_dep, get_current_tenant_id_dep
router = APIRouter(tags=["data-import"])
logger = structlog.get_logger()
def get_import_service():
"""Dependency injection for DataImportService"""
return DataImportService()
@router.post("/tenants/{tenant_id}/sales/import/validate-json")
async def validate_json_data(
tenant_id: UUID = Path(..., description="Tenant ID"),
data: Dict[str, Any] = None,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
current_tenant: str = Depends(get_current_tenant_id_dep),
import_service: DataImportService = Depends(get_import_service)
):
"""Validate JSON sales data"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
if not data:
raise HTTPException(status_code=400, detail="No data provided")
logger.info("Validating JSON data", tenant_id=tenant_id, record_count=len(data.get("records", [])))
# Validate the data - handle different input formats
if "records" in data:
# New format with records array
validation_data = {
"tenant_id": str(tenant_id),
"data": json.dumps(data.get("records", [])),
"data_format": "json"
}
else:
# Legacy format where the entire payload is the validation data
validation_data = data.copy()
validation_data["tenant_id"] = str(tenant_id)
if "data_format" not in validation_data:
validation_data["data_format"] = "json"
validation_result = await import_service.validate_import_data(validation_data)
logger.info("JSON validation completed", tenant_id=tenant_id, valid=validation_result.is_valid)
return {
"is_valid": validation_result.is_valid,
"total_records": validation_result.total_records,
"valid_records": validation_result.valid_records,
"invalid_records": validation_result.invalid_records,
"errors": validation_result.errors,
"warnings": validation_result.warnings,
"summary": validation_result.summary
}
except Exception as e:
logger.error("Failed to validate JSON data", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to validate data: {str(e)}")
@router.post("/tenants/{tenant_id}/sales/import/validate")
async def validate_sales_data_universal(
tenant_id: UUID = Path(..., description="Tenant ID"),
file: Optional[UploadFile] = File(None),
data: Optional[Dict[str, Any]] = None,
file_format: Optional[str] = Form(None),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
current_tenant: str = Depends(get_current_tenant_id_dep),
import_service: DataImportService = Depends(get_import_service)
):
"""Universal validation endpoint for sales data - supports files and JSON"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
# Handle file upload validation
if file:
logger.info("Validating uploaded file", tenant_id=tenant_id, filename=file.filename)
# Auto-detect format from filename
filename = file.filename.lower()
if filename.endswith('.csv'):
detected_format = 'csv'
elif filename.endswith('.xlsx') or filename.endswith('.xls'):
detected_format = 'excel'
elif filename.endswith('.json'):
detected_format = 'json'
else:
detected_format = file_format or 'csv' # Default to CSV
# Read file content
content = await file.read()
if detected_format in ['xlsx', 'xls', 'excel']:
# For Excel files, encode as base64
import base64
file_content = base64.b64encode(content).decode('utf-8')
else:
# For CSV/JSON, decode as text
file_content = content.decode('utf-8')
validation_data = {
"tenant_id": str(tenant_id),
"data": file_content,
"data_format": detected_format,
"filename": file.filename
}
# Handle JSON data validation
elif data:
logger.info("Validating JSON data", tenant_id=tenant_id)
validation_data = data.copy()
validation_data["tenant_id"] = str(tenant_id)
if "data_format" not in validation_data:
validation_data["data_format"] = "json"
else:
raise HTTPException(status_code=400, detail="No file or data provided for validation")
# Perform validation
validation_result = await import_service.validate_import_data(validation_data)
logger.info("Validation completed",
tenant_id=tenant_id,
valid=validation_result.is_valid,
total_records=validation_result.total_records)
return {
"is_valid": validation_result.is_valid,
"total_records": validation_result.total_records,
"valid_records": validation_result.valid_records,
"invalid_records": validation_result.invalid_records,
"errors": validation_result.errors,
"warnings": validation_result.warnings,
"summary": validation_result.summary,
"message": "Validation completed successfully" if validation_result.is_valid else "Validation found errors",
"details": {
"total_records": validation_result.total_records,
"format": validation_data.get("data_format", "unknown")
}
}
except Exception as e:
logger.error("Failed to validate sales data", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to validate data: {str(e)}")
@router.post("/tenants/{tenant_id}/sales/import/validate-csv")
async def validate_csv_data_legacy(
tenant_id: UUID = Path(..., description="Tenant ID"),
file: UploadFile = File(...),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
current_tenant: str = Depends(get_current_tenant_id_dep),
import_service: DataImportService = Depends(get_import_service)
):
"""Legacy CSV validation endpoint - redirects to universal validator"""
return await validate_sales_data_universal(
tenant_id=tenant_id,
file=file,
current_user=current_user,
current_tenant=current_tenant,
import_service=import_service
)
@router.post("/tenants/{tenant_id}/sales/import")
async def import_sales_data(
tenant_id: UUID = Path(..., description="Tenant ID"),
data: Optional[Dict[str, Any]] = None,
file: Optional[UploadFile] = File(None),
file_format: Optional[str] = Form(None),
update_existing: bool = Form(False, description="Whether to update existing records"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
current_tenant: str = Depends(get_current_tenant_id_dep),
import_service: DataImportService = Depends(get_import_service)
):
"""Enhanced import sales data - supports multiple file formats and JSON"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
# Handle file upload (form data)
if file:
if not file.filename:
raise HTTPException(status_code=400, detail="No file provided")
logger.info("Starting enhanced file import", tenant_id=tenant_id, filename=file.filename)
# Auto-detect format from filename
filename = file.filename.lower()
if filename.endswith('.csv'):
detected_format = 'csv'
elif filename.endswith('.xlsx') or filename.endswith('.xls'):
detected_format = 'excel'
elif filename.endswith('.json'):
detected_format = 'json'
else:
detected_format = file_format or 'csv' # Default to CSV
# Read file content
content = await file.read()
if detected_format in ['xlsx', 'xls', 'excel']:
# For Excel files, encode as base64
import base64
file_content = base64.b64encode(content).decode('utf-8')
else:
# For CSV/JSON, decode as text
file_content = content.decode('utf-8')
# Import the file using enhanced service
import_result = await import_service.process_import(
str(tenant_id), # Ensure string type
file_content,
detected_format,
filename=file.filename
)
# Handle JSON data
elif data:
logger.info("Starting enhanced JSON data import", tenant_id=tenant_id, record_count=len(data.get("records", [])))
# Import the data - handle different input formats
if "records" in data:
# New format with records array
records_json = json.dumps(data.get("records", []))
import_result = await import_service.process_import(
str(tenant_id),
records_json,
"json"
)
else:
# Legacy format - data field contains the data directly
import_result = await import_service.process_import(
str(tenant_id),
data.get("data", ""),
data.get("data_format", "json")
)
else:
raise HTTPException(status_code=400, detail="No data or file provided")
logger.info("Enhanced import completed",
tenant_id=tenant_id,
created=import_result.records_created,
updated=import_result.records_updated,
failed=import_result.records_failed,
processing_time=import_result.processing_time_seconds)
# Return enhanced response matching frontend expectations
response = {
"success": import_result.success,
"records_processed": import_result.records_processed,
"records_created": import_result.records_created,
"records_updated": import_result.records_updated,
"records_failed": import_result.records_failed,
"errors": import_result.errors,
"warnings": import_result.warnings,
"processing_time_seconds": import_result.processing_time_seconds,
"records_imported": import_result.records_created, # Frontend compatibility
"message": f"Successfully imported {import_result.records_created} records" if import_result.success else "Import completed with errors"
}
# Add file-specific information if available
if file:
response["file_info"] = {
"name": file.filename,
"format": detected_format,
"size_bytes": len(content) if 'content' in locals() else 0
}
return response
except Exception as e:
logger.error("Failed to import sales data", error=str(e), tenant_id=tenant_id, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to import data: {str(e)}")
@router.post("/tenants/{tenant_id}/sales/import/csv")
async def import_csv_data(
tenant_id: UUID = Path(..., description="Tenant ID"),
file: UploadFile = File(...),
update_existing: bool = Form(False, description="Whether to update existing records"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
current_tenant: str = Depends(get_current_tenant_id_dep),
import_service: DataImportService = Depends(get_import_service)
):
"""Import CSV sales data file"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
if not file.filename.endswith('.csv'):
raise HTTPException(status_code=400, detail="File must be a CSV file")
logger.info("Starting CSV data import", tenant_id=tenant_id, filename=file.filename)
# Read file content
content = await file.read()
file_content = content.decode('utf-8')
# Import the data
import_result = await import_service.process_import(
tenant_id,
file_content,
"csv",
filename=file.filename
)
logger.info("CSV import completed",
tenant_id=tenant_id,
filename=file.filename,
created=import_result.records_created,
updated=import_result.records_updated,
failed=import_result.records_failed)
return {
"success": import_result.success,
"records_processed": import_result.records_processed,
"records_created": import_result.records_created,
"records_updated": import_result.records_updated,
"records_failed": import_result.records_failed,
"errors": import_result.errors,
"warnings": import_result.warnings,
"processing_time_seconds": import_result.processing_time_seconds
}
except Exception as e:
logger.error("Failed to import CSV data", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to import CSV data: {str(e)}")
@router.get("/tenants/{tenant_id}/sales/import/template")
async def get_import_template(
tenant_id: UUID = Path(..., description="Tenant ID"),
format: str = "csv",
current_tenant: str = Depends(get_current_tenant_id_dep)
):
"""Get sales data import template"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
if format not in ["csv", "json"]:
raise HTTPException(status_code=400, detail="Format must be 'csv' or 'json'")
if format == "csv":
template = "date,product_name,product_category,product_sku,quantity_sold,unit_price,revenue,cost_of_goods,discount_applied,location_id,sales_channel,source,notes,weather_condition,is_holiday,is_weekend"
else:
template = {
"records": [
{
"date": "2024-01-01T10:00:00Z",
"product_name": "Sample Product",
"product_category": "Sample Category",
"product_sku": "SAMPLE001",
"quantity_sold": 1,
"unit_price": 10.50,
"revenue": 10.50,
"cost_of_goods": 5.25,
"discount_applied": 0.0,
"location_id": "LOC001",
"sales_channel": "in_store",
"source": "manual",
"notes": "Sample sales record",
"weather_condition": "sunny",
"is_holiday": False,
"is_weekend": False
}
]
}
return {"template": template, "format": format}
except Exception as e:
logger.error("Failed to get import template", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to get import template: {str(e)}")

View File

@@ -0,0 +1,325 @@
# services/sales/app/api/sales.py
"""
Sales API Endpoints
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Path
from typing import List, Optional, Dict, Any
from uuid import UUID
from datetime import datetime
import structlog
from app.schemas.sales import (
SalesDataCreate,
SalesDataUpdate,
SalesDataResponse,
SalesDataQuery
)
from app.services.sales_service import SalesService
from shared.auth.decorators import get_current_user_dep, get_current_tenant_id_dep
router = APIRouter(tags=["sales"])
logger = structlog.get_logger()
def get_sales_service():
"""Dependency injection for SalesService"""
return SalesService()
@router.get("/tenants/{tenant_id}/sales/products")
async def get_products_list(
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
sales_service: SalesService = Depends(get_sales_service)
):
"""Get list of products using repository pattern"""
try:
logger.debug("Getting products list with repository pattern", tenant_id=tenant_id)
products = await sales_service.get_products_list(str(tenant_id))
logger.debug("Products list retrieved using repository",
count=len(products),
tenant_id=tenant_id)
return products
except Exception as e:
logger.error("Failed to get products list",
error=str(e),
tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to get products list: {str(e)}")
@router.post("/tenants/{tenant_id}/sales", response_model=SalesDataResponse)
async def create_sales_record(
sales_data: SalesDataCreate,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
current_tenant: str = Depends(get_current_tenant_id_dep),
sales_service: SalesService = Depends(get_sales_service)
):
"""Create a new sales record"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
logger.info(
"Creating sales record",
product=sales_data.product_name,
quantity=sales_data.quantity_sold,
tenant_id=tenant_id,
user_id=current_user.get("user_id")
)
# Create the record
record = await sales_service.create_sales_record(
sales_data,
tenant_id,
user_id=UUID(current_user["user_id"]) if current_user.get("user_id") else None
)
logger.info("Successfully created sales record", record_id=record.id, tenant_id=tenant_id)
return record
except ValueError as ve:
logger.warning("Validation error creating sales record", error=str(ve), tenant_id=tenant_id)
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
logger.error("Failed to create sales record", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to create sales record: {str(e)}")
@router.get("/tenants/{tenant_id}/sales", response_model=List[SalesDataResponse])
async def get_sales_records(
tenant_id: UUID = Path(..., description="Tenant ID"),
start_date: Optional[datetime] = Query(None, description="Start date filter"),
end_date: Optional[datetime] = Query(None, description="End date filter"),
product_name: Optional[str] = Query(None, description="Product name filter"),
product_category: Optional[str] = Query(None, description="Product category filter"),
location_id: Optional[str] = Query(None, description="Location filter"),
sales_channel: Optional[str] = Query(None, description="Sales channel filter"),
source: Optional[str] = Query(None, description="Data source filter"),
is_validated: Optional[bool] = Query(None, description="Validation status filter"),
limit: int = Query(50, ge=1, le=1000, description="Number of records to return"),
offset: int = Query(0, ge=0, description="Number of records to skip"),
order_by: str = Query("date", description="Field to order by"),
order_direction: str = Query("desc", description="Order direction (asc/desc)"),
current_tenant: str = Depends(get_current_tenant_id_dep),
sales_service: SalesService = Depends(get_sales_service)
):
"""Get sales records for a tenant with filtering and pagination"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
# Build query parameters
query_params = SalesDataQuery(
start_date=start_date,
end_date=end_date,
product_name=product_name,
product_category=product_category,
location_id=location_id,
sales_channel=sales_channel,
source=source,
is_validated=is_validated,
limit=limit,
offset=offset,
order_by=order_by,
order_direction=order_direction
)
records = await sales_service.get_sales_records(tenant_id, query_params)
logger.info("Retrieved sales records", count=len(records), tenant_id=tenant_id)
return records
except Exception as e:
logger.error("Failed to get sales records", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to get sales records: {str(e)}")
@router.get("/tenants/{tenant_id}/sales/analytics/summary")
async def get_sales_analytics(
tenant_id: UUID = Path(..., description="Tenant ID"),
start_date: Optional[datetime] = Query(None, description="Start date filter"),
end_date: Optional[datetime] = Query(None, description="End date filter"),
current_tenant: str = Depends(get_current_tenant_id_dep),
sales_service: SalesService = Depends(get_sales_service)
):
"""Get sales analytics summary for a tenant"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
analytics = await sales_service.get_sales_analytics(tenant_id, start_date, end_date)
logger.info("Retrieved sales analytics", tenant_id=tenant_id)
return analytics
except Exception as e:
logger.error("Failed to get sales analytics", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to get sales analytics: {str(e)}")
@router.get("/tenants/{tenant_id}/products/{product_name}/sales", response_model=List[SalesDataResponse])
async def get_product_sales(
tenant_id: UUID = Path(..., description="Tenant ID"),
product_name: str = Path(..., description="Product name"),
start_date: Optional[datetime] = Query(None, description="Start date filter"),
end_date: Optional[datetime] = Query(None, description="End date filter"),
current_tenant: str = Depends(get_current_tenant_id_dep),
sales_service: SalesService = Depends(get_sales_service)
):
"""Get sales records for a specific product"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
records = await sales_service.get_product_sales(tenant_id, product_name, start_date, end_date)
logger.info("Retrieved product sales", count=len(records), product=product_name, tenant_id=tenant_id)
return records
except Exception as e:
logger.error("Failed to get product sales", error=str(e), tenant_id=tenant_id, product=product_name)
raise HTTPException(status_code=500, detail=f"Failed to get product sales: {str(e)}")
@router.get("/tenants/{tenant_id}/sales/categories", response_model=List[str])
async def get_product_categories(
tenant_id: UUID = Path(..., description="Tenant ID"),
current_tenant: str = Depends(get_current_tenant_id_dep),
sales_service: SalesService = Depends(get_sales_service)
):
"""Get distinct product categories from sales data"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
categories = await sales_service.get_product_categories(tenant_id)
return categories
except Exception as e:
logger.error("Failed to get product categories", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to get product categories: {str(e)}")
# ================================================================
# PARAMETERIZED ROUTES - Keep these at the end to avoid conflicts
# ================================================================
@router.get("/tenants/{tenant_id}/sales/{record_id}", response_model=SalesDataResponse)
async def get_sales_record(
tenant_id: UUID = Path(..., description="Tenant ID"),
record_id: UUID = Path(..., description="Sales record ID"),
current_tenant: str = Depends(get_current_tenant_id_dep),
sales_service: SalesService = Depends(get_sales_service)
):
"""Get a specific sales record"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
record = await sales_service.get_sales_record(record_id, tenant_id)
if not record:
raise HTTPException(status_code=404, detail="Sales record not found")
return record
except HTTPException:
raise
except Exception as e:
logger.error("Failed to get sales record", error=str(e), record_id=record_id, tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to get sales record: {str(e)}")
@router.put("/tenants/{tenant_id}/sales/{record_id}", response_model=SalesDataResponse)
async def update_sales_record(
update_data: SalesDataUpdate,
tenant_id: UUID = Path(..., description="Tenant ID"),
record_id: UUID = Path(..., description="Sales record ID"),
current_tenant: str = Depends(get_current_tenant_id_dep),
sales_service: SalesService = Depends(get_sales_service)
):
"""Update a sales record"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
updated_record = await sales_service.update_sales_record(record_id, update_data, tenant_id)
logger.info("Updated sales record", record_id=record_id, tenant_id=tenant_id)
return updated_record
except ValueError as ve:
logger.warning("Validation error updating sales record", error=str(ve), record_id=record_id)
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
logger.error("Failed to update sales record", error=str(e), record_id=record_id, tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to update sales record: {str(e)}")
@router.delete("/tenants/{tenant_id}/sales/{record_id}")
async def delete_sales_record(
tenant_id: UUID = Path(..., description="Tenant ID"),
record_id: UUID = Path(..., description="Sales record ID"),
current_tenant: str = Depends(get_current_tenant_id_dep),
sales_service: SalesService = Depends(get_sales_service)
):
"""Delete a sales record"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
success = await sales_service.delete_sales_record(record_id, tenant_id)
if not success:
raise HTTPException(status_code=404, detail="Sales record not found")
logger.info("Deleted sales record", record_id=record_id, tenant_id=tenant_id)
return {"message": "Sales record deleted successfully"}
except ValueError as ve:
logger.warning("Error deleting sales record", error=str(ve), record_id=record_id)
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
logger.error("Failed to delete sales record", error=str(e), record_id=record_id, tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to delete sales record: {str(e)}")
@router.post("/tenants/{tenant_id}/sales/{record_id}/validate", response_model=SalesDataResponse)
async def validate_sales_record(
tenant_id: UUID = Path(..., description="Tenant ID"),
record_id: UUID = Path(..., description="Sales record ID"),
validation_notes: Optional[str] = Query(None, description="Validation notes"),
current_tenant: str = Depends(get_current_tenant_id_dep),
sales_service: SalesService = Depends(get_sales_service)
):
"""Mark a sales record as validated"""
try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
validated_record = await sales_service.validate_sales_record(record_id, tenant_id, validation_notes)
logger.info("Validated sales record", record_id=record_id, tenant_id=tenant_id)
return validated_record
except ValueError as ve:
logger.warning("Error validating sales record", error=str(ve), record_id=record_id)
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
logger.error("Failed to validate sales record", error=str(e), record_id=record_id, tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to validate sales record: {str(e)}")