refactor lot matching into shared module

This commit is contained in:
2026-02-07 06:22:56 +03:00
parent b629af9742
commit 95b5f8bf65
14 changed files with 1190 additions and 520 deletions

View File

@@ -5,7 +5,10 @@ import (
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
@@ -22,78 +25,159 @@ func NewPricelistHandler(service *pricelist.Service, localDB *localdb.LocalDB) *
return &PricelistHandler{service: service, localDB: localDB}
}
// refreshLocalPricelistCacheFromServer rehydrates local metadata + items for one server pricelist.
func (h *PricelistHandler) refreshLocalPricelistCacheFromServer(serverID uint, onProgress func(synced, total int, message string)) error {
if h.localDB == nil {
return nil
}
report := func(synced, total int, message string) {
if onProgress != nil {
onProgress(synced, total, message)
}
}
report(0, 0, "Подготовка локального кэша")
pl, err := h.service.GetByID(serverID)
if err != nil {
return err
}
if existing, err := h.localDB.GetLocalPricelistByServerID(serverID); err == nil {
if err := h.localDB.DeleteLocalPricelist(existing.ID); err != nil {
return err
}
}
localPL := &localdb.LocalPricelist{
ServerID: pl.ID,
Source: pl.Source,
Version: pl.Version,
Name: pl.Notification,
CreatedAt: pl.CreatedAt,
SyncedAt: time.Now(),
IsUsed: false,
}
if err := h.localDB.SaveLocalPricelist(localPL); err != nil {
return err
}
report(0, 0, "Локальный кэш обновлён")
// Ensure we use persisted local row id (upsert path may not populate struct ID reliably).
persistedLocalPL, err := h.localDB.GetLocalPricelistByServerID(serverID)
if err != nil {
return err
}
if persistedLocalPL.ID == 0 {
return fmt.Errorf("local pricelist id is zero after save (server_id=%d)", serverID)
}
const perPage = 2000
synced := 0
totalItems := 0
gotTotal := false
for page := 1; ; page++ {
items, total, err := h.service.GetItems(serverID, page, perPage, "")
if err != nil {
return err
}
if !gotTotal {
totalItems = int(total)
gotTotal = true
}
if len(items) == 0 {
break
}
localItems := make([]localdb.LocalPricelistItem, 0, len(items))
for _, item := range items {
partnumbers := make(localdb.LocalStringList, 0, len(item.Partnumbers))
partnumbers = append(partnumbers, item.Partnumbers...)
localItems = append(localItems, localdb.LocalPricelistItem{
PricelistID: persistedLocalPL.ID,
LotName: item.LotName,
Price: item.Price,
AvailableQty: item.AvailableQty,
Partnumbers: partnumbers,
})
}
if err := h.localDB.SaveLocalPricelistItems(localItems); err != nil {
return err
}
synced += len(localItems)
report(synced, totalItems, "Синхронизация позиций в локальный кэш")
if int64(page*perPage) >= total {
break
}
}
report(synced, totalItems, "Локальный кэш синхронизирован")
return nil
}
// List returns all pricelists with pagination
func (h *PricelistHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
activeOnly := c.DefaultQuery("active_only", "false") == "true"
source := c.Query("source")
var (
pricelists any
total int64
err error
)
if activeOnly {
pricelists, total, err = h.service.ListActiveBySource(page, perPage, source)
} else {
pricelists, total, err = h.service.ListBySource(page, perPage, source)
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 20
}
source := c.Query("source")
activeOnly := c.DefaultQuery("active_only", "false") == "true"
localPLs, err := h.localDB.GetLocalPricelists()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
isOffline := false
if v, ok := c.Get("is_offline"); ok {
if b, ok := v.(bool); ok {
isOffline = b
if source != "" {
filtered := localPLs[:0]
for _, lpl := range localPLs {
if strings.EqualFold(lpl.Source, source) {
filtered = append(filtered, lpl)
}
}
localPLs = filtered
}
// Fallback to local pricelists only in explicit offline mode.
if isOffline && total == 0 && h.localDB != nil {
localPLs, err := h.localDB.GetLocalPricelists()
if err == nil && len(localPLs) > 0 {
if source != "" {
filtered := localPLs[:0]
for _, lpl := range localPLs {
if lpl.Source == source {
filtered = append(filtered, lpl)
}
}
localPLs = filtered
}
// Convert to PricelistSummary format
summaries := make([]map[string]interface{}, len(localPLs))
for i, lpl := range localPLs {
summaries[i] = map[string]interface{}{
"id": lpl.ServerID,
"source": lpl.Source,
"version": lpl.Version,
"created_by": "sync",
"item_count": 0, // Not tracked
"usage_count": 0, // Not tracked in local
"is_active": true,
"created_at": lpl.CreatedAt,
"synced_from": "local",
}
}
c.JSON(http.StatusOK, gin.H{
"pricelists": summaries,
"total": len(summaries),
"page": page,
"per_page": perPage,
"offline": true,
})
return
if activeOnly {
// Local cache stores only active snapshots for normal operations.
}
sort.SliceStable(localPLs, func(i, j int) bool { return localPLs[i].CreatedAt.After(localPLs[j].CreatedAt) })
total := len(localPLs)
start := (page - 1) * perPage
if start > total {
start = total
}
end := start + perPage
if end > total {
end = total
}
pageSlice := localPLs[start:end]
summaries := make([]map[string]interface{}, 0, len(pageSlice))
for _, lpl := range pageSlice {
itemCount := h.localDB.CountLocalPricelistItems(lpl.ID)
usageCount := 0
if lpl.IsUsed {
usageCount = 1
}
summaries = append(summaries, map[string]interface{}{
"id": lpl.ServerID,
"source": lpl.Source,
"version": lpl.Version,
"created_by": "sync",
"item_count": itemCount,
"usage_count": usageCount,
"is_active": true,
"created_at": lpl.CreatedAt,
"synced_from": "local",
})
}
c.JSON(http.StatusOK, gin.H{
"pricelists": pricelists,
"pricelists": summaries,
"total": total,
"page": page,
"per_page": perPage,
@@ -109,13 +193,22 @@ func (h *PricelistHandler) Get(c *gin.Context) {
return
}
pl, err := h.service.GetByID(uint(id))
localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "pricelist not found"})
return
}
c.JSON(http.StatusOK, pl)
c.JSON(http.StatusOK, gin.H{
"id": localPL.ServerID,
"source": localPL.Source,
"version": localPL.Version,
"created_by": "sync",
"item_count": h.localDB.CountLocalPricelistItems(localPL.ID),
"is_active": true,
"created_at": localPL.CreatedAt,
"synced_from": "local",
})
}
// Create creates a new pricelist from current prices
@@ -161,6 +254,14 @@ func (h *PricelistHandler) Create(c *gin.Context) {
return
}
// Keep local cache consistent for local-first reads (metadata + items).
if err := h.refreshLocalPricelistCacheFromServer(pl.ID, nil); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "pricelist created on server but failed to refresh local cache: " + err.Error(),
})
return
}
c.JSON(http.StatusCreated, pl)
}
@@ -223,10 +324,19 @@ func (h *PricelistHandler) CreateWithProgress(c *gin.Context) {
sendProgress(gin.H{"current": 0, "total": 4, "status": "starting", "message": "Запуск..."})
pl, err := h.service.CreateForSourceWithProgress(createdBy, source, sourceItems, func(p pricelist.CreateProgress) {
// Composite progress: 0-85% server creation, 86-99% local cache sync.
current := int(float64(p.Current) * 0.85)
if p.Status == "completed" {
current = 85
}
status := p.Status
if status == "completed" {
status = "server_completed"
}
sendProgress(gin.H{
"current": p.Current,
"current": current,
"total": p.Total,
"status": p.Status,
"status": status,
"message": p.Message,
"updated": p.Updated,
"errors": p.Errors,
@@ -243,6 +353,34 @@ func (h *PricelistHandler) CreateWithProgress(c *gin.Context) {
return
}
if err := h.refreshLocalPricelistCacheFromServer(pl.ID, func(synced, total int, message string) {
current := 86
if total > 0 {
progressPart := int(float64(synced) / float64(total) * 13.0) // 86..99
if progressPart > 13 {
progressPart = 13
}
current = 86 + progressPart
}
if current > 99 {
current = 99
}
sendProgress(gin.H{
"current": current,
"total": 100,
"status": "sync_local_cache",
"message": message,
})
}); err != nil {
sendProgress(gin.H{
"current": 4,
"total": 4,
"status": "error",
"message": fmt.Sprintf("Прайслист создан, но локальный кэш не обновлён: %v", err),
})
return
}
sendProgress(gin.H{
"current": 4,
"total": 4,
@@ -275,6 +413,18 @@ func (h *PricelistHandler) Delete(c *gin.Context) {
return
}
// Local-first UI reads pricelists from SQLite cache. Keep cache in sync right away.
if h.localDB != nil {
if localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id)); err == nil {
if err := h.localDB.DeleteLocalPricelist(localPL.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "pricelist deleted on server but failed to update local cache: " + err.Error(),
})
return
}
}
}
c.JSON(http.StatusOK, gin.H{"message": "pricelist deleted"})
}
@@ -309,6 +459,47 @@ func (h *PricelistHandler) SetActive(c *gin.Context) {
return
}
// Local-first table stores only active snapshots. Reflect toggles immediately.
if h.localDB != nil {
localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id))
if err == nil {
if req.IsActive {
// Ensure local active row has complete cache (metadata + items).
if h.localDB.CountLocalPricelistItems(localPL.ID) == 0 {
if err := h.refreshLocalPricelistCacheFromServer(uint(id), nil); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "updated on server but failed to refresh local cache: " + err.Error(),
})
return
}
} else {
localPL.SyncedAt = time.Now()
if saveErr := h.localDB.SaveLocalPricelist(localPL); saveErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "updated on server but failed to update local cache: " + saveErr.Error(),
})
return
}
}
} else {
// Inactive entries should disappear from local active cache list.
if delErr := h.localDB.DeleteLocalPricelist(localPL.ID); delErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "updated on server but failed to update local cache: " + delErr.Error(),
})
return
}
}
} else if req.IsActive {
if err := h.refreshLocalPricelistCacheFromServer(uint(id), nil); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "updated on server but failed to seed local cache: " + err.Error(),
})
return
}
}
}
c.JSON(http.StatusOK, gin.H{"message": "updated", "is_active": req.IsActive})
}
@@ -325,20 +516,52 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
search := c.Query("search")
items, total, err := h.service.GetItems(uint(id), page, perPage, search)
localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "pricelist not found"})
return
}
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
var items []localdb.LocalPricelistItem
dbq := h.localDB.DB().Model(&localdb.LocalPricelistItem{}).Where("pricelist_id = ?", localPL.ID)
if strings.TrimSpace(search) != "" {
dbq = dbq.Where("lot_name LIKE ?", "%"+strings.TrimSpace(search)+"%")
}
var total int64
if err := dbq.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
pl, _ := h.service.GetByID(uint(id))
source := ""
if pl != nil {
source = pl.Source
offset := (page - 1) * perPage
if err := dbq.Order("lot_name").Offset(offset).Limit(perPage).Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
resultItems := make([]gin.H, 0, len(items))
for _, item := range items {
category := ""
if parts := strings.SplitN(item.LotName, "_", 2); len(parts) > 0 {
category = parts[0]
}
resultItems = append(resultItems, gin.H{
"id": item.ID,
"lot_name": item.LotName,
"price": item.Price,
"category": category,
"available_qty": item.AvailableQty,
"partnumbers": []string(item.Partnumbers),
})
}
c.JSON(http.StatusOK, gin.H{
"source": source,
"items": items,
"source": localPL.Source,
"items": resultItems,
"total": total,
"page": page,
"per_page": perPage,
@@ -353,11 +576,21 @@ func (h *PricelistHandler) GetLotNames(c *gin.Context) {
return
}
lotNames, err := h.service.GetLotNames(uint(id))
localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "pricelist not found"})
return
}
items, err := h.localDB.GetLocalPricelistItems(localPL.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
lotNames := make([]string, 0, len(items))
for _, item := range items {
lotNames = append(lotNames, item.LotName)
}
sort.Strings(lotNames)
c.JSON(http.StatusOK, gin.H{
"lot_names": lotNames,
@@ -376,36 +609,19 @@ func (h *PricelistHandler) GetLatest(c *gin.Context) {
source := c.DefaultQuery("source", string(models.PricelistSourceEstimate))
source = string(models.NormalizePricelistSource(source))
// Try to get from server first
pl, err := h.service.GetLatestActiveBySource(source)
localPL, err := h.localDB.GetLatestLocalPricelistBySource(source)
if err != nil {
// If offline or no server pricelists, try to get from local cache
if h.localDB == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no database available"})
return
}
localPL, localErr := h.localDB.GetLatestLocalPricelistBySource(source)
if localErr != nil {
// No local pricelists either
c.JSON(http.StatusNotFound, gin.H{
"error": "no pricelists available",
"local_error": localErr.Error(),
})
return
}
// Return local pricelist
c.JSON(http.StatusOK, gin.H{
"id": localPL.ServerID,
"source": localPL.Source,
"version": localPL.Version,
"created_by": "sync",
"item_count": 0, // Not tracked in local pricelists
"is_active": true,
"created_at": localPL.CreatedAt,
"synced_from": "local",
})
c.JSON(http.StatusNotFound, gin.H{"error": "no pricelists available"})
return
}
c.JSON(http.StatusOK, pl)
c.JSON(http.StatusOK, gin.H{
"id": localPL.ServerID,
"source": localPL.Source,
"version": localPL.Version,
"created_by": "sync",
"item_count": h.localDB.CountLocalPricelistItems(localPL.ID),
"is_active": true,
"created_at": localPL.CreatedAt,
"synced_from": "local",
})
}

View File

@@ -14,6 +14,7 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/services"
"git.mchus.pro/mchus/quoteforge/internal/services/alerts"
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
"git.mchus.pro/mchus/quoteforge/internal/warehouse"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
@@ -1296,41 +1297,11 @@ func (h *PricingHandler) ListLotsTable(c *gin.Context) {
estimateMap[ec.Lot] = ec.Count
}
type stockRow struct {
LotName string `gorm:"column:lot_name"`
Qty *float64 `gorm:"column:total_qty"`
}
var stockRows []stockRow
if err := h.db.Raw(`
SELECT lp.lot_name, SUM(sl.qty) as total_qty
FROM stock_log sl
INNER JOIN lot_partnumbers lp ON LOWER(TRIM(lp.partnumber)) = LOWER(TRIM(sl.partnumber))
INNER JOIN (SELECT MAX(date) as max_date FROM stock_log) md ON sl.date = md.max_date
WHERE lp.lot_name IN ?
GROUP BY lp.lot_name
`, lotNames).Scan(&stockRows).Error; err != nil {
stockQtyByLot, pnMap, err := warehouse.LoadLotMetrics(h.db, lotNames, true)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
stockMap := make(map[string]*float64, len(stockRows))
for _, sr := range stockRows {
qty := sr.Qty
stockMap[sr.LotName] = qty
}
type pnRow struct {
LotName string `gorm:"column:lot_name"`
Partnumber string `gorm:"column:partnumber"`
}
var pnRows []pnRow
if err := h.db.Raw("SELECT lot_name, partnumber FROM lot_partnumbers WHERE lot_name IN ? ORDER BY lot_name, partnumber", lotNames).Scan(&pnRows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
pnMap := make(map[string][]string, len(pnRows))
for _, pn := range pnRows {
pnMap[pn.LotName] = append(pnMap[pn.LotName], pn.Partnumber)
}
result := make([]LotTableRow, len(rows))
for i, r := range rows {
@@ -1349,7 +1320,10 @@ func (h *PricingHandler) ListLotsTable(c *gin.Context) {
Partnumbers: pnMap[r.LotName],
Popularity: pop,
EstimateCount: estimateMap[r.LotName],
StockQty: stockMap[r.LotName],
}
if qty, ok := stockQtyByLot[r.LotName]; ok {
q := qty
result[i].StockQty = &q
}
if result[i].Partnumbers == nil {
result[i].Partnumbers = []string{}

View File

@@ -164,20 +164,28 @@ func LocalToPricelist(local *LocalPricelist) *models.Pricelist {
// PricelistItemToLocal converts models.PricelistItem to LocalPricelistItem
func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *LocalPricelistItem {
partnumbers := make(LocalStringList, 0, len(item.Partnumbers))
partnumbers = append(partnumbers, item.Partnumbers...)
return &LocalPricelistItem{
PricelistID: localPricelistID,
LotName: item.LotName,
Price: item.Price,
PricelistID: localPricelistID,
LotName: item.LotName,
Price: item.Price,
AvailableQty: item.AvailableQty,
Partnumbers: partnumbers,
}
}
// LocalToPricelistItem converts LocalPricelistItem to models.PricelistItem
func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *models.PricelistItem {
partnumbers := make([]string, 0, len(local.Partnumbers))
partnumbers = append(partnumbers, local.Partnumbers...)
return &models.PricelistItem{
ID: local.ID,
PricelistID: serverPricelistID,
LotName: local.LotName,
Price: local.Price,
ID: local.ID,
PricelistID: serverPricelistID,
LotName: local.LotName,
Price: local.Price,
AvailableQty: local.AvailableQty,
Partnumbers: partnumbers,
}
}

View File

@@ -57,6 +57,30 @@ func (c LocalConfigItems) Total() float64 {
return total
}
// LocalStringList is a JSON-encoded list of strings stored as TEXT in SQLite.
type LocalStringList []string
func (s LocalStringList) Value() (driver.Value, error) {
return json.Marshal(s)
}
func (s *LocalStringList) Scan(value interface{}) error {
if value == nil {
*s = make(LocalStringList, 0)
return nil
}
var bytes []byte
switch v := value.(type) {
case []byte:
bytes = v
case string:
bytes = []byte(v)
default:
return errors.New("type assertion failed for LocalStringList")
}
return json.Unmarshal(bytes, s)
}
// LocalConfiguration stores configurations in local SQLite
type LocalConfiguration struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
@@ -143,10 +167,12 @@ func (LocalPricelist) TableName() string {
// LocalPricelistItem stores pricelist items
type LocalPricelistItem struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
PricelistID uint `gorm:"not null;index" json:"pricelist_id"`
LotName string `gorm:"not null" json:"lot_name"`
Price float64 `gorm:"not null" json:"price"`
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
PricelistID uint `gorm:"not null;index" json:"pricelist_id"`
LotName string `gorm:"not null" json:"lot_name"`
Price float64 `gorm:"not null" json:"price"`
AvailableQty *float64 `json:"available_qty,omitempty"`
Partnumbers LocalStringList `gorm:"type:text" json:"partnumbers,omitempty"`
}
func (LocalPricelistItem) TableName() string {
@@ -167,6 +193,33 @@ func (LocalComponent) TableName() string {
return "local_components"
}
// LocalRemoteMigrationApplied tracks remote SQLite migrations received from server and applied locally.
type LocalRemoteMigrationApplied struct {
ID string `gorm:"primaryKey;size:128" json:"id"`
Checksum string `gorm:"size:128;not null" json:"checksum"`
AppVersion string `gorm:"size:64" json:"app_version,omitempty"`
AppliedAt time.Time `gorm:"not null" json:"applied_at"`
}
func (LocalRemoteMigrationApplied) TableName() string {
return "local_remote_migrations_applied"
}
// LocalSyncGuardState stores latest sync readiness decision for UI and preflight checks.
type LocalSyncGuardState struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
Status string `gorm:"size:32;not null;index" json:"status"` // ready|blocked|unknown
ReasonCode string `gorm:"size:128" json:"reason_code,omitempty"`
ReasonText string `gorm:"type:text" json:"reason_text,omitempty"`
RequiredMinAppVersion *string `gorm:"size:64" json:"required_min_app_version,omitempty"`
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
func (LocalSyncGuardState) TableName() string {
return "local_sync_guard_state"
}
// PendingChange stores changes that need to be synced to the server
type PendingChange struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`

View File

@@ -0,0 +1,238 @@
package lotmatch
import (
"errors"
"regexp"
"sort"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
)
var (
ErrResolveConflict = errors.New("multiple lot matches")
ErrResolveNotFound = errors.New("lot not found")
)
type LotResolver struct {
partnumberToLots map[string][]string
exactLots map[string]string
allLots []string
}
type MappingMatcher struct {
exact map[string][]string
exactLot map[string]string
wildcard []wildcardMapping
}
type wildcardMapping struct {
lotName string
re *regexp.Regexp
}
func NewLotResolverFromDB(db *gorm.DB) (*LotResolver, error) {
mappings, lots, err := loadMappingsAndLots(db)
if err != nil {
return nil, err
}
return NewLotResolver(mappings, lots), nil
}
func NewMappingMatcherFromDB(db *gorm.DB) (*MappingMatcher, error) {
mappings, lots, err := loadMappingsAndLots(db)
if err != nil {
return nil, err
}
return NewMappingMatcher(mappings, lots), nil
}
func NewLotResolver(mappings []models.LotPartnumber, lots []models.Lot) *LotResolver {
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 := range partnumberToLots {
partnumberToLots[key] = uniqueCaseInsensitive(partnumberToLots[key])
}
exactLots := make(map[string]string, len(lots))
allLots := make([]string, 0, len(lots))
for _, l := range lots {
name := strings.TrimSpace(l.LotName)
if name == "" {
continue
}
exactLots[NormalizeKey(name)] = name
allLots = append(allLots, name)
}
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 &LotResolver{
partnumberToLots: partnumberToLots,
exactLots: exactLots,
allLots: allLots,
}
}
func NewMappingMatcher(mappings []models.LotPartnumber, lots []models.Lot) *MappingMatcher {
exact := make(map[string][]string, len(mappings))
wildcards := make([]wildcardMapping, 0, len(mappings))
for _, m := range mappings {
pn := NormalizeKey(m.Partnumber)
lot := strings.TrimSpace(m.LotName)
if pn == "" || lot == "" {
continue
}
if strings.Contains(pn, "*") {
pattern := "^" + regexp.QuoteMeta(pn) + "$"
pattern = strings.ReplaceAll(pattern, "\\*", ".*")
re, err := regexp.Compile(pattern)
if err != nil {
continue
}
wildcards = append(wildcards, wildcardMapping{lotName: lot, re: re})
continue
}
exact[pn] = append(exact[pn], lot)
}
for key := range exact {
exact[key] = uniqueCaseInsensitive(exact[key])
}
exactLot := make(map[string]string, len(lots))
for _, l := range lots {
name := strings.TrimSpace(l.LotName)
if name == "" {
continue
}
exactLot[NormalizeKey(name)] = name
}
return &MappingMatcher{
exact: exact,
exactLot: exactLot,
wildcard: wildcards,
}
}
func (r *LotResolver) Resolve(partnumber string) (string, string, error) {
key := NormalizeKey(partnumber)
if key == "" {
return "", "", ErrResolveNotFound
}
if mapped := r.partnumberToLots[key]; len(mapped) > 0 {
if len(mapped) == 1 {
return mapped[0], "mapping_table", nil
}
return "", "", ErrResolveConflict
}
if exact, ok := r.exactLots[key]; ok {
return exact, "article_exact", nil
}
best := ""
bestLen := -1
tie := false
for _, lot := range r.allLots {
lotKey := NormalizeKey(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 "", "", ErrResolveNotFound
}
if tie {
return "", "", ErrResolveConflict
}
return best, "prefix", nil
}
func (m *MappingMatcher) MatchLots(partnumber string) []string {
if m == nil {
return nil
}
key := NormalizeKey(partnumber)
if key == "" {
return nil
}
lots := make([]string, 0, 2)
if exact := m.exact[key]; len(exact) > 0 {
lots = append(lots, exact...)
}
for _, wc := range m.wildcard {
if wc.re == nil || !wc.re.MatchString(key) {
continue
}
lots = append(lots, wc.lotName)
}
if lot, ok := m.exactLot[key]; ok && strings.TrimSpace(lot) != "" {
lots = append(lots, lot)
}
return uniqueCaseInsensitive(lots)
}
func NormalizeKey(v string) string {
s := strings.ToLower(strings.TrimSpace(v))
replacer := strings.NewReplacer(" ", "", "-", "", "_", "", ".", "", "/", "", "\\", "", "\"", "", "'", "", "(", "", ")", "")
return replacer.Replace(s)
}
func loadMappingsAndLots(db *gorm.DB) ([]models.LotPartnumber, []models.Lot, error) {
var mappings []models.LotPartnumber
if err := db.Find(&mappings).Error; err != nil {
return nil, nil, err
}
var lots []models.Lot
if err := db.Select("lot_name").Find(&lots).Error; err != nil {
return nil, nil, err
}
return mappings, lots, nil
}
func uniqueCaseInsensitive(values []string) []string {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, v := range values {
trimmed := strings.TrimSpace(v)
if trimmed == "" {
continue
}
key := strings.ToLower(trimmed)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, trimmed)
}
sort.Slice(out, func(i, j int) bool {
return strings.ToLower(out[i]) < strings.ToLower(out[j])
})
return out
}

View File

@@ -0,0 +1,62 @@
package lotmatch
import (
"testing"
"git.mchus.pro/mchus/quoteforge/internal/models"
)
func TestLotResolverPrecedence(t *testing.T) {
resolver := NewLotResolver(
[]models.LotPartnumber{
{Partnumber: "PN-1", LotName: "LOT_A"},
},
[]models.Lot{
{LotName: "CPU_X_LONG"},
{LotName: "CPU_X"},
},
)
lot, by, err := resolver.Resolve("PN-1")
if err != nil || lot != "LOT_A" || by != "mapping_table" {
t.Fatalf("expected mapping_table LOT_A, got lot=%s by=%s err=%v", lot, by, err)
}
lot, by, err = resolver.Resolve("CPU_X")
if err != nil || lot != "CPU_X" || by != "article_exact" {
t.Fatalf("expected article_exact CPU_X, got lot=%s by=%s err=%v", lot, by, err)
}
lot, by, err = resolver.Resolve("CPU_X_LONG_001")
if err != nil || lot != "CPU_X_LONG" || by != "prefix" {
t.Fatalf("expected prefix CPU_X_LONG, got lot=%s by=%s err=%v", lot, by, err)
}
}
func TestMappingMatcherWildcardAndExactLot(t *testing.T) {
matcher := NewMappingMatcher(
[]models.LotPartnumber{
{Partnumber: "R750*", LotName: "SERVER_R750"},
{Partnumber: "HDD-01", LotName: "HDD_01"},
},
[]models.Lot{
{LotName: "MEM_DDR5_16G_4800"},
},
)
check := func(partnumber string, want string) {
t.Helper()
got := matcher.MatchLots(partnumber)
if len(got) != 1 || got[0] != want {
t.Fatalf("partnumber %s: expected [%s], got %#v", partnumber, want, got)
}
}
check("R750XD", "SERVER_R750")
check("HDD-01", "HDD_01")
check("MEM_DDR5_16G_4800", "MEM_DDR5_16G_4800")
if got := matcher.MatchLots("UNKNOWN"); len(got) != 0 {
t.Fatalf("expected no matches for UNKNOWN, got %#v", got)
}
}

View File

@@ -3,12 +3,12 @@ package repository
import (
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/warehouse"
"gorm.io/gorm"
)
@@ -288,60 +288,11 @@ func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem)
return nil
}
lotSet := make(map[string]struct{}, len(lots))
for _, lot := range lots {
lotSet[lot] = struct{}{}
}
resolver, err := r.newWarehouseLotResolver()
qtyByLot, partnumbersByLot, err := warehouse.LoadLotMetrics(r.db, lots, true)
if err != nil {
return err
}
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
if err := r.db.Where("lot_name IN ? AND TRIM(lot_name) <> ''", lots).
Order("partnumber ASC").
Find(&mappings).Error; err != nil {
return err
}
partnumbersByLot := make(map[string][]string, len(lots))
seenPair := make(map[string]struct{}, len(mappings))
for _, m := range mappings {
lot := strings.TrimSpace(m.LotName)
pn := strings.TrimSpace(m.Partnumber)
if lot == "" || pn == "" {
continue
}
key := lot + "\x00" + strings.ToLower(pn)
if _, ok := seenPair[key]; ok {
continue
}
seenPair[key] = struct{}{}
partnumbersByLot[lot] = append(partnumbersByLot[lot], pn)
}
for i := range items {
if qty, ok := qtyByLot[items[i].LotName]; ok {
q := qty
@@ -352,131 +303,6 @@ 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

View File

@@ -10,6 +10,7 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
"git.mchus.pro/mchus/quoteforge/internal/warehouse"
"gorm.io/gorm"
)
@@ -132,6 +133,22 @@ func (s *Service) CreateForSourceWithProgress(createdBy, source string, sourceIt
}
items := make([]models.PricelistItem, 0)
if len(sourceItems) == 0 && source == string(models.PricelistSourceWarehouse) {
warehouseItems, err := warehouse.ComputePricelistItemsFromStockLog(s.db)
if err != nil {
_ = s.repo.Delete(pricelist.ID)
return nil, fmt.Errorf("building warehouse pricelist from stock_log: %w", err)
}
sourceItems = make([]CreateItemInput, 0, len(warehouseItems))
for _, item := range warehouseItems {
sourceItems = append(sourceItems, CreateItemInput{
LotName: item.LotName,
Price: item.Price,
PriceMethod: item.PriceMethod,
})
}
}
if len(sourceItems) > 0 {
items = make([]models.PricelistItem, 0, len(sourceItems))
for _, srcItem := range sourceItems {

View File

@@ -0,0 +1,72 @@
package pricelist
import (
"math"
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestCreateWarehousePricelistFromStockLog(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(
&models.Pricelist{},
&models.PricelistItem{},
&models.StockLog{},
&models.Lot{},
&models.LotPartnumber{},
); err != nil {
t.Fatalf("automigrate: %v", err)
}
if err := db.Create(&models.Lot{LotName: "CPU_X", LotDescription: "CPU"}).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)
}
qty1 := 2.0
qty2 := 8.0
now := time.Now()
rows := []models.StockLog{
{Partnumber: "PN-CPU-X", Date: now, Price: 100, Qty: &qty1},
{Partnumber: "PN-CPU-X", Date: now, Price: 200, Qty: &qty2},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatalf("seed stock log: %v", err)
}
repo := repository.NewPricelistRepository(db)
svc := NewService(db, repo, nil, nil)
pl, err := svc.CreateForSourceWithProgress("tester", string(models.PricelistSourceWarehouse), nil, nil)
if err != nil {
t.Fatalf("create warehouse pricelist: %v", err)
}
if pl.Source != string(models.PricelistSourceWarehouse) {
t.Fatalf("unexpected source: %s", pl.Source)
}
var items []models.PricelistItem
if err := db.Where("pricelist_id = ?", pl.ID).Find(&items).Error; err != nil {
t.Fatalf("load pricelist items: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
if items[0].LotName != "CPU_X" {
t.Fatalf("unexpected lot name: %s", items[0].LotName)
}
if math.Abs(items[0].Price-200) > 0.001 {
t.Fatalf("expected weighted median price 200, got %f", items[0].Price)
}
}

View File

@@ -4,7 +4,6 @@ import (
"archive/zip"
"bytes"
"encoding/xml"
"errors"
"fmt"
"io"
"path/filepath"
@@ -14,8 +13,10 @@ import (
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/lotmatch"
"git.mchus.pro/mchus/quoteforge/internal/models"
pricelistsvc "git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
"git.mchus.pro/mchus/quoteforge/internal/warehouse"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -137,7 +138,7 @@ func (s *StockImportService) Import(
Total: 100,
})
partnumberMappings, err := s.loadPartnumberMappings()
partnumberMatcher, err := lotmatch.NewMappingMatcherFromDB(s.db)
if err != nil {
return nil, err
}
@@ -173,7 +174,7 @@ func (s *StockImportService) Import(
}
partnumber := strings.TrimSpace(row.Article)
key := normalizeKey(partnumber)
mappedLots := partnumberMappings[key]
mappedLots := partnumberMatcher.MatchLots(partnumber)
if len(mappedLots) == 0 {
unmapped++
suggestionsByPN[key] = upsertSuggestion(suggestionsByPN[key], StockMappingSuggestion{
@@ -342,74 +343,22 @@ func (s *StockImportService) replaceStockLogs(records []models.StockLog) (int64,
}
func (s *StockImportService) buildWarehousePricelistItems() ([]pricelistsvc.CreateItemInput, error) {
var logs []models.StockLog
if err := s.db.Select("partnumber, price, qty").Where("price > 0").Find(&logs).Error; err != nil {
return nil, err
}
resolver, err := s.newLotResolver()
warehouseItems, err := warehouse.ComputePricelistItemsFromStockLog(s.db)
if err != nil {
return nil, err
}
grouped := make(map[string][]weightedPricePoint)
for _, l := range logs {
partnumber := strings.TrimSpace(l.Partnumber)
if partnumber == "" || l.Price <= 0 {
continue
}
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, values := range grouped {
price := weightedMedian(values)
if price <= 0 {
continue
}
items := make([]pricelistsvc.CreateItemInput, 0, len(warehouseItems))
for _, item := range warehouseItems {
items = append(items, pricelistsvc.CreateItemInput{
LotName: lot,
Price: price,
PriceMethod: "weighted_median",
LotName: item.LotName,
Price: item.Price,
PriceMethod: item.PriceMethod,
})
}
sort.Slice(items, func(i, j int) bool {
return items[i].LotName < items[j].LotName
})
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
@@ -674,11 +623,9 @@ func normalizeIgnoreMatchType(v string) string {
}
var (
reISODate = regexp.MustCompile(`\b(20\d{2})-(\d{2})-(\d{2})\b`)
reRuDate = regexp.MustCompile(`\b([0-3]\d)\.([01]\d)\.(20\d{2})\b`)
mxlCellRe = regexp.MustCompile(`\{16,\d+,\s*\{1,1,\s*\{"ru","(.*?)"\}\s*\},0\},(\d+),`)
errResolveConflict = errors.New("multiple lot matches")
errResolveNotFound = errors.New("lot not found")
reISODate = regexp.MustCompile(`\b(20\d{2})-(\d{2})-(\d{2})\b`)
reRuDate = regexp.MustCompile(`\b([0-3]\d)\.([01]\d)\.(20\d{2})\b`)
mxlCellRe = regexp.MustCompile(`\{16,\d+,\s*\{1,1,\s*\{"ru","(.*?)"\}\s*\},0\},(\d+),`)
)
func parseStockRows(filename string, content []byte) ([]stockImportRow, error) {
@@ -1020,124 +967,8 @@ func weightedMedian(values []weightedPricePoint) float64 {
return items[len(items)-1].price
}
type lotResolver struct {
partnumberToLots map[string][]string
exactLots map[string]string
allLots []string
}
func (s *StockImportService) newLotResolver() (*lotResolver, 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 {
p := normalizeKey(m.Partnumber)
if p == "" || strings.TrimSpace(m.LotName) == "" {
continue
}
partnumberToLots[p] = append(partnumberToLots[p], m.LotName)
}
var lots []models.Lot
if err := s.db.Select("lot_name").Find(&lots).Error; err != nil {
return nil, err
}
exactLots := make(map[string]string, len(lots))
allLots := make([]string, 0, len(lots))
for _, l := range lots {
name := strings.TrimSpace(l.LotName)
if name == "" {
continue
}
k := normalizeKey(name)
exactLots[k] = name
allLots = append(allLots, name)
}
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 &lotResolver{
partnumberToLots: partnumberToLots,
exactLots: exactLots,
allLots: allLots,
}, nil
}
func (r *lotResolver) resolve(article string) (string, string, error) {
key := normalizeKey(article)
if key == "" {
return "", "", errResolveNotFound
}
if mapped := r.partnumberToLots[key]; len(mapped) > 0 {
uniq := uniqueStrings(mapped)
if len(uniq) == 1 {
return uniq[0], "mapping_table", nil
}
return "", "", errResolveConflict
}
if lot, ok := r.exactLots[key]; ok {
return lot, "article_exact", nil
}
best := ""
bestLen := -1
tie := false
for _, lot := range r.allLots {
lotKey := normalizeKey(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 "", "", errResolveNotFound
}
if tie {
return "", "", errResolveConflict
}
return best, "prefix", nil
}
func normalizeKey(v string) string {
return strings.ToLower(strings.TrimSpace(v))
}
func uniqueStrings(values []string) []string {
seen := make(map[string]bool, len(values))
out := make([]string, 0, len(values))
for _, v := range values {
v = strings.TrimSpace(v)
if v == "" {
continue
}
k := strings.ToLower(v)
if seen[k] {
continue
}
seen[k] = true
out = append(out, v)
}
sort.Strings(out)
return out
return lotmatch.NormalizeKey(v)
}
func readZipFile(zr *zip.Reader, name string) ([]byte, error) {

View File

@@ -7,6 +7,7 @@ import (
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/lotmatch"
"git.mchus.pro/mchus/quoteforge/internal/models"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
@@ -99,39 +100,42 @@ func TestParseXLSXRows(t *testing.T) {
}
func TestLotResolverPrecedenceAndConflicts(t *testing.T) {
r := &lotResolver{
partnumberToLots: map[string][]string{
"pn-1": {"LOT_MAPPED"},
"pn-conflict": {"LOT_A", "LOT_B"},
resolver := lotmatch.NewLotResolver(
[]models.LotPartnumber{
{Partnumber: "pn-1", LotName: "LOT_MAPPED"},
{Partnumber: "pn-conflict", LotName: "LOT_A"},
{Partnumber: "pn-conflict", LotName: "LOT_B"},
},
exactLots: map[string]string{
"cpu_a": "CPU_A",
[]models.Lot{
{LotName: "CPU_A_LONG"},
{LotName: "CPU_A"},
{LotName: "ABC "},
{LotName: "ABC\t"},
},
allLots: []string{"CPU_A_LONG", "CPU_A", "ABC ", "ABC\t"},
}
)
lot, typ, err := r.resolve("pn-1")
lot, typ, err := resolver.Resolve("pn-1")
if err != nil || lot != "LOT_MAPPED" || typ != "mapping_table" {
t.Fatalf("mapping_table mismatch: lot=%s typ=%s err=%v", lot, typ, err)
}
lot, typ, err = r.resolve("cpu_a")
lot, typ, err = resolver.Resolve("cpu_a")
if err != nil || lot != "CPU_A" || typ != "article_exact" {
t.Fatalf("article_exact mismatch: lot=%s typ=%s err=%v", lot, typ, err)
}
lot, typ, err = r.resolve("cpu_a_long_suffix")
lot, typ, err = resolver.Resolve("cpu_a_long_suffix")
if err != nil || lot != "CPU_A_LONG" || typ != "prefix" {
t.Fatalf("prefix mismatch: lot=%s typ=%s err=%v", lot, typ, err)
}
_, _, err = r.resolve("abx")
if err == nil {
t.Fatalf("expected not found error")
_, _, err = resolver.Resolve("abx")
if err == nil || err != lotmatch.ErrResolveNotFound {
t.Fatalf("expected not found error, got %v", err)
}
_, _, err = r.resolve("pn-conflict")
if err == nil || err != errResolveConflict {
_, _, err = resolver.Resolve("pn-conflict")
if err == nil || err != lotmatch.ErrResolveConflict {
t.Fatalf("expected conflict, got %v", err)
}
}
@@ -267,6 +271,42 @@ func TestBuildWarehousePricelistItems_UsesPrefixResolver(t *testing.T) {
}
}
func TestPartnumberMappings_WildcardMatch(t *testing.T) {
db := openTestDB(t)
if err := db.AutoMigrate(&models.LotPartnumber{}, &models.Lot{}); err != nil {
t.Fatalf("automigrate: %v", err)
}
mappings := []models.LotPartnumber{
{Partnumber: "R750*", LotName: "SERVER_R750"},
{Partnumber: "HDD-01", LotName: "HDD_01"},
}
if err := db.Create(&mappings).Error; err != nil {
t.Fatalf("seed mappings: %v", err)
}
if err := db.Create(&models.Lot{LotName: "MEM_DDR5_16G_4800"}).Error; err != nil {
t.Fatalf("seed lot: %v", err)
}
resolver, err := lotmatch.NewMappingMatcherFromDB(db)
if err != nil {
t.Fatalf("NewMappingMatcherFromDB: %v", err)
}
if got := resolver.MatchLots("R750XD"); len(got) != 1 || got[0] != "SERVER_R750" {
t.Fatalf("expected wildcard match SERVER_R750, got %#v", got)
}
if got := resolver.MatchLots("HDD-01"); len(got) != 1 || got[0] != "HDD_01" {
t.Fatalf("expected exact match HDD_01, got %#v", got)
}
if got := resolver.MatchLots("UNKNOWN"); len(got) != 0 {
t.Fatalf("expected no matches, got %#v", got)
}
if got := resolver.MatchLots("MEM_DDR5_16G_4800"); len(got) != 1 || got[0] != "MEM_DDR5_16G_4800" {
t.Fatalf("expected exact lot fallback, got %#v", got)
}
}
func openTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})

View File

@@ -322,6 +322,9 @@ func (s *Service) NeedSync() (bool, error) {
// SyncPricelists synchronizes all active pricelists from server to local SQLite
func (s *Service) SyncPricelists() (int, error) {
slog.Info("starting pricelist sync")
if _, err := s.EnsureReadinessForSync(); err != nil {
return 0, err
}
// Get database connection
mariaDB, err := s.getDB()
@@ -592,10 +595,14 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
// Convert and save locally
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
for i, item := range serverItems {
partnumbers := make(localdb.LocalStringList, 0, len(item.Partnumbers))
partnumbers = append(partnumbers, item.Partnumbers...)
localItems[i] = localdb.LocalPricelistItem{
PricelistID: localPricelistID,
LotName: item.LotName,
Price: item.Price,
PricelistID: localPricelistID,
LotName: item.LotName,
Price: item.Price,
AvailableQty: item.AvailableQty,
Partnumbers: partnumbers,
}
}
@@ -672,6 +679,10 @@ func (s *Service) SyncPricelistsIfNeeded() error {
// PushPendingChanges pushes all pending changes to the server
func (s *Service) PushPendingChanges() (int, error) {
if _, err := s.EnsureReadinessForSync(); err != nil {
return 0, err
}
removed, err := s.localDB.PurgeOrphanConfigurationPendingChanges()
if err != nil {
slog.Warn("failed to purge orphan configuration pending changes", "error", err)

View 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]
}

View 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
}