package processor import ( "apskel-pos-be/internal/appcontext" "context" "fmt" "time" "apskel-pos-be/internal/contract" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/mappers" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" "github.com/google/uuid" ) type InventoryProcessor interface { Create(ctx context.Context, req *models.CreateInventoryRequest, organizationID uuid.UUID) (*models.InventoryResponse, error) GetByID(ctx context.Context, id, organizationID uuid.UUID) (*models.InventoryResponse, error) GetByProductAndOutlet(ctx context.Context, productID, outletID, organizationID uuid.UUID) (*models.InventoryResponse, error) GetByOutlet(ctx context.Context, outletID, organizationID uuid.UUID) ([]*models.InventoryResponse, error) GetByProduct(ctx context.Context, productID, organizationID uuid.UUID) ([]*models.InventoryResponse, error) GetLowStock(ctx context.Context, outletID, organizationID uuid.UUID) ([]*models.InventoryResponse, error) GetZeroStock(ctx context.Context, outletID, organizationID uuid.UUID) ([]*models.InventoryResponse, error) Update(ctx context.Context, id uuid.UUID, req *models.UpdateInventoryRequest, organizationID uuid.UUID) (*models.InventoryResponse, error) Delete(ctx context.Context, id, organizationID uuid.UUID) error List(ctx context.Context, filters map[string]interface{}, limit, offset int, organizationID uuid.UUID) ([]*models.InventoryResponse, int64, 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) UpdateReorderLevel(ctx context.Context, id uuid.UUID, reorderLevel int, organizationID uuid.UUID) error RestockInventory(ctx context.Context, outletID uuid.UUID, items []contract.RestockItem, reason string) (*contract.RestockInventoryResponse, 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) } type InventoryProcessorImpl struct { inventoryRepo repository.InventoryRepository productRepo ProductRepository outletRepo OutletRepository ingredientRepo IngredientRepository inventoryMovementRepo repository.InventoryMovementRepository } func NewInventoryProcessorImpl( inventoryRepo repository.InventoryRepository, productRepo ProductRepository, outletRepo OutletRepository, ingredientRepo IngredientRepository, inventoryMovementRepo repository.InventoryMovementRepository, ) *InventoryProcessorImpl { return &InventoryProcessorImpl{ inventoryRepo: inventoryRepo, productRepo: productRepo, outletRepo: outletRepo, ingredientRepo: ingredientRepo, inventoryMovementRepo: inventoryMovementRepo, } } // Create creates a new inventory record func (p *InventoryProcessorImpl) Create(ctx context.Context, req *models.CreateInventoryRequest, organizationID uuid.UUID) (*models.InventoryResponse, error) { _, err := p.productRepo.GetByID(ctx, req.ProductID) if err != nil { return nil, fmt.Errorf("invalid product: %w", err) } // Validate outlet exists _, err = p.outletRepo.GetByID(ctx, req.OutletID) if err != nil { return nil, fmt.Errorf("invalid outlet: %w", err) } // Check if inventory already exists for this product-outlet combination existingInventory, err := p.inventoryRepo.GetByProductAndOutlet(ctx, req.ProductID, req.OutletID) if err == nil && existingInventory != nil { return nil, fmt.Errorf("inventory already exists for product %s in outlet %s", req.ProductID, req.OutletID) } // Map request to entity inventoryEntity := mappers.CreateInventoryRequestToEntity(req) // Create inventory if err := p.inventoryRepo.Create(ctx, inventoryEntity); err != nil { return nil, fmt.Errorf("failed to create inventory: %w", err) } // Get inventory with relations for response inventoryWithRelations, err := p.inventoryRepo.GetWithRelations(ctx, inventoryEntity.ID) if err != nil { return nil, fmt.Errorf("failed to retrieve created inventory: %w", err) } // Map entity to response model response := mappers.InventoryEntityToResponse(inventoryWithRelations) return response, nil } // Update updates an existing inventory record func (p *InventoryProcessorImpl) Update(ctx context.Context, id uuid.UUID, req *models.UpdateInventoryRequest, organizationID uuid.UUID) (*models.InventoryResponse, error) { // Get existing inventory existingInventory, err := p.inventoryRepo.GetByID(ctx, id) if err != nil { return nil, fmt.Errorf("inventory not found: %w", err) } // Apply updates to entity mappers.UpdateInventoryEntityFromRequest(existingInventory, req) // Update inventory if err := p.inventoryRepo.Update(ctx, existingInventory); err != nil { return nil, fmt.Errorf("failed to update inventory: %w", err) } // Get updated inventory with relations for response inventoryWithRelations, err := p.inventoryRepo.GetWithRelations(ctx, id) if err != nil { return nil, fmt.Errorf("failed to retrieve updated inventory: %w", err) } // Map entity to response model response := mappers.InventoryEntityToResponse(inventoryWithRelations) return response, nil } // Delete deletes an inventory record func (p *InventoryProcessorImpl) Delete(ctx context.Context, id uuid.UUID, organizationID uuid.UUID) error { // Check if inventory exists _, err := p.inventoryRepo.GetByID(ctx, id) if err != nil { return fmt.Errorf("inventory not found: %w", err) } // Delete inventory if err := p.inventoryRepo.Delete(ctx, id); err != nil { return fmt.Errorf("failed to delete inventory: %w", err) } return nil } // GetByID retrieves an inventory record by ID func (p *InventoryProcessorImpl) GetByID(ctx context.Context, id, organizationID uuid.UUID) (*models.InventoryResponse, error) { inventory, err := p.inventoryRepo.GetWithRelations(ctx, id) if err != nil { return nil, fmt.Errorf("inventory not found: %w", err) } response := mappers.InventoryEntityToResponse(inventory) return response, nil } // GetByProductAndOutlet retrieves inventory by product and outlet func (p *InventoryProcessorImpl) GetByProductAndOutlet(ctx context.Context, productID, outletID, organizationID uuid.UUID) (*models.InventoryResponse, error) { inventory, err := p.inventoryRepo.GetByProductAndOutlet(ctx, productID, outletID) if err != nil { return nil, fmt.Errorf("inventory not found: %w", err) } response := mappers.InventoryEntityToResponse(inventory) return response, nil } // GetByOutlet retrieves all inventory records for a specific outlet func (p *InventoryProcessorImpl) GetByOutlet(ctx context.Context, outletID, organizationID uuid.UUID) ([]*models.InventoryResponse, error) { inventories, err := p.inventoryRepo.GetByOutlet(ctx, outletID) if err != nil { return nil, fmt.Errorf("failed to get inventory by outlet: %w", err) } var responses []*models.InventoryResponse for _, inventory := range inventories { response := mappers.InventoryEntityToResponse(inventory) responses = append(responses, response) } return responses, nil } // GetByProduct retrieves all inventory records for a specific product func (p *InventoryProcessorImpl) GetByProduct(ctx context.Context, productID, organizationID uuid.UUID) ([]*models.InventoryResponse, error) { inventories, err := p.inventoryRepo.GetByProduct(ctx, productID) if err != nil { return nil, fmt.Errorf("failed to get inventory by product: %w", err) } var responses []*models.InventoryResponse for _, inventory := range inventories { response := mappers.InventoryEntityToResponse(inventory) responses = append(responses, response) } return responses, nil } // List retrieves inventory records with filtering and pagination func (p *InventoryProcessorImpl) List(ctx context.Context, filters map[string]interface{}, limit, offset int, organizationID uuid.UUID) ([]*models.InventoryResponse, int64, error) { inventories, totalCount, err := p.inventoryRepo.List(ctx, filters, limit, offset) if err != nil { return nil, 0, fmt.Errorf("failed to list inventory: %w", err) } var responses []*models.InventoryResponse for _, inventory := range inventories { response := mappers.InventoryEntityToResponse(inventory) responses = append(responses, response) } return responses, totalCount, nil } // AdjustQuantity adjusts the quantity of an inventory item func (p *InventoryProcessorImpl) AdjustQuantity(ctx context.Context, productID, outletID, organizationID uuid.UUID, delta int) (*models.InventoryResponse, error) { inventory, err := p.inventoryRepo.AdjustQuantity(ctx, productID, outletID, delta) if err != nil { return nil, fmt.Errorf("failed to adjust inventory quantity: %w", err) } response := mappers.InventoryEntityToResponse(inventory) return response, nil } // SetQuantity sets the quantity of an inventory item func (p *InventoryProcessorImpl) SetQuantity(ctx context.Context, productID, outletID, organizationID uuid.UUID, quantity int) (*models.InventoryResponse, error) { inventory, err := p.inventoryRepo.SetQuantity(ctx, productID, outletID, quantity) if err != nil { return nil, fmt.Errorf("failed to set inventory quantity: %w", err) } response := mappers.InventoryEntityToResponse(inventory) return response, nil } // UpdateReorderLevel updates the reorder level of an inventory item func (p *InventoryProcessorImpl) UpdateReorderLevel(ctx context.Context, id uuid.UUID, reorderLevel int, organizationID uuid.UUID) error { return p.inventoryRepo.UpdateReorderLevel(ctx, id, reorderLevel) } // GetLowStock retrieves low stock inventory items for a specific outlet func (p *InventoryProcessorImpl) GetLowStock(ctx context.Context, outletID, organizationID uuid.UUID) ([]*models.InventoryResponse, error) { inventories, err := p.inventoryRepo.GetLowStock(ctx, outletID) if err != nil { return nil, fmt.Errorf("failed to get low stock inventory: %w", err) } var responses []*models.InventoryResponse for _, inventory := range inventories { response := mappers.InventoryEntityToResponse(inventory) responses = append(responses, response) } return responses, nil } // GetZeroStock retrieves zero stock inventory items for a specific outlet func (p *InventoryProcessorImpl) GetZeroStock(ctx context.Context, outletID, organizationID uuid.UUID) ([]*models.InventoryResponse, error) { inventories, err := p.inventoryRepo.GetZeroStock(ctx, outletID) if err != nil { return nil, fmt.Errorf("failed to get zero stock inventory: %w", err) } var responses []*models.InventoryResponse for _, inventory := range inventories { response := mappers.InventoryEntityToResponse(inventory) responses = append(responses, response) } return responses, nil } func (p *InventoryProcessorImpl) GetInventoryReportSummary(ctx context.Context, outletID, organizationID uuid.UUID, dateFrom, dateTo *time.Time) (*models.InventoryReportSummary, error) { outlet, err := p.outletRepo.GetByID(ctx, outletID) if err != nil { return nil, fmt.Errorf("outlet not found: %w", err) } if outlet.OrganizationID != organizationID { return nil, fmt.Errorf("outlet does not belong to the organization") } summary, err := p.inventoryRepo.GetInventoryReportSummary(ctx, outletID, dateFrom, dateTo) if err != nil { return nil, fmt.Errorf("failed to get inventory report summary: %w", err) } return summary, nil } // GetInventoryReportDetails returns detailed inventory report with products and ingredients func (p *InventoryProcessorImpl) GetInventoryReportDetails(ctx context.Context, filter *models.InventoryReportFilter, organizationID uuid.UUID) (*models.InventoryReportDetail, error) { if filter.OutletID == nil { return nil, fmt.Errorf("outlet_id is required for inventory report") } outlet, err := p.outletRepo.GetByID(ctx, *filter.OutletID) if err != nil { return nil, fmt.Errorf("outlet not found: %w", err) } if outlet.OrganizationID != organizationID { return nil, fmt.Errorf("outlet does not belong to the organization") } report, err := p.inventoryRepo.GetInventoryReportDetails(ctx, filter) if err != nil { return nil, fmt.Errorf("failed to get inventory report details: %w", err) } return report, nil } func (p *InventoryProcessorImpl) GetInventoryByID(ctx context.Context, id uuid.UUID) (*models.InventoryResponse, error) { inventoryEntity, err := p.inventoryRepo.GetWithRelations(ctx, id) if err != nil { return nil, fmt.Errorf("inventory not found: %w", err) } response := mappers.InventoryEntityToResponse(inventoryEntity) return response, nil } func (p *InventoryProcessorImpl) ListInventory(ctx context.Context, filters map[string]interface{}, page, limit int) ([]models.InventoryResponse, int, error) { offset := (page - 1) * limit inventoryEntities, total, err := p.inventoryRepo.List(ctx, filters, limit, offset) if err != nil { return nil, 0, fmt.Errorf("failed to list inventory: %w", err) } responses := make([]models.InventoryResponse, len(inventoryEntities)) for i, entity := range inventoryEntities { response := mappers.InventoryEntityToResponse(entity) if response != nil { responses[i] = *response } } return responses, int(total), nil } func (p *InventoryProcessorImpl) AdjustInventory(ctx context.Context, productID, outletID uuid.UUID, req *models.InventoryAdjustmentRequest) (*models.InventoryResponse, error) { // Validate product exists _, err := p.productRepo.GetByID(ctx, productID) if err != nil { return nil, fmt.Errorf("invalid product: %w", err) } // Validate outlet exists _, err = p.outletRepo.GetByID(ctx, outletID) if err != nil { return nil, fmt.Errorf("invalid outlet: %w", err) } // Perform quantity adjustment adjustedInventory, err := p.inventoryRepo.AdjustQuantity(ctx, productID, outletID, req.Delta) if err != nil { return nil, fmt.Errorf("failed to adjust inventory quantity: %w", err) } // Get inventory with relations for response inventoryWithRelations, err := p.inventoryRepo.GetWithRelations(ctx, adjustedInventory.ID) if err != nil { return nil, fmt.Errorf("failed to retrieve adjusted inventory: %w", err) } // Map entity to response model response := mappers.InventoryEntityToResponse(inventoryWithRelations) return response, nil } func (p *InventoryProcessorImpl) GetLowStockItems(ctx context.Context, outletID uuid.UUID) ([]models.InventoryResponse, error) { // Validate outlet exists _, err := p.outletRepo.GetByID(ctx, outletID) if err != nil { return nil, fmt.Errorf("invalid outlet: %w", err) } inventoryEntities, err := p.inventoryRepo.GetLowStock(ctx, outletID) if err != nil { return nil, fmt.Errorf("failed to get low stock items: %w", err) } responses := make([]models.InventoryResponse, len(inventoryEntities)) for i, entity := range inventoryEntities { response := mappers.InventoryEntityToResponse(entity) if response != nil { responses[i] = *response } } return responses, nil } func (p *InventoryProcessorImpl) GetZeroStockItems(ctx context.Context, outletID uuid.UUID) ([]models.InventoryResponse, error) { // Validate outlet exists _, err := p.outletRepo.GetByID(ctx, outletID) if err != nil { return nil, fmt.Errorf("invalid outlet: %w", err) } inventoryEntities, err := p.inventoryRepo.GetZeroStock(ctx, outletID) if err != nil { return nil, fmt.Errorf("failed to get zero stock items: %w", err) } responses := make([]models.InventoryResponse, len(inventoryEntities)) for i, entity := range inventoryEntities { response := mappers.InventoryEntityToResponse(entity) if response != nil { responses[i] = *response } } return responses, nil } func (p *InventoryProcessorImpl) RestockInventory(ctx context.Context, outletID uuid.UUID, items []contract.RestockItem, reason string) (*contract.RestockInventoryResponse, error) { outlet, err := p.outletRepo.GetByID(ctx, outletID) if err != nil { return nil, fmt.Errorf("invalid outlet: %w", err) } if len(items) == 0 { return nil, fmt.Errorf("no items provided for restocking") } restockResults := make([]contract.RestockItemResult, 0, len(items)) restockedAt := time.Now() for _, item := range items { result := contract.RestockItemResult{ ItemID: item.ItemID, ItemType: item.ItemType, AddedQty: item.Quantity, Success: false, } switch item.ItemType { case "PRODUCT": if err := p.restockProduct(ctx, outletID, item.ItemID, item.Quantity, reason, outlet.OrganizationID); err != nil { result.Error = err.Error() } case "INGREDIENT": if err := p.restockIngredient(ctx, outletID, item.ItemID, item.Quantity, reason, outlet.OrganizationID); err != nil { result.Error = err.Error() } default: result.Error = fmt.Sprintf("unsupported item type: %s", item.ItemType) } restockResults = append(restockResults, result) } return &contract.RestockInventoryResponse{ OutletID: outletID, Items: restockResults, Reason: reason, RestockedAt: restockedAt, }, nil } func (p *InventoryProcessorImpl) restockProduct(ctx context.Context, outletID, productID uuid.UUID, quantity int, reason string, organizationID uuid.UUID) error { contextInfo := appcontext.FromGinContext(ctx) var previousQuantity int var newQuantity int inventory, err := p.inventoryRepo.GetByProductAndOutlet(ctx, productID, outletID) if err != nil { createReq := &models.CreateInventoryRequest{ OutletID: outletID, ProductID: productID, Quantity: quantity, ReorderLevel: 0, } inventoryEntity := mappers.CreateInventoryRequestToEntity(createReq) if err := p.inventoryRepo.Create(ctx, inventoryEntity); err != nil { return fmt.Errorf("failed to create inventory for product %s: %w", productID, err) } previousQuantity = 0 newQuantity = quantity } else { previousQuantity = inventory.Quantity newQuantity = inventory.Quantity + quantity updateReq := &models.UpdateInventoryRequest{ Quantity: &newQuantity, } mappers.UpdateInventoryEntityFromRequest(inventory, updateReq) if err := p.inventoryRepo.Update(ctx, inventory); err != nil { return fmt.Errorf("failed to update inventory for product %s: %w", productID, err) } } referenceType := entities.InventoryMovementReferenceTypeManual movement := &entities.InventoryMovement{ OrganizationID: organizationID, OutletID: outletID, ItemID: productID, ItemType: "PRODUCT", MovementType: entities.InventoryMovementTypePurchase, Quantity: float64(quantity), PreviousQuantity: float64(previousQuantity), NewQuantity: float64(newQuantity), ReferenceType: &referenceType, Reason: &reason, UserID: contextInfo.UserID, } if err := p.inventoryMovementRepo.Create(ctx, movement); err != nil { return fmt.Errorf("failed to create inventory movement record: %w", err) } return nil } func (p *InventoryProcessorImpl) restockIngredient(ctx context.Context, outletID, ingredientID uuid.UUID, quantity int, reason string, organizationID uuid.UUID) error { contextInfo := appcontext.FromGinContext(ctx) ingredient, err := p.getIngredientByID(ctx, ingredientID, organizationID) if err != nil { return fmt.Errorf("failed to get ingredient %s: %w", ingredientID, err) } previousStock := ingredient.Stock newStock := ingredient.Stock + float64(quantity) if err := p.updateIngredientStock(ctx, ingredientID, float64(quantity), organizationID); err != nil { return fmt.Errorf("failed to update ingredient stock: %w", err) } referenceType := entities.InventoryMovementReferenceTypeManual movement := &entities.InventoryMovement{ OrganizationID: organizationID, OutletID: outletID, ItemID: ingredientID, ItemType: "INGREDIENT", MovementType: entities.InventoryMovementTypePurchase, Quantity: float64(quantity), PreviousQuantity: previousStock, NewQuantity: newStock, ReferenceType: &referenceType, Reason: &reason, UserID: contextInfo.UserID, } if err := p.inventoryMovementRepo.Create(ctx, movement); err != nil { return fmt.Errorf("failed to create inventory movement record: %w", err) } return nil } func (p *InventoryProcessorImpl) getIngredientByID(ctx context.Context, ingredientID, organizationID uuid.UUID) (*models.Ingredient, error) { ingredient, err := p.ingredientRepo.GetByID(ctx, ingredientID, organizationID) if err != nil { return nil, fmt.Errorf("failed to get ingredient %s: %w", ingredientID, err) } return &models.Ingredient{ ID: ingredient.ID, Name: ingredient.Name, Stock: ingredient.Stock, UnitID: ingredient.UnitID, OrganizationID: ingredient.OrganizationID, OutletID: ingredient.OutletID, IsActive: ingredient.IsActive, CreatedAt: ingredient.CreatedAt, UpdatedAt: ingredient.UpdatedAt, }, nil } func (p *InventoryProcessorImpl) updateIngredientStock(ctx context.Context, ingredientID uuid.UUID, quantityToAdd float64, organizationID uuid.UUID) error { if err := p.ingredientRepo.UpdateStock(ctx, ingredientID, quantityToAdd, organizationID); err != nil { return fmt.Errorf("failed to update ingredient stock: %w", err) } return nil }