470 lines
12 KiB
Go
470 lines
12 KiB
Go
package warehouse
|
|
|
|
import (
|
|
"errors"
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.mchus.pro/mchus/priceforge/internal/lotmatch"
|
|
"git.mchus.pro/mchus/priceforge/internal/models"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type SnapshotItem struct {
|
|
LotName string
|
|
Price float64
|
|
PriceMethod string
|
|
Category string // Historical snapshot of lot_category
|
|
}
|
|
|
|
type weightedPricePoint struct {
|
|
price float64
|
|
weight float64
|
|
}
|
|
|
|
type bundleComponent struct {
|
|
LotName string
|
|
Qty float64
|
|
}
|
|
|
|
// ComputePricelistItemsFromStockLog builds warehouse snapshot items from stock_log.
|
|
func ComputePricelistItemsFromStockLog(db *gorm.DB) ([]SnapshotItem, error) {
|
|
type stockRow struct {
|
|
Partnumber string `gorm:"column:partnumber"`
|
|
Vendor *string `gorm:"column:vendor"`
|
|
Price float64 `gorm:"column:price"`
|
|
Qty *float64 `gorm:"column:qty"`
|
|
}
|
|
|
|
var rows []stockRow
|
|
if err := db.Table(models.StockLog{}.TableName()).Select("partnumber, vendor, price, qty").Where("price > 0").Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resolver, err := lotmatch.NewLotResolverFromDB(db)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
bundleMap, err := loadBundleComponents(db)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
refPrices, err := loadBundleRefPrices(db, bundleMap)
|
|
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
|
|
}
|
|
vendor := ""
|
|
if row.Vendor != nil {
|
|
vendor = strings.TrimSpace(*row.Vendor)
|
|
}
|
|
lot, _, err := resolver.ResolveWithVendor(pn, vendor)
|
|
if err != nil || strings.TrimSpace(lot) == "" {
|
|
continue
|
|
}
|
|
weight := 0.0
|
|
if row.Qty != nil && *row.Qty > 0 {
|
|
weight = *row.Qty
|
|
}
|
|
if comps, isBundle := bundleMap[lot]; isBundle && len(comps) > 0 {
|
|
denominator := 0.0
|
|
for _, comp := range comps {
|
|
denominator += comp.Qty * refPrices[comp.LotName]
|
|
}
|
|
if denominator <= 0 {
|
|
continue
|
|
}
|
|
for _, comp := range comps {
|
|
ref := refPrices[comp.LotName]
|
|
if ref < 0 {
|
|
ref = 0
|
|
}
|
|
share := (comp.Qty * ref) / denominator
|
|
componentPrice := row.Price * share
|
|
componentWeight := weight
|
|
if componentWeight > 0 {
|
|
componentWeight *= comp.Qty
|
|
}
|
|
grouped[comp.LotName] = append(grouped[comp.LotName], weightedPricePoint{price: componentPrice, weight: componentWeight})
|
|
}
|
|
continue
|
|
}
|
|
grouped[lot] = append(grouped[lot], weightedPricePoint{price: row.Price, weight: weight})
|
|
}
|
|
|
|
items := make([]SnapshotItem, 0, len(grouped))
|
|
for lot, values := range grouped {
|
|
price := weightedAverage(values)
|
|
if price <= 0 {
|
|
continue
|
|
}
|
|
items = append(items, SnapshotItem{LotName: lot, Price: price, PriceMethod: "weighted_avg", Category: ""})
|
|
}
|
|
|
|
// Load categories for all lots in a single query
|
|
if len(items) > 0 {
|
|
lotNames := make([]string, 0, len(items))
|
|
lotToIdx := make(map[string]int, len(items))
|
|
for i, item := range items {
|
|
lotToIdx[item.LotName] = i
|
|
lotNames = append(lotNames, item.LotName)
|
|
}
|
|
|
|
var categories []struct {
|
|
LotName string
|
|
LotCategory string
|
|
}
|
|
if err := db.Table("lot").Select("lot_name, lot_category").Where("lot_name IN ?", lotNames).Scan(&categories).Error; err == nil {
|
|
for _, cat := range categories {
|
|
if idx, ok := lotToIdx[cat.LotName]; ok {
|
|
items[idx].Category = cat.LotCategory
|
|
}
|
|
}
|
|
}
|
|
defaultCategory := models.DefaultLotCategoryCode
|
|
missingCategoryLots := make([]string, 0)
|
|
for i := range items {
|
|
if strings.TrimSpace(items[i].Category) == "" {
|
|
items[i].Category = defaultCategory
|
|
missingCategoryLots = append(missingCategoryLots, items[i].LotName)
|
|
}
|
|
}
|
|
if len(missingCategoryLots) > 0 {
|
|
ensureCategoryExists(db, defaultCategory)
|
|
_ = db.Model(&models.Lot{}).
|
|
Where("lot_name IN ?", missingCategoryLots).
|
|
Update("lot_category", defaultCategory).Error
|
|
}
|
|
}
|
|
|
|
sort.Slice(items, func(i, j int) bool { return items[i].LotName < items[j].LotName })
|
|
return items, nil
|
|
}
|
|
|
|
func loadBundleComponents(db *gorm.DB) (map[string][]bundleComponent, error) {
|
|
type row struct {
|
|
BundleLotName string `gorm:"column:bundle_lot_name"`
|
|
LotName string `gorm:"column:lot_name"`
|
|
Qty float64 `gorm:"column:qty"`
|
|
}
|
|
var rows []row
|
|
if err := db.Table(models.LotBundleItem{}.TableName() + " bi").
|
|
Select("bi.bundle_lot_name, bi.lot_name, bi.qty").
|
|
Joins("INNER JOIN qt_lot_bundles b ON b.bundle_lot_name = bi.bundle_lot_name AND b.is_active = 1").
|
|
Scan(&rows).Error; err != nil {
|
|
if isMissingTableError(err) {
|
|
return map[string][]bundleComponent{}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
result := make(map[string][]bundleComponent)
|
|
for _, row := range rows {
|
|
bundleLot := strings.TrimSpace(row.BundleLotName)
|
|
lot := strings.TrimSpace(row.LotName)
|
|
if bundleLot == "" || lot == "" || row.Qty <= 0 {
|
|
continue
|
|
}
|
|
result[bundleLot] = append(result[bundleLot], bundleComponent{LotName: lot, Qty: row.Qty})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func loadBundleRefPrices(db *gorm.DB, bundleMap map[string][]bundleComponent) (map[string]float64, error) {
|
|
ref := make(map[string]float64)
|
|
if len(bundleMap) == 0 {
|
|
return ref, nil
|
|
}
|
|
lotSet := make(map[string]struct{})
|
|
for _, comps := range bundleMap {
|
|
for _, comp := range comps {
|
|
if strings.TrimSpace(comp.LotName) != "" {
|
|
lotSet[comp.LotName] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
if len(lotSet) == 0 {
|
|
return ref, nil
|
|
}
|
|
lots := make([]string, 0, len(lotSet))
|
|
for lot := range lotSet {
|
|
lots = append(lots, lot)
|
|
}
|
|
|
|
type metaRow struct {
|
|
LotName string `gorm:"column:lot_name"`
|
|
CurrentPrice *float64 `gorm:"column:current_price"`
|
|
}
|
|
var metaRows []metaRow
|
|
if err := db.Table("qt_lot_metadata").Select("lot_name, current_price").Where("lot_name IN ?", lots).Scan(&metaRows).Error; err != nil {
|
|
if isMissingTableError(err) {
|
|
return ref, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
for _, row := range metaRows {
|
|
if row.CurrentPrice != nil && *row.CurrentPrice > 0 {
|
|
ref[row.LotName] = *row.CurrentPrice
|
|
}
|
|
}
|
|
|
|
// Fallback source: previous active warehouse pricelist.
|
|
var pl models.Pricelist
|
|
if err := db.Table(models.Pricelist{}.TableName()).
|
|
Where("source = ? AND is_active = 1", string(models.PricelistSourceWarehouse)).
|
|
Order("created_at DESC").
|
|
Limit(1).
|
|
First(&pl).Error; err != nil {
|
|
if isMissingTableError(err) || errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ref, nil
|
|
}
|
|
return ref, nil
|
|
}
|
|
var rows []struct {
|
|
LotName string `gorm:"column:lot_name"`
|
|
Price float64 `gorm:"column:price"`
|
|
}
|
|
if err := db.Table(models.PricelistItem{}.TableName()).
|
|
Select("lot_name, price").
|
|
Where("pricelist_id = ? AND lot_name IN ?", pl.ID, lots).
|
|
Scan(&rows).Error; err != nil {
|
|
if isMissingTableError(err) {
|
|
return ref, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
for _, row := range rows {
|
|
if row.Price > 0 {
|
|
if _, ok := ref[row.LotName]; !ok {
|
|
ref[row.LotName] = row.Price
|
|
}
|
|
}
|
|
}
|
|
return ref, nil
|
|
}
|
|
|
|
func isMissingTableError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
msg := strings.ToLower(err.Error())
|
|
return strings.Contains(msg, "no such table") || strings.Contains(msg, "doesn't exist")
|
|
}
|
|
|
|
func ensureCategoryExists(db *gorm.DB, code string) {
|
|
var count int64
|
|
if err := db.Model(&models.Category{}).Where("code = ?", code).Count(&count).Error; err != nil || count > 0 {
|
|
return
|
|
}
|
|
var maxOrder int
|
|
if err := db.Model(&models.Category{}).Select("COALESCE(MAX(display_order), 0)").Scan(&maxOrder).Error; err != nil {
|
|
return
|
|
}
|
|
_ = db.Create(&models.Category{
|
|
Code: code,
|
|
Name: code,
|
|
NameRu: code,
|
|
DisplayOrder: maxOrder + 1,
|
|
}).Error
|
|
}
|
|
|
|
// 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
|
|
}
|
|
bundleMap, err := loadBundleComponents(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"`
|
|
Vendor *string `gorm:"column:vendor"`
|
|
Qty *float64 `gorm:"column:qty"`
|
|
}
|
|
var stockRows []stockRow
|
|
if latestOnly {
|
|
err = db.Raw(`
|
|
SELECT sl.partnumber, sl.vendor, 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, vendor, qty").Scan(&stockRows).Error
|
|
}
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
for _, row := range stockRows {
|
|
pn := strings.TrimSpace(row.Partnumber)
|
|
if pn == "" {
|
|
continue
|
|
}
|
|
vendor := ""
|
|
if row.Vendor != nil {
|
|
vendor = strings.TrimSpace(*row.Vendor)
|
|
}
|
|
lot, _, err := resolver.ResolveWithVendor(pn, vendor)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if comps, isBundle := bundleMap[lot]; isBundle && len(comps) > 0 {
|
|
for _, comp := range comps {
|
|
if _, exists := lotSet[comp.LotName]; !exists {
|
|
continue
|
|
}
|
|
if row.Qty != nil {
|
|
qtyByLot[comp.LotName] += (*row.Qty) * comp.Qty
|
|
}
|
|
addPartnumber(comp.LotName, pn)
|
|
}
|
|
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 weightedAverage(values []weightedPricePoint) float64 {
|
|
if len(values) == 0 {
|
|
return 0
|
|
}
|
|
sumWeighted := 0.0
|
|
sumWeight := 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 {
|
|
sumWeighted += v.price * v.weight
|
|
sumWeight += v.weight
|
|
}
|
|
}
|
|
if sumWeight > 0 {
|
|
return sumWeighted / sumWeight
|
|
}
|
|
return median(prices)
|
|
}
|
|
|
|
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]
|
|
}
|