package processor import ( "apskel-pos-be/internal/appcontext" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" "apskel-pos-be/internal/transformer" "context" "fmt" "time" "github.com/google/uuid" ) type IngredientProcessorImpl struct { ingredientRepo IngredientRepository unitRepo UnitRepository compositionRepo IngredientCompositionRepository } func NewIngredientProcessor(ingredientRepo IngredientRepository, unitRepo UnitRepository, compositionRepo IngredientCompositionRepository) *IngredientProcessorImpl { return &IngredientProcessorImpl{ ingredientRepo: ingredientRepo, unitRepo: unitRepo, compositionRepo: compositionRepo, } } func (p *IngredientProcessorImpl) CreateIngredient(ctx context.Context, req *models.CreateIngredientRequest) (*models.IngredientResponse, error) { if _, err := p.unitRepo.GetByID(ctx, req.UnitID, req.OrganizationID); err != nil { return nil, err } ingredient := &entities.Ingredient{ ID: uuid.New(), OrganizationID: req.OrganizationID, OutletID: req.OutletID, Name: req.Name, UnitID: req.UnitID, Cost: req.Cost, // akan di-override oleh recalculateCost kalau semi-finished Stock: req.Stock, IsSemiFinished: req.IsSemiFinished, IsActive: req.IsActive, Metadata: req.Metadata, CreatedAt: time.Now(), UpdatedAt: time.Now(), } if req.IsSemiFinished { ingredient.Cost = 0 // dihitung dari compositions } if err := p.ingredientRepo.Create(ctx, ingredient); err != nil { return nil, err } // Save compositions if provided (only valid for semi-finished ingredients) if req.IsSemiFinished && len(req.Compositions) > 0 { if err := p.saveCompositions(ctx, ingredient.ID, req.OrganizationID, req.Compositions); err != nil { return nil, err } } return p.buildIngredientResponse(ctx, ingredient.ID, req.OrganizationID) } func (p *IngredientProcessorImpl) GetIngredientByID(ctx context.Context, id uuid.UUID) (*models.IngredientResponse, error) { ctxInfo := appcontext.FromGinContext(ctx) return p.buildIngredientResponse(ctx, id, ctxInfo.OrganizationID) } func (p *IngredientProcessorImpl) ListIngredients(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int, search string) (*models.PaginatedResponse[models.IngredientResponse], error) { if page < 1 { page = 1 } if limit < 1 { limit = 10 } if limit > 100 { limit = 100 } ingredients, total, err := p.ingredientRepo.GetAll(ctx, organizationID, outletID, page, limit, search, nil) if err != nil { return nil, err } ingredientResponses := transformer.IngredientsToResponses(ingredients) return &models.PaginatedResponse[models.IngredientResponse]{ Data: ingredientResponses, Pagination: models.Pagination{ Page: page, Limit: limit, Total: int64(total), TotalPages: (total + limit - 1) / limit, }, }, nil } func (p *IngredientProcessorImpl) UpdateIngredient(ctx context.Context, id uuid.UUID, req *models.UpdateIngredientRequest) (*models.IngredientResponse, error) { ctxInfo := appcontext.FromGinContext(ctx) organizationID := ctxInfo.OrganizationID existing, err := p.ingredientRepo.GetByID(ctx, id, organizationID) if err != nil { return nil, err } if req.UnitID != existing.UnitID { if _, err := p.unitRepo.GetByID(ctx, req.UnitID, organizationID); err != nil { return nil, err } } existing.OutletID = req.OutletID existing.Name = req.Name existing.UnitID = req.UnitID existing.Stock = req.Stock existing.IsSemiFinished = req.IsSemiFinished existing.IsActive = req.IsActive existing.Metadata = req.Metadata existing.UpdatedAt = time.Now() // Cost hanya dipakai kalau bukan semi-finished // Kalau semi-finished, cost dihitung dari compositions if !req.IsSemiFinished { existing.Cost = req.Cost } if err := p.ingredientRepo.Update(ctx, existing); err != nil { return nil, err } return p.buildIngredientResponse(ctx, id, organizationID) } func (p *IngredientProcessorImpl) DeleteIngredient(ctx context.Context, id uuid.UUID) error { ctxInfo := appcontext.FromGinContext(ctx) return p.ingredientRepo.Delete(ctx, id, ctxInfo.OrganizationID) } func (p *IngredientProcessorImpl) AddCompositions(ctx context.Context, parentID uuid.UUID, req *models.AddIngredientCompositionsRequest) (*models.AddIngredientCompositionsResponse, error) { ctxInfo := appcontext.FromGinContext(ctx) organizationID := ctxInfo.OrganizationID // Parent must exist and be semi-finished parent, err := p.ingredientRepo.GetByID(ctx, parentID, organizationID) if err != nil { return nil, fmt.Errorf("parent ingredient not found: %w", err) } if !parent.IsSemiFinished { return nil, fmt.Errorf("parent ingredient must be marked as semi-finished") } now := time.Now() createdIDs := make([]uuid.UUID, 0, len(req.Compositions)) // Validate and create all compositions for _, item := range req.Compositions { // Child must exist if _, err := p.ingredientRepo.GetByID(ctx, item.ChildIngredientID, organizationID); err != nil { return nil, fmt.Errorf("child ingredient %s not found: %w", item.ChildIngredientID, err) } if item.ChildIngredientID == parentID { return nil, fmt.Errorf("child ingredient cannot be the same as parent") } // Resolve outlet outletID := item.OutletID if outletID == nil && ctxInfo.OutletID != uuid.Nil { outletID = &ctxInfo.OutletID } compositionID := uuid.New() composition := &entities.IngredientComposition{ ID: compositionID, OrganizationID: organizationID, OutletID: outletID, ParentIngredientID: parentID, ChildIngredientID: item.ChildIngredientID, Quantity: item.Quantity, CreatedAt: now, UpdatedAt: now, } if err := p.compositionRepo.Create(ctx, composition); err != nil { return nil, fmt.Errorf("failed to add composition: %w", err) } createdIDs = append(createdIDs, compositionID) } // Recalculate parent cost if err := p.recalculateCost(ctx, parentID, organizationID); err != nil { return nil, err } // Get updated parent ingredient with all compositions updatedParent, err := p.buildIngredientResponse(ctx, parentID, organizationID) if err != nil { return nil, err } // Get created compositions with full details createdCompositions := make([]*models.IngredientCompositionResponse, 0, len(createdIDs)) for _, id := range createdIDs { comp, err := p.compositionRepo.GetByID(ctx, id, organizationID) if err != nil { return nil, err } createdCompositions = append(createdCompositions, transformer.CompositionEntityToResponse(comp)) } return &models.AddIngredientCompositionsResponse{ ParentIngredient: updatedParent, Compositions: createdCompositions, }, nil } func (p *IngredientProcessorImpl) UpdateComposition(ctx context.Context, id uuid.UUID, req *models.UpdateIngredientCompositionRequest) (*models.IngredientCompositionResponse, error) { ctxInfo := appcontext.FromGinContext(ctx) existing, err := p.compositionRepo.GetByID(ctx, id, ctxInfo.OrganizationID) if err != nil { return nil, fmt.Errorf("composition not found: %w", err) } existing.Quantity = req.Quantity existing.OutletID = req.OutletID existing.UpdatedAt = time.Now() if err := p.compositionRepo.Update(ctx, existing); err != nil { return nil, fmt.Errorf("failed to update composition: %w", err) } // Recalculate parent cost since quantity changed if err := p.recalculateCost(ctx, existing.ParentIngredientID, ctxInfo.OrganizationID); err != nil { return nil, fmt.Errorf("failed to recalculate cost: %w", err) } updated, err := p.compositionRepo.GetByID(ctx, id, ctxInfo.OrganizationID) if err != nil { return nil, err } return transformer.CompositionEntityToResponse(updated), nil } func (p *IngredientProcessorImpl) DeleteComposition(ctx context.Context, id uuid.UUID) (*models.IngredientResponse, error) { ctxInfo := appcontext.FromGinContext(ctx) existing, err := p.compositionRepo.GetByID(ctx, id, ctxInfo.OrganizationID) if err != nil { return nil, fmt.Errorf("composition not found: %w", err) } parentID := existing.ParentIngredientID if err := p.compositionRepo.Delete(ctx, id, ctxInfo.OrganizationID); err != nil { return nil, err } // Recalculate parent cost after removal if err := p.recalculateCost(ctx, parentID, ctxInfo.OrganizationID); err != nil { return nil, err } // Return updated parent ingredient return p.buildIngredientResponse(ctx, parentID, ctxInfo.OrganizationID) } // --- private helpers --- func (p *IngredientProcessorImpl) saveCompositions(ctx context.Context, parentID, organizationID uuid.UUID, items []models.CompositionItemRequest) error { ctxInfo := appcontext.FromGinContext(ctx) now := time.Now() for _, item := range items { if item.ChildIngredientID == parentID { return fmt.Errorf("child ingredient cannot be the same as parent") } if _, err := p.ingredientRepo.GetByID(ctx, item.ChildIngredientID, organizationID); err != nil { return fmt.Errorf("child ingredient %s not found: %w", item.ChildIngredientID, err) } // Resolve outlet: use item's outlet if provided, otherwise fall back to context outlet outletID := item.OutletID if outletID == nil && ctxInfo.OutletID != uuid.Nil { outletID = &ctxInfo.OutletID } composition := &entities.IngredientComposition{ ID: uuid.New(), OrganizationID: organizationID, OutletID: outletID, ParentIngredientID: parentID, ChildIngredientID: item.ChildIngredientID, Quantity: item.Quantity, CreatedAt: now, UpdatedAt: now, } if err := p.compositionRepo.Create(ctx, composition); err != nil { return fmt.Errorf("failed to save composition: %w", err) } } // Recalculate cost of the semi-finished parent from child costs return p.recalculateCost(ctx, parentID, organizationID) } // recalculateCost sums up (child.cost * composition.quantity) and updates the parent ingredient cost. func (p *IngredientProcessorImpl) recalculateCost(ctx context.Context, parentID, organizationID uuid.UUID) error { compositions, err := p.compositionRepo.GetByParentIngredientID(ctx, parentID, organizationID) if err != nil { return err } var totalCost float64 for _, c := range compositions { if c.ChildIngredient != nil { totalCost += c.ChildIngredient.Cost * c.Quantity } } parent, err := p.ingredientRepo.GetByID(ctx, parentID, organizationID) if err != nil { return err } parent.Cost = totalCost parent.UpdatedAt = time.Now() return p.ingredientRepo.Update(ctx, parent) } func (p *IngredientProcessorImpl) buildIngredientResponse(ctx context.Context, id, organizationID uuid.UUID) (*models.IngredientResponse, error) { ingredient, err := p.ingredientRepo.GetByID(ctx, id, organizationID) if err != nil { return nil, err } return transformer.IngredientEntityToResponse(ingredient), nil }