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

View File

@ -77,6 +77,8 @@ type InventoryReportSummaryResponse struct {
LowStockIngredients int `json:"low_stock_ingredients"` LowStockIngredients int `json:"low_stock_ingredients"`
ZeroStockProducts int `json:"zero_stock_products"` ZeroStockProducts int `json:"zero_stock_products"`
ZeroStockIngredients int `json:"zero_stock_ingredients"` ZeroStockIngredients int `json:"zero_stock_ingredients"`
TotalSoldProducts float64 `json:"total_sold_products"`
TotalSoldIngredients float64 `json:"total_sold_ingredients"`
OutletID string `json:"outlet_id"` OutletID string `json:"outlet_id"`
OutletName string `json:"outlet_name"` OutletName string `json:"outlet_name"`
GeneratedAt string `json:"generated_at"` GeneratedAt string `json:"generated_at"`
@ -97,6 +99,7 @@ type InventoryProductDetailResponse struct {
ReorderLevel int `json:"reorder_level"` ReorderLevel int `json:"reorder_level"`
UnitCost float64 `json:"unit_cost"` UnitCost float64 `json:"unit_cost"`
TotalValue float64 `json:"total_value"` TotalValue float64 `json:"total_value"`
TotalSold float64 `json:"total_sold"`
IsLowStock bool `json:"is_low_stock"` IsLowStock bool `json:"is_low_stock"`
IsZeroStock bool `json:"is_zero_stock"` IsZeroStock bool `json:"is_zero_stock"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
@ -111,6 +114,7 @@ type InventoryIngredientDetailResponse struct {
ReorderLevel int `json:"reorder_level"` ReorderLevel int `json:"reorder_level"`
UnitCost float64 `json:"unit_cost"` UnitCost float64 `json:"unit_cost"`
TotalValue float64 `json:"total_value"` TotalValue float64 `json:"total_value"`
TotalSold float64 `json:"total_sold"`
IsLowStock bool `json:"is_low_stock"` IsLowStock bool `json:"is_low_stock"`
IsZeroStock bool `json:"is_zero_stock"` IsZeroStock bool `json:"is_zero_stock"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`

View File

@ -2,6 +2,7 @@ package handler
import ( import (
"strconv" "strconv"
"time"
"apskel-pos-be/internal/appcontext" "apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants" "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") util.HandleResponse(c.Writer, c.Request, inventoryResponse, "InventoryHandler::GetZeroStockItems")
} }
// GetInventoryReportSummary returns summary statistics for inventory report
func (h *InventoryHandler) GetInventoryReportSummary(c *gin.Context) { func (h *InventoryHandler) GetInventoryReportSummary(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx) contextInfo := appcontext.FromGinContext(ctx)
@ -293,7 +293,20 @@ func (h *InventoryHandler) GetInventoryReportSummary(c *gin.Context) {
return 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 { if err != nil {
logger.FromContext(ctx).WithError(err).Error("InventoryHandler::GetInventoryReportSummary -> Failed to get inventory report summary from service") 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()) 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") 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) { func (h *InventoryHandler) GetInventoryReportDetails(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx) contextInfo := appcontext.FromGinContext(ctx)
// Parse query parameters
filter := &models.InventoryReportFilter{} filter := &models.InventoryReportFilter{}
// Parse outlet_id (required) if outletIDStr := c.Param("outlet_id"); outletIDStr != "" {
if outletIDStr := c.Query("outlet_id"); outletIDStr != "" {
outletID, err := uuid.Parse(outletIDStr) outletID, err := uuid.Parse(outletIDStr)
if err != nil { if err != nil {
logger.FromContext(ctx).WithError(err).Error("InventoryHandler::GetInventoryReportDetails -> Invalid outlet ID") logger.FromContext(ctx).WithError(err).Error("InventoryHandler::GetInventoryReportDetails -> Invalid outlet ID")
@ -330,7 +340,6 @@ func (h *InventoryHandler) GetInventoryReportDetails(c *gin.Context) {
return return
} }
// Parse category_id (optional)
if categoryIDStr := c.Query("category_id"); categoryIDStr != "" { if categoryIDStr := c.Query("category_id"); categoryIDStr != "" {
categoryID, err := uuid.Parse(categoryIDStr) categoryID, err := uuid.Parse(categoryIDStr)
if err != nil { 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) report, err := h.inventoryService.GetInventoryReportDetails(ctx, filter, contextInfo.OrganizationID)
if err != nil { if err != nil {
logger.FromContext(ctx).WithError(err).Error("InventoryHandler::GetInventoryReportDetails -> Failed to get inventory report details from service") logger.FromContext(ctx).WithError(err).Error("InventoryHandler::GetInventoryReportDetails -> Failed to get inventory report details from service")

View File

@ -70,6 +70,8 @@ type InventoryReportSummary struct {
LowStockIngredients int `json:"low_stock_ingredients"` LowStockIngredients int `json:"low_stock_ingredients"`
ZeroStockProducts int `json:"zero_stock_products"` ZeroStockProducts int `json:"zero_stock_products"`
ZeroStockIngredients int `json:"zero_stock_ingredients"` ZeroStockIngredients int `json:"zero_stock_ingredients"`
TotalSoldProducts float64 `json:"total_sold_products"`
TotalSoldIngredients float64 `json:"total_sold_ingredients"`
OutletID uuid.UUID `json:"outlet_id"` OutletID uuid.UUID `json:"outlet_id"`
OutletName string `json:"outlet_name"` OutletName string `json:"outlet_name"`
GeneratedAt time.Time `json:"generated_at"` GeneratedAt time.Time `json:"generated_at"`
@ -90,6 +92,7 @@ type InventoryProductDetail struct {
ReorderLevel int `json:"reorder_level"` ReorderLevel int `json:"reorder_level"`
UnitCost float64 `json:"unit_cost"` UnitCost float64 `json:"unit_cost"`
TotalValue float64 `json:"total_value"` TotalValue float64 `json:"total_value"`
TotalSold float64 `json:"total_sold"`
IsLowStock bool `json:"is_low_stock"` IsLowStock bool `json:"is_low_stock"`
IsZeroStock bool `json:"is_zero_stock"` IsZeroStock bool `json:"is_zero_stock"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
@ -104,6 +107,7 @@ type InventoryIngredientDetail struct {
ReorderLevel int `json:"reorder_level"` ReorderLevel int `json:"reorder_level"`
UnitCost float64 `json:"unit_cost"` UnitCost float64 `json:"unit_cost"`
TotalValue float64 `json:"total_value"` TotalValue float64 `json:"total_value"`
TotalSold float64 `json:"total_sold"`
IsLowStock bool `json:"is_low_stock"` IsLowStock bool `json:"is_low_stock"`
IsZeroStock bool `json:"is_zero_stock"` IsZeroStock bool `json:"is_zero_stock"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
@ -117,4 +121,6 @@ type InventoryReportFilter struct {
Search *string `json:"search"` Search *string `json:"search"`
Limit *int `json:"limit"` Limit *int `json:"limit"`
Offset *int `json:"offset"` Offset *int `json:"offset"`
DateFrom *time.Time `json:"date_from"`
DateTo *time.Time `json:"date_to"`
} }

View File

@ -3,6 +3,7 @@ package processor
import ( import (
"context" "context"
"fmt" "fmt"
"time"
"apskel-pos-be/internal/mappers" "apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models" "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) 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) 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 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) 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 return responses, nil
} }
// GetInventoryReportSummary returns summary statistics for inventory report func (p *InventoryProcessorImpl) GetInventoryReportSummary(ctx context.Context, outletID, organizationID uuid.UUID, dateFrom, dateTo *time.Time) (*models.InventoryReportSummary, error) {
func (p *InventoryProcessorImpl) GetInventoryReportSummary(ctx context.Context, outletID, organizationID uuid.UUID) (*models.InventoryReportSummary, error) {
// Verify outlet belongs to organization
outlet, err := p.outletRepo.GetByID(ctx, outletID) outlet, err := p.outletRepo.GetByID(ctx, outletID)
if err != nil { if err != nil {
return nil, fmt.Errorf("outlet not found: %w", err) 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") 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 { if err != nil {
return nil, fmt.Errorf("failed to get inventory report summary: %w", err) 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") return nil, fmt.Errorf("outlet_id is required for inventory report")
} }
// Verify outlet belongs to organization
outlet, err := p.outletRepo.GetByID(ctx, *filter.OutletID) outlet, err := p.outletRepo.GetByID(ctx, *filter.OutletID)
if err != nil { if err != nil {
return nil, fmt.Errorf("outlet not found: %w", err) return nil, fmt.Errorf("outlet not found: %w", err)

View File

@ -1334,18 +1334,8 @@ type ingredientRecipeData struct {
// prepareIngredientRecipeData prepares ingredient recipe data without making database calls // 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) { 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 recipes []*entities.ProductRecipe
var err error
if item.ProductVariantID != nil { if item.ProductVariantID != nil {
recipes, err = p.productRecipeRepo.GetByProductAndVariantID(ctx, item.ProductID, item.ProductVariantID, order.OrganizationID) recipes, err = p.productRecipeRepo.GetByProductAndVariantID(ctx, item.ProductID, item.ProductVariantID, order.OrganizationID)
} else { } else {
@ -1363,7 +1353,6 @@ func (p *OrderProcessorImpl) prepareIngredientRecipeData(ctx context.Context, it
var ingredientUpdates []*entities.Ingredient var ingredientUpdates []*entities.Ingredient
var movements []*entities.InventoryMovement var movements []*entities.InventoryMovement
// Process each ingredient in the recipe
for _, recipe := range recipes { for _, recipe := range recipes {
ingredientData, err := p.prepareIngredientRecipeItem(ctx, recipe, item, order, payment) ingredientData, err := p.prepareIngredientRecipeItem(ctx, recipe, item, order, payment)
if err != nil { if err != nil {
@ -1388,20 +1377,15 @@ type ingredientRecipeItem struct {
// prepareIngredientRecipeItem prepares data for a single ingredient recipe without making database calls // 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) { 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) totalIngredientQuantity := recipe.Quantity * float64(item.Quantity)
// Get current ingredient details
currentIngredient, err := p.ingredientRepo.GetByID(ctx, recipe.IngredientID, order.OrganizationID) currentIngredient, err := p.ingredientRepo.GetByID(ctx, recipe.IngredientID, order.OrganizationID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get ingredient: %w", err) return nil, fmt.Errorf("failed to get ingredient: %w", err)
} }
// For ingredients, we typically don't track quantity in the ingredient entity itself currentIngredient.Stock -= totalIngredientQuantity
// Instead, we create inventory movement records to track consumption
// The ingredient entity remains unchanged, but we track the movement
// Prepare movement record
movement := &entities.InventoryMovement{ movement := &entities.InventoryMovement{
OrganizationID: order.OrganizationID, OrganizationID: order.OrganizationID,
OutletID: order.OutletID, OutletID: order.OutletID,
@ -1409,8 +1393,8 @@ func (p *OrderProcessorImpl) prepareIngredientRecipeItem(ctx context.Context, re
ItemType: "INGREDIENT", ItemType: "INGREDIENT",
MovementType: entities.InventoryMovementTypeIngredient, MovementType: entities.InventoryMovementTypeIngredient,
Quantity: -totalIngredientQuantity, Quantity: -totalIngredientQuantity,
PreviousQuantity: 0, // We don't track current quantity in ingredient entity PreviousQuantity: 0,
NewQuantity: 0, // We don't track current quantity in ingredient entity NewQuantity: 0,
UnitCost: currentIngredient.Cost, UnitCost: currentIngredient.Cost,
TotalCost: totalIngredientQuantity * currentIngredient.Cost, TotalCost: totalIngredientQuantity * currentIngredient.Cost,
ReferenceType: func() *entities.InventoryMovementReferenceType { ReferenceType: func() *entities.InventoryMovementReferenceType {

View File

@ -33,7 +33,7 @@ type InventoryRepository interface {
BulkUpdate(ctx context.Context, inventoryItems []*entities.Inventory) error BulkUpdate(ctx context.Context, inventoryItems []*entities.Inventory) error
BulkAdjustQuantity(ctx context.Context, adjustments map[uuid.UUID]int, outletID uuid.UUID) error BulkAdjustQuantity(ctx context.Context, adjustments map[uuid.UUID]int, outletID uuid.UUID) error
GetTotalValueByOutlet(ctx context.Context, outletID uuid.UUID) (float64, 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) 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 // 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 var summary models.InventoryReportSummary
summary.OutletID = outletID summary.OutletID = outletID
summary.GeneratedAt = time.Now() summary.GeneratedAt = time.Now()
@ -355,37 +355,32 @@ func (r *InventoryRepositoryImpl) GetInventoryReportSummary(ctx context.Context,
} }
summary.OutletName = outlet.Name summary.OutletName = outlet.Name
// Get total products count
var totalProducts int64 var totalProducts int64
if err := r.db.WithContext(ctx).Model(&entities.Inventory{}). if err := r.db.WithContext(ctx).Model(&entities.Inventory{}).
Joins("JOIN products ON inventory.product_id = products.id"). 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 { Count(&totalProducts).Error; err != nil {
return nil, fmt.Errorf("failed to count total products: %w", err) return nil, fmt.Errorf("failed to count total products: %w", err)
} }
summary.TotalProducts = int(totalProducts) summary.TotalProducts = int(totalProducts)
// Get total ingredients count
var totalIngredients int64 var totalIngredients int64
if err := r.db.WithContext(ctx).Model(&entities.Inventory{}). if err := r.db.WithContext(ctx).Model(&entities.Ingredient{}).
Joins("JOIN ingredients ON inventory.product_id = ingredients.id"). Where("outlet_id = ? AND is_active = ?", outletID, true).
Where("inventory.outlet_id = ?", outletID).
Count(&totalIngredients).Error; err != nil { Count(&totalIngredients).Error; err != nil {
return nil, fmt.Errorf("failed to count total ingredients: %w", err) return nil, fmt.Errorf("failed to count total ingredients: %w", err)
} }
summary.TotalIngredients = int(totalIngredients) summary.TotalIngredients = int(totalIngredients)
// Get low stock products count
var lowStockProducts int64 var lowStockProducts int64
if err := r.db.WithContext(ctx).Model(&entities.Inventory{}). if err := r.db.WithContext(ctx).Model(&entities.Inventory{}).
Joins("JOIN products ON inventory.product_id = products.id"). 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 { Count(&lowStockProducts).Error; err != nil {
return nil, fmt.Errorf("failed to count low stock products: %w", err) return nil, fmt.Errorf("failed to count low stock products: %w", err)
} }
summary.LowStockProducts = int(lowStockProducts) summary.LowStockProducts = int(lowStockProducts)
// Get low stock ingredients count
var lowStockIngredients int64 var lowStockIngredients int64
if err := r.db.WithContext(ctx).Model(&entities.Inventory{}). if err := r.db.WithContext(ctx).Model(&entities.Inventory{}).
Joins("JOIN ingredients ON inventory.product_id = ingredients.id"). Joins("JOIN ingredients ON inventory.product_id = ingredients.id").
@ -395,27 +390,31 @@ func (r *InventoryRepositoryImpl) GetInventoryReportSummary(ctx context.Context,
} }
summary.LowStockIngredients = int(lowStockIngredients) summary.LowStockIngredients = int(lowStockIngredients)
// Get zero stock products count
var zeroStockProducts int64 var zeroStockProducts int64
if err := r.db.WithContext(ctx).Model(&entities.Inventory{}). if err := r.db.WithContext(ctx).Model(&entities.Inventory{}).
Joins("JOIN products ON inventory.product_id = products.id"). 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 { Count(&zeroStockProducts).Error; err != nil {
return nil, fmt.Errorf("failed to count zero stock products: %w", err) return nil, fmt.Errorf("failed to count zero stock products: %w", err)
} }
summary.ZeroStockProducts = int(zeroStockProducts) summary.ZeroStockProducts = int(zeroStockProducts)
// Get zero stock ingredients count
var zeroStockIngredients int64 var zeroStockIngredients int64
if err := r.db.WithContext(ctx).Model(&entities.Inventory{}). if err := r.db.WithContext(ctx).Model(&entities.Ingredient{}).
Joins("JOIN ingredients ON inventory.product_id = ingredients.id"). Where("outlet_id = ? AND is_active = ? AND stock = 0", outletID, true).
Where("inventory.outlet_id = ? AND inventory.quantity = 0", outletID).
Count(&zeroStockIngredients).Error; err != nil { Count(&zeroStockIngredients).Error; err != nil {
return nil, fmt.Errorf("failed to count zero stock ingredients: %w", err) return nil, fmt.Errorf("failed to count zero stock ingredients: %w", err)
} }
summary.ZeroStockIngredients = int(zeroStockIngredients) 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) totalValue, err := r.GetTotalValueByOutlet(ctx, outletID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get total value: %w", err) 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) { func (r *InventoryRepositoryImpl) GetInventoryReportDetails(ctx context.Context, filter *models.InventoryReportFilter) (*models.InventoryReportDetail, error) {
report := &models.InventoryReportDetail{} report := &models.InventoryReportDetail{}
// Get summary
if filter.OutletID != nil { 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 { if err != nil {
return nil, fmt.Errorf("failed to get report summary: %w", err) return nil, fmt.Errorf("failed to get report summary: %w", err)
} }
report.Summary = summary report.Summary = summary
} }
// Get products details
products, err := r.getInventoryProductsDetails(ctx, filter) products, err := r.getInventoryProductsDetails(ctx, filter)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get products details: %w", err) return nil, fmt.Errorf("failed to get products details: %w", err)
} }
report.Products = products report.Products = products
// Get ingredients details
ingredients, err := r.getInventoryIngredientsDetails(ctx, filter) ingredients, err := r.getInventoryIngredientsDetails(ctx, filter)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get ingredients details: %w", err) 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 // getInventoryProductsDetails retrieves detailed product inventory information
func (r *InventoryRepositoryImpl) getInventoryProductsDetails(ctx context.Context, filter *models.InventoryReportFilter) ([]*models.InventoryProductDetail, error) { func (r *InventoryRepositoryImpl) getInventoryProductsDetails(ctx context.Context, filter *models.InventoryReportFilter) ([]*models.InventoryProductDetail, error) {
// Build the base query
query := r.db.WithContext(ctx).Table("inventory"). query := r.db.WithContext(ctx).Table("inventory").
Select(` Select(`
inventory.id, inventory.id,
@ -472,7 +469,7 @@ func (r *InventoryRepositoryImpl) getInventoryProductsDetails(ctx context.Contex
Joins("JOIN products ON inventory.product_id = products.id"). Joins("JOIN products ON inventory.product_id = products.id").
Joins("LEFT JOIN categories ON products.category_id = categories.id"). Joins("LEFT JOIN categories ON products.category_id = categories.id").
Joins("LEFT JOIN product_variants ON products.id = product_variants.product_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 // Apply filters
if filter.CategoryID != nil { if filter.CategoryID != nil {
@ -499,7 +496,8 @@ func (r *InventoryRepositoryImpl) getInventoryProductsDetails(ctx context.Contex
query = query.Order("products.name ASC") query = query.Order("products.name ASC")
var results []struct { // Execute the base query first
var baseResults []struct {
ID uuid.UUID ID uuid.UUID
ProductID uuid.UUID ProductID uuid.UUID
ProductName string ProductName string
@ -511,12 +509,33 @@ func (r *InventoryRepositoryImpl) getInventoryProductsDetails(ctx context.Contex
UpdatedAt time.Time UpdatedAt time.Time
} }
if err := query.Find(&results).Error; err != nil { if err := query.Find(&baseResults).Error; err != nil {
return nil, err return nil, err
} }
// Now get total sold for each product
var products []*models.InventoryProductDetail 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 := "" categoryName := ""
if result.CategoryName != nil { if result.CategoryName != nil {
categoryName = *result.CategoryName categoryName = *result.CategoryName
@ -531,6 +550,7 @@ func (r *InventoryRepositoryImpl) getInventoryProductsDetails(ctx context.Contex
ReorderLevel: result.ReorderLevel, ReorderLevel: result.ReorderLevel,
UnitCost: result.UnitCost, UnitCost: result.UnitCost,
TotalValue: result.TotalValue, TotalValue: result.TotalValue,
TotalSold: totalSold,
IsLowStock: result.Quantity <= result.ReorderLevel && result.Quantity > 0, IsLowStock: result.Quantity <= result.ReorderLevel && result.Quantity > 0,
IsZeroStock: result.Quantity == 0, IsZeroStock: result.Quantity == 0,
UpdatedAt: result.UpdatedAt, UpdatedAt: result.UpdatedAt,
@ -543,28 +563,26 @@ func (r *InventoryRepositoryImpl) getInventoryProductsDetails(ctx context.Contex
// getInventoryIngredientsDetails retrieves detailed ingredient inventory information // getInventoryIngredientsDetails retrieves detailed ingredient inventory information
func (r *InventoryRepositoryImpl) getInventoryIngredientsDetails(ctx context.Context, filter *models.InventoryReportFilter) ([]*models.InventoryIngredientDetail, error) { 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(` Select(`
inventory.id, ingredients.id,
inventory.product_id as ingredient_id, ingredients.id as ingredient_id,
ingredients.name as ingredient_name, ingredients.name as ingredient_name,
units.name as unit_name, units.name as unit_name,
inventory.quantity, ingredients.stock as quantity,
inventory.reorder_level, 0 as reorder_level,
ingredients.cost as unit_cost, ingredients.cost as unit_cost,
(ingredients.cost * inventory.quantity) as total_value, (ingredients.cost * ingredients.stock) as total_value,
inventory.updated_at ingredients.updated_at
`). `).
Joins("JOIN ingredients ON inventory.product_id = ingredients.id").
Joins("LEFT JOIN units ON ingredients.unit_id = units.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 { 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 { if filter.ShowZeroStock != nil && *filter.ShowZeroStock {
query = query.Where("inventory.quantity = 0") query = query.Where("ingredients.stock = 0")
} }
if filter.Search != nil && *filter.Search != "" { if filter.Search != nil && *filter.Search != "" {
searchTerm := "%" + *filter.Search + "%" searchTerm := "%" + *filter.Search + "%"
@ -581,24 +599,42 @@ func (r *InventoryRepositoryImpl) getInventoryIngredientsDetails(ctx context.Con
query = query.Order("ingredients.name ASC") query = query.Order("ingredients.name ASC")
var results []struct { var baseResults []struct {
ID uuid.UUID ID uuid.UUID
IngredientID uuid.UUID IngredientID uuid.UUID
IngredientName string IngredientName string
UnitName *string UnitName *string
Quantity int Quantity float64
ReorderLevel int ReorderLevel int
UnitCost float64 UnitCost float64
TotalValue float64 TotalValue float64
UpdatedAt time.Time UpdatedAt time.Time
} }
if err := query.Find(&results).Error; err != nil { if err := query.Find(&baseResults).Error; err != nil {
return nil, err return nil, err
} }
var ingredients []*models.InventoryIngredientDetail 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 := "" unitName := ""
if result.UnitName != nil { if result.UnitName != nil {
unitName = *result.UnitName unitName = *result.UnitName
@ -609,11 +645,12 @@ func (r *InventoryRepositoryImpl) getInventoryIngredientsDetails(ctx context.Con
IngredientID: result.IngredientID, IngredientID: result.IngredientID,
IngredientName: result.IngredientName, IngredientName: result.IngredientName,
UnitName: unitName, UnitName: unitName,
Quantity: result.Quantity, Quantity: int(result.Quantity),
ReorderLevel: result.ReorderLevel, ReorderLevel: result.ReorderLevel,
UnitCost: result.UnitCost, UnitCost: result.UnitCost,
TotalValue: result.TotalValue, TotalValue: result.TotalValue,
IsLowStock: result.Quantity <= result.ReorderLevel && result.Quantity > 0, TotalSold: totalSold,
IsLowStock: result.Quantity <= 0 && result.Quantity > 0,
IsZeroStock: result.Quantity == 0, IsZeroStock: result.Quantity == 0,
UpdatedAt: result.UpdatedAt, UpdatedAt: result.UpdatedAt,
} }
@ -622,3 +659,43 @@ func (r *InventoryRepositoryImpl) getInventoryIngredientsDetails(ctx context.Con
return ingredients, nil 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
}

View File

@ -206,7 +206,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
inventory.GET("/low-stock/:outlet_id", r.inventoryHandler.GetLowStockItems) inventory.GET("/low-stock/:outlet_id", r.inventoryHandler.GetLowStockItems)
inventory.GET("/zero-stock/:outlet_id", r.inventoryHandler.GetZeroStockItems) inventory.GET("/zero-stock/:outlet_id", r.inventoryHandler.GetZeroStockItems)
inventory.GET("/report/summary/:outlet_id", r.inventoryHandler.GetInventoryReportSummary) 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") orders := protected.Group("/orders")

View File

@ -23,7 +23,7 @@ type InventoryService interface {
AdjustInventory(ctx context.Context, req *contract.AdjustInventoryRequest) *contract.Response AdjustInventory(ctx context.Context, req *contract.AdjustInventoryRequest) *contract.Response
GetLowStockItems(ctx context.Context, outletID uuid.UUID) *contract.Response GetLowStockItems(ctx context.Context, outletID uuid.UUID) *contract.Response
GetZeroStockItems(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) 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 // GetInventoryReportSummary returns summary statistics for inventory report
func (s *InventoryServiceImpl) GetInventoryReportSummary(ctx context.Context, outletID, organizationID uuid.UUID) (*contract.InventoryReportSummaryResponse, error) { 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) summary, err := s.inventoryProcessor.GetInventoryReportSummary(ctx, outletID, organizationID, dateFrom, dateTo)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -202,6 +202,8 @@ func (s *InventoryServiceImpl) GetInventoryReportSummary(ctx context.Context, ou
LowStockIngredients: summary.LowStockIngredients, LowStockIngredients: summary.LowStockIngredients,
ZeroStockProducts: summary.ZeroStockProducts, ZeroStockProducts: summary.ZeroStockProducts,
ZeroStockIngredients: summary.ZeroStockIngredients, ZeroStockIngredients: summary.ZeroStockIngredients,
TotalSoldProducts: summary.TotalSoldProducts,
TotalSoldIngredients: summary.TotalSoldIngredients,
OutletID: summary.OutletID.String(), OutletID: summary.OutletID.String(),
OutletName: summary.OutletName, OutletName: summary.OutletName,
GeneratedAt: summary.GeneratedAt.Format(time.RFC3339), GeneratedAt: summary.GeneratedAt.Format(time.RFC3339),
@ -217,7 +219,6 @@ func (s *InventoryServiceImpl) GetInventoryReportDetails(ctx context.Context, fi
response := &contract.InventoryReportDetailResponse{} response := &contract.InventoryReportDetailResponse{}
// Transform summary
if report.Summary != nil { if report.Summary != nil {
response.Summary = &contract.InventoryReportSummaryResponse{ response.Summary = &contract.InventoryReportSummaryResponse{
TotalProducts: report.Summary.TotalProducts, TotalProducts: report.Summary.TotalProducts,
@ -227,13 +228,14 @@ func (s *InventoryServiceImpl) GetInventoryReportDetails(ctx context.Context, fi
LowStockIngredients: report.Summary.LowStockIngredients, LowStockIngredients: report.Summary.LowStockIngredients,
ZeroStockProducts: report.Summary.ZeroStockProducts, ZeroStockProducts: report.Summary.ZeroStockProducts,
ZeroStockIngredients: report.Summary.ZeroStockIngredients, ZeroStockIngredients: report.Summary.ZeroStockIngredients,
TotalSoldProducts: report.Summary.TotalSoldProducts,
TotalSoldIngredients: report.Summary.TotalSoldIngredients,
OutletID: report.Summary.OutletID.String(), OutletID: report.Summary.OutletID.String(),
OutletName: report.Summary.OutletName, OutletName: report.Summary.OutletName,
GeneratedAt: report.Summary.GeneratedAt.Format(time.RFC3339), GeneratedAt: report.Summary.GeneratedAt.Format(time.RFC3339),
} }
} }
// Transform products
response.Products = make([]*contract.InventoryProductDetailResponse, len(report.Products)) response.Products = make([]*contract.InventoryProductDetailResponse, len(report.Products))
for i, product := range report.Products { for i, product := range report.Products {
response.Products[i] = &contract.InventoryProductDetailResponse{ response.Products[i] = &contract.InventoryProductDetailResponse{
@ -245,6 +247,7 @@ func (s *InventoryServiceImpl) GetInventoryReportDetails(ctx context.Context, fi
ReorderLevel: product.ReorderLevel, ReorderLevel: product.ReorderLevel,
UnitCost: product.UnitCost, UnitCost: product.UnitCost,
TotalValue: product.TotalValue, TotalValue: product.TotalValue,
TotalSold: product.TotalSold,
IsLowStock: product.IsLowStock, IsLowStock: product.IsLowStock,
IsZeroStock: product.IsZeroStock, IsZeroStock: product.IsZeroStock,
UpdatedAt: product.UpdatedAt.Format(time.RFC3339), UpdatedAt: product.UpdatedAt.Format(time.RFC3339),
@ -263,6 +266,7 @@ func (s *InventoryServiceImpl) GetInventoryReportDetails(ctx context.Context, fi
ReorderLevel: ingredient.ReorderLevel, ReorderLevel: ingredient.ReorderLevel,
UnitCost: ingredient.UnitCost, UnitCost: ingredient.UnitCost,
TotalValue: ingredient.TotalValue, TotalValue: ingredient.TotalValue,
TotalSold: ingredient.TotalSold,
IsLowStock: ingredient.IsLowStock, IsLowStock: ingredient.IsLowStock,
IsZeroStock: ingredient.IsZeroStock, IsZeroStock: ingredient.IsZeroStock,
UpdatedAt: ingredient.UpdatedAt.Format(time.RFC3339), UpdatedAt: ingredient.UpdatedAt.Format(time.RFC3339),

View File

@ -27,8 +27,6 @@ func ParseDateToJakartaTime(dateStr string) (*time.Time, error) {
return &jakartaTime, nil 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) { func ParseDateToJakartaTimeEndOfDay(dateStr string) (*time.Time, error) {
if dateStr == "" { if dateStr == "" {
return nil, nil return nil, nil