package repository import ( "context" "errors" "fmt" "time" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" "github.com/google/uuid" "gorm.io/gorm" ) type InventoryRepository interface { Create(ctx context.Context, inventory *entities.Inventory) error GetByID(ctx context.Context, id uuid.UUID) (*entities.Inventory, error) GetWithRelations(ctx context.Context, id uuid.UUID) (*entities.Inventory, error) GetByProductAndOutlet(ctx context.Context, productID, outletID uuid.UUID) (*entities.Inventory, error) GetByOutlet(ctx context.Context, outletID uuid.UUID) ([]*entities.Inventory, error) GetByProduct(ctx context.Context, productID uuid.UUID) ([]*entities.Inventory, error) GetLowStock(ctx context.Context, outletID uuid.UUID) ([]*entities.Inventory, error) GetZeroStock(ctx context.Context, outletID uuid.UUID) ([]*entities.Inventory, error) Update(ctx context.Context, inventory *entities.Inventory) error Delete(ctx context.Context, id uuid.UUID) error List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.Inventory, int64, error) Count(ctx context.Context, filters map[string]interface{}) (int64, error) AdjustQuantity(ctx context.Context, productID, outletID uuid.UUID, delta int) (*entities.Inventory, error) SetQuantity(ctx context.Context, productID, outletID uuid.UUID, quantity int) (*entities.Inventory, error) UpdateReorderLevel(ctx context.Context, id uuid.UUID, reorderLevel int) error BulkCreate(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 GetTotalValueByOutlet(ctx context.Context, outletID uuid.UUID) (float64, 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) } type InventoryRepositoryImpl struct { db *gorm.DB } func NewInventoryRepositoryImpl(db *gorm.DB) *InventoryRepositoryImpl { return &InventoryRepositoryImpl{ db: db, } } func (r *InventoryRepositoryImpl) Create(ctx context.Context, inventory *entities.Inventory) error { return r.db.WithContext(ctx).Create(inventory).Error } func (r *InventoryRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.Inventory, error) { var inventory entities.Inventory err := r.db.WithContext(ctx).First(&inventory, "id = ?", id).Error if err != nil { return nil, err } return &inventory, nil } func (r *InventoryRepositoryImpl) GetWithRelations(ctx context.Context, id uuid.UUID) (*entities.Inventory, error) { var inventory entities.Inventory err := r.db.WithContext(ctx). Preload("Product"). Preload("Product.Category"). Preload("Outlet"). First(&inventory, "id = ?", id).Error if err != nil { return nil, err } return &inventory, nil } func (r *InventoryRepositoryImpl) GetByProductAndOutlet(ctx context.Context, productID, outletID uuid.UUID) (*entities.Inventory, error) { var inventory entities.Inventory err := r.db.WithContext(ctx).Where("product_id = ? AND outlet_id = ?", productID, outletID).First(&inventory).Error if err != nil { return nil, err } return &inventory, nil } func (r *InventoryRepositoryImpl) GetByOutlet(ctx context.Context, outletID uuid.UUID) ([]*entities.Inventory, error) { var inventory []*entities.Inventory err := r.db.WithContext(ctx). Preload("Product"). Preload("Product.Category"). Where("outlet_id = ?", outletID). Find(&inventory).Error return inventory, err } func (r *InventoryRepositoryImpl) GetByProduct(ctx context.Context, productID uuid.UUID) ([]*entities.Inventory, error) { var inventory []*entities.Inventory err := r.db.WithContext(ctx). Preload("Outlet"). Where("product_id = ?", productID). Find(&inventory).Error return inventory, err } func (r *InventoryRepositoryImpl) GetLowStock(ctx context.Context, outletID uuid.UUID) ([]*entities.Inventory, error) { var inventory []*entities.Inventory err := r.db.WithContext(ctx). Preload("Product"). Preload("Product.Category"). Where("outlet_id = ? AND quantity <= reorder_level", outletID). Find(&inventory).Error return inventory, err } func (r *InventoryRepositoryImpl) GetZeroStock(ctx context.Context, outletID uuid.UUID) ([]*entities.Inventory, error) { var inventory []*entities.Inventory err := r.db.WithContext(ctx). Preload("Product"). Preload("Product.Category"). Where("outlet_id = ? AND quantity = 0", outletID). Find(&inventory).Error return inventory, err } func (r *InventoryRepositoryImpl) Update(ctx context.Context, inventory *entities.Inventory) error { return r.db.WithContext(ctx).Save(inventory).Error } func (r *InventoryRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error { return r.db.WithContext(ctx).Delete(&entities.Inventory{}, "id = ?", id).Error } func (r *InventoryRepositoryImpl) List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.Inventory, int64, error) { var inventory []*entities.Inventory var total int64 query := r.db.WithContext(ctx).Model(&entities.Inventory{}). Preload("Product"). Preload("Product.Category"). Preload("Outlet") for key, value := range filters { switch key { case "search": searchValue := "%" + value.(string) + "%" query = query.Joins("JOIN products ON inventory.product_id = products.id"). Where("products.name ILIKE ? OR products.sku ILIKE ?", searchValue, searchValue) case "low_stock": if value.(bool) { query = query.Where("quantity <= reorder_level") } case "zero_stock": if value.(bool) { query = query.Where("quantity = 0") } case "category_id": query = query.Joins("JOIN products ON inventory.product_id = products.id"). Where("products.category_id = ?", value) default: query = query.Where(key+" = ?", value) } } if err := query.Count(&total).Error; err != nil { return nil, 0, err } err := query.Limit(limit).Offset(offset).Find(&inventory).Error return inventory, total, err } func (r *InventoryRepositoryImpl) Count(ctx context.Context, filters map[string]interface{}) (int64, error) { var count int64 query := r.db.WithContext(ctx).Model(&entities.Inventory{}) for key, value := range filters { switch key { case "search": searchValue := "%" + value.(string) + "%" query = query.Joins("JOIN products ON inventory.product_id = products.id"). Where("products.name ILIKE ? OR products.sku ILIKE ?", searchValue, searchValue) case "low_stock": if value.(bool) { query = query.Where("quantity <= reorder_level") } case "zero_stock": if value.(bool) { query = query.Where("quantity = 0") } case "category_id": query = query.Joins("JOIN products ON inventory.product_id = products.id"). Where("products.category_id = ?", value) default: query = query.Where(key+" = ?", value) } } err := query.Count(&count).Error return count, err } func (r *InventoryRepositoryImpl) AdjustQuantity(ctx context.Context, productID, outletID uuid.UUID, delta int) (*entities.Inventory, error) { var inventory entities.Inventory err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // Try to find existing inventory if err := tx.Where("product_id = ? AND outlet_id = ?", productID, outletID).First(&inventory).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { // Inventory doesn't exist, create it with initial quantity inventory = entities.Inventory{ ProductID: productID, OutletID: outletID, Quantity: 0, ReorderLevel: 0, } if err := tx.Create(&inventory).Error; err != nil { return fmt.Errorf("failed to create inventory record: %w", err) } } else { return err } } inventory.UpdateQuantity(delta) return tx.Save(&inventory).Error }) if err != nil { return nil, err } return &inventory, nil } func (r *InventoryRepositoryImpl) SetQuantity(ctx context.Context, productID, outletID uuid.UUID, quantity int) (*entities.Inventory, error) { var inventory entities.Inventory err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Where("product_id = ? AND outlet_id = ?", productID, outletID).First(&inventory).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { inventory = entities.Inventory{ ProductID: productID, OutletID: outletID, Quantity: quantity, ReorderLevel: 0, } if inventory.Quantity < 0 { inventory.Quantity = 0 } if err := tx.Create(&inventory).Error; err != nil { return fmt.Errorf("failed to create inventory record: %w", err) } return nil } return err } // Set new quantity inventory.Quantity = quantity if inventory.Quantity < 0 { inventory.Quantity = 0 } // Save updated inventory return tx.Save(&inventory).Error }) if err != nil { return nil, err } return &inventory, nil } func (r *InventoryRepositoryImpl) UpdateReorderLevel(ctx context.Context, id uuid.UUID, reorderLevel int) error { return r.db.WithContext(ctx).Model(&entities.Inventory{}). Where("id = ?", id). Update("reorder_level", reorderLevel).Error } func (r *InventoryRepositoryImpl) BulkCreate(ctx context.Context, inventoryItems []*entities.Inventory) error { return r.db.WithContext(ctx).CreateInBatches(inventoryItems, 100).Error } func (r *InventoryRepositoryImpl) BulkUpdate(ctx context.Context, inventoryItems []*entities.Inventory) error { if len(inventoryItems) == 0 { return nil } // Use GORM's transaction for bulk updates return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { for _, inventory := range inventoryItems { if err := tx.Save(inventory).Error; err != nil { return fmt.Errorf("failed to update inventory for product %s: %w", inventory.ProductID, err) } } return nil }) } func (r *InventoryRepositoryImpl) BulkAdjustQuantity(ctx context.Context, adjustments map[uuid.UUID]int, outletID uuid.UUID) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { for productID, delta := range adjustments { var inventory entities.Inventory if err := tx.Where("product_id = ? AND outlet_id = ?", productID, outletID).First(&inventory).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { // Inventory doesn't exist, create it with initial quantity inventory = entities.Inventory{ ProductID: productID, OutletID: outletID, Quantity: 0, ReorderLevel: 0, } if err := tx.Create(&inventory).Error; err != nil { return fmt.Errorf("failed to create inventory record for product %s: %w", productID, err) } } else { return err } } inventory.UpdateQuantity(delta) if err := tx.Save(&inventory).Error; err != nil { return err } } return nil }) } func (r *InventoryRepositoryImpl) GetTotalValueByOutlet(ctx context.Context, outletID uuid.UUID) (float64, error) { var totalValue float64 if err := r.db.WithContext(ctx). Table("inventory"). Select("SUM(inventory.quantity * products.cost)"). Joins("JOIN products ON inventory.product_id = products.id"). Where("inventory.outlet_id = ?", outletID). Scan(&totalValue).Error; err != nil { return 0, fmt.Errorf("failed to get total value: %w", err) } return totalValue, nil } // GetInventoryReportSummary returns summary statistics for inventory report 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() // Get outlet name var outlet entities.Outlet if err := r.db.WithContext(ctx).Select("name").First(&outlet, "id = ?", outletID).Error; err != nil { return nil, fmt.Errorf("failed to get outlet name: %w", err) } summary.OutletName = outlet.Name 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 = ?", outletID). Count(&totalProducts).Error; err != nil { return nil, fmt.Errorf("failed to count total products: %w", err) } summary.TotalProducts = int(totalProducts) var totalIngredients int64 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) 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 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) var lowStockIngredients 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 <= inventory.reorder_level AND inventory.quantity > 0", outletID). Count(&lowStockIngredients).Error; err != nil { return nil, fmt.Errorf("failed to count low stock ingredients: %w", err) } summary.LowStockIngredients = int(lowStockIngredients) 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 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) var zeroStockIngredients int64 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 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) } summary.TotalValue = totalValue return &summary, nil } // GetInventoryReportDetails returns detailed inventory report with products and ingredients func (r *InventoryRepositoryImpl) GetInventoryReportDetails(ctx context.Context, filter *models.InventoryReportFilter) (*models.InventoryReportDetail, error) { report := &models.InventoryReportDetail{} if filter.OutletID != nil { 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 } products, err := r.getInventoryProductsDetails(ctx, filter) if err != nil { return nil, fmt.Errorf("failed to get products details: %w", err) } report.Products = products ingredients, err := r.getInventoryIngredientsDetails(ctx, filter) if err != nil { return nil, fmt.Errorf("failed to get ingredients details: %w", err) } report.Ingredients = ingredients return report, nil } // 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, inventory.product_id, products.name as product_name, categories.name as category_name, inventory.quantity, inventory.reorder_level, COALESCE(product_variants.cost, products.cost) as unit_cost, (COALESCE(product_variants.cost, products.cost) * inventory.quantity) as total_value, inventory.updated_at `). 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 = ?", filter.OutletID) // Apply filters if filter.CategoryID != nil { query = query.Where("products.category_id = ?", *filter.CategoryID) } if filter.ShowLowStock != nil && *filter.ShowLowStock { query = query.Where("inventory.quantity <= inventory.reorder_level AND inventory.quantity > 0") } if filter.ShowZeroStock != nil && *filter.ShowZeroStock { query = query.Where("inventory.quantity = 0") } if filter.Search != nil && *filter.Search != "" { searchTerm := "%" + *filter.Search + "%" query = query.Where("products.name ILIKE ? OR categories.name ILIKE ?", searchTerm, searchTerm) } // Apply pagination if filter.Limit != nil { query = query.Limit(*filter.Limit) } if filter.Offset != nil { query = query.Offset(*filter.Offset) } query = query.Order("products.name ASC") // Execute the base query first var baseResults []struct { ID uuid.UUID ProductID uuid.UUID ProductName string CategoryName *string Quantity int ReorderLevel int UnitCost float64 TotalValue float64 UpdatedAt time.Time } 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 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 } product := &models.InventoryProductDetail{ ID: result.ID, ProductID: result.ProductID, ProductName: result.ProductName, CategoryName: categoryName, Quantity: result.Quantity, 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, } products = append(products, product) } return products, nil } // 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("ingredients"). Select(` ingredients.id, ingredients.id as ingredient_id, ingredients.name as ingredient_name, units.name as unit_name, ingredients.stock as quantity, 0 as reorder_level, ingredients.cost as unit_cost, (ingredients.cost * ingredients.stock) as total_value, ingredients.updated_at `). Joins("LEFT JOIN units ON ingredients.unit_id = units.id"). Where("ingredients.outlet_id = ? AND ingredients.is_active = ?", filter.OutletID, true) if filter.ShowLowStock != nil && *filter.ShowLowStock { query = query.Where("ingredients.stock <= 0 AND ingredients.stock > 0") } if filter.ShowZeroStock != nil && *filter.ShowZeroStock { query = query.Where("ingredients.stock = 0") } if filter.Search != nil && *filter.Search != "" { searchTerm := "%" + *filter.Search + "%" query = query.Where("ingredients.name ILIKE ? OR units.name ILIKE ?", searchTerm, searchTerm) } // Apply pagination if filter.Limit != nil { query = query.Limit(*filter.Limit) } if filter.Offset != nil { query = query.Offset(*filter.Offset) } query = query.Order("ingredients.name ASC") var baseResults []struct { ID uuid.UUID IngredientID uuid.UUID IngredientName string UnitName *string Quantity float64 ReorderLevel int UnitCost float64 TotalValue float64 UpdatedAt time.Time } if err := query.Find(&baseResults).Error; err != nil { return nil, err } var ingredients []*models.InventoryIngredientDetail 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 } ingredient := &models.InventoryIngredientDetail{ ID: result.ID, IngredientID: result.IngredientID, IngredientName: result.IngredientName, UnitName: unitName, Quantity: int(result.Quantity), ReorderLevel: result.ReorderLevel, UnitCost: result.UnitCost, TotalValue: result.TotalValue, TotalSold: totalSold, IsLowStock: result.Quantity <= 0 && result.Quantity > 0, IsZeroStock: result.Quantity == 0, UpdatedAt: result.UpdatedAt, } ingredients = append(ingredients, ingredient) } 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 }