refactor lot matching into shared module
This commit is contained in:
219
internal/warehouse/snapshot.go
Normal file
219
internal/warehouse/snapshot.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package warehouse
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/lotmatch"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type SnapshotItem struct {
|
||||
LotName string
|
||||
Price float64
|
||||
PriceMethod string
|
||||
}
|
||||
|
||||
type weightedPricePoint struct {
|
||||
price float64
|
||||
weight float64
|
||||
}
|
||||
|
||||
// ComputePricelistItemsFromStockLog builds warehouse snapshot items from stock_log.
|
||||
func ComputePricelistItemsFromStockLog(db *gorm.DB) ([]SnapshotItem, error) {
|
||||
type stockRow struct {
|
||||
Partnumber string `gorm:"column:partnumber"`
|
||||
Price float64 `gorm:"column:price"`
|
||||
Qty *float64 `gorm:"column:qty"`
|
||||
}
|
||||
|
||||
var rows []stockRow
|
||||
if err := db.Table(models.StockLog{}.TableName()).Select("partnumber, price, qty").Where("price > 0").Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resolver, err := lotmatch.NewLotResolverFromDB(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
grouped := make(map[string][]weightedPricePoint)
|
||||
for _, row := range rows {
|
||||
pn := strings.TrimSpace(row.Partnumber)
|
||||
if pn == "" || row.Price <= 0 {
|
||||
continue
|
||||
}
|
||||
lot, _, err := resolver.Resolve(pn)
|
||||
if err != nil || strings.TrimSpace(lot) == "" {
|
||||
continue
|
||||
}
|
||||
weight := 0.0
|
||||
if row.Qty != nil && *row.Qty > 0 {
|
||||
weight = *row.Qty
|
||||
}
|
||||
grouped[lot] = append(grouped[lot], weightedPricePoint{price: row.Price, weight: weight})
|
||||
}
|
||||
|
||||
items := make([]SnapshotItem, 0, len(grouped))
|
||||
for lot, values := range grouped {
|
||||
price := weightedMedian(values)
|
||||
if price <= 0 {
|
||||
continue
|
||||
}
|
||||
items = append(items, SnapshotItem{LotName: lot, Price: price, PriceMethod: "weighted_median"})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].LotName < items[j].LotName })
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// LoadLotMetrics returns stock qty and partnumbers for selected lots.
|
||||
// If latestOnly is true, qty/partnumbers from stock_log are calculated only for latest import date.
|
||||
func LoadLotMetrics(db *gorm.DB, lotNames []string, latestOnly bool) (map[string]float64, map[string][]string, error) {
|
||||
qtyByLot := make(map[string]float64, len(lotNames))
|
||||
partnumbersByLot := make(map[string][]string, len(lotNames))
|
||||
if len(lotNames) == 0 {
|
||||
return qtyByLot, partnumbersByLot, nil
|
||||
}
|
||||
|
||||
lotSet := make(map[string]struct{}, len(lotNames))
|
||||
for _, lot := range lotNames {
|
||||
trimmed := strings.TrimSpace(lot)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
lotSet[trimmed] = struct{}{}
|
||||
}
|
||||
|
||||
resolver, err := lotmatch.NewLotResolverFromDB(db)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
seenPN := make(map[string]map[string]struct{}, len(lotSet))
|
||||
addPartnumber := func(lotName, partnumber string) {
|
||||
lotName = strings.TrimSpace(lotName)
|
||||
partnumber = strings.TrimSpace(partnumber)
|
||||
if lotName == "" || partnumber == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := lotSet[lotName]; !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := seenPN[lotName]; !ok {
|
||||
seenPN[lotName] = map[string]struct{}{}
|
||||
}
|
||||
key := strings.ToLower(partnumber)
|
||||
if _, ok := seenPN[lotName][key]; ok {
|
||||
return
|
||||
}
|
||||
seenPN[lotName][key] = struct{}{}
|
||||
partnumbersByLot[lotName] = append(partnumbersByLot[lotName], partnumber)
|
||||
}
|
||||
|
||||
var mappingRows []models.LotPartnumber
|
||||
if err := db.Select("partnumber, lot_name").Find(&mappingRows).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, row := range mappingRows {
|
||||
addPartnumber(row.LotName, row.Partnumber)
|
||||
}
|
||||
|
||||
type stockRow struct {
|
||||
Partnumber string `gorm:"column:partnumber"`
|
||||
Qty *float64 `gorm:"column:qty"`
|
||||
}
|
||||
var stockRows []stockRow
|
||||
if latestOnly {
|
||||
err = db.Raw(`
|
||||
SELECT sl.partnumber, sl.qty
|
||||
FROM stock_log sl
|
||||
INNER JOIN (SELECT MAX(date) AS max_date FROM stock_log) md ON sl.date = md.max_date
|
||||
`).Scan(&stockRows).Error
|
||||
} else {
|
||||
err = db.Table(models.StockLog{}.TableName()).Select("partnumber, qty").Scan(&stockRows).Error
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
for _, row := range stockRows {
|
||||
pn := strings.TrimSpace(row.Partnumber)
|
||||
if pn == "" {
|
||||
continue
|
||||
}
|
||||
lot, _, err := resolver.Resolve(pn)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, exists := lotSet[lot]; !exists {
|
||||
continue
|
||||
}
|
||||
if row.Qty != nil {
|
||||
qtyByLot[lot] += *row.Qty
|
||||
}
|
||||
addPartnumber(lot, pn)
|
||||
}
|
||||
|
||||
for lot := range partnumbersByLot {
|
||||
sort.Slice(partnumbersByLot[lot], func(i, j int) bool {
|
||||
return strings.ToLower(partnumbersByLot[lot][i]) < strings.ToLower(partnumbersByLot[lot][j])
|
||||
})
|
||||
}
|
||||
|
||||
return qtyByLot, partnumbersByLot, nil
|
||||
}
|
||||
|
||||
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)
|
||||
if v.weight > 0 {
|
||||
items = append(items, pair{price: v.price, weight: v.weight})
|
||||
totalWeight += v.weight
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func median(values []float64) float64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
cp := append([]float64(nil), values...)
|
||||
sort.Float64s(cp)
|
||||
n := len(cp)
|
||||
if n%2 == 0 {
|
||||
return (cp[n/2-1] + cp[n/2]) / 2
|
||||
}
|
||||
return cp[n/2]
|
||||
}
|
||||
103
internal/warehouse/snapshot_test.go
Normal file
103
internal/warehouse/snapshot_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package warehouse
|
||||
|
||||
import (
|
||||
"math"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestComputePricelistItemsFromStockLog(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
if err := db.AutoMigrate(&models.Lot{}, &models.LotPartnumber{}, &models.StockLog{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(&models.Lot{LotName: "CPU_X"}).Error; err != nil {
|
||||
t.Fatalf("seed lot: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.LotPartnumber{Partnumber: "PN-CPU-X", LotName: "CPU_X"}).Error; err != nil {
|
||||
t.Fatalf("seed mapping: %v", err)
|
||||
}
|
||||
|
||||
qtySmall := 1.0
|
||||
qtyBig := 9.0
|
||||
now := time.Now()
|
||||
rows := []models.StockLog{
|
||||
{Partnumber: "PN CPU X", Date: now, Price: 100, Qty: &qtySmall},
|
||||
{Partnumber: "CPU_X-EXTRA", Date: now, Price: 200, Qty: &qtyBig},
|
||||
}
|
||||
if err := db.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed stock rows: %v", err)
|
||||
}
|
||||
|
||||
items, err := ComputePricelistItemsFromStockLog(db)
|
||||
if err != nil {
|
||||
t.Fatalf("ComputePricelistItemsFromStockLog: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 item, got %d", len(items))
|
||||
}
|
||||
if items[0].LotName != "CPU_X" {
|
||||
t.Fatalf("expected lot CPU_X, got %s", items[0].LotName)
|
||||
}
|
||||
if math.Abs(items[0].Price-200) > 0.001 {
|
||||
t.Fatalf("expected weighted median 200, got %f", items[0].Price)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLotMetricsLatestOnlyIncludesPartnumbers(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
if err := db.AutoMigrate(&models.Lot{}, &models.LotPartnumber{}, &models.StockLog{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(&models.Lot{LotName: "CPU_X"}).Error; err != nil {
|
||||
t.Fatalf("seed lot: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.LotPartnumber{Partnumber: "PN-MAPPED", LotName: "CPU_X"}).Error; err != nil {
|
||||
t.Fatalf("seed mapping: %v", err)
|
||||
}
|
||||
|
||||
oldDate := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
newDate := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)
|
||||
oldQty := 10.0
|
||||
newQty := 3.0
|
||||
rows := []models.StockLog{
|
||||
{Partnumber: "CPU_X-001", Date: oldDate, Price: 100, Qty: &oldQty},
|
||||
{Partnumber: "CPU_X-001", Date: newDate, Price: 100, Qty: &newQty},
|
||||
}
|
||||
if err := db.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed stock rows: %v", err)
|
||||
}
|
||||
|
||||
qtyByLot, pnsByLot, err := LoadLotMetrics(db, []string{"CPU_X"}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLotMetrics: %v", err)
|
||||
}
|
||||
|
||||
if got := qtyByLot["CPU_X"]; math.Abs(got-3.0) > 0.001 {
|
||||
t.Fatalf("expected latest qty 3, got %f", got)
|
||||
}
|
||||
|
||||
pns := pnsByLot["CPU_X"]
|
||||
if !slices.Contains(pns, "PN-MAPPED") {
|
||||
t.Fatalf("expected mapped PN-MAPPED in partnumbers, got %v", pns)
|
||||
}
|
||||
if !slices.Contains(pns, "CPU_X-001") {
|
||||
t.Fatalf("expected stock CPU_X-001 in partnumbers, got %v", pns)
|
||||
}
|
||||
}
|
||||
|
||||
func openTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
Reference in New Issue
Block a user