From 7adba2c8f5146376f94ddcd4676c4f010d13d3be Mon Sep 17 00:00:00 2001 From: Aditya Siregar Date: Thu, 14 Aug 2025 00:38:26 +0700 Subject: [PATCH] Add Inventory Report --- internal/contract/inventory_contract.go | 4 + internal/handler/inventory_handler.go | 35 ++++- internal/models/inventory.go | 6 + internal/processor/inventory_processor.go | 10 +- internal/processor/order_processor.go | 24 +-- internal/repository/inventory_repository.go | 165 ++++++++++++++------ internal/router/router.go | 2 +- internal/service/inventory_service.go | 14 +- internal/util/date_util.go | 2 - 9 files changed, 177 insertions(+), 85 deletions(-) diff --git a/internal/contract/inventory_contract.go b/internal/contract/inventory_contract.go index 15daa87..ae8da7e 100644 --- a/internal/contract/inventory_contract.go +++ b/internal/contract/inventory_contract.go @@ -77,6 +77,8 @@ type InventoryReportSummaryResponse struct { LowStockIngredients int `json:"low_stock_ingredients"` ZeroStockProducts int `json:"zero_stock_products"` ZeroStockIngredients int `json:"zero_stock_ingredients"` + TotalSoldProducts float64 `json:"total_sold_products"` + TotalSoldIngredients float64 `json:"total_sold_ingredients"` OutletID string `json:"outlet_id"` OutletName string `json:"outlet_name"` GeneratedAt string `json:"generated_at"` @@ -97,6 +99,7 @@ type InventoryProductDetailResponse struct { ReorderLevel int `json:"reorder_level"` UnitCost float64 `json:"unit_cost"` TotalValue float64 `json:"total_value"` + TotalSold float64 `json:"total_sold"` IsLowStock bool `json:"is_low_stock"` IsZeroStock bool `json:"is_zero_stock"` UpdatedAt string `json:"updated_at"` @@ -111,6 +114,7 @@ type InventoryIngredientDetailResponse struct { ReorderLevel int `json:"reorder_level"` UnitCost float64 `json:"unit_cost"` TotalValue float64 `json:"total_value"` + TotalSold float64 `json:"total_sold"` IsLowStock bool `json:"is_low_stock"` IsZeroStock bool `json:"is_zero_stock"` UpdatedAt string `json:"updated_at"` diff --git a/internal/handler/inventory_handler.go b/internal/handler/inventory_handler.go index d92dc8e..e48cd3b 100644 --- a/internal/handler/inventory_handler.go +++ b/internal/handler/inventory_handler.go @@ -2,6 +2,7 @@ package handler import ( "strconv" + "time" "apskel-pos-be/internal/appcontext" "apskel-pos-be/internal/constants" @@ -279,7 +280,6 @@ func (h *InventoryHandler) GetZeroStockItems(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, inventoryResponse, "InventoryHandler::GetZeroStockItems") } -// GetInventoryReportSummary returns summary statistics for inventory report func (h *InventoryHandler) GetInventoryReportSummary(c *gin.Context) { ctx := c.Request.Context() contextInfo := appcontext.FromGinContext(ctx) @@ -293,7 +293,20 @@ func (h *InventoryHandler) GetInventoryReportSummary(c *gin.Context) { return } - summary, err := h.inventoryService.GetInventoryReportSummary(ctx, outletID, contextInfo.OrganizationID) + // Parse date range parameters for summary + var dateFrom, dateTo *time.Time + if dateFromStr := c.Query("date_from"); dateFromStr != "" { + if parsedDateFrom, err := time.Parse("2006-01-02", dateFromStr); err == nil { + dateFrom = &parsedDateFrom + } + } + if dateToStr := c.Query("date_to"); dateToStr != "" { + if parsedDateTo, err := time.Parse("2006-01-02", dateToStr); err == nil { + dateTo = &parsedDateTo + } + } + + summary, err := h.inventoryService.GetInventoryReportSummary(ctx, outletID, contextInfo.OrganizationID, dateFrom, dateTo) if err != nil { logger.FromContext(ctx).WithError(err).Error("InventoryHandler::GetInventoryReportSummary -> Failed to get inventory report summary from service") responseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()) @@ -305,16 +318,13 @@ func (h *InventoryHandler) GetInventoryReportSummary(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, response, "InventoryHandler::GetInventoryReportSummary") } -// GetInventoryReportDetails returns detailed inventory report with products and ingredients func (h *InventoryHandler) GetInventoryReportDetails(c *gin.Context) { ctx := c.Request.Context() contextInfo := appcontext.FromGinContext(ctx) - // Parse query parameters filter := &models.InventoryReportFilter{} - // Parse outlet_id (required) - if outletIDStr := c.Query("outlet_id"); outletIDStr != "" { + if outletIDStr := c.Param("outlet_id"); outletIDStr != "" { outletID, err := uuid.Parse(outletIDStr) if err != nil { logger.FromContext(ctx).WithError(err).Error("InventoryHandler::GetInventoryReportDetails -> Invalid outlet ID") @@ -330,7 +340,6 @@ func (h *InventoryHandler) GetInventoryReportDetails(c *gin.Context) { return } - // Parse category_id (optional) if categoryIDStr := c.Query("category_id"); categoryIDStr != "" { categoryID, err := uuid.Parse(categoryIDStr) if err != nil { @@ -375,6 +384,18 @@ func (h *InventoryHandler) GetInventoryReportDetails(c *gin.Context) { } } + dateFromStr := c.Query("date_from") + dateToStr := c.Query("date_to") + + if fromTime, toTime, err := util.ParseDateRangeToJakartaTime(dateFromStr, dateToStr); err == nil { + if fromTime != nil { + filter.DateFrom = fromTime + } + if toTime != nil { + filter.DateTo = toTime + } + } + report, err := h.inventoryService.GetInventoryReportDetails(ctx, filter, contextInfo.OrganizationID) if err != nil { logger.FromContext(ctx).WithError(err).Error("InventoryHandler::GetInventoryReportDetails -> Failed to get inventory report details from service") diff --git a/internal/models/inventory.go b/internal/models/inventory.go index c14ab8c..ad98b19 100644 --- a/internal/models/inventory.go +++ b/internal/models/inventory.go @@ -70,6 +70,8 @@ type InventoryReportSummary struct { LowStockIngredients int `json:"low_stock_ingredients"` ZeroStockProducts int `json:"zero_stock_products"` ZeroStockIngredients int `json:"zero_stock_ingredients"` + TotalSoldProducts float64 `json:"total_sold_products"` + TotalSoldIngredients float64 `json:"total_sold_ingredients"` OutletID uuid.UUID `json:"outlet_id"` OutletName string `json:"outlet_name"` GeneratedAt time.Time `json:"generated_at"` @@ -90,6 +92,7 @@ type InventoryProductDetail struct { ReorderLevel int `json:"reorder_level"` UnitCost float64 `json:"unit_cost"` TotalValue float64 `json:"total_value"` + TotalSold float64 `json:"total_sold"` IsLowStock bool `json:"is_low_stock"` IsZeroStock bool `json:"is_zero_stock"` UpdatedAt time.Time `json:"updated_at"` @@ -104,6 +107,7 @@ type InventoryIngredientDetail struct { ReorderLevel int `json:"reorder_level"` UnitCost float64 `json:"unit_cost"` TotalValue float64 `json:"total_value"` + TotalSold float64 `json:"total_sold"` IsLowStock bool `json:"is_low_stock"` IsZeroStock bool `json:"is_zero_stock"` UpdatedAt time.Time `json:"updated_at"` @@ -117,4 +121,6 @@ type InventoryReportFilter struct { Search *string `json:"search"` Limit *int `json:"limit"` Offset *int `json:"offset"` + DateFrom *time.Time `json:"date_from"` + DateTo *time.Time `json:"date_to"` } diff --git a/internal/processor/inventory_processor.go b/internal/processor/inventory_processor.go index 9a5aea3..d4ee511 100644 --- a/internal/processor/inventory_processor.go +++ b/internal/processor/inventory_processor.go @@ -3,6 +3,7 @@ package processor import ( "context" "fmt" + "time" "apskel-pos-be/internal/mappers" "apskel-pos-be/internal/models" @@ -25,7 +26,7 @@ type InventoryProcessor interface { AdjustQuantity(ctx context.Context, productID, outletID, organizationID uuid.UUID, delta int) (*models.InventoryResponse, error) SetQuantity(ctx context.Context, productID, outletID, organizationID uuid.UUID, quantity int) (*models.InventoryResponse, error) UpdateReorderLevel(ctx context.Context, id uuid.UUID, reorderLevel int, organizationID uuid.UUID) error - GetInventoryReportSummary(ctx context.Context, outletID, organizationID uuid.UUID) (*models.InventoryReportSummary, error) + GetInventoryReportSummary(ctx context.Context, outletID, organizationID uuid.UUID, dateFrom, dateTo *time.Time) (*models.InventoryReportSummary, error) GetInventoryReportDetails(ctx context.Context, filter *models.InventoryReportFilter, organizationID uuid.UUID) (*models.InventoryReportDetail, error) } @@ -257,9 +258,7 @@ func (p *InventoryProcessorImpl) GetZeroStock(ctx context.Context, outletID, org return responses, nil } -// GetInventoryReportSummary returns summary statistics for inventory report -func (p *InventoryProcessorImpl) GetInventoryReportSummary(ctx context.Context, outletID, organizationID uuid.UUID) (*models.InventoryReportSummary, error) { - // Verify outlet belongs to organization +func (p *InventoryProcessorImpl) GetInventoryReportSummary(ctx context.Context, outletID, organizationID uuid.UUID, dateFrom, dateTo *time.Time) (*models.InventoryReportSummary, error) { outlet, err := p.outletRepo.GetByID(ctx, outletID) if err != nil { return nil, fmt.Errorf("outlet not found: %w", err) @@ -268,7 +267,7 @@ func (p *InventoryProcessorImpl) GetInventoryReportSummary(ctx context.Context, return nil, fmt.Errorf("outlet does not belong to the organization") } - summary, err := p.inventoryRepo.GetInventoryReportSummary(ctx, outletID) + summary, err := p.inventoryRepo.GetInventoryReportSummary(ctx, outletID, dateFrom, dateTo) if err != nil { return nil, fmt.Errorf("failed to get inventory report summary: %w", err) } @@ -282,7 +281,6 @@ func (p *InventoryProcessorImpl) GetInventoryReportDetails(ctx context.Context, return nil, fmt.Errorf("outlet_id is required for inventory report") } - // Verify outlet belongs to organization outlet, err := p.outletRepo.GetByID(ctx, *filter.OutletID) if err != nil { return nil, fmt.Errorf("outlet not found: %w", err) diff --git a/internal/processor/order_processor.go b/internal/processor/order_processor.go index 25ea2a9..0d13688 100644 --- a/internal/processor/order_processor.go +++ b/internal/processor/order_processor.go @@ -1334,18 +1334,8 @@ type ingredientRecipeData struct { // prepareIngredientRecipeData prepares ingredient recipe data without making database calls func (p *OrderProcessorImpl) prepareIngredientRecipeData(ctx context.Context, item *entities.OrderItem, order *entities.Order, payment *entities.Payment) (*ingredientRecipeData, error) { - // Check if the product has ingredients - product, err := p.productRepo.GetByID(ctx, item.ProductID) - if err != nil { - return nil, fmt.Errorf("failed to get product: %w", err) - } - - if !product.HasIngredients { - return &ingredientRecipeData{}, nil // Product doesn't have ingredients - } - - // Get product recipes based on variant (if any) var recipes []*entities.ProductRecipe + var err error if item.ProductVariantID != nil { recipes, err = p.productRecipeRepo.GetByProductAndVariantID(ctx, item.ProductID, item.ProductVariantID, order.OrganizationID) } else { @@ -1363,7 +1353,6 @@ func (p *OrderProcessorImpl) prepareIngredientRecipeData(ctx context.Context, it var ingredientUpdates []*entities.Ingredient var movements []*entities.InventoryMovement - // Process each ingredient in the recipe for _, recipe := range recipes { ingredientData, err := p.prepareIngredientRecipeItem(ctx, recipe, item, order, payment) if err != nil { @@ -1388,20 +1377,15 @@ type ingredientRecipeItem struct { // prepareIngredientRecipeItem prepares data for a single ingredient recipe without making database calls func (p *OrderProcessorImpl) prepareIngredientRecipeItem(ctx context.Context, recipe *entities.ProductRecipe, item *entities.OrderItem, order *entities.Order, payment *entities.Payment) (*ingredientRecipeItem, error) { - // Calculate total ingredient quantity needed totalIngredientQuantity := recipe.Quantity * float64(item.Quantity) - // Get current ingredient details currentIngredient, err := p.ingredientRepo.GetByID(ctx, recipe.IngredientID, order.OrganizationID) if err != nil { return nil, fmt.Errorf("failed to get ingredient: %w", err) } - // For ingredients, we typically don't track quantity in the ingredient entity itself - // Instead, we create inventory movement records to track consumption - // The ingredient entity remains unchanged, but we track the movement + currentIngredient.Stock -= totalIngredientQuantity - // Prepare movement record movement := &entities.InventoryMovement{ OrganizationID: order.OrganizationID, OutletID: order.OutletID, @@ -1409,8 +1393,8 @@ func (p *OrderProcessorImpl) prepareIngredientRecipeItem(ctx context.Context, re ItemType: "INGREDIENT", MovementType: entities.InventoryMovementTypeIngredient, Quantity: -totalIngredientQuantity, - PreviousQuantity: 0, // We don't track current quantity in ingredient entity - NewQuantity: 0, // We don't track current quantity in ingredient entity + PreviousQuantity: 0, + NewQuantity: 0, UnitCost: currentIngredient.Cost, TotalCost: totalIngredientQuantity * currentIngredient.Cost, ReferenceType: func() *entities.InventoryMovementReferenceType { diff --git a/internal/repository/inventory_repository.go b/internal/repository/inventory_repository.go index d2ee96d..dd2237e 100644 --- a/internal/repository/inventory_repository.go +++ b/internal/repository/inventory_repository.go @@ -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 +} diff --git a/internal/router/router.go b/internal/router/router.go index 96271cb..133b18a 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -206,7 +206,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { inventory.GET("/low-stock/:outlet_id", r.inventoryHandler.GetLowStockItems) inventory.GET("/zero-stock/:outlet_id", r.inventoryHandler.GetZeroStockItems) inventory.GET("/report/summary/:outlet_id", r.inventoryHandler.GetInventoryReportSummary) - inventory.GET("/report/details", r.inventoryHandler.GetInventoryReportDetails) + inventory.GET("/report/details/:outlet_id", r.inventoryHandler.GetInventoryReportDetails) } orders := protected.Group("/orders") diff --git a/internal/service/inventory_service.go b/internal/service/inventory_service.go index 1efcf0f..3040bf2 100644 --- a/internal/service/inventory_service.go +++ b/internal/service/inventory_service.go @@ -23,7 +23,7 @@ type InventoryService interface { AdjustInventory(ctx context.Context, req *contract.AdjustInventoryRequest) *contract.Response GetLowStockItems(ctx context.Context, outletID uuid.UUID) *contract.Response GetZeroStockItems(ctx context.Context, outletID uuid.UUID) *contract.Response - GetInventoryReportSummary(ctx context.Context, outletID, organizationID uuid.UUID) (*contract.InventoryReportSummaryResponse, error) + GetInventoryReportSummary(ctx context.Context, outletID, organizationID uuid.UUID, dateFrom, dateTo *time.Time) (*contract.InventoryReportSummaryResponse, error) GetInventoryReportDetails(ctx context.Context, filter *models.InventoryReportFilter, organizationID uuid.UUID) (*contract.InventoryReportDetailResponse, error) } @@ -188,8 +188,8 @@ func (s *InventoryServiceImpl) GetZeroStockItems(ctx context.Context, outletID u } // GetInventoryReportSummary returns summary statistics for inventory report -func (s *InventoryServiceImpl) GetInventoryReportSummary(ctx context.Context, outletID, organizationID uuid.UUID) (*contract.InventoryReportSummaryResponse, error) { - summary, err := s.inventoryProcessor.GetInventoryReportSummary(ctx, outletID, organizationID) +func (s *InventoryServiceImpl) GetInventoryReportSummary(ctx context.Context, outletID, organizationID uuid.UUID, dateFrom, dateTo *time.Time) (*contract.InventoryReportSummaryResponse, error) { + summary, err := s.inventoryProcessor.GetInventoryReportSummary(ctx, outletID, organizationID, dateFrom, dateTo) if err != nil { return nil, err } @@ -202,6 +202,8 @@ func (s *InventoryServiceImpl) GetInventoryReportSummary(ctx context.Context, ou LowStockIngredients: summary.LowStockIngredients, ZeroStockProducts: summary.ZeroStockProducts, ZeroStockIngredients: summary.ZeroStockIngredients, + TotalSoldProducts: summary.TotalSoldProducts, + TotalSoldIngredients: summary.TotalSoldIngredients, OutletID: summary.OutletID.String(), OutletName: summary.OutletName, GeneratedAt: summary.GeneratedAt.Format(time.RFC3339), @@ -217,7 +219,6 @@ func (s *InventoryServiceImpl) GetInventoryReportDetails(ctx context.Context, fi response := &contract.InventoryReportDetailResponse{} - // Transform summary if report.Summary != nil { response.Summary = &contract.InventoryReportSummaryResponse{ TotalProducts: report.Summary.TotalProducts, @@ -227,13 +228,14 @@ func (s *InventoryServiceImpl) GetInventoryReportDetails(ctx context.Context, fi LowStockIngredients: report.Summary.LowStockIngredients, ZeroStockProducts: report.Summary.ZeroStockProducts, ZeroStockIngredients: report.Summary.ZeroStockIngredients, + TotalSoldProducts: report.Summary.TotalSoldProducts, + TotalSoldIngredients: report.Summary.TotalSoldIngredients, OutletID: report.Summary.OutletID.String(), OutletName: report.Summary.OutletName, GeneratedAt: report.Summary.GeneratedAt.Format(time.RFC3339), } } - // Transform products response.Products = make([]*contract.InventoryProductDetailResponse, len(report.Products)) for i, product := range report.Products { response.Products[i] = &contract.InventoryProductDetailResponse{ @@ -245,6 +247,7 @@ func (s *InventoryServiceImpl) GetInventoryReportDetails(ctx context.Context, fi ReorderLevel: product.ReorderLevel, UnitCost: product.UnitCost, TotalValue: product.TotalValue, + TotalSold: product.TotalSold, IsLowStock: product.IsLowStock, IsZeroStock: product.IsZeroStock, UpdatedAt: product.UpdatedAt.Format(time.RFC3339), @@ -263,6 +266,7 @@ func (s *InventoryServiceImpl) GetInventoryReportDetails(ctx context.Context, fi ReorderLevel: ingredient.ReorderLevel, UnitCost: ingredient.UnitCost, TotalValue: ingredient.TotalValue, + TotalSold: ingredient.TotalSold, IsLowStock: ingredient.IsLowStock, IsZeroStock: ingredient.IsZeroStock, UpdatedAt: ingredient.UpdatedAt.Format(time.RFC3339), diff --git a/internal/util/date_util.go b/internal/util/date_util.go index e9459f5..1202dbe 100644 --- a/internal/util/date_util.go +++ b/internal/util/date_util.go @@ -27,8 +27,6 @@ func ParseDateToJakartaTime(dateStr string) (*time.Time, error) { return &jakartaTime, nil } -// ParseDateToJakartaTimeEndOfDay parses a date string in DD-MM-YYYY format and converts it to Jakarta timezone -// Returns end of day (23:59:59.999999999) in Jakarta timezone func ParseDateToJakartaTimeEndOfDay(dateStr string) (*time.Time, error) { if dateStr == "" { return nil, nil