CalculatePriceLevels now falls back to localDB when pricelistRepo is nil (offline mode) to resolve the latest pricelist ID per source. Previously all price lookups were skipped, resulting in empty prices on the pricing tab. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
521 lines
14 KiB
Go
521 lines
14 KiB
Go
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
|
|
}
|