package services import ( "errors" "fmt" "sync" "time" "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/repository" ) 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 priceResolver cacheMu sync.RWMutex priceCache map[string]cachedLotPrice cacheTTL time.Duration } type priceResolver interface { GetEffectivePrice(lotName string) (*float64, error) } func NewQuoteService( componentRepo *repository.ComponentRepository, statsRepo *repository.StatsRepository, pricelistRepo *repository.PricelistRepository, localDB *localdb.LocalDB, pricingService priceResolver, ) *QuoteService { return &QuoteService{ componentRepo: componentRepo, statsRepo: statsRepo, pricelistRepo: pricelistRepo, localDB: localDB, pricingService: pricingService, priceCache: make(map[string]cachedLotPrice, 4096), cacheTTL: 10 * time.Second, } } type cachedLotPrice struct { price *float64 expiresAt time.Time } 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"` PricelistID *uint `json:"pricelist_id,omitempty"` // Optional: use specific pricelist for pricing } type PriceLevelsRequest struct { Items []struct { LotName string `json:"lot_name"` Quantity int `json:"quantity"` } `json:"items"` PricelistIDs map[string]uint `json:"pricelist_ids,omitempty"` NoCache bool `json:"no_cache,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 } // Strict local-first path: calculations use local SQLite snapshot regardless of online status. if s.localDB != nil { result := &QuoteValidationResult{ Valid: true, Items: make([]QuoteItem, 0, len(req.Items)), Errors: make([]string, 0), Warnings: make([]string, 0), } // Determine which pricelist to use for pricing pricelistID := req.PricelistID if pricelistID == nil || *pricelistID == 0 { // By default, use latest estimate pricelist latestPricelist, err := s.localDB.GetLatestLocalPricelistBySource("estimate") if err == nil && latestPricelist != nil { pricelistID = &latestPricelist.ServerID } } var total float64 for _, reqItem := range req.Items { localComp, err := s.localDB.GetLocalComponent(reqItem.LotName) if err != nil { result.Valid = false result.Errors = append(result.Errors, "Component not found: "+reqItem.LotName) continue } item := QuoteItem{ LotName: reqItem.LotName, Quantity: reqItem.Quantity, Description: localComp.LotDescription, Category: localComp.Category, HasPrice: false, UnitPrice: 0, TotalPrice: 0, } // Get price from pricelist_items if pricelistID != nil { price, found := s.lookupPriceByPricelistID(*pricelistID, reqItem.LotName) if found && 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) } } else { result.Warnings = append(result.Warnings, "No pricelist available for: "+reqItem.LotName) } result.Items = append(result.Items, item) } result.Total = total return result, nil } if s.componentRepo == nil || s.pricingService == nil { return nil, errors.New("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 } lotNames := make([]string, 0, len(req.Items)) seenLots := make(map[string]struct{}, len(req.Items)) for _, reqItem := range req.Items { if _, ok := seenLots[reqItem.LotName]; ok { continue } seenLots[reqItem.LotName] = struct{}{} lotNames = append(lotNames, reqItem.LotName) } result := &PriceLevelsResult{ Items: make([]PriceLevelsItem, 0, len(req.Items)), ResolvedPricelistIDs: map[string]uint{}, } type levelState struct { id uint prices map[string]float64 } levelBySource := map[models.PricelistSource]*levelState{ models.PricelistSourceEstimate: {prices: map[string]float64{}}, models.PricelistSourceWarehouse: {prices: map[string]float64{}}, models.PricelistSourceCompetitor: {prices: map[string]float64{}}, } for source, st := range levelBySource { sourceKey := string(source) if req.PricelistIDs != nil { if explicitID, ok := req.PricelistIDs[sourceKey]; ok && explicitID > 0 { st.id = explicitID result.ResolvedPricelistIDs[sourceKey] = explicitID } } if st.id == 0 && s.pricelistRepo != nil { latest, err := s.pricelistRepo.GetLatestActiveBySource(sourceKey) if err == nil { st.id = latest.ID result.ResolvedPricelistIDs[sourceKey] = latest.ID } } if st.id == 0 && s.localDB != nil { localPL, err := s.localDB.GetLatestLocalPricelistBySource(sourceKey) if err == nil && localPL != nil { st.id = localPL.ServerID result.ResolvedPricelistIDs[sourceKey] = localPL.ServerID } } if st.id == 0 { continue } prices, err := s.lookupPricesByPricelistID(st.id, lotNames, req.NoCache) if err == nil { st.prices = prices } } for _, reqItem := range req.Items { item := PriceLevelsItem{ LotName: reqItem.LotName, Quantity: reqItem.Quantity, PriceMissing: make([]string, 0, 3), } if p, ok := levelBySource[models.PricelistSourceEstimate].prices[reqItem.LotName]; ok && p > 0 { price := p item.EstimatePrice = &price } if p, ok := levelBySource[models.PricelistSourceWarehouse].prices[reqItem.LotName]; ok && p > 0 { price := p item.WarehousePrice = &price } if p, ok := levelBySource[models.PricelistSourceCompetitor].prices[reqItem.LotName]; ok && p > 0 { price := p item.CompetitorPrice = &price } 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 (s *QuoteService) lookupPricesByPricelistID(pricelistID uint, lotNames []string, noCache bool) (map[string]float64, error) { result := make(map[string]float64, len(lotNames)) if pricelistID == 0 || len(lotNames) == 0 { return result, nil } missing := make([]string, 0, len(lotNames)) if noCache { missing = append(missing, lotNames...) } else { now := time.Now() s.cacheMu.RLock() for _, lotName := range lotNames { if entry, ok := s.priceCache[s.cacheKey(pricelistID, lotName)]; ok && entry.expiresAt.After(now) { if entry.price != nil && *entry.price > 0 { result[lotName] = *entry.price } continue } missing = append(missing, lotName) } s.cacheMu.RUnlock() } if len(missing) == 0 { return result, nil } loaded := make(map[string]float64, len(missing)) if s.pricelistRepo != nil { prices, err := s.pricelistRepo.GetPricesForLots(pricelistID, missing) if err == nil { for lotName, price := range prices { if price > 0 { result[lotName] = price loaded[lotName] = price } } s.updateCache(pricelistID, missing, loaded) return result, nil } } // Fallback path (usually offline): local per-lot lookup. if s.localDB != nil { for _, lotName := range missing { price, found := s.lookupPriceByPricelistID(pricelistID, lotName) if found && price > 0 { result[lotName] = price loaded[lotName] = price } } s.updateCache(pricelistID, missing, loaded) return result, nil } return result, fmt.Errorf("price lookup unavailable for pricelist %d", pricelistID) } func (s *QuoteService) updateCache(pricelistID uint, requested []string, loaded map[string]float64) { if len(requested) == 0 { return } expiresAt := time.Now().Add(s.cacheTTL) s.cacheMu.Lock() defer s.cacheMu.Unlock() for _, lotName := range requested { if price, ok := loaded[lotName]; ok && price > 0 { priceCopy := price s.priceCache[s.cacheKey(pricelistID, lotName)] = cachedLotPrice{ price: &priceCopy, expiresAt: expiresAt, } continue } s.priceCache[s.cacheKey(pricelistID, lotName)] = cachedLotPrice{ price: nil, expiresAt: expiresAt, } } } func (s *QuoteService) cacheKey(pricelistID uint, lotName string) string { return fmt.Sprintf("%d|%s", pricelistID, lotName) } 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 }