Implement warehouse/lot pricing updates and configurator performance fixes
This commit is contained in:
@@ -53,6 +53,7 @@ type CreateConfigRequest struct {
|
||||
IsTemplate bool `json:"is_template"`
|
||||
ServerCount int `json:"server_count"`
|
||||
PricelistID *uint `json:"pricelist_id,omitempty"`
|
||||
OnlyInStock bool `json:"only_in_stock"`
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||
@@ -84,6 +85,7 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
|
||||
IsTemplate: req.IsTemplate,
|
||||
ServerCount: req.ServerCount,
|
||||
PricelistID: pricelistID,
|
||||
OnlyInStock: req.OnlyInStock,
|
||||
}
|
||||
|
||||
if err := s.configRepo.Create(config); err != nil {
|
||||
@@ -145,6 +147,7 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
|
||||
config.IsTemplate = req.IsTemplate
|
||||
config.ServerCount = req.ServerCount
|
||||
config.PricelistID = pricelistID
|
||||
config.OnlyInStock = req.OnlyInStock
|
||||
|
||||
if err := s.configRepo.Update(config); err != nil {
|
||||
return nil, err
|
||||
@@ -222,6 +225,7 @@ func (s *ConfigurationService) CloneToProject(configUUID string, ownerUsername s
|
||||
IsTemplate: false, // Clone is never a template
|
||||
ServerCount: original.ServerCount,
|
||||
PricelistID: original.PricelistID,
|
||||
OnlyInStock: original.OnlyInStock,
|
||||
}
|
||||
|
||||
if err := s.configRepo.Create(clone); err != nil {
|
||||
@@ -295,6 +299,7 @@ func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigReques
|
||||
config.IsTemplate = req.IsTemplate
|
||||
config.ServerCount = req.ServerCount
|
||||
config.PricelistID = pricelistID
|
||||
config.OnlyInStock = req.OnlyInStock
|
||||
|
||||
if err := s.configRepo.Update(config); err != nil {
|
||||
return nil, err
|
||||
@@ -362,6 +367,7 @@ func (s *ConfigurationService) CloneNoAuthToProject(configUUID string, newName s
|
||||
IsTemplate: false,
|
||||
ServerCount: original.ServerCount,
|
||||
PricelistID: original.PricelistID,
|
||||
OnlyInStock: original.OnlyInStock,
|
||||
}
|
||||
|
||||
if err := s.configRepo.Create(clone); err != nil {
|
||||
|
||||
@@ -81,6 +81,7 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
||||
IsTemplate: req.IsTemplate,
|
||||
ServerCount: req.ServerCount,
|
||||
PricelistID: pricelistID,
|
||||
OnlyInStock: req.OnlyInStock,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -163,6 +164,7 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
||||
localCfg.IsTemplate = req.IsTemplate
|
||||
localCfg.ServerCount = req.ServerCount
|
||||
localCfg.PricelistID = pricelistID
|
||||
localCfg.OnlyInStock = req.OnlyInStock
|
||||
localCfg.UpdatedAt = time.Now()
|
||||
localCfg.SyncStatus = "pending"
|
||||
|
||||
@@ -268,6 +270,7 @@ func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsern
|
||||
IsTemplate: false,
|
||||
ServerCount: original.ServerCount,
|
||||
PricelistID: original.PricelistID,
|
||||
OnlyInStock: original.OnlyInStock,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -454,6 +457,7 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
||||
localCfg.IsTemplate = req.IsTemplate
|
||||
localCfg.ServerCount = req.ServerCount
|
||||
localCfg.PricelistID = pricelistID
|
||||
localCfg.OnlyInStock = req.OnlyInStock
|
||||
localCfg.UpdatedAt = time.Now()
|
||||
localCfg.SyncStatus = "pending"
|
||||
|
||||
@@ -546,6 +550,7 @@ func (s *LocalConfigurationService) CloneNoAuthToProject(configUUID string, newN
|
||||
IsTemplate: false,
|
||||
ServerCount: original.ServerCount,
|
||||
PricelistID: original.PricelistID,
|
||||
OnlyInStock: original.OnlyInStock,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -1029,6 +1034,7 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
|
||||
current.IsTemplate = rollbackData.IsTemplate
|
||||
current.ServerCount = rollbackData.ServerCount
|
||||
current.PricelistID = rollbackData.PricelistID
|
||||
current.OnlyInStock = rollbackData.OnlyInStock
|
||||
current.PriceUpdatedAt = rollbackData.PriceUpdatedAt
|
||||
current.UpdatedAt = time.Now()
|
||||
current.SyncStatus = "pending"
|
||||
|
||||
@@ -31,8 +31,9 @@ type CreateProgress struct {
|
||||
}
|
||||
|
||||
type CreateItemInput struct {
|
||||
LotName string
|
||||
Price float64
|
||||
LotName string
|
||||
Price float64
|
||||
PriceMethod string
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository, pricingSvc *pricing.Service) *Service {
|
||||
@@ -141,6 +142,7 @@ func (s *Service) CreateForSourceWithProgress(createdBy, source string, sourceIt
|
||||
PricelistID: pricelist.ID,
|
||||
LotName: strings.TrimSpace(srcItem.LotName),
|
||||
Price: srcItem.Price,
|
||||
PriceMethod: strings.TrimSpace(srcItem.PriceMethod),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
@@ -169,6 +171,11 @@ func (s *Service) CreateForSourceWithProgress(createdBy, source string, sourceIt
|
||||
}
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
_ = s.repo.Delete(pricelist.ID)
|
||||
return nil, fmt.Errorf("cannot create empty pricelist for source %q", source)
|
||||
}
|
||||
|
||||
if err := s.repo.CreateItems(items); err != nil {
|
||||
// Clean up the pricelist if items creation fails
|
||||
s.repo.Delete(pricelist.ID)
|
||||
@@ -262,6 +269,13 @@ func (s *Service) GetItems(pricelistID uint, page, perPage int, search string) (
|
||||
return s.repo.GetItems(pricelistID, offset, perPage, search)
|
||||
}
|
||||
|
||||
func (s *Service) GetLotNames(pricelistID uint) ([]string, error) {
|
||||
if s.repo == nil {
|
||||
return []string{}, nil
|
||||
}
|
||||
return s.repo.GetLotNames(pricelistID)
|
||||
}
|
||||
|
||||
// Delete deletes a pricelist by ID
|
||||
func (s *Service) Delete(id uint) error {
|
||||
if s.repo == nil {
|
||||
|
||||
@@ -2,6 +2,9 @@ package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
@@ -21,6 +24,9 @@ type QuoteService struct {
|
||||
pricelistRepo *repository.PricelistRepository
|
||||
localDB *localdb.LocalDB
|
||||
pricingService *pricing.Service
|
||||
cacheMu sync.RWMutex
|
||||
priceCache map[string]cachedLotPrice
|
||||
cacheTTL time.Duration
|
||||
}
|
||||
|
||||
func NewQuoteService(
|
||||
@@ -36,9 +42,16 @@ func NewQuoteService(
|
||||
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"`
|
||||
@@ -70,6 +83,7 @@ type PriceLevelsRequest struct {
|
||||
Quantity int `json:"quantity"`
|
||||
} `json:"items"`
|
||||
PricelistIDs map[string]uint `json:"pricelist_ids,omitempty"`
|
||||
NoCache bool `json:"no_cache,omitempty"`
|
||||
}
|
||||
|
||||
type PriceLevelsItem struct {
|
||||
@@ -170,11 +184,55 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve
|
||||
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,
|
||||
@@ -182,22 +240,17 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve
|
||||
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 p, ok := levelBySource[models.PricelistSourceEstimate].prices[reqItem.LotName]; ok && p > 0 {
|
||||
price := p
|
||||
item.EstimatePrice = &price
|
||||
}
|
||||
if warehouseID != 0 {
|
||||
result.ResolvedPricelistIDs[string(models.PricelistSourceWarehouse)] = warehouseID
|
||||
if p, ok := levelBySource[models.PricelistSourceWarehouse].prices[reqItem.LotName]; ok && p > 0 {
|
||||
price := p
|
||||
item.WarehousePrice = &price
|
||||
}
|
||||
if competitorID != 0 {
|
||||
result.ResolvedPricelistIDs[string(models.PricelistSourceCompetitor)] = competitorID
|
||||
if p, ok := levelBySource[models.PricelistSourceCompetitor].prices[reqItem.LotName]; ok && p > 0 {
|
||||
price := p
|
||||
item.CompetitorPrice = &price
|
||||
}
|
||||
|
||||
if item.EstimatePrice == nil {
|
||||
@@ -220,6 +273,93 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve
|
||||
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
|
||||
|
||||
@@ -33,6 +33,7 @@ type StockImportProgress struct {
|
||||
Conflicts int `json:"conflicts,omitempty"`
|
||||
FallbackMatches int `json:"fallback_matches,omitempty"`
|
||||
ParseErrors int `json:"parse_errors,omitempty"`
|
||||
QtyParseErrors int `json:"qty_parse_errors,omitempty"`
|
||||
Ignored int `json:"ignored,omitempty"`
|
||||
MappingSuggestions []StockMappingSuggestion `json:"mapping_suggestions,omitempty"`
|
||||
ImportDate string `json:"import_date,omitempty"`
|
||||
@@ -49,6 +50,7 @@ type StockImportResult struct {
|
||||
Conflicts int
|
||||
FallbackMatches int
|
||||
ParseErrors int
|
||||
QtyParseErrors int
|
||||
Ignored int
|
||||
MappingSuggestions []StockMappingSuggestion
|
||||
ImportDate time.Time
|
||||
@@ -87,6 +89,13 @@ type stockImportRow struct {
|
||||
Vendor string
|
||||
Price float64
|
||||
Qty float64
|
||||
QtyRaw string
|
||||
QtyInvalid bool
|
||||
}
|
||||
|
||||
type weightedPricePoint struct {
|
||||
price float64
|
||||
weight float64
|
||||
}
|
||||
|
||||
func (s *StockImportService) Import(
|
||||
@@ -128,7 +137,7 @@ func (s *StockImportService) Import(
|
||||
Total: 100,
|
||||
})
|
||||
|
||||
resolver, err := s.newLotResolver()
|
||||
partnumberMappings, err := s.loadPartnumberMappings()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -139,6 +148,7 @@ func (s *StockImportService) Import(
|
||||
conflicts int
|
||||
fallbackMatches int
|
||||
parseErrors int
|
||||
qtyParseErrors int
|
||||
ignored int
|
||||
suggestionsByPN = make(map[string]StockMappingSuggestion)
|
||||
)
|
||||
@@ -152,41 +162,32 @@ func (s *StockImportService) Import(
|
||||
parseErrors++
|
||||
continue
|
||||
}
|
||||
if row.QtyInvalid {
|
||||
qtyParseErrors++
|
||||
parseErrors++
|
||||
continue
|
||||
}
|
||||
if shouldIgnoreStockRow(row, ignoreRules) {
|
||||
ignored++
|
||||
continue
|
||||
}
|
||||
lot, matchType, resolveErr := resolver.resolve(row.Article)
|
||||
if resolveErr != nil {
|
||||
trimmedPN := strings.TrimSpace(row.Article)
|
||||
if trimmedPN != "" {
|
||||
key := normalizeKey(trimmedPN)
|
||||
if key != "" {
|
||||
reason := "unmapped"
|
||||
if errors.Is(resolveErr, errResolveConflict) {
|
||||
reason = "conflict"
|
||||
}
|
||||
candidate := StockMappingSuggestion{
|
||||
Partnumber: trimmedPN,
|
||||
Description: strings.TrimSpace(row.Description),
|
||||
Reason: reason,
|
||||
}
|
||||
if prev, ok := suggestionsByPN[key]; !ok ||
|
||||
(strings.TrimSpace(prev.Description) == "" && candidate.Description != "") ||
|
||||
(prev.Reason != "conflict" && candidate.Reason == "conflict") {
|
||||
suggestionsByPN[key] = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
if errors.Is(resolveErr, errResolveConflict) {
|
||||
conflicts++
|
||||
} else {
|
||||
unmapped++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if matchType == "article_exact" || matchType == "prefix" {
|
||||
fallbackMatches++
|
||||
partnumber := strings.TrimSpace(row.Article)
|
||||
key := normalizeKey(partnumber)
|
||||
mappedLots := partnumberMappings[key]
|
||||
if len(mappedLots) == 0 {
|
||||
unmapped++
|
||||
suggestionsByPN[key] = upsertSuggestion(suggestionsByPN[key], StockMappingSuggestion{
|
||||
Partnumber: partnumber,
|
||||
Description: strings.TrimSpace(row.Description),
|
||||
Reason: "unmapped",
|
||||
})
|
||||
} else if len(mappedLots) > 1 {
|
||||
conflicts++
|
||||
suggestionsByPN[key] = upsertSuggestion(suggestionsByPN[key], StockMappingSuggestion{
|
||||
Partnumber: partnumber,
|
||||
Description: strings.TrimSpace(row.Description),
|
||||
Reason: "conflict",
|
||||
})
|
||||
}
|
||||
|
||||
var comments *string
|
||||
@@ -199,30 +200,31 @@ func (s *StockImportService) Import(
|
||||
}
|
||||
qty := row.Qty
|
||||
records = append(records, models.StockLog{
|
||||
Lot: lot,
|
||||
Date: importDate,
|
||||
Price: row.Price,
|
||||
Comments: comments,
|
||||
Vendor: vendor,
|
||||
Qty: &qty,
|
||||
Partnumber: partnumber,
|
||||
Date: importDate,
|
||||
Price: row.Price,
|
||||
Comments: comments,
|
||||
Vendor: vendor,
|
||||
Qty: &qty,
|
||||
})
|
||||
}
|
||||
|
||||
suggestions := collectSortedSuggestions(suggestionsByPN, 200)
|
||||
|
||||
if len(records) == 0 {
|
||||
return nil, fmt.Errorf("no valid rows after mapping")
|
||||
return nil, fmt.Errorf("no valid rows after filtering")
|
||||
}
|
||||
|
||||
report(StockImportProgress{
|
||||
Status: "mapping",
|
||||
Message: "Сопоставление article -> lot завершено",
|
||||
Message: "Валидация строк завершена",
|
||||
RowsTotal: len(rows),
|
||||
ValidRows: len(records),
|
||||
Unmapped: unmapped,
|
||||
Conflicts: conflicts,
|
||||
FallbackMatches: fallbackMatches,
|
||||
ParseErrors: parseErrors,
|
||||
QtyParseErrors: qtyParseErrors,
|
||||
Current: 40,
|
||||
Total: 100,
|
||||
})
|
||||
@@ -261,10 +263,14 @@ func (s *StockImportService) Import(
|
||||
return nil, fmt.Errorf("pricelist service unavailable")
|
||||
}
|
||||
pl, err := s.pricelistSvc.CreateForSourceWithProgress(createdBy, string(models.PricelistSourceWarehouse), items, func(p pricelistsvc.CreateProgress) {
|
||||
current := 70 + int(float64(p.Current)*0.3)
|
||||
if p.Status != "completed" && current >= 100 {
|
||||
current = 99
|
||||
}
|
||||
report(StockImportProgress{
|
||||
Status: "recalculating_warehouse",
|
||||
Message: p.Message,
|
||||
Current: 70 + int(float64(p.Current)*0.3),
|
||||
Current: current,
|
||||
Total: 100,
|
||||
})
|
||||
})
|
||||
@@ -283,6 +289,7 @@ func (s *StockImportService) Import(
|
||||
Conflicts: conflicts,
|
||||
FallbackMatches: fallbackMatches,
|
||||
ParseErrors: parseErrors,
|
||||
QtyParseErrors: qtyParseErrors,
|
||||
Ignored: ignored,
|
||||
MappingSuggestions: suggestions,
|
||||
ImportDate: importDate,
|
||||
@@ -301,6 +308,7 @@ func (s *StockImportService) Import(
|
||||
Conflicts: result.Conflicts,
|
||||
FallbackMatches: result.FallbackMatches,
|
||||
ParseErrors: result.ParseErrors,
|
||||
QtyParseErrors: result.QtyParseErrors,
|
||||
Ignored: result.Ignored,
|
||||
MappingSuggestions: result.MappingSuggestions,
|
||||
ImportDate: result.ImportDate.Format("2006-01-02"),
|
||||
@@ -335,28 +343,45 @@ func (s *StockImportService) replaceStockLogs(records []models.StockLog) (int64,
|
||||
|
||||
func (s *StockImportService) buildWarehousePricelistItems() ([]pricelistsvc.CreateItemInput, error) {
|
||||
var logs []models.StockLog
|
||||
if err := s.db.Select("lot, price").Where("price > 0").Find(&logs).Error; err != nil {
|
||||
if err := s.db.Select("partnumber, price, qty").Where("price > 0").Find(&logs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
grouped := make(map[string][]float64)
|
||||
resolver, err := s.newLotResolver()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
grouped := make(map[string][]weightedPricePoint)
|
||||
for _, l := range logs {
|
||||
lot := strings.TrimSpace(l.Lot)
|
||||
if lot == "" || l.Price <= 0 {
|
||||
partnumber := strings.TrimSpace(l.Partnumber)
|
||||
if partnumber == "" || l.Price <= 0 {
|
||||
continue
|
||||
}
|
||||
grouped[lot] = append(grouped[lot], l.Price)
|
||||
lotName, _, err := resolver.resolve(partnumber)
|
||||
if err != nil || strings.TrimSpace(lotName) == "" {
|
||||
continue
|
||||
}
|
||||
weight := 0.0
|
||||
if l.Qty != nil && *l.Qty > 0 {
|
||||
weight = *l.Qty
|
||||
}
|
||||
grouped[lotName] = append(grouped[lotName], weightedPricePoint{
|
||||
price: l.Price,
|
||||
weight: weight,
|
||||
})
|
||||
}
|
||||
|
||||
items := make([]pricelistsvc.CreateItemInput, 0, len(grouped))
|
||||
for lot, prices := range grouped {
|
||||
price := median(prices)
|
||||
for lot, values := range grouped {
|
||||
price := weightedMedian(values)
|
||||
if price <= 0 {
|
||||
continue
|
||||
}
|
||||
items = append(items, pricelistsvc.CreateItemInput{
|
||||
LotName: lot,
|
||||
Price: price,
|
||||
LotName: lot,
|
||||
Price: price,
|
||||
PriceMethod: "weighted_median",
|
||||
})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
@@ -365,6 +390,39 @@ func (s *StockImportService) buildWarehousePricelistItems() ([]pricelistsvc.Crea
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *StockImportService) loadPartnumberMappings() (map[string][]string, error) {
|
||||
var mappings []models.LotPartnumber
|
||||
if err := s.db.Find(&mappings).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
partnumberToLots := make(map[string][]string, len(mappings))
|
||||
for _, m := range mappings {
|
||||
pn := normalizeKey(m.Partnumber)
|
||||
lot := strings.TrimSpace(m.LotName)
|
||||
if pn == "" || lot == "" {
|
||||
continue
|
||||
}
|
||||
partnumberToLots[pn] = append(partnumberToLots[pn], lot)
|
||||
}
|
||||
for key, lots := range partnumberToLots {
|
||||
partnumberToLots[key] = uniqueStrings(lots)
|
||||
}
|
||||
return partnumberToLots, nil
|
||||
}
|
||||
|
||||
func upsertSuggestion(prev StockMappingSuggestion, candidate StockMappingSuggestion) StockMappingSuggestion {
|
||||
if strings.TrimSpace(prev.Partnumber) == "" {
|
||||
return candidate
|
||||
}
|
||||
if strings.TrimSpace(prev.Description) == "" && strings.TrimSpace(candidate.Description) != "" {
|
||||
prev.Description = candidate.Description
|
||||
}
|
||||
if prev.Reason != "conflict" && candidate.Reason == "conflict" {
|
||||
prev.Reason = "conflict"
|
||||
}
|
||||
return prev
|
||||
}
|
||||
|
||||
func (s *StockImportService) ListMappings(page, perPage int, search string) ([]models.LotPartnumber, int64, error) {
|
||||
if s.db == nil {
|
||||
return nil, 0, fmt.Errorf("offline mode: mappings unavailable")
|
||||
@@ -669,7 +727,8 @@ func parseMXLRows(content []byte) ([]stockImportRow, error) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
qty, err := parseLocalizedFloat(r[6])
|
||||
qtyRaw := strings.TrimSpace(r[6])
|
||||
qty, err := parseLocalizedQty(qtyRaw)
|
||||
if err != nil {
|
||||
qty = 0
|
||||
}
|
||||
@@ -680,6 +739,8 @@ func parseMXLRows(content []byte) ([]stockImportRow, error) {
|
||||
Vendor: strings.TrimSpace(r[4]),
|
||||
Price: price,
|
||||
Qty: qty,
|
||||
QtyRaw: qtyRaw,
|
||||
QtyInvalid: err != nil,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
@@ -767,6 +828,9 @@ func parseXLSXRows(content []byte) ([]stockImportRow, error) {
|
||||
idxVendor, hasVendor := headers["вендор"]
|
||||
idxPrice := headers["стоимость"]
|
||||
idxQty, hasQty := headers["свободно"]
|
||||
if !hasQty {
|
||||
return nil, fmt.Errorf("xlsx parsing failed: qty column 'Свободно' not found")
|
||||
}
|
||||
for i := headerRow + 1; i < len(grid); i++ {
|
||||
row := grid[i]
|
||||
article := strings.TrimSpace(row[idxArticle])
|
||||
@@ -778,10 +842,14 @@ func parseXLSXRows(content []byte) ([]stockImportRow, error) {
|
||||
continue
|
||||
}
|
||||
qty := 0.0
|
||||
qtyRaw := ""
|
||||
qtyInvalid := false
|
||||
if hasQty {
|
||||
qty, err = parseLocalizedFloat(row[idxQty])
|
||||
qtyRaw = strings.TrimSpace(row[idxQty])
|
||||
qty, err = parseLocalizedQty(qtyRaw)
|
||||
if err != nil {
|
||||
qty = 0
|
||||
qtyInvalid = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -805,6 +873,8 @@ func parseXLSXRows(content []byte) ([]stockImportRow, error) {
|
||||
Vendor: vendor,
|
||||
Price: price,
|
||||
Qty: qty,
|
||||
QtyRaw: qtyRaw,
|
||||
QtyInvalid: qtyInvalid,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
@@ -821,6 +891,23 @@ func parseLocalizedFloat(value string) (float64, error) {
|
||||
return strconv.ParseFloat(clean, 64)
|
||||
}
|
||||
|
||||
func parseLocalizedQty(value string) (float64, error) {
|
||||
clean := strings.TrimSpace(value)
|
||||
if clean == "" {
|
||||
return 0, fmt.Errorf("empty qty")
|
||||
}
|
||||
if v, err := parseLocalizedFloat(clean); err == nil {
|
||||
return v, nil
|
||||
}
|
||||
// Tolerate strings like "1 200 шт" by extracting the first numeric token.
|
||||
re := regexp.MustCompile(`[-+]?\d[\d\s\u00a0]*(?:[.,]\d+)?`)
|
||||
match := re.FindString(clean)
|
||||
if strings.TrimSpace(match) == "" {
|
||||
return 0, fmt.Errorf("invalid qty: %s", value)
|
||||
}
|
||||
return parseLocalizedFloat(match)
|
||||
}
|
||||
|
||||
func detectImportDate(content []byte, filename string, fileModTime time.Time) time.Time {
|
||||
if d, ok := extractDateFromText(string(content)); ok {
|
||||
return d
|
||||
@@ -885,6 +972,54 @@ func median(values []float64) float64 {
|
||||
return c[n/2]
|
||||
}
|
||||
|
||||
func weightedMedian(values []weightedPricePoint) float64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
type pair struct {
|
||||
price float64
|
||||
weight float64
|
||||
}
|
||||
items := make([]pair, 0, len(values))
|
||||
totalWeight := 0.0
|
||||
prices := make([]float64, 0, len(values))
|
||||
|
||||
for _, v := range values {
|
||||
if v.price <= 0 {
|
||||
continue
|
||||
}
|
||||
prices = append(prices, v.price)
|
||||
w := v.weight
|
||||
if w > 0 {
|
||||
items = append(items, pair{price: v.price, weight: w})
|
||||
totalWeight += w
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for rows without positive weights.
|
||||
if totalWeight <= 0 {
|
||||
return median(prices)
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].price == items[j].price {
|
||||
return items[i].weight < items[j].weight
|
||||
}
|
||||
return items[i].price < items[j].price
|
||||
})
|
||||
|
||||
threshold := totalWeight / 2.0
|
||||
acc := 0.0
|
||||
for _, it := range items {
|
||||
acc += it.weight
|
||||
if acc >= threshold {
|
||||
return it.price
|
||||
}
|
||||
}
|
||||
return items[len(items)-1].price
|
||||
}
|
||||
|
||||
type lotResolver struct {
|
||||
partnumberToLots map[string][]string
|
||||
exactLots map[string]string
|
||||
|
||||
@@ -47,6 +47,35 @@ func TestParseMXLRows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMXLRows_EmptyQtyMarkedInvalid(t *testing.T) {
|
||||
content := strings.Join([]string{
|
||||
`MOXCEL`,
|
||||
`{16,2,{1,1,{"ru","Папка"}},0},1,`,
|
||||
`{16,2,{1,1,{"ru","Артикул"}},0},2,`,
|
||||
`{16,2,{1,1,{"ru","Описание"}},0},3,`,
|
||||
`{16,2,{1,1,{"ru","Вендор"}},0},4,`,
|
||||
`{16,2,{1,1,{"ru","Стоимость"}},0},5,`,
|
||||
`{16,2,{1,1,{"ru","Свободно"}},0},6,`,
|
||||
`{16,2,{1,1,{"ru","Серверы"}},0},1,`,
|
||||
`{16,2,{1,1,{"ru","CPU_X"}},0},2,`,
|
||||
`{16,2,{1,1,{"ru","Процессор"}},0},3,`,
|
||||
`{16,2,{1,1,{"ru","AMD"}},0},4,`,
|
||||
`{16,2,{1,1,{"ru","125,50"}},0},5,`,
|
||||
`{16,2,{1,1,{"ru",""}},0},6,`,
|
||||
}, "\n")
|
||||
|
||||
rows, err := parseMXLRows([]byte(content))
|
||||
if err != nil {
|
||||
t.Fatalf("parseMXLRows: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
||||
}
|
||||
if !rows[0].QtyInvalid {
|
||||
t.Fatalf("expected QtyInvalid=true for empty qty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXLSXRows(t *testing.T) {
|
||||
xlsx := buildMinimalXLSX(t, []string{
|
||||
"Папка", "Артикул", "Описание", "Вендор", "Стоимость", "Свободно",
|
||||
@@ -114,9 +143,9 @@ func TestImportNoValidRowsKeepsStockLog(t *testing.T) {
|
||||
}
|
||||
|
||||
existing := models.StockLog{
|
||||
Lot: "CPU_A",
|
||||
Date: time.Now(),
|
||||
Price: 10,
|
||||
Partnumber: "CPU_A",
|
||||
Date: time.Now(),
|
||||
Price: 10,
|
||||
}
|
||||
if err := db.Create(&existing).Error; err != nil {
|
||||
t.Fatalf("seed stock_log: %v", err)
|
||||
@@ -152,14 +181,14 @@ func TestReplaceStockLogs(t *testing.T) {
|
||||
t.Fatalf("automigrate stock_log: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(&models.StockLog{Lot: "OLD", Date: time.Now(), Price: 1}).Error; err != nil {
|
||||
if err := db.Create(&models.StockLog{Partnumber: "OLD", Date: time.Now(), Price: 1}).Error; err != nil {
|
||||
t.Fatalf("seed old row: %v", err)
|
||||
}
|
||||
|
||||
svc := NewStockImportService(db, nil)
|
||||
records := []models.StockLog{
|
||||
{Lot: "NEW_1", Date: time.Now(), Price: 2},
|
||||
{Lot: "NEW_2", Date: time.Now(), Price: 3},
|
||||
{Partnumber: "NEW_1", Date: time.Now(), Price: 2},
|
||||
{Partnumber: "NEW_2", Date: time.Now(), Price: 3},
|
||||
}
|
||||
|
||||
deleted, inserted, err := svc.replaceStockLogs(records)
|
||||
@@ -171,14 +200,73 @@ func TestReplaceStockLogs(t *testing.T) {
|
||||
}
|
||||
|
||||
var rows []models.StockLog
|
||||
if err := db.Order("lot").Find(&rows).Error; err != nil {
|
||||
if err := db.Order("partnumber").Find(&rows).Error; err != nil {
|
||||
t.Fatalf("read rows: %v", err)
|
||||
}
|
||||
if len(rows) != 2 || rows[0].Lot != "NEW_1" || rows[1].Lot != "NEW_2" {
|
||||
if len(rows) != 2 || rows[0].Partnumber != "NEW_1" || rows[1].Partnumber != "NEW_2" {
|
||||
t.Fatalf("unexpected rows after replace: %#v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightedMedian(t *testing.T) {
|
||||
got := weightedMedian([]weightedPricePoint{
|
||||
{price: 10, weight: 1},
|
||||
{price: 20, weight: 3},
|
||||
{price: 50, weight: 1},
|
||||
})
|
||||
if got != 20 {
|
||||
t.Fatalf("expected weighted median 20, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightedMedianFallbackToMedianWhenNoWeights(t *testing.T) {
|
||||
got := weightedMedian([]weightedPricePoint{
|
||||
{price: 10, weight: 0},
|
||||
{price: 20, weight: 0},
|
||||
{price: 30, weight: 0},
|
||||
})
|
||||
if got != 20 {
|
||||
t.Fatalf("expected fallback median 20, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWarehousePricelistItems_UsesPrefixResolver(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
if err := db.AutoMigrate(&models.StockLog{}, &models.Lot{}, &models.LotPartnumber{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(&models.Lot{LotName: "CPU_A"}).Error; err != nil {
|
||||
t.Fatalf("seed lot: %v", err)
|
||||
}
|
||||
|
||||
qty1 := 3.0
|
||||
qty2 := 1.0
|
||||
now := time.Now()
|
||||
rows := []models.StockLog{
|
||||
{Partnumber: "CPU_A-001", Date: now, Price: 100, Qty: &qty1},
|
||||
{Partnumber: "CPU_A-XYZ", Date: now, Price: 120, Qty: &qty2},
|
||||
}
|
||||
if err := db.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed stock_log: %v", err)
|
||||
}
|
||||
|
||||
svc := NewStockImportService(db, nil)
|
||||
items, err := svc.buildWarehousePricelistItems()
|
||||
if err != nil {
|
||||
t.Fatalf("buildWarehousePricelistItems: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 item, got %d", len(items))
|
||||
}
|
||||
if items[0].LotName != "CPU_A" {
|
||||
t.Fatalf("expected lot CPU_A, got %s", items[0].LotName)
|
||||
}
|
||||
if items[0].Price != 100 {
|
||||
t.Fatalf("expected weighted median 100, got %v", items[0].Price)
|
||||
}
|
||||
}
|
||||
|
||||
func openTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
|
||||
@@ -349,6 +349,10 @@ CREATE TABLE qt_configurations (
|
||||
is_template INTEGER NOT NULL DEFAULT 0,
|
||||
server_count INTEGER NOT NULL DEFAULT 1,
|
||||
pricelist_id INTEGER NULL,
|
||||
warehouse_pricelist_id INTEGER NULL,
|
||||
competitor_pricelist_id INTEGER NULL,
|
||||
disable_price_refresh INTEGER NOT NULL DEFAULT 0,
|
||||
only_in_stock INTEGER NOT NULL DEFAULT 0,
|
||||
price_updated_at DATETIME NULL,
|
||||
created_at DATETIME
|
||||
);`).Error; err != nil {
|
||||
|
||||
Reference in New Issue
Block a user