Implement warehouse/lot pricing updates and configurator performance fixes

This commit is contained in:
2026-02-07 05:20:35 +03:00
parent c1a31e5ee0
commit 7c741ff675
26 changed files with 1701 additions and 305 deletions

View File

@@ -3,6 +3,7 @@ package repository
import (
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
@@ -26,7 +27,8 @@ func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary
// ListBySource returns pricelists filtered by source when provided.
func (r *PricelistRepository) ListBySource(source string, offset, limit int) ([]models.PricelistSummary, int64, error) {
query := r.db.Model(&models.Pricelist{})
query := r.db.Model(&models.Pricelist{}).
Where("EXISTS (SELECT 1 FROM qt_pricelist_items WHERE qt_pricelist_items.pricelist_id = qt_pricelists.id)")
if source != "" {
query = query.Where("source = ?", source)
}
@@ -51,7 +53,9 @@ func (r *PricelistRepository) ListActive(offset, limit int) ([]models.PricelistS
// ListActiveBySource returns active pricelists filtered by source when provided.
func (r *PricelistRepository) ListActiveBySource(source string, offset, limit int) ([]models.PricelistSummary, int64, error) {
query := r.db.Model(&models.Pricelist{}).Where("is_active = ?", true)
query := r.db.Model(&models.Pricelist{}).
Where("is_active = ?", true).
Where("EXISTS (SELECT 1 FROM qt_pricelist_items WHERE qt_pricelist_items.pricelist_id = qt_pricelists.id)")
if source != "" {
query = query.Where("source = ?", source)
}
@@ -250,6 +254,19 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
return items, total, nil
}
// GetLotNames returns distinct lot names from pricelist items.
func (r *PricelistRepository) GetLotNames(pricelistID uint) ([]string, error) {
var lotNames []string
if err := r.db.Model(&models.PricelistItem{}).
Where("pricelist_id = ?", pricelistID).
Distinct("lot_name").
Order("lot_name ASC").
Pluck("lot_name", &lotNames).Error; err != nil {
return nil, fmt.Errorf("listing pricelist lot names: %w", err)
}
return lotNames, nil
}
func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem) error {
if len(items) == 0 {
return nil
@@ -271,21 +288,36 @@ func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem)
return nil
}
type lotQty struct {
Lot string
Qty float64
lotSet := make(map[string]struct{}, len(lots))
for _, lot := range lots {
lotSet[lot] = struct{}{}
}
var qtyRows []lotQty
if err := r.db.Model(&models.StockLog{}).
Select("lot, COALESCE(SUM(qty), 0) AS qty").
Where("lot IN ?", lots).
Group("lot").
Scan(&qtyRows).Error; err != nil {
resolver, err := r.newWarehouseLotResolver()
if err != nil {
return err
}
qtyByLot := make(map[string]float64, len(qtyRows))
for _, row := range qtyRows {
qtyByLot[row.Lot] = row.Qty
var logs []struct {
Partnumber string `gorm:"column:partnumber"`
Qty *float64 `gorm:"column:qty"`
}
if err := r.db.Model(&models.StockLog{}).Select("partnumber, qty").Find(&logs).Error; err != nil {
return err
}
qtyByLot := make(map[string]float64, len(lots))
for _, row := range logs {
if row.Qty == nil {
continue
}
lot, err := resolver.resolve(row.Partnumber)
if err != nil {
continue
}
if _, ok := lotSet[lot]; !ok {
continue
}
qtyByLot[lot] += *row.Qty
}
var mappings []models.LotPartnumber
@@ -320,6 +352,131 @@ func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem)
return nil
}
var (
errWarehouseResolveConflict = errors.New("multiple lot matches")
errWarehouseResolveNotFound = errors.New("lot not found")
)
type warehouseLotResolver struct {
partnumberToLots map[string][]string
exactLots map[string]string
allLots []string
}
func (r *PricelistRepository) newWarehouseLotResolver() (*warehouseLotResolver, error) {
var mappings []models.LotPartnumber
if err := r.db.Find(&mappings).Error; err != nil {
return nil, err
}
partnumberToLots := make(map[string][]string, len(mappings))
for _, m := range mappings {
pn := normalizeWarehouseResolverKey(m.Partnumber)
lot := strings.TrimSpace(m.LotName)
if pn == "" || lot == "" {
continue
}
partnumberToLots[pn] = append(partnumberToLots[pn], lot)
}
for key, vals := range partnumberToLots {
partnumberToLots[key] = uniqueWarehouseStrings(vals)
}
var allLotsRows []models.Lot
if err := r.db.Select("lot_name").Find(&allLotsRows).Error; err != nil {
return nil, err
}
exactLots := make(map[string]string, len(allLotsRows))
allLots := make([]string, 0, len(allLotsRows))
for _, row := range allLotsRows {
lot := strings.TrimSpace(row.LotName)
if lot == "" {
continue
}
exactLots[normalizeWarehouseResolverKey(lot)] = lot
allLots = append(allLots, lot)
}
sort.Slice(allLots, func(i, j int) bool {
li := len([]rune(allLots[i]))
lj := len([]rune(allLots[j]))
if li == lj {
return allLots[i] < allLots[j]
}
return li > lj
})
return &warehouseLotResolver{
partnumberToLots: partnumberToLots,
exactLots: exactLots,
allLots: allLots,
}, nil
}
func (r *warehouseLotResolver) resolve(partnumber string) (string, error) {
key := normalizeWarehouseResolverKey(partnumber)
if key == "" {
return "", errWarehouseResolveNotFound
}
if mapped := r.partnumberToLots[key]; len(mapped) > 0 {
if len(mapped) == 1 {
return mapped[0], nil
}
return "", errWarehouseResolveConflict
}
if exact, ok := r.exactLots[key]; ok {
return exact, nil
}
best := ""
bestLen := -1
tie := false
for _, lot := range r.allLots {
lotKey := normalizeWarehouseResolverKey(lot)
if lotKey == "" {
continue
}
if strings.HasPrefix(key, lotKey) {
l := len([]rune(lotKey))
if l > bestLen {
best = lot
bestLen = l
tie = false
} else if l == bestLen && !strings.EqualFold(best, lot) {
tie = true
}
}
}
if best == "" {
return "", errWarehouseResolveNotFound
}
if tie {
return "", errWarehouseResolveConflict
}
return best, nil
}
func normalizeWarehouseResolverKey(v string) string {
return strings.ToLower(strings.TrimSpace(v))
}
func uniqueWarehouseStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, v := range values {
n := strings.TrimSpace(v)
if n == "" {
continue
}
k := strings.ToLower(n)
if _, ok := seen[k]; ok {
continue
}
seen[k] = struct{}{}
out = append(out, n)
}
return out
}
// GetPriceForLot returns item price for a lot within a pricelist.
func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
var item models.PricelistItem
@@ -329,6 +486,28 @@ func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (
return item.Price, nil
}
// GetPricesForLots returns price map for given lots within a pricelist.
func (r *PricelistRepository) GetPricesForLots(pricelistID uint, lotNames []string) (map[string]float64, error) {
result := make(map[string]float64, len(lotNames))
if pricelistID == 0 || len(lotNames) == 0 {
return result, nil
}
var rows []models.PricelistItem
if err := r.db.Select("lot_name, price").
Where("pricelist_id = ? AND lot_name IN ?", pricelistID, lotNames).
Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
if row.Price > 0 {
result[row.LotName] = row.Price
}
}
return result, nil
}
// SetActive toggles active flag on a pricelist.
func (r *PricelistRepository) SetActive(id uint, isActive bool) error {
return r.db.Model(&models.Pricelist{}).Where("id = ?", id).Update("is_active", isActive).Error

View File

@@ -75,6 +75,57 @@ func TestGenerateVersion_IsolatedBySource(t *testing.T) {
}
}
func TestGetItems_WarehouseAvailableQtyUsesPrefixResolver(t *testing.T) {
repo := newTestPricelistRepository(t)
db := repo.db
warehouse := models.Pricelist{
Source: string(models.PricelistSourceWarehouse),
Version: "S-2026-02-07-001",
CreatedBy: "test",
IsActive: true,
}
if err := db.Create(&warehouse).Error; err != nil {
t.Fatalf("create pricelist: %v", err)
}
if err := db.Create(&models.PricelistItem{
PricelistID: warehouse.ID,
LotName: "SSD_NVME_03.2T",
Price: 100,
}).Error; err != nil {
t.Fatalf("create pricelist item: %v", err)
}
if err := db.Create(&models.Lot{LotName: "SSD_NVME_03.2T"}).Error; err != nil {
t.Fatalf("create lot: %v", err)
}
qty := 5.0
if err := db.Create(&models.StockLog{
Partnumber: "SSD_NVME_03.2T_GEN3_P4610",
Date: time.Now(),
Price: 200,
Qty: &qty,
}).Error; err != nil {
t.Fatalf("create stock log: %v", err)
}
items, total, err := repo.GetItems(warehouse.ID, 0, 20, "")
if err != nil {
t.Fatalf("GetItems: %v", err)
}
if total != 1 {
t.Fatalf("expected total=1, got %d", total)
}
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
if items[0].AvailableQty == nil {
t.Fatalf("expected available qty to be set")
}
if *items[0].AvailableQty != 5 {
t.Fatalf("expected available qty=5, got %v", *items[0].AvailableQty)
}
}
func newTestPricelistRepository(t *testing.T) *PricelistRepository {
t.Helper()
@@ -82,7 +133,7 @@ func newTestPricelistRepository(t *testing.T) *PricelistRepository {
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.Pricelist{}); err != nil {
if err := db.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}, &models.Lot{}, &models.LotPartnumber{}, &models.StockLog{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return NewPricelistRepository(db)