Add supplier and imporve inventory frontend

This commit is contained in:
Urtzi Alfaro
2025-09-18 23:32:53 +02:00
parent ae77a0e1c5
commit d61056df33
40 changed files with 2022 additions and 629 deletions

View File

@@ -106,7 +106,6 @@ class Ingredient(Base):
# Product details
description = Column(Text, nullable=True)
brand = Column(String(100), nullable=True) # Brand or central baker name
supplier_name = Column(String(200), nullable=True) # Central baker or distributor
unit_of_measure = Column(SQLEnum(UnitOfMeasure), nullable=False)
package_size = Column(Float, nullable=True) # Size per package/unit
@@ -158,7 +157,6 @@ class Ingredient(Base):
Index('idx_ingredients_ingredient_category', 'tenant_id', 'ingredient_category', 'is_active'),
Index('idx_ingredients_product_category', 'tenant_id', 'product_category', 'is_active'),
Index('idx_ingredients_stock_levels', 'tenant_id', 'low_stock_threshold', 'reorder_point'),
Index('idx_ingredients_central_baker', 'tenant_id', 'supplier_name', 'product_type'),
)
def to_dict(self) -> Dict[str, Any]:
@@ -194,7 +192,6 @@ class Ingredient(Base):
'subcategory': self.subcategory,
'description': self.description,
'brand': self.brand,
'supplier_name': self.supplier_name,
'unit_of_measure': self.unit_of_measure.value if self.unit_of_measure else None,
'package_size': self.package_size,
'average_cost': float(self.average_cost) if self.average_cost else None,
@@ -230,6 +227,9 @@ class Stock(Base):
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
ingredient_id = Column(UUID(as_uuid=True), ForeignKey('ingredients.id'), nullable=False, index=True)
# Supplier association
supplier_id = Column(UUID(as_uuid=True), nullable=True, index=True)
# Stock identification
batch_number = Column(String(100), nullable=True, index=True)
lot_number = Column(String(100), nullable=True, index=True)

View File

@@ -61,13 +61,6 @@ class IngredientCreate(InventoryBaseSchema):
is_perishable: bool = Field(False, description="Is perishable")
allergen_info: Optional[Dict[str, Any]] = Field(None, description="Allergen information")
@validator('storage_temperature_max')
def validate_temperature_range(cls, v, values):
if v is not None and 'storage_temperature_min' in values and values['storage_temperature_min'] is not None:
if v <= values['storage_temperature_min']:
raise ValueError('Max temperature must be greater than min temperature')
return v
@validator('reorder_point')
def validate_reorder_point(cls, v, values):
if 'low_stock_threshold' in values and v <= values['low_stock_threshold']:
@@ -147,6 +140,7 @@ class IngredientResponse(InventoryBaseSchema):
class StockCreate(InventoryBaseSchema):
"""Schema for creating stock entries"""
ingredient_id: str = Field(..., description="Ingredient ID")
supplier_id: Optional[str] = Field(None, description="Supplier ID")
batch_number: Optional[str] = Field(None, max_length=100, description="Batch number")
lot_number: Optional[str] = Field(None, max_length=100, description="Lot number")
supplier_batch_ref: Optional[str] = Field(None, max_length=100, description="Supplier batch reference")
@@ -181,9 +175,16 @@ class StockCreate(InventoryBaseSchema):
shelf_life_days: Optional[int] = Field(None, gt=0, description="Batch-specific shelf life in days")
storage_instructions: Optional[str] = Field(None, description="Batch-specific storage instructions")
@validator('storage_temperature_max')
def validate_temperature_range(cls, v, values):
min_temp = values.get('storage_temperature_min')
if v is not None and min_temp is not None and v <= min_temp:
raise ValueError('Max temperature must be greater than min temperature')
return v
class StockUpdate(InventoryBaseSchema):
"""Schema for updating stock entries"""
supplier_id: Optional[str] = Field(None, description="Supplier ID")
batch_number: Optional[str] = Field(None, max_length=100, description="Batch number")
lot_number: Optional[str] = Field(None, max_length=100, description="Lot number")
supplier_batch_ref: Optional[str] = Field(None, max_length=100, description="Supplier batch reference")
@@ -226,6 +227,7 @@ class StockResponse(InventoryBaseSchema):
id: str
tenant_id: str
ingredient_id: str
supplier_id: Optional[str]
batch_number: Optional[str]
lot_number: Optional[str]
supplier_batch_ref: Optional[str]

View File

@@ -451,17 +451,17 @@ class DashboardService:
SELECT
'stock_movement' as activity_type,
CASE
WHEN movement_type = 'purchase' THEN 'Stock added: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')'
WHEN movement_type = 'production_use' THEN 'Stock consumed: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')'
WHEN movement_type = 'waste' THEN 'Stock wasted: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')'
WHEN movement_type = 'adjustment' THEN 'Stock adjusted: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')'
WHEN movement_type = 'PURCHASE' THEN 'Stock added: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')'
WHEN movement_type = 'PRODUCTION_USE' THEN 'Stock consumed: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')'
WHEN movement_type = 'WASTE' THEN 'Stock wasted: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')'
WHEN movement_type = 'ADJUSTMENT' THEN 'Stock adjusted: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')'
ELSE 'Stock movement: ' || i.name
END as description,
sm.movement_date as timestamp,
sm.created_by as user_id,
CASE
WHEN movement_type = 'waste' THEN 'high'
WHEN movement_type = 'adjustment' THEN 'medium'
WHEN movement_type = 'WASTE' THEN 'high'
WHEN movement_type = 'ADJUSTMENT' THEN 'medium'
ELSE 'low'
END as impact_level,
sm.id as entity_id,
@@ -613,17 +613,22 @@ class DashboardService:
# Get ingredient metrics
query = """
SELECT
SELECT
COUNT(*) as total_ingredients,
COUNT(CASE WHEN product_type = 'finished_product' THEN 1 END) as finished_products,
COUNT(CASE WHEN product_type = 'ingredient' THEN 1 END) as raw_ingredients,
COUNT(DISTINCT supplier_name) as supplier_count,
COUNT(DISTINCT st.supplier_id) as supplier_count,
AVG(CASE WHEN s.available_quantity IS NOT NULL THEN s.available_quantity ELSE 0 END) as avg_stock_level
FROM ingredients i
LEFT JOIN (
SELECT ingredient_id, SUM(available_quantity) as available_quantity
FROM stock WHERE tenant_id = :tenant_id GROUP BY ingredient_id
) s ON i.id = s.ingredient_id
LEFT JOIN (
SELECT ingredient_id, supplier_id
FROM stock WHERE tenant_id = :tenant_id AND supplier_id IS NOT NULL
GROUP BY ingredient_id, supplier_id
) st ON i.id = st.ingredient_id
WHERE i.tenant_id = :tenant_id AND i.is_active = true
"""

View File

@@ -428,17 +428,17 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
i.low_stock_threshold as minimum_stock,
i.max_stock_level as maximum_stock,
COALESCE(SUM(s.current_quantity), 0) as current_stock,
AVG(sm.quantity) FILTER (WHERE sm.movement_type = 'production_use'
AVG(sm.quantity) FILTER (WHERE sm.movement_type = 'PRODUCTION_USE'
AND sm.created_at > CURRENT_DATE - INTERVAL '30 days') as avg_daily_usage,
COUNT(sm.id) FILTER (WHERE sm.movement_type = 'production_use'
COUNT(sm.id) FILTER (WHERE sm.movement_type = 'PRODUCTION_USE'
AND sm.created_at > CURRENT_DATE - INTERVAL '30 days') as usage_days,
MAX(sm.created_at) FILTER (WHERE sm.movement_type = 'production_use') as last_used
MAX(sm.created_at) FILTER (WHERE sm.movement_type = 'PRODUCTION_USE') as last_used
FROM ingredients i
LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true
LEFT JOIN stock_movements sm ON sm.ingredient_id = i.id
WHERE i.is_active = true AND i.tenant_id = :tenant_id
GROUP BY i.id, i.name, i.tenant_id, i.low_stock_threshold, i.max_stock_level
HAVING COUNT(sm.id) FILTER (WHERE sm.movement_type = 'production_use'
HAVING COUNT(sm.id) FILTER (WHERE sm.movement_type = 'PRODUCTION_USE'
AND sm.created_at > CURRENT_DATE - INTERVAL '30 days') >= 3
),
recommendations AS (