package services import ( "errors" "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/services/pricing" ) var ( ErrEmptyQuote = errors.New("quote cannot be empty") ErrComponentNotFound = errors.New("component not found") ErrNoPriceAvailable = errors.New("no price available for component") ) type QuoteService struct { componentRepo *repository.ComponentRepository statsRepo *repository.StatsRepository pricelistRepo *repository.PricelistRepository localDB *localdb.LocalDB pricingService *pricing.Service } func NewQuoteService( componentRepo *repository.ComponentRepository, statsRepo *repository.StatsRepository, pricelistRepo *repository.PricelistRepository, localDB *localdb.LocalDB, pricingService *pricing.Service, ) *QuoteService { return &QuoteService{ componentRepo: componentRepo, statsRepo: statsRepo, pricelistRepo: pricelistRepo, localDB: localDB, pricingService: pricingService, } } type QuoteItem struct { LotName string `json:"lot_name"` Quantity int `json:"quantity"` UnitPrice float64 `json:"unit_price"` TotalPrice float64 `json:"total_price"` Description string `json:"description"` Category string `json:"category"` HasPrice bool `json:"has_price"` } type QuoteValidationResult struct { Valid bool `json:"valid"` Items []QuoteItem `json:"items"` Errors []string `json:"errors"` Warnings []string `json:"warnings"` Total float64 `json:"total"` } type QuoteRequest struct { Items []struct { LotName string `json:"lot_name"` Quantity int `json:"quantity"` } `json:"items"` } type PriceLevelsRequest struct { Items []struct { LotName string `json:"lot_name"` Quantity int `json:"quantity"` } `json:"items"` PricelistIDs map[string]uint `json:"pricelist_ids,omitempty"` } type PriceLevelsItem struct { LotName string `json:"lot_name"` Quantity int `json:"quantity"` EstimatePrice *float64 `json:"estimate_price"` WarehousePrice *float64 `json:"warehouse_price"` CompetitorPrice *float64 `json:"competitor_price"` DeltaWhEstimateAbs *float64 `json:"delta_wh_estimate_abs"` DeltaWhEstimatePct *float64 `json:"delta_wh_estimate_pct"` DeltaCompEstimateAbs *float64 `json:"delta_comp_estimate_abs"` DeltaCompEstimatePct *float64 `json:"delta_comp_estimate_pct"` DeltaCompWhAbs *float64 `json:"delta_comp_wh_abs"` DeltaCompWhPct *float64 `json:"delta_comp_wh_pct"` PriceMissing []string `json:"price_missing"` } type PriceLevelsResult struct { Items []PriceLevelsItem `json:"items"` ResolvedPricelistIDs map[string]uint `json:"resolved_pricelist_ids"` } func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidationResult, error) { if len(req.Items) == 0 { return nil, ErrEmptyQuote } if s.componentRepo == nil || s.pricingService == nil { return nil, errors.New("offline mode: quote calculation not available") } result := &QuoteValidationResult{ Valid: true, Items: make([]QuoteItem, 0, len(req.Items)), Errors: make([]string, 0), Warnings: make([]string, 0), } lotNames := make([]string, len(req.Items)) quantities := make(map[string]int) for i, item := range req.Items { lotNames[i] = item.LotName quantities[item.LotName] = item.Quantity } components, err := s.componentRepo.GetMultiple(lotNames) if err != nil { return nil, err } componentMap := make(map[string]*models.LotMetadata) for i := range components { componentMap[components[i].LotName] = &components[i] } var total float64 for _, reqItem := range req.Items { comp, exists := componentMap[reqItem.LotName] if !exists { result.Valid = false result.Errors = append(result.Errors, "Component not found: "+reqItem.LotName) continue } item := QuoteItem{ LotName: reqItem.LotName, Quantity: reqItem.Quantity, HasPrice: false, } if comp.Lot != nil { item.Description = comp.Lot.LotDescription } if comp.Category != nil { item.Category = comp.Category.Code } // Get effective price (override or calculated) price, err := s.pricingService.GetEffectivePrice(reqItem.LotName) if err == nil && price != nil && *price > 0 { item.UnitPrice = *price item.TotalPrice = *price * float64(reqItem.Quantity) item.HasPrice = true total += item.TotalPrice } else { result.Warnings = append(result.Warnings, "No price available for: "+reqItem.LotName) } result.Items = append(result.Items, item) } result.Total = total return result, nil } func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLevelsResult, error) { if len(req.Items) == 0 { return nil, ErrEmptyQuote } result := &PriceLevelsResult{ Items: make([]PriceLevelsItem, 0, len(req.Items)), ResolvedPricelistIDs: map[string]uint{}, } for _, reqItem := range req.Items { item := PriceLevelsItem{ LotName: reqItem.LotName, Quantity: reqItem.Quantity, PriceMissing: make([]string, 0, 3), } estimatePrice, estimateID := s.lookupLevelPrice(models.PricelistSourceEstimate, reqItem.LotName, req.PricelistIDs) warehousePrice, warehouseID := s.lookupLevelPrice(models.PricelistSourceWarehouse, reqItem.LotName, req.PricelistIDs) competitorPrice, competitorID := s.lookupLevelPrice(models.PricelistSourceCompetitor, reqItem.LotName, req.PricelistIDs) item.EstimatePrice = estimatePrice item.WarehousePrice = warehousePrice item.CompetitorPrice = competitorPrice if estimateID != 0 { result.ResolvedPricelistIDs[string(models.PricelistSourceEstimate)] = estimateID } if warehouseID != 0 { result.ResolvedPricelistIDs[string(models.PricelistSourceWarehouse)] = warehouseID } if competitorID != 0 { result.ResolvedPricelistIDs[string(models.PricelistSourceCompetitor)] = competitorID } if item.EstimatePrice == nil { item.PriceMissing = append(item.PriceMissing, string(models.PricelistSourceEstimate)) } if item.WarehousePrice == nil { item.PriceMissing = append(item.PriceMissing, string(models.PricelistSourceWarehouse)) } if item.CompetitorPrice == nil { item.PriceMissing = append(item.PriceMissing, string(models.PricelistSourceCompetitor)) } item.DeltaWhEstimateAbs, item.DeltaWhEstimatePct = calculateDelta(item.WarehousePrice, item.EstimatePrice) item.DeltaCompEstimateAbs, item.DeltaCompEstimatePct = calculateDelta(item.CompetitorPrice, item.EstimatePrice) item.DeltaCompWhAbs, item.DeltaCompWhPct = calculateDelta(item.CompetitorPrice, item.WarehousePrice) result.Items = append(result.Items, item) } return result, nil } func calculateDelta(target, base *float64) (*float64, *float64) { if target == nil || base == nil { return nil, nil } abs := *target - *base if *base == 0 { return &abs, nil } pct := (abs / *base) * 100 return &abs, &pct } func (s *QuoteService) lookupLevelPrice(source models.PricelistSource, lotName string, pricelistIDs map[string]uint) (*float64, uint) { sourceKey := string(source) if id, ok := pricelistIDs[sourceKey]; ok && id > 0 { price, found := s.lookupPriceByPricelistID(id, lotName) if found { return &price, id } return nil, id } if s.pricelistRepo != nil { price, id, err := s.pricelistRepo.GetPriceForLotBySource(sourceKey, lotName) if err == nil && price > 0 { return &price, id } latest, latestErr := s.pricelistRepo.GetLatestActiveBySource(sourceKey) if latestErr == nil { return nil, latest.ID } } if s.localDB != nil { localPL, err := s.localDB.GetLatestLocalPricelistBySource(sourceKey) if err != nil { return nil, 0 } price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName) if err != nil || price <= 0 { return nil, localPL.ServerID } return &price, localPL.ServerID } return nil, 0 } func (s *QuoteService) lookupPriceByPricelistID(pricelistID uint, lotName string) (float64, bool) { if s.pricelistRepo != nil { price, err := s.pricelistRepo.GetPriceForLot(pricelistID, lotName) if err == nil && price > 0 { return price, true } } if s.localDB != nil { localPL, err := s.localDB.GetLocalPricelistByServerID(pricelistID) if err != nil { return 0, false } price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName) if err == nil && price > 0 { return price, true } } return 0, false } // RecordUsage records that components were used in a quote func (s *QuoteService) RecordUsage(items []models.ConfigItem) error { if s.statsRepo == nil { // Offline mode: usage stats are unavailable and should not block config saves. return nil } for _, item := range items { revenue := item.UnitPrice * float64(item.Quantity) if err := s.statsRepo.IncrementUsage(item.LotName, item.Quantity, revenue); err != nil { return err } } return nil }