Implement warehouse/lot pricing updates and configurator performance fixes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user