Fix new services implementation 3
This commit is contained in:
@@ -39,7 +39,7 @@ class SalesRepository(BaseRepository[SalesData, SalesDataCreate, SalesDataUpdate
|
||||
logger.info(
|
||||
"Created sales record",
|
||||
record_id=record.id,
|
||||
product=record.product_name,
|
||||
inventory_product_id=record.inventory_product_id,
|
||||
quantity=record.quantity_sold,
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
@@ -65,10 +65,16 @@ class SalesRepository(BaseRepository[SalesData, SalesDataCreate, SalesDataUpdate
|
||||
stmt = stmt.where(SalesData.date >= query_params.start_date)
|
||||
if query_params.end_date:
|
||||
stmt = stmt.where(SalesData.date <= query_params.end_date)
|
||||
if query_params.product_name:
|
||||
stmt = stmt.where(SalesData.product_name.ilike(f"%{query_params.product_name}%"))
|
||||
if query_params.product_category:
|
||||
stmt = stmt.where(SalesData.product_category == query_params.product_category)
|
||||
# Note: product_name queries now require joining with inventory service
|
||||
# if query_params.product_name:
|
||||
# # Would need to join with inventory service to filter by product name
|
||||
# pass
|
||||
# Note: product_category field was removed - filtering by category now requires inventory service
|
||||
# if query_params.product_category:
|
||||
# # Would need to join with inventory service to filter by product category
|
||||
# pass
|
||||
if hasattr(query_params, 'inventory_product_id') and query_params.inventory_product_id:
|
||||
stmt = stmt.where(SalesData.inventory_product_id == query_params.inventory_product_id)
|
||||
if query_params.location_id:
|
||||
stmt = stmt.where(SalesData.location_id == query_params.location_id)
|
||||
if query_params.sales_channel:
|
||||
@@ -174,7 +180,7 @@ class SalesRepository(BaseRepository[SalesData, SalesDataCreate, SalesDataUpdate
|
||||
|
||||
# Top products
|
||||
top_products_query = select(
|
||||
SalesData.product_name,
|
||||
SalesData.inventory_product_id, # Note: was product_name
|
||||
func.sum(SalesData.revenue).label('revenue'),
|
||||
func.sum(SalesData.quantity_sold).label('quantity')
|
||||
).where(SalesData.tenant_id == tenant_id)
|
||||
@@ -185,7 +191,7 @@ class SalesRepository(BaseRepository[SalesData, SalesDataCreate, SalesDataUpdate
|
||||
top_products_query = top_products_query.where(SalesData.date <= end_date)
|
||||
|
||||
top_products_query = top_products_query.group_by(
|
||||
SalesData.product_name
|
||||
SalesData.inventory_product_id # Note: was product_name
|
||||
).order_by(
|
||||
desc(func.sum(SalesData.revenue))
|
||||
).limit(10)
|
||||
@@ -193,7 +199,7 @@ class SalesRepository(BaseRepository[SalesData, SalesDataCreate, SalesDataUpdate
|
||||
top_products_result = await self.session.execute(top_products_query)
|
||||
top_products = [
|
||||
{
|
||||
'product_name': row.product_name,
|
||||
'inventory_product_id': str(row.inventory_product_id), # Note: was product_name
|
||||
'revenue': float(row.revenue) if row.revenue else 0,
|
||||
'quantity': row.quantity or 0
|
||||
}
|
||||
@@ -239,15 +245,12 @@ class SalesRepository(BaseRepository[SalesData, SalesDataCreate, SalesDataUpdate
|
||||
async def get_product_categories(self, tenant_id: UUID) -> List[str]:
|
||||
"""Get distinct product categories for a tenant"""
|
||||
try:
|
||||
stmt = select(SalesData.product_category).where(
|
||||
and_(
|
||||
SalesData.tenant_id == tenant_id,
|
||||
SalesData.product_category.is_not(None)
|
||||
)
|
||||
).distinct()
|
||||
|
||||
result = await self.session.execute(stmt)
|
||||
categories = [row[0] for row in result if row[0]]
|
||||
# Note: product_category field was removed - categories now managed via inventory service
|
||||
# This method should be updated to query categories from inventory service
|
||||
# For now, return empty list to avoid breaking existing code
|
||||
logger.warning("get_product_categories called but product_category field was removed",
|
||||
tenant_id=tenant_id)
|
||||
categories = []
|
||||
|
||||
return sorted(categories)
|
||||
|
||||
@@ -279,15 +282,18 @@ class SalesRepository(BaseRepository[SalesData, SalesDataCreate, SalesDataUpdate
|
||||
async def get_product_statistics(self, tenant_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get product statistics for tenant"""
|
||||
try:
|
||||
stmt = select(SalesData.product_name).where(
|
||||
# Note: product_name field was removed - product info now managed via inventory service
|
||||
# This method should be updated to query products from inventory service
|
||||
# For now, return inventory_product_ids to avoid breaking existing code
|
||||
stmt = select(SalesData.inventory_product_id).where(
|
||||
and_(
|
||||
SalesData.tenant_id == tenant_id,
|
||||
SalesData.product_name.is_not(None)
|
||||
SalesData.inventory_product_id.is_not(None)
|
||||
)
|
||||
).distinct()
|
||||
|
||||
result = await self.session.execute(stmt)
|
||||
products = [row[0] for row in result if row[0]]
|
||||
products = [str(row[0]) for row in result if row[0]]
|
||||
|
||||
return sorted(products)
|
||||
|
||||
|
||||
@@ -53,9 +53,10 @@ class SalesDataCreate(SalesDataBase):
|
||||
|
||||
class SalesDataUpdate(BaseModel):
|
||||
"""Schema for updating sales data"""
|
||||
product_name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
product_category: Optional[str] = Field(None, max_length=100)
|
||||
product_sku: Optional[str] = Field(None, max_length=100)
|
||||
# Note: product_name and product_category fields removed - use inventory service for product management
|
||||
# product_name: Optional[str] = Field(None, min_length=1, max_length=255) # DEPRECATED
|
||||
# product_category: Optional[str] = Field(None, max_length=100) # DEPRECATED
|
||||
# product_sku: Optional[str] = Field(None, max_length=100) # DEPRECATED - use inventory service
|
||||
|
||||
quantity_sold: Optional[int] = Field(None, gt=0)
|
||||
unit_price: Optional[Decimal] = Field(None, ge=0)
|
||||
@@ -98,8 +99,10 @@ class SalesDataQuery(BaseModel):
|
||||
"""Schema for sales data queries"""
|
||||
start_date: Optional[datetime] = None
|
||||
end_date: Optional[datetime] = None
|
||||
product_name: Optional[str] = None
|
||||
product_category: Optional[str] = None
|
||||
# Note: product_name and product_category filtering now requires inventory service integration
|
||||
# product_name: Optional[str] = None # DEPRECATED - use inventory_product_id or join with inventory service
|
||||
# product_category: Optional[str] = None # DEPRECATED - use inventory service categories
|
||||
inventory_product_id: Optional[UUID] = None # Filter by specific inventory product ID
|
||||
location_id: Optional[str] = None
|
||||
sales_channel: Optional[str] = None
|
||||
source: Optional[str] = None
|
||||
@@ -136,7 +139,8 @@ class SalesAnalytics(BaseModel):
|
||||
|
||||
class ProductSalesAnalytics(BaseModel):
|
||||
"""Product-specific sales analytics"""
|
||||
product_name: str
|
||||
inventory_product_id: UUID # Reference to inventory service product
|
||||
# Note: product_name can be fetched from inventory service using inventory_product_id
|
||||
total_revenue: Decimal
|
||||
total_quantity: int
|
||||
total_transactions: int
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Add inventory product reference and remove redundant product model
|
||||
|
||||
Revision ID: 003
|
||||
Revises: 002
|
||||
Create Date: 2025-01-15 11:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '003'
|
||||
down_revision = '002'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add inventory product reference to sales_data table
|
||||
op.add_column('sales_data', sa.Column('inventory_product_id',
|
||||
postgresql.UUID(as_uuid=True), nullable=True))
|
||||
|
||||
# Add product_type column for caching product type from inventory
|
||||
op.add_column('sales_data', sa.Column('product_type',
|
||||
sa.String(20), nullable=True))
|
||||
|
||||
# Create indexes for new columns
|
||||
op.create_index('idx_sales_inventory_product', 'sales_data',
|
||||
['inventory_product_id', 'tenant_id'])
|
||||
op.create_index('idx_sales_product_type', 'sales_data',
|
||||
['product_type', 'tenant_id', 'date'])
|
||||
|
||||
# Drop the redundant products table if it exists
|
||||
op.execute("DROP TABLE IF EXISTS products CASCADE;")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop new indexes
|
||||
op.drop_index('idx_sales_product_type', table_name='sales_data')
|
||||
op.drop_index('idx_sales_inventory_product', table_name='sales_data')
|
||||
|
||||
# Remove new columns
|
||||
op.drop_column('sales_data', 'product_type')
|
||||
op.drop_column('sales_data', 'inventory_product_id')
|
||||
|
||||
# Recreate products table (basic version)
|
||||
op.create_table(
|
||||
'products',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('sku', sa.String(100), nullable=True),
|
||||
sa.Column('category', sa.String(100), nullable=True),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), default=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Recreate basic indexes
|
||||
op.create_index('idx_products_tenant_name', 'products', ['tenant_id', 'name'], unique=True)
|
||||
op.create_index('idx_products_tenant_sku', 'products', ['tenant_id', 'sku'])
|
||||
@@ -1,61 +0,0 @@
|
||||
"""Remove cached product fields - use only inventory_product_id
|
||||
|
||||
Revision ID: 004
|
||||
Revises: 003
|
||||
Create Date: 2025-01-15 12:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '004'
|
||||
down_revision = '003'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Make inventory_product_id required (NOT NULL)
|
||||
op.alter_column('sales_data', 'inventory_product_id', nullable=False)
|
||||
|
||||
# Remove cached product fields - inventory service is single source of truth
|
||||
op.drop_column('sales_data', 'product_name')
|
||||
op.drop_column('sales_data', 'product_category')
|
||||
op.drop_column('sales_data', 'product_sku')
|
||||
op.drop_column('sales_data', 'product_type')
|
||||
|
||||
# Drop old indexes that referenced removed fields
|
||||
op.execute("DROP INDEX IF EXISTS idx_sales_tenant_product")
|
||||
op.execute("DROP INDEX IF EXISTS idx_sales_tenant_category")
|
||||
op.execute("DROP INDEX IF EXISTS idx_sales_product_date")
|
||||
op.execute("DROP INDEX IF EXISTS idx_sales_sku_date")
|
||||
op.execute("DROP INDEX IF EXISTS idx_sales_product_type")
|
||||
|
||||
# Create optimized indexes for inventory-only approach
|
||||
op.create_index('idx_sales_inventory_product_date', 'sales_data',
|
||||
['inventory_product_id', 'date', 'tenant_id'])
|
||||
op.create_index('idx_sales_tenant_inventory_product', 'sales_data',
|
||||
['tenant_id', 'inventory_product_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop new indexes
|
||||
op.drop_index('idx_sales_tenant_inventory_product', table_name='sales_data')
|
||||
op.drop_index('idx_sales_inventory_product_date', table_name='sales_data')
|
||||
|
||||
# Add back cached product fields for downgrade compatibility
|
||||
op.add_column('sales_data', sa.Column('product_name', sa.String(255), nullable=True))
|
||||
op.add_column('sales_data', sa.Column('product_category', sa.String(100), nullable=True))
|
||||
op.add_column('sales_data', sa.Column('product_sku', sa.String(100), nullable=True))
|
||||
op.add_column('sales_data', sa.Column('product_type', sa.String(20), nullable=True))
|
||||
|
||||
# Make inventory_product_id optional again
|
||||
op.alter_column('sales_data', 'inventory_product_id', nullable=True)
|
||||
|
||||
# Recreate old indexes
|
||||
op.create_index('idx_sales_tenant_product', 'sales_data', ['tenant_id', 'product_name'])
|
||||
op.create_index('idx_sales_tenant_category', 'sales_data', ['tenant_id', 'product_category'])
|
||||
op.create_index('idx_sales_product_date', 'sales_data', ['product_name', 'date', 'tenant_id'])
|
||||
op.create_index('idx_sales_sku_date', 'sales_data', ['product_sku', 'date', 'tenant_id'])
|
||||
@@ -91,6 +91,7 @@ def sample_sales_data(sample_tenant_id: UUID) -> SalesDataCreate:
|
||||
"""Sample sales data for testing"""
|
||||
return SalesDataCreate(
|
||||
date=datetime.now(timezone.utc),
|
||||
inventory_product_id="550e8400-e29b-41d4-a716-446655440000",
|
||||
product_name="Pan Integral",
|
||||
product_category="Panadería",
|
||||
product_sku="PAN001",
|
||||
@@ -117,6 +118,7 @@ def sample_sales_records(sample_tenant_id: UUID) -> list[dict]:
|
||||
{
|
||||
"tenant_id": sample_tenant_id,
|
||||
"date": base_date,
|
||||
"inventory_product_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"product_name": "Croissant",
|
||||
"quantity_sold": 3,
|
||||
"revenue": Decimal("7.50"),
|
||||
@@ -126,6 +128,7 @@ def sample_sales_records(sample_tenant_id: UUID) -> list[dict]:
|
||||
{
|
||||
"tenant_id": sample_tenant_id,
|
||||
"date": base_date,
|
||||
"inventory_product_id": "550e8400-e29b-41d4-a716-446655440002",
|
||||
"product_name": "Café Americano",
|
||||
"quantity_sold": 2,
|
||||
"revenue": Decimal("5.00"),
|
||||
@@ -135,6 +138,7 @@ def sample_sales_records(sample_tenant_id: UUID) -> list[dict]:
|
||||
{
|
||||
"tenant_id": sample_tenant_id,
|
||||
"date": base_date,
|
||||
"inventory_product_id": "550e8400-e29b-41d4-a716-446655440003",
|
||||
"product_name": "Bocadillo Jamón",
|
||||
"quantity_sold": 1,
|
||||
"revenue": Decimal("4.50"),
|
||||
@@ -229,6 +233,7 @@ def performance_test_data(sample_tenant_id: UUID) -> list[dict]:
|
||||
records.append({
|
||||
"tenant_id": sample_tenant_id,
|
||||
"date": base_date,
|
||||
"inventory_product_id": f"550e8400-e29b-41d4-a716-{i:012x}",
|
||||
"product_name": f"Test Product {i % 20}",
|
||||
"quantity_sold": (i % 10) + 1,
|
||||
"revenue": Decimal(str(((i % 10) + 1) * 2.5)),
|
||||
|
||||
@@ -26,7 +26,7 @@ class TestSalesRepository:
|
||||
assert record is not None
|
||||
assert record.id is not None
|
||||
assert record.tenant_id == sample_tenant_id
|
||||
assert record.product_name == sample_sales_data.product_name
|
||||
assert record.inventory_product_id == sample_sales_data.inventory_product_id
|
||||
assert record.quantity_sold == sample_sales_data.quantity_sold
|
||||
assert record.revenue == sample_sales_data.revenue
|
||||
|
||||
@@ -42,7 +42,7 @@ class TestSalesRepository:
|
||||
|
||||
assert retrieved_record is not None
|
||||
assert retrieved_record.id == created_record.id
|
||||
assert retrieved_record.product_name == created_record.product_name
|
||||
assert retrieved_record.inventory_product_id == created_record.inventory_product_id
|
||||
|
||||
async def test_get_by_tenant(self, populated_db, sample_tenant_id):
|
||||
"""Test getting records by tenant"""
|
||||
@@ -57,10 +57,12 @@ class TestSalesRepository:
|
||||
"""Test getting records by product"""
|
||||
repository = SalesRepository(populated_db)
|
||||
|
||||
records = await repository.get_by_product(sample_tenant_id, "Croissant")
|
||||
# Get by inventory_product_id instead of product name
|
||||
test_product_id = "550e8400-e29b-41d4-a716-446655440001"
|
||||
records = await repository.get_by_inventory_product_id(sample_tenant_id, test_product_id)
|
||||
|
||||
assert len(records) == 1
|
||||
assert records[0].product_name == "Croissant"
|
||||
assert records[0].inventory_product_id == test_product_id
|
||||
|
||||
async def test_update_record(self, test_db_session, sample_tenant_id, sample_sales_data):
|
||||
"""Test updating a sales record"""
|
||||
@@ -71,6 +73,7 @@ class TestSalesRepository:
|
||||
|
||||
# Update record
|
||||
update_data = SalesDataUpdate(
|
||||
inventory_product_id="550e8400-e29b-41d4-a716-446655440999",
|
||||
product_name="Updated Product",
|
||||
quantity_sold=10,
|
||||
revenue=Decimal("25.00")
|
||||
@@ -78,7 +81,7 @@ class TestSalesRepository:
|
||||
|
||||
updated_record = await repository.update(created_record.id, update_data.model_dump(exclude_unset=True))
|
||||
|
||||
assert updated_record.product_name == "Updated Product"
|
||||
assert updated_record.inventory_product_id == "550e8400-e29b-41d4-a716-446655440999"
|
||||
assert updated_record.quantity_sold == 10
|
||||
assert updated_record.revenue == Decimal("25.00")
|
||||
|
||||
@@ -137,7 +140,7 @@ class TestSalesRepository:
|
||||
repository = SalesRepository(populated_db)
|
||||
|
||||
query = SalesDataQuery(
|
||||
product_name="Croissant",
|
||||
inventory_product_id="550e8400-e29b-41d4-a716-446655440001",
|
||||
limit=10,
|
||||
offset=0
|
||||
)
|
||||
@@ -145,7 +148,7 @@ class TestSalesRepository:
|
||||
records = await repository.get_by_tenant(sample_tenant_id, query)
|
||||
|
||||
assert len(records) == 1
|
||||
assert records[0].product_name == "Croissant"
|
||||
assert records[0].inventory_product_id == "550e8400-e29b-41d4-a716-446655440001"
|
||||
|
||||
async def test_bulk_create(self, test_db_session, sample_tenant_id):
|
||||
"""Test bulk creating records"""
|
||||
@@ -155,6 +158,7 @@ class TestSalesRepository:
|
||||
bulk_data = [
|
||||
{
|
||||
"date": datetime.now(timezone.utc),
|
||||
"inventory_product_id": f"550e8400-e29b-41d4-a716-{i+100:012x}",
|
||||
"product_name": f"Product {i}",
|
||||
"quantity_sold": i + 1,
|
||||
"revenue": Decimal(str((i + 1) * 2.5)),
|
||||
|
||||
@@ -31,7 +31,7 @@ class TestSalesService:
|
||||
mock_repository = AsyncMock()
|
||||
mock_record = AsyncMock()
|
||||
mock_record.id = uuid4()
|
||||
mock_record.product_name = sample_sales_data.product_name
|
||||
mock_record.inventory_product_id = sample_sales_data.inventory_product_id
|
||||
mock_repository.create_sales_record.return_value = mock_record
|
||||
|
||||
with patch('app.services.sales_service.SalesRepository', return_value=mock_repository):
|
||||
@@ -49,6 +49,7 @@ class TestSalesService:
|
||||
# Create invalid sales data (future date)
|
||||
invalid_data = SalesDataCreate(
|
||||
date=datetime(2030, 1, 1, tzinfo=timezone.utc), # Future date
|
||||
inventory_product_id="550e8400-e29b-41d4-a716-446655440000",
|
||||
product_name="Test Product",
|
||||
quantity_sold=1,
|
||||
revenue=Decimal("5.00")
|
||||
@@ -61,6 +62,7 @@ class TestSalesService:
|
||||
"""Test updating a sales record"""
|
||||
record_id = uuid4()
|
||||
update_data = SalesDataUpdate(
|
||||
inventory_product_id="550e8400-e29b-41d4-a716-446655440999",
|
||||
product_name="Updated Product",
|
||||
quantity_sold=10
|
||||
)
|
||||
@@ -78,7 +80,7 @@ class TestSalesService:
|
||||
|
||||
# Mock updated record
|
||||
mock_updated = AsyncMock()
|
||||
mock_updated.product_name = "Updated Product"
|
||||
mock_updated.inventory_product_id = "550e8400-e29b-41d4-a716-446655440999"
|
||||
mock_repository.update.return_value = mock_updated
|
||||
|
||||
with patch('app.services.sales_service.SalesRepository', return_value=mock_repository):
|
||||
@@ -88,13 +90,13 @@ class TestSalesService:
|
||||
sample_tenant_id
|
||||
)
|
||||
|
||||
assert result.product_name == "Updated Product"
|
||||
assert result.inventory_product_id == "550e8400-e29b-41d4-a716-446655440999"
|
||||
mock_repository.update.assert_called_once()
|
||||
|
||||
async def test_update_nonexistent_record(self, sales_service, sample_tenant_id):
|
||||
"""Test updating a non-existent record"""
|
||||
record_id = uuid4()
|
||||
update_data = SalesDataUpdate(product_name="Updated Product")
|
||||
update_data = SalesDataUpdate(inventory_product_id="550e8400-e29b-41d4-a716-446655440999", product_name="Updated Product")
|
||||
|
||||
with patch('app.services.sales_service.get_db_transaction') as mock_get_db:
|
||||
mock_db = AsyncMock()
|
||||
@@ -195,7 +197,7 @@ class TestSalesService:
|
||||
|
||||
async def test_get_product_sales(self, sales_service, sample_tenant_id):
|
||||
"""Test getting sales for specific product"""
|
||||
product_name = "Test Product"
|
||||
inventory_product_id = "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
with patch('app.services.sales_service.get_db_transaction') as mock_get_db:
|
||||
mock_db = AsyncMock()
|
||||
@@ -206,7 +208,7 @@ class TestSalesService:
|
||||
mock_repository.get_by_product.return_value = mock_records
|
||||
|
||||
with patch('app.services.sales_service.SalesRepository', return_value=mock_repository):
|
||||
result = await sales_service.get_product_sales(sample_tenant_id, product_name)
|
||||
result = await sales_service.get_product_sales(sample_tenant_id, inventory_product_id)
|
||||
|
||||
assert len(result) == 2
|
||||
mock_repository.get_by_product.assert_called_once()
|
||||
@@ -268,6 +270,7 @@ class TestSalesService:
|
||||
# Test revenue mismatch detection
|
||||
sales_data = SalesDataCreate(
|
||||
date=datetime.now(timezone.utc),
|
||||
inventory_product_id="550e8400-e29b-41d4-a716-446655440000",
|
||||
product_name="Test Product",
|
||||
quantity_sold=5,
|
||||
unit_price=Decimal("2.00"),
|
||||
|
||||
Reference in New Issue
Block a user