309 lines
8.8 KiB
Go
309 lines
8.8 KiB
Go
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
|
|
}
|