2025-07-18 20:10:29 +07:00
|
|
|
package repository
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
2025-08-13 23:36:31 +07:00
|
|
|
"time"
|
2025-07-18 20:10:29 +07:00
|
|
|
|
|
|
|
|
"apskel-pos-be/internal/entities"
|
2025-08-13 23:36:31 +07:00
|
|
|
"apskel-pos-be/internal/models"
|
2025-07-18 20:10:29 +07:00
|
|
|
|
|
|
|
|
"github.com/google/uuid"
|
|
|
|
|
"gorm.io/gorm"
|
|
|
|
|
)
|
|
|
|
|
|
2025-07-30 23:18:20 +07:00
|
|
|
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
|
2025-08-13 23:14:26 +07:00
|
|
|
BulkUpdate(ctx context.Context, inventoryItems []*entities.Inventory) error
|
2025-07-30 23:18:20 +07:00
|
|
|
BulkAdjustQuantity(ctx context.Context, adjustments map[uuid.UUID]int, outletID uuid.UUID) error
|
|
|
|
|
GetTotalValueByOutlet(ctx context.Context, outletID uuid.UUID) (float64, error)
|
2025-08-14 00:38:26 +07:00
|
|
|
GetInventoryReportSummary(ctx context.Context, outletID uuid.UUID, dateFrom, dateTo *time.Time) (*models.InventoryReportSummary, error)
|
2025-08-13 23:36:31 +07:00
|
|
|
GetInventoryReportDetails(ctx context.Context, filter *models.InventoryReportFilter) (*models.InventoryReportDetail, error)
|
2025-07-30 23:18:20 +07:00
|
|
|
}
|
|
|
|
|
|
2025-07-18 20:10:29 +07:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-13 23:14:26 +07:00
|
|
|
func (r *InventoryRepositoryImpl) BulkUpdate(ctx context.Context, inventoryItems []*entities.Inventory) error {
|
|
|
|
|
if len(inventoryItems) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2025-08-13 23:36:31 +07:00
|
|
|
|
2025-08-13 23:14:26 +07:00
|
|
|
// 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
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-18 20:10:29 +07:00
|
|
|
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
|
2025-08-13 23:36:31 +07:00
|
|
|
if err := r.db.WithContext(ctx).
|
2025-07-18 20:10:29 +07:00
|
|
|
Table("inventory").
|
|
|
|
|
Select("SUM(inventory.quantity * products.cost)").
|
|
|
|
|
Joins("JOIN products ON inventory.product_id = products.id").
|
|
|
|
|
Where("inventory.outlet_id = ?", outletID).
|
2025-08-13 23:36:31 +07:00
|
|
|
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
|
2025-08-14 00:38:26 +07:00
|
|
|
func (r *InventoryRepositoryImpl) GetInventoryReportSummary(ctx context.Context, outletID uuid.UUID, dateFrom, dateTo *time.Time) (*models.InventoryReportSummary, error) {
|
2025-08-13 23:36:31 +07:00
|
|
|
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").
|
2025-08-14 00:38:26 +07:00
|
|
|
Where("inventory.outlet_id = ?", outletID).
|
2025-08-13 23:36:31 +07:00
|
|
|
Count(&totalProducts).Error; err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to count total products: %w", err)
|
|
|
|
|
}
|
|
|
|
|
summary.TotalProducts = int(totalProducts)
|
|
|
|
|
|
|
|
|
|
var totalIngredients int64
|
2025-08-14 00:38:26 +07:00
|
|
|
if err := r.db.WithContext(ctx).Model(&entities.Ingredient{}).
|
|
|
|
|
Where("outlet_id = ? AND is_active = ?", outletID, true).
|
2025-08-13 23:36:31 +07:00
|
|
|
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").
|
2025-08-14 00:38:26 +07:00
|
|
|
Where("inventory.outlet_id = ? AND inventory.quantity <= inventory.reorder_level AND inventory.quantity > 0", outletID).
|
2025-08-13 23:36:31 +07:00
|
|
|
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").
|
2025-08-14 00:38:26 +07:00
|
|
|
Where("inventory.outlet_id = ? AND inventory.quantity = 0", outletID).
|
2025-08-13 23:36:31 +07:00
|
|
|
Count(&zeroStockProducts).Error; err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to count zero stock products: %w", err)
|
|
|
|
|
}
|
|
|
|
|
summary.ZeroStockProducts = int(zeroStockProducts)
|
|
|
|
|
|
|
|
|
|
var zeroStockIngredients int64
|
2025-08-14 00:38:26 +07:00
|
|
|
if err := r.db.WithContext(ctx).Model(&entities.Ingredient{}).
|
|
|
|
|
Where("outlet_id = ? AND is_active = ? AND stock = 0", outletID, true).
|
2025-08-13 23:36:31 +07:00
|
|
|
Count(&zeroStockIngredients).Error; err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to count zero stock ingredients: %w", err)
|
|
|
|
|
}
|
|
|
|
|
summary.ZeroStockIngredients = int(zeroStockIngredients)
|
|
|
|
|
|
2025-08-14 00:38:26 +07:00
|
|
|
// 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
|
|
|
|
|
|
2025-08-13 23:36:31 +07:00
|
|
|
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 {
|
2025-08-14 00:38:26 +07:00
|
|
|
summary, err := r.GetInventoryReportSummary(ctx, *filter.OutletID, filter.DateFrom, filter.DateTo)
|
2025-08-13 23:36:31 +07:00
|
|
|
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) {
|
2025-08-14 00:38:26 +07:00
|
|
|
// Build the base query
|
2025-08-13 23:36:31 +07:00
|
|
|
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").
|
2025-08-14 00:38:26 +07:00
|
|
|
Where("inventory.outlet_id = ?", filter.OutletID)
|
2025-08-13 23:36:31 +07:00
|
|
|
|
|
|
|
|
// 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")
|
|
|
|
|
|
2025-08-14 00:38:26 +07:00
|
|
|
// Execute the base query first
|
|
|
|
|
var baseResults []struct {
|
2025-08-13 23:36:31 +07:00
|
|
|
ID uuid.UUID
|
|
|
|
|
ProductID uuid.UUID
|
|
|
|
|
ProductName string
|
|
|
|
|
CategoryName *string
|
|
|
|
|
Quantity int
|
|
|
|
|
ReorderLevel int
|
|
|
|
|
UnitCost float64
|
|
|
|
|
TotalValue float64
|
|
|
|
|
UpdatedAt time.Time
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-14 00:38:26 +07:00
|
|
|
if err := query.Find(&baseResults).Error; err != nil {
|
2025-08-13 23:36:31 +07:00
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-14 01:00:36 +07:00
|
|
|
// Now get total in and out for each product
|
2025-08-13 23:36:31 +07:00
|
|
|
var products []*models.InventoryProductDetail
|
2025-08-14 00:38:26 +07:00
|
|
|
for _, result := range baseResults {
|
2025-08-14 01:00:36 +07:00
|
|
|
var totalIn, totalOut float64
|
2025-08-14 00:38:26 +07:00
|
|
|
|
2025-08-14 01:00:36 +07:00
|
|
|
// Query total in (positive movements) for this specific product
|
|
|
|
|
inQuery := r.db.WithContext(ctx).Model(&entities.InventoryMovement{}).
|
|
|
|
|
Select("COALESCE(SUM(quantity), 0)").
|
|
|
|
|
Where("item_id = ? AND outlet_id = ? AND item_type = ? AND quantity > 0",
|
|
|
|
|
result.ProductID, filter.OutletID, "PRODUCT")
|
|
|
|
|
|
|
|
|
|
// Query total out (negative movements) for this specific product
|
|
|
|
|
outQuery := r.db.WithContext(ctx).Model(&entities.InventoryMovement{}).
|
2025-08-14 00:38:26 +07:00
|
|
|
Select("COALESCE(SUM(ABS(quantity)), 0)").
|
2025-08-14 01:00:36 +07:00
|
|
|
Where("item_id = ? AND outlet_id = ? AND item_type = ? AND quantity < 0",
|
|
|
|
|
result.ProductID, filter.OutletID, "PRODUCT")
|
2025-08-14 00:38:26 +07:00
|
|
|
|
|
|
|
|
// Apply date filters if provided
|
|
|
|
|
if filter.DateFrom != nil {
|
2025-08-14 01:00:36 +07:00
|
|
|
inQuery = inQuery.Where("created_at >= ?", *filter.DateFrom)
|
|
|
|
|
outQuery = outQuery.Where("created_at >= ?", *filter.DateFrom)
|
2025-08-14 00:38:26 +07:00
|
|
|
}
|
|
|
|
|
if filter.DateTo != nil {
|
2025-08-14 01:00:36 +07:00
|
|
|
inQuery = inQuery.Where("created_at <= ?", *filter.DateTo)
|
|
|
|
|
outQuery = outQuery.Where("created_at <= ?", *filter.DateTo)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := inQuery.Scan(&totalIn).Error; err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to get total in for product %s: %w", result.ProductID, err)
|
2025-08-14 00:38:26 +07:00
|
|
|
}
|
|
|
|
|
|
2025-08-14 01:00:36 +07:00
|
|
|
if err := outQuery.Scan(&totalOut).Error; err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to get total out for product %s: %w", result.ProductID, err)
|
2025-08-14 00:38:26 +07:00
|
|
|
}
|
|
|
|
|
|
2025-08-13 23:36:31 +07:00
|
|
|
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,
|
2025-08-14 01:00:36 +07:00
|
|
|
TotalIn: totalIn,
|
|
|
|
|
TotalOut: totalOut,
|
2025-08-13 23:36:31 +07:00
|
|
|
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) {
|
2025-08-14 00:38:26 +07:00
|
|
|
query := r.db.WithContext(ctx).Table("ingredients").
|
2025-08-13 23:36:31 +07:00
|
|
|
Select(`
|
2025-08-14 00:38:26 +07:00
|
|
|
ingredients.id,
|
|
|
|
|
ingredients.id as ingredient_id,
|
2025-08-13 23:36:31 +07:00
|
|
|
ingredients.name as ingredient_name,
|
|
|
|
|
units.name as unit_name,
|
2025-08-14 00:38:26 +07:00
|
|
|
ingredients.stock as quantity,
|
|
|
|
|
0 as reorder_level,
|
2025-08-13 23:36:31 +07:00
|
|
|
ingredients.cost as unit_cost,
|
2025-08-14 00:38:26 +07:00
|
|
|
(ingredients.cost * ingredients.stock) as total_value,
|
|
|
|
|
ingredients.updated_at
|
2025-08-13 23:36:31 +07:00
|
|
|
`).
|
|
|
|
|
Joins("LEFT JOIN units ON ingredients.unit_id = units.id").
|
2025-08-14 00:38:26 +07:00
|
|
|
Where("ingredients.outlet_id = ? AND ingredients.is_active = ?", filter.OutletID, true)
|
2025-08-13 23:36:31 +07:00
|
|
|
|
|
|
|
|
if filter.ShowLowStock != nil && *filter.ShowLowStock {
|
2025-08-14 00:38:26 +07:00
|
|
|
query = query.Where("ingredients.stock <= 0 AND ingredients.stock > 0")
|
2025-08-13 23:36:31 +07:00
|
|
|
}
|
|
|
|
|
if filter.ShowZeroStock != nil && *filter.ShowZeroStock {
|
2025-08-14 00:38:26 +07:00
|
|
|
query = query.Where("ingredients.stock = 0")
|
2025-08-13 23:36:31 +07:00
|
|
|
}
|
|
|
|
|
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")
|
|
|
|
|
|
2025-08-14 00:38:26 +07:00
|
|
|
var baseResults []struct {
|
2025-08-13 23:36:31 +07:00
|
|
|
ID uuid.UUID
|
|
|
|
|
IngredientID uuid.UUID
|
|
|
|
|
IngredientName string
|
|
|
|
|
UnitName *string
|
2025-08-14 00:38:26 +07:00
|
|
|
Quantity float64
|
2025-08-13 23:36:31 +07:00
|
|
|
ReorderLevel int
|
|
|
|
|
UnitCost float64
|
|
|
|
|
TotalValue float64
|
|
|
|
|
UpdatedAt time.Time
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-14 00:38:26 +07:00
|
|
|
if err := query.Find(&baseResults).Error; err != nil {
|
2025-08-13 23:36:31 +07:00
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var ingredients []*models.InventoryIngredientDetail
|
2025-08-14 00:38:26 +07:00
|
|
|
for _, result := range baseResults {
|
2025-08-14 01:00:36 +07:00
|
|
|
var totalIn, totalOut float64
|
2025-08-14 00:38:26 +07:00
|
|
|
|
2025-08-14 01:00:36 +07:00
|
|
|
// Query total in (positive movements) for this specific ingredient
|
|
|
|
|
inQuery := r.db.WithContext(ctx).Model(&entities.InventoryMovement{}).
|
|
|
|
|
Select("COALESCE(SUM(quantity), 0)").
|
|
|
|
|
Where("item_id = ? AND outlet_id = ? AND item_type = ? AND quantity > 0",
|
|
|
|
|
result.IngredientID, filter.OutletID, "INGREDIENT")
|
|
|
|
|
|
|
|
|
|
// Query total out (negative movements) for this specific ingredient
|
|
|
|
|
outQuery := r.db.WithContext(ctx).Model(&entities.InventoryMovement{}).
|
2025-08-14 00:38:26 +07:00
|
|
|
Select("COALESCE(SUM(ABS(quantity)), 0)").
|
2025-08-14 01:00:36 +07:00
|
|
|
Where("item_id = ? AND outlet_id = ? AND item_type = ? AND quantity < 0",
|
|
|
|
|
result.IngredientID, filter.OutletID, "INGREDIENT")
|
2025-08-14 00:38:26 +07:00
|
|
|
|
|
|
|
|
if filter.DateFrom != nil {
|
2025-08-14 01:00:36 +07:00
|
|
|
inQuery = inQuery.Where("created_at >= ?", *filter.DateFrom)
|
|
|
|
|
outQuery = outQuery.Where("created_at >= ?", *filter.DateFrom)
|
2025-08-14 00:38:26 +07:00
|
|
|
}
|
|
|
|
|
if filter.DateTo != nil {
|
2025-08-14 01:00:36 +07:00
|
|
|
inQuery = inQuery.Where("created_at <= ?", *filter.DateTo)
|
|
|
|
|
outQuery = outQuery.Where("created_at <= ?", *filter.DateTo)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := inQuery.Scan(&totalIn).Error; err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to get total in for ingredient %s: %w", result.IngredientID, err)
|
2025-08-14 00:38:26 +07:00
|
|
|
}
|
|
|
|
|
|
2025-08-14 01:00:36 +07:00
|
|
|
if err := outQuery.Scan(&totalOut).Error; err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to get total out for ingredient %s: %w", result.IngredientID, err)
|
2025-08-14 00:38:26 +07:00
|
|
|
}
|
|
|
|
|
|
2025-08-13 23:36:31 +07:00
|
|
|
unitName := ""
|
|
|
|
|
if result.UnitName != nil {
|
|
|
|
|
unitName = *result.UnitName
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ingredient := &models.InventoryIngredientDetail{
|
|
|
|
|
ID: result.ID,
|
|
|
|
|
IngredientID: result.IngredientID,
|
|
|
|
|
IngredientName: result.IngredientName,
|
|
|
|
|
UnitName: unitName,
|
2025-08-14 00:38:26 +07:00
|
|
|
Quantity: int(result.Quantity),
|
2025-08-13 23:36:31 +07:00
|
|
|
ReorderLevel: result.ReorderLevel,
|
|
|
|
|
UnitCost: result.UnitCost,
|
|
|
|
|
TotalValue: result.TotalValue,
|
2025-08-14 01:00:36 +07:00
|
|
|
TotalIn: totalIn,
|
|
|
|
|
TotalOut: totalOut,
|
2025-08-14 00:38:26 +07:00
|
|
|
IsLowStock: result.Quantity <= 0 && result.Quantity > 0,
|
2025-08-13 23:36:31 +07:00
|
|
|
IsZeroStock: result.Quantity == 0,
|
|
|
|
|
UpdatedAt: result.UpdatedAt,
|
|
|
|
|
}
|
|
|
|
|
ingredients = append(ingredients, ingredient)
|
|
|
|
|
}
|
2025-07-18 20:10:29 +07:00
|
|
|
|
2025-08-13 23:36:31 +07:00
|
|
|
return ingredients, nil
|
2025-07-18 20:10:29 +07:00
|
|
|
}
|
2025-08-14 00:38:26 +07:00
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|