Add Inventory Report

This commit is contained in:
Aditya Siregar
2025-08-14 00:38:26 +07:00
parent bb9a81c7c1
commit 7adba2c8f5
9 changed files with 177 additions and 85 deletions
+121 -44
View File
@@ -33,7 +33,7 @@ type InventoryRepository interface {
BulkUpdate(ctx context.Context, inventoryItems []*entities.Inventory) error
BulkAdjustQuantity(ctx context.Context, adjustments map[uuid.UUID]int, outletID uuid.UUID) error
GetTotalValueByOutlet(ctx context.Context, outletID uuid.UUID) (float64, error)
GetInventoryReportSummary(ctx context.Context, outletID uuid.UUID) (*models.InventoryReportSummary, error)
GetInventoryReportSummary(ctx context.Context, outletID uuid.UUID, dateFrom, dateTo *time.Time) (*models.InventoryReportSummary, error)
GetInventoryReportDetails(ctx context.Context, filter *models.InventoryReportFilter) (*models.InventoryReportDetail, error)
}
@@ -343,7 +343,7 @@ func (r *InventoryRepositoryImpl) GetTotalValueByOutlet(ctx context.Context, out
}
// GetInventoryReportSummary returns summary statistics for inventory report
func (r *InventoryRepositoryImpl) GetInventoryReportSummary(ctx context.Context, outletID uuid.UUID) (*models.InventoryReportSummary, error) {
func (r *InventoryRepositoryImpl) GetInventoryReportSummary(ctx context.Context, outletID uuid.UUID, dateFrom, dateTo *time.Time) (*models.InventoryReportSummary, error) {
var summary models.InventoryReportSummary
summary.OutletID = outletID
summary.GeneratedAt = time.Now()
@@ -355,37 +355,32 @@ func (r *InventoryRepositoryImpl) GetInventoryReportSummary(ctx context.Context,
}
summary.OutletName = outlet.Name
// Get total products count
var totalProducts int64
if err := r.db.WithContext(ctx).Model(&entities.Inventory{}).
Joins("JOIN products ON inventory.product_id = products.id").
Where("inventory.outlet_id = ? AND products.has_ingredients = false", outletID).
Where("inventory.outlet_id = ?", outletID).
Count(&totalProducts).Error; err != nil {
return nil, fmt.Errorf("failed to count total products: %w", err)
}
summary.TotalProducts = int(totalProducts)
// Get total ingredients count
var totalIngredients int64
if err := r.db.WithContext(ctx).Model(&entities.Inventory{}).
Joins("JOIN ingredients ON inventory.product_id = ingredients.id").
Where("inventory.outlet_id = ?", outletID).
if err := r.db.WithContext(ctx).Model(&entities.Ingredient{}).
Where("outlet_id = ? AND is_active = ?", outletID, true).
Count(&totalIngredients).Error; err != nil {
return nil, fmt.Errorf("failed to count total ingredients: %w", err)
}
summary.TotalIngredients = int(totalIngredients)
// Get low stock products count
var lowStockProducts int64
if err := r.db.WithContext(ctx).Model(&entities.Inventory{}).
Joins("JOIN products ON inventory.product_id = products.id").
Where("inventory.outlet_id = ? AND products.has_ingredients = false AND inventory.quantity <= inventory.reorder_level AND inventory.quantity > 0", outletID).
Where("inventory.outlet_id = ? AND inventory.quantity <= inventory.reorder_level AND inventory.quantity > 0", outletID).
Count(&lowStockProducts).Error; err != nil {
return nil, fmt.Errorf("failed to count low stock products: %w", err)
}
summary.LowStockProducts = int(lowStockProducts)
// Get low stock ingredients count
var lowStockIngredients int64
if err := r.db.WithContext(ctx).Model(&entities.Inventory{}).
Joins("JOIN ingredients ON inventory.product_id = ingredients.id").
@@ -395,27 +390,31 @@ func (r *InventoryRepositoryImpl) GetInventoryReportSummary(ctx context.Context,
}
summary.LowStockIngredients = int(lowStockIngredients)
// Get zero stock products count
var zeroStockProducts int64
if err := r.db.WithContext(ctx).Model(&entities.Inventory{}).
Joins("JOIN products ON inventory.product_id = products.id").
Where("inventory.outlet_id = ? AND products.has_ingredients = false AND inventory.quantity = 0", outletID).
Where("inventory.outlet_id = ? AND inventory.quantity = 0", outletID).
Count(&zeroStockProducts).Error; err != nil {
return nil, fmt.Errorf("failed to count zero stock products: %w", err)
}
summary.ZeroStockProducts = int(zeroStockProducts)
// Get zero stock ingredients count
var zeroStockIngredients int64
if err := r.db.WithContext(ctx).Model(&entities.Inventory{}).
Joins("JOIN ingredients ON inventory.product_id = ingredients.id").
Where("inventory.outlet_id = ? AND inventory.quantity = 0", outletID).
if err := r.db.WithContext(ctx).Model(&entities.Ingredient{}).
Where("outlet_id = ? AND is_active = ? AND stock = 0", outletID, true).
Count(&zeroStockIngredients).Error; err != nil {
return nil, fmt.Errorf("failed to count zero stock ingredients: %w", err)
}
summary.ZeroStockIngredients = int(zeroStockIngredients)
// Get total value
// Get total sold from inventory movements
totalSoldProducts, totalSoldIngredients, err := r.getTotalSoldFromMovements(ctx, outletID, dateFrom, dateTo)
if err != nil {
return nil, fmt.Errorf("failed to get total sold from movements: %w", err)
}
summary.TotalSoldProducts = totalSoldProducts
summary.TotalSoldIngredients = totalSoldIngredients
totalValue, err := r.GetTotalValueByOutlet(ctx, outletID)
if err != nil {
return nil, fmt.Errorf("failed to get total value: %w", err)
@@ -429,23 +428,20 @@ func (r *InventoryRepositoryImpl) GetInventoryReportSummary(ctx context.Context,
func (r *InventoryRepositoryImpl) GetInventoryReportDetails(ctx context.Context, filter *models.InventoryReportFilter) (*models.InventoryReportDetail, error) {
report := &models.InventoryReportDetail{}
// Get summary
if filter.OutletID != nil {
summary, err := r.GetInventoryReportSummary(ctx, *filter.OutletID)
summary, err := r.GetInventoryReportSummary(ctx, *filter.OutletID, filter.DateFrom, filter.DateTo)
if err != nil {
return nil, fmt.Errorf("failed to get report summary: %w", err)
}
report.Summary = summary
}
// Get products details
products, err := r.getInventoryProductsDetails(ctx, filter)
if err != nil {
return nil, fmt.Errorf("failed to get products details: %w", err)
}
report.Products = products
// Get ingredients details
ingredients, err := r.getInventoryIngredientsDetails(ctx, filter)
if err != nil {
return nil, fmt.Errorf("failed to get ingredients details: %w", err)
@@ -457,6 +453,7 @@ func (r *InventoryRepositoryImpl) GetInventoryReportDetails(ctx context.Context,
// getInventoryProductsDetails retrieves detailed product inventory information
func (r *InventoryRepositoryImpl) getInventoryProductsDetails(ctx context.Context, filter *models.InventoryReportFilter) ([]*models.InventoryProductDetail, error) {
// Build the base query
query := r.db.WithContext(ctx).Table("inventory").
Select(`
inventory.id,
@@ -472,7 +469,7 @@ func (r *InventoryRepositoryImpl) getInventoryProductsDetails(ctx context.Contex
Joins("JOIN products ON inventory.product_id = products.id").
Joins("LEFT JOIN categories ON products.category_id = categories.id").
Joins("LEFT JOIN product_variants ON products.id = product_variants.product_id").
Where("inventory.outlet_id = ? AND products.has_ingredients = false", filter.OutletID)
Where("inventory.outlet_id = ?", filter.OutletID)
// Apply filters
if filter.CategoryID != nil {
@@ -499,7 +496,8 @@ func (r *InventoryRepositoryImpl) getInventoryProductsDetails(ctx context.Contex
query = query.Order("products.name ASC")
var results []struct {
// Execute the base query first
var baseResults []struct {
ID uuid.UUID
ProductID uuid.UUID
ProductName string
@@ -511,12 +509,33 @@ func (r *InventoryRepositoryImpl) getInventoryProductsDetails(ctx context.Contex
UpdatedAt time.Time
}
if err := query.Find(&results).Error; err != nil {
if err := query.Find(&baseResults).Error; err != nil {
return nil, err
}
// Now get total sold for each product
var products []*models.InventoryProductDetail
for _, result := range results {
for _, result := range baseResults {
var totalSold float64
// Query total sold for this specific product
soldQuery := r.db.WithContext(ctx).Model(&entities.InventoryMovement{}).
Select("COALESCE(SUM(ABS(quantity)), 0)").
Where("item_id = ? AND outlet_id = ? AND item_type = ? AND movement_type = ? AND quantity < 0",
result.ProductID, filter.OutletID, "PRODUCT", entities.InventoryMovementTypeSale)
// Apply date filters if provided
if filter.DateFrom != nil {
soldQuery = soldQuery.Where("created_at >= ?", *filter.DateFrom)
}
if filter.DateTo != nil {
soldQuery = soldQuery.Where("created_at <= ?", *filter.DateTo)
}
if err := soldQuery.Scan(&totalSold).Error; err != nil {
return nil, fmt.Errorf("failed to get total sold for product %s: %w", result.ProductID, err)
}
categoryName := ""
if result.CategoryName != nil {
categoryName = *result.CategoryName
@@ -531,6 +550,7 @@ func (r *InventoryRepositoryImpl) getInventoryProductsDetails(ctx context.Contex
ReorderLevel: result.ReorderLevel,
UnitCost: result.UnitCost,
TotalValue: result.TotalValue,
TotalSold: totalSold,
IsLowStock: result.Quantity <= result.ReorderLevel && result.Quantity > 0,
IsZeroStock: result.Quantity == 0,
UpdatedAt: result.UpdatedAt,
@@ -543,28 +563,26 @@ func (r *InventoryRepositoryImpl) getInventoryProductsDetails(ctx context.Contex
// getInventoryIngredientsDetails retrieves detailed ingredient inventory information
func (r *InventoryRepositoryImpl) getInventoryIngredientsDetails(ctx context.Context, filter *models.InventoryReportFilter) ([]*models.InventoryIngredientDetail, error) {
query := r.db.WithContext(ctx).Table("inventory").
query := r.db.WithContext(ctx).Table("ingredients").
Select(`
inventory.id,
inventory.product_id as ingredient_id,
ingredients.id,
ingredients.id as ingredient_id,
ingredients.name as ingredient_name,
units.name as unit_name,
inventory.quantity,
inventory.reorder_level,
ingredients.stock as quantity,
0 as reorder_level,
ingredients.cost as unit_cost,
(ingredients.cost * inventory.quantity) as total_value,
inventory.updated_at
(ingredients.cost * ingredients.stock) as total_value,
ingredients.updated_at
`).
Joins("JOIN ingredients ON inventory.product_id = ingredients.id").
Joins("LEFT JOIN units ON ingredients.unit_id = units.id").
Where("inventory.outlet_id = ?", filter.OutletID)
Where("ingredients.outlet_id = ? AND ingredients.is_active = ?", filter.OutletID, true)
// Apply filters
if filter.ShowLowStock != nil && *filter.ShowLowStock {
query = query.Where("inventory.quantity <= inventory.reorder_level AND inventory.quantity > 0")
query = query.Where("ingredients.stock <= 0 AND ingredients.stock > 0")
}
if filter.ShowZeroStock != nil && *filter.ShowZeroStock {
query = query.Where("inventory.quantity = 0")
query = query.Where("ingredients.stock = 0")
}
if filter.Search != nil && *filter.Search != "" {
searchTerm := "%" + *filter.Search + "%"
@@ -581,24 +599,42 @@ func (r *InventoryRepositoryImpl) getInventoryIngredientsDetails(ctx context.Con
query = query.Order("ingredients.name ASC")
var results []struct {
var baseResults []struct {
ID uuid.UUID
IngredientID uuid.UUID
IngredientName string
UnitName *string
Quantity int
Quantity float64
ReorderLevel int
UnitCost float64
TotalValue float64
UpdatedAt time.Time
}
if err := query.Find(&results).Error; err != nil {
if err := query.Find(&baseResults).Error; err != nil {
return nil, err
}
var ingredients []*models.InventoryIngredientDetail
for _, result := range results {
for _, result := range baseResults {
var totalSold float64
soldQuery := r.db.WithContext(ctx).Model(&entities.InventoryMovement{}).
Select("COALESCE(SUM(ABS(quantity)), 0)").
Where("item_id = ? AND outlet_id = ? AND item_type = ? AND movement_type = ? AND quantity < 0",
result.IngredientID, filter.OutletID, "INGREDIENT", entities.InventoryMovementTypeSale)
if filter.DateFrom != nil {
soldQuery = soldQuery.Where("created_at >= ?", *filter.DateFrom)
}
if filter.DateTo != nil {
soldQuery = soldQuery.Where("created_at <= ?", *filter.DateTo)
}
if err := soldQuery.Scan(&totalSold).Error; err != nil {
return nil, fmt.Errorf("failed to get total sold for ingredient %s: %w", result.IngredientID, err)
}
unitName := ""
if result.UnitName != nil {
unitName = *result.UnitName
@@ -609,11 +645,12 @@ func (r *InventoryRepositoryImpl) getInventoryIngredientsDetails(ctx context.Con
IngredientID: result.IngredientID,
IngredientName: result.IngredientName,
UnitName: unitName,
Quantity: result.Quantity,
Quantity: int(result.Quantity),
ReorderLevel: result.ReorderLevel,
UnitCost: result.UnitCost,
TotalValue: result.TotalValue,
IsLowStock: result.Quantity <= result.ReorderLevel && result.Quantity > 0,
TotalSold: totalSold,
IsLowStock: result.Quantity <= 0 && result.Quantity > 0,
IsZeroStock: result.Quantity == 0,
UpdatedAt: result.UpdatedAt,
}
@@ -622,3 +659,43 @@ func (r *InventoryRepositoryImpl) getInventoryIngredientsDetails(ctx context.Con
return ingredients, nil
}
// getTotalSoldFromMovements calculates total sold quantities from inventory movements
func (r *InventoryRepositoryImpl) getTotalSoldFromMovements(ctx context.Context, outletID uuid.UUID, dateFrom, dateTo *time.Time) (float64, float64, error) {
var totalSoldProducts float64
var totalSoldIngredients float64
// Build base query for products
productQuery := r.db.WithContext(ctx).Model(&entities.InventoryMovement{}).
Select("COALESCE(SUM(ABS(quantity)), 0)").
Where("outlet_id = ? AND item_type = ? AND movement_type = ? AND quantity < 0",
outletID, "PRODUCT", entities.InventoryMovementTypeSale)
// Build base query for ingredients
ingredientQuery := r.db.WithContext(ctx).Model(&entities.InventoryMovement{}).
Select("COALESCE(SUM(ABS(quantity)), 0)").
Where("outlet_id = ? AND item_type = ? AND movement_type = ? AND quantity < 0",
outletID, "INGREDIENT", entities.InventoryMovementTypeSale)
// Apply date range filters if provided
if dateFrom != nil {
productQuery = productQuery.Where("created_at >= ?", *dateFrom)
ingredientQuery = ingredientQuery.Where("created_at >= ?", *dateFrom)
}
if dateTo != nil {
productQuery = productQuery.Where("created_at <= ?", *dateTo)
ingredientQuery = ingredientQuery.Where("created_at <= ?", *dateTo)
}
// Get total sold products from inventory movements
if err := productQuery.Scan(&totalSoldProducts).Error; err != nil {
return 0, 0, fmt.Errorf("failed to get total sold products: %w", err)
}
// Get total sold ingredients from inventory movements
if err := ingredientQuery.Scan(&totalSoldIngredients).Error; err != nil {
return 0, 0, fmt.Errorf("failed to get total sold ingredients: %w", err)
}
return totalSoldProducts, totalSoldIngredients, nil
}