497 lines
14 KiB
Go
497 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"`
|
|
}
|
|
|
|
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),
|
|
}
|
|
|
|
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,
|
|
}
|
|
|
|
if localComp.CurrentPrice != nil && *localComp.CurrentPrice > 0 {
|
|
item.UnitPrice = *localComp.CurrentPrice
|
|
item.TotalPrice = *localComp.CurrentPrice * 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
|
|
}
|
|
|
|
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 {
|
|
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
|
|
}
|