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

@@ -45,8 +45,15 @@ func (h *PricelistHandler) List(c *gin.Context) {
return
}
// If offline (empty list), fallback to local pricelists
if total == 0 && h.localDB != nil {
isOffline := false
if v, ok := c.Get("is_offline"); ok {
if b, ok := v.(bool); ok {
isOffline = b
}
}
// 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 != "" {
@@ -338,6 +345,26 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
})
}
func (h *PricelistHandler) GetLotNames(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
return
}
lotNames, err := h.service.GetLotNames(uint(id))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"lot_names": lotNames,
"total": len(lotNames),
})
}
// CanWrite returns whether the current user can create pricelists
func (h *PricelistHandler) CanWrite(c *gin.Context) {
canWrite, debugInfo := h.service.CanWriteDebug()

View File

@@ -997,6 +997,7 @@ func (h *PricingHandler) ImportStockLog(c *gin.Context) {
"conflicts": result.Conflicts,
"fallback_matches": result.FallbackMatches,
"parse_errors": result.ParseErrors,
"qty_parse_errors": result.QtyParseErrors,
"ignored": result.Ignored,
"mapping_suggestions": result.MappingSuggestions,
"import_date": result.ImportDate.Format("2006-01-02"),
@@ -1031,6 +1032,7 @@ func (h *PricingHandler) ImportStockLog(c *gin.Context) {
"conflicts": p.Conflicts,
"fallback_matches": p.FallbackMatches,
"parse_errors": p.ParseErrors,
"qty_parse_errors": p.QtyParseErrors,
"ignored": p.Ignored,
"mapping_suggestions": p.MappingSuggestions,
"import_date": p.ImportDate,
@@ -1183,6 +1185,231 @@ func (h *PricingHandler) DeleteStockIgnoreRule(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"deleted": deleted})
}
type LotTableRow struct {
LotName string `json:"lot_name"`
LotDescription string `json:"lot_description"`
Category string `json:"category"`
Partnumbers []string `json:"partnumbers"`
Popularity float64 `json:"popularity"`
EstimateCount int64 `json:"estimate_count"`
StockQty *float64 `json:"stock_qty"`
}
func (h *PricingHandler) ListLotsTable(c *gin.Context) {
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Список LOT доступен только в онлайн режиме",
"offline": true,
})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
search := strings.TrimSpace(c.Query("search"))
sortFieldParam := c.DefaultQuery("sort", "lot_name")
sortDirParam := strings.ToUpper(c.DefaultQuery("dir", "asc"))
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 200 {
perPage = 50
}
if sortDirParam != "ASC" && sortDirParam != "DESC" {
sortDirParam = "ASC"
}
type lotRow struct {
LotName string `gorm:"column:lot_name"`
LotDescription string `gorm:"column:lot_description"`
CategoryCode *string `gorm:"column:category_code"`
Popularity *float64 `gorm:"column:popularity_score"`
}
baseQuery := h.db.Table("lot").
Select("lot.lot_name, lot.lot_description, qt_categories.code as category_code, qt_lot_metadata.popularity_score").
Joins("LEFT JOIN qt_lot_metadata ON qt_lot_metadata.lot_name = lot.lot_name").
Joins("LEFT JOIN qt_categories ON qt_categories.id = qt_lot_metadata.category_id")
if search != "" {
baseQuery = baseQuery.Where("lot.lot_name LIKE ? OR lot.lot_description LIKE ?", "%"+search+"%", "%"+search+"%")
}
var total int64
if err := baseQuery.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
allowedDBSorts := map[string]string{
"lot_name": "lot.lot_name",
"category": "qt_categories.code",
"popularity_score": "qt_lot_metadata.popularity_score",
}
needsComputedSort := sortFieldParam == "estimate_count" || sortFieldParam == "stock_qty"
var rows []lotRow
rowsQuery := baseQuery.Session(&gorm.Session{})
if needsComputedSort {
rowsQuery = rowsQuery.Order("lot.lot_name ASC")
} else {
orderCol, ok := allowedDBSorts[sortFieldParam]
if !ok {
orderCol = "lot.lot_name"
}
rowsQuery = rowsQuery.Order(orderCol + " " + sortDirParam).Order("lot.lot_name ASC")
rowsQuery = rowsQuery.Offset((page - 1) * perPage).Limit(perPage)
}
if err := rowsQuery.Find(&rows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if len(rows) == 0 {
c.JSON(http.StatusOK, gin.H{
"lots": []LotTableRow{},
"total": total,
"page": page,
"per_page": perPage,
})
return
}
// Collect lot names for batch subqueries
lotNames := make([]string, len(rows))
for i, r := range rows {
lotNames[i] = r.LotName
}
type countRow struct {
Lot string `gorm:"column:lot"`
Count int64 `gorm:"column:cnt"`
}
var estimateCounts []countRow
if err := h.db.Raw("SELECT lot, COUNT(*) as cnt FROM lot_log WHERE lot IN ? GROUP BY lot", lotNames).Scan(&estimateCounts).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
estimateMap := make(map[string]int64, len(estimateCounts))
for _, ec := range estimateCounts {
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 {
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 {
cat := ""
if r.CategoryCode != nil {
cat = *r.CategoryCode
}
pop := 0.0
if r.Popularity != nil {
pop = *r.Popularity
}
result[i] = LotTableRow{
LotName: r.LotName,
LotDescription: r.LotDescription,
Category: cat,
Partnumbers: pnMap[r.LotName],
Popularity: pop,
EstimateCount: estimateMap[r.LotName],
StockQty: stockMap[r.LotName],
}
if result[i].Partnumbers == nil {
result[i].Partnumbers = []string{}
}
}
if needsComputedSort {
sort.SliceStable(result, func(i, j int) bool {
if sortFieldParam == "estimate_count" {
if result[i].EstimateCount == result[j].EstimateCount {
if sortDirParam == "DESC" {
return result[i].LotName > result[j].LotName
}
return result[i].LotName < result[j].LotName
}
if sortDirParam == "DESC" {
return result[i].EstimateCount > result[j].EstimateCount
}
return result[i].EstimateCount < result[j].EstimateCount
}
qi := 0.0
if result[i].StockQty != nil {
qi = *result[i].StockQty
}
qj := 0.0
if result[j].StockQty != nil {
qj = *result[j].StockQty
}
if qi == qj {
if sortDirParam == "DESC" {
return result[i].LotName > result[j].LotName
}
return result[i].LotName < result[j].LotName
}
if sortDirParam == "DESC" {
return qi > qj
}
return qi < qj
})
start := (page - 1) * perPage
if start >= len(result) {
result = []LotTableRow{}
} else {
end := start + perPage
if end > len(result) {
end = len(result)
}
result = result[start:end]
}
}
c.JSON(http.StatusOK, gin.H{
"lots": result,
"total": total,
"page": page,
"per_page": perPage,
})
}
func (h *PricingHandler) ListLots(c *gin.Context) {
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{

View File

@@ -1,6 +1,7 @@
package handlers
import (
"fmt"
"html/template"
"log/slog"
"net/http"
@@ -364,10 +365,27 @@ func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
})
}
// SyncInfoResponse represents sync information
// SyncInfoResponse represents sync information for the modal
type SyncInfoResponse struct {
LastSyncAt *time.Time `json:"last_sync_at"`
IsOnline bool `json:"is_online"`
// Connection
DBHost string `json:"db_host"`
DBUser string `json:"db_user"`
DBName string `json:"db_name"`
// Status
IsOnline bool `json:"is_online"`
LastSyncAt *time.Time `json:"last_sync_at"`
// Statistics
LotCount int64 `json:"lot_count"`
LotLogCount int64 `json:"lot_log_count"`
ConfigCount int64 `json:"config_count"`
ProjectCount int64 `json:"project_count"`
// Pending changes
PendingChanges []localdb.PendingChange `json:"pending_changes"`
// Errors
ErrorCount int `json:"error_count"`
Errors []SyncError `json:"errors,omitempty"`
}
@@ -392,31 +410,44 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
// Check online status by pinging MariaDB
isOnline := h.checkOnline()
// Get DB connection info
var dbHost, dbUser, dbName string
if settings, err := h.localDB.GetSettings(); err == nil {
dbHost = settings.Host + ":" + fmt.Sprintf("%d", settings.Port)
dbUser = settings.User
dbName = settings.Database
}
// Get sync times
lastPricelistSync := h.localDB.GetLastSyncTime()
// Get MariaDB counts (if online)
var lotCount, lotLogCount int64
if isOnline {
if mariaDB, err := h.connMgr.GetDB(); err == nil {
mariaDB.Table("lot").Count(&lotCount)
mariaDB.Table("lot_log").Count(&lotLogCount)
}
}
// Get local counts
configCount := h.localDB.CountConfigurations()
projectCount := h.localDB.CountProjects()
// Get error count (only changes with LastError != "")
errorCount := int(h.localDB.CountErroredChanges())
// Get recent errors (last 10)
// Get pending changes
changes, err := h.localDB.GetPendingChanges()
if err != nil {
slog.Error("failed to get pending changes for sync info", "error", err)
// Even if we can't get changes, we can still return the error count
c.JSON(http.StatusOK, SyncInfoResponse{
LastSyncAt: lastPricelistSync,
IsOnline: isOnline,
ErrorCount: errorCount,
Errors: []SyncError{}, // Return empty errors list
})
return
changes = []localdb.PendingChange{}
}
var errors []SyncError
var syncErrors []SyncError
for _, change := range changes {
// Check if there's a last error and it's not empty
if change.LastError != "" {
errors = append(errors, SyncError{
syncErrors = append(syncErrors, SyncError{
Timestamp: change.CreatedAt,
Message: change.LastError,
})
@@ -424,15 +455,23 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
}
// Limit to last 10 errors
if len(errors) > 10 {
errors = errors[:10]
if len(syncErrors) > 10 {
syncErrors = syncErrors[:10]
}
c.JSON(http.StatusOK, SyncInfoResponse{
LastSyncAt: lastPricelistSync,
IsOnline: isOnline,
ErrorCount: errorCount,
Errors: errors,
DBHost: dbHost,
DBUser: dbUser,
DBName: dbName,
IsOnline: isOnline,
LastSyncAt: lastPricelistSync,
LotCount: lotCount,
LotLogCount: lotLogCount,
ConfigCount: configCount,
ProjectCount: projectCount,
PendingChanges: changes,
ErrorCount: errorCount,
Errors: syncErrors,
})
}

View File

@@ -29,6 +29,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
IsTemplate: cfg.IsTemplate,
ServerCount: cfg.ServerCount,
PricelistID: cfg.PricelistID,
OnlyInStock: cfg.OnlyInStock,
PriceUpdatedAt: cfg.PriceUpdatedAt,
CreatedAt: cfg.CreatedAt,
UpdatedAt: time.Now(),
@@ -72,6 +73,7 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
IsTemplate: local.IsTemplate,
ServerCount: local.ServerCount,
PricelistID: local.PricelistID,
OnlyInStock: local.OnlyInStock,
PriceUpdatedAt: local.PriceUpdatedAt,
CreatedAt: local.CreatedAt,
}

View File

@@ -483,6 +483,13 @@ func (l *LocalDB) CountConfigurations() int64 {
return count
}
// CountProjects returns the number of local projects
func (l *LocalDB) CountProjects() int64 {
var count int64
l.db.Model(&LocalProject{}).Count(&count)
return count
}
// Pricelist methods
// GetLastSyncTime returns the last sync timestamp

View File

@@ -73,6 +73,7 @@ type LocalConfiguration struct {
IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"`
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`

View File

@@ -23,6 +23,7 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
"is_template": localCfg.IsTemplate,
"server_count": localCfg.ServerCount,
"pricelist_id": localCfg.PricelistID,
"only_in_stock": localCfg.OnlyInStock,
"price_updated_at": localCfg.PriceUpdatedAt,
"created_at": localCfg.CreatedAt,
"updated_at": localCfg.UpdatedAt,
@@ -52,6 +53,7 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"`
PricelistID *uint `json:"pricelist_id"`
OnlyInStock bool `json:"only_in_stock"`
PriceUpdatedAt *time.Time `json:"price_updated_at"`
OriginalUserID uint `json:"original_user_id"`
OriginalUsername string `json:"original_username"`
@@ -77,6 +79,7 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
IsTemplate: snapshot.IsTemplate,
ServerCount: snapshot.ServerCount,
PricelistID: snapshot.PricelistID,
OnlyInStock: snapshot.OnlyInStock,
PriceUpdatedAt: snapshot.PriceUpdatedAt,
OriginalUserID: snapshot.OriginalUserID,
OriginalUsername: snapshot.OriginalUsername,

View File

@@ -40,25 +40,26 @@ func (c ConfigItems) Total() float64 {
}
type Configuration struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
UserID *uint `json:"user_id,omitempty"` // Legacy field, no longer required for ownership
OwnerUsername string `gorm:"size:100;not null;default:'';index" json:"owner_username"`
ProjectUUID *string `gorm:"size:36;index" json:"project_uuid,omitempty"`
AppVersion string `gorm:"size:64" json:"app_version,omitempty"`
Name string `gorm:"size:200;not null" json:"name"`
Items ConfigItems `gorm:"type:json;not null" json:"items"`
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"`
Notes string `gorm:"type:text" json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"`
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
UserID *uint `json:"user_id,omitempty"` // Legacy field, no longer required for ownership
OwnerUsername string `gorm:"size:100;not null;default:'';index" json:"owner_username"`
ProjectUUID *string `gorm:"size:36;index" json:"project_uuid,omitempty"`
AppVersion string `gorm:"size:64" json:"app_version,omitempty"`
Name string `gorm:"size:200;not null" json:"name"`
Items ConfigItems `gorm:"type:json;not null" json:"items"`
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"`
Notes string `gorm:"type:text" json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"`
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
}

View File

@@ -41,7 +41,7 @@ func (Supplier) TableName() string {
// StockLog stores warehouse stock snapshots imported from external files.
type StockLog struct {
StockLogID uint `gorm:"column:stock_log_id;primaryKey;autoIncrement"`
Lot string `gorm:"column:lot;size:255;not null"`
Partnumber string `gorm:"column:partnumber;size:255;not null"`
Supplier *string `gorm:"column:supplier;size:255"`
Date time.Time `gorm:"column:date;type:date;not null"`
Price float64 `gorm:"column:price;not null"`

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)

View File

@@ -53,6 +53,7 @@ type CreateConfigRequest struct {
IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"`
PricelistID *uint `json:"pricelist_id,omitempty"`
OnlyInStock bool `json:"only_in_stock"`
}
func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
@@ -84,6 +85,7 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount,
PricelistID: pricelistID,
OnlyInStock: req.OnlyInStock,
}
if err := s.configRepo.Create(config); err != nil {
@@ -145,6 +147,7 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
config.IsTemplate = req.IsTemplate
config.ServerCount = req.ServerCount
config.PricelistID = pricelistID
config.OnlyInStock = req.OnlyInStock
if err := s.configRepo.Update(config); err != nil {
return nil, err
@@ -222,6 +225,7 @@ func (s *ConfigurationService) CloneToProject(configUUID string, ownerUsername s
IsTemplate: false, // Clone is never a template
ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
OnlyInStock: original.OnlyInStock,
}
if err := s.configRepo.Create(clone); err != nil {
@@ -295,6 +299,7 @@ func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigReques
config.IsTemplate = req.IsTemplate
config.ServerCount = req.ServerCount
config.PricelistID = pricelistID
config.OnlyInStock = req.OnlyInStock
if err := s.configRepo.Update(config); err != nil {
return nil, err
@@ -362,6 +367,7 @@ func (s *ConfigurationService) CloneNoAuthToProject(configUUID string, newName s
IsTemplate: false,
ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
OnlyInStock: original.OnlyInStock,
}
if err := s.configRepo.Create(clone); err != nil {

View File

@@ -81,6 +81,7 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount,
PricelistID: pricelistID,
OnlyInStock: req.OnlyInStock,
CreatedAt: time.Now(),
}
@@ -163,6 +164,7 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
localCfg.IsTemplate = req.IsTemplate
localCfg.ServerCount = req.ServerCount
localCfg.PricelistID = pricelistID
localCfg.OnlyInStock = req.OnlyInStock
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
@@ -268,6 +270,7 @@ func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsern
IsTemplate: false,
ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
OnlyInStock: original.OnlyInStock,
CreatedAt: time.Now(),
}
@@ -454,6 +457,7 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
localCfg.IsTemplate = req.IsTemplate
localCfg.ServerCount = req.ServerCount
localCfg.PricelistID = pricelistID
localCfg.OnlyInStock = req.OnlyInStock
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
@@ -546,6 +550,7 @@ func (s *LocalConfigurationService) CloneNoAuthToProject(configUUID string, newN
IsTemplate: false,
ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
OnlyInStock: original.OnlyInStock,
CreatedAt: time.Now(),
}
@@ -1029,6 +1034,7 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
current.IsTemplate = rollbackData.IsTemplate
current.ServerCount = rollbackData.ServerCount
current.PricelistID = rollbackData.PricelistID
current.OnlyInStock = rollbackData.OnlyInStock
current.PriceUpdatedAt = rollbackData.PriceUpdatedAt
current.UpdatedAt = time.Now()
current.SyncStatus = "pending"

View File

@@ -31,8 +31,9 @@ type CreateProgress struct {
}
type CreateItemInput struct {
LotName string
Price float64
LotName string
Price float64
PriceMethod string
}
func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository, pricingSvc *pricing.Service) *Service {
@@ -141,6 +142,7 @@ func (s *Service) CreateForSourceWithProgress(createdBy, source string, sourceIt
PricelistID: pricelist.ID,
LotName: strings.TrimSpace(srcItem.LotName),
Price: srcItem.Price,
PriceMethod: strings.TrimSpace(srcItem.PriceMethod),
})
}
} else {
@@ -169,6 +171,11 @@ func (s *Service) CreateForSourceWithProgress(createdBy, source string, sourceIt
}
}
if len(items) == 0 {
_ = s.repo.Delete(pricelist.ID)
return nil, fmt.Errorf("cannot create empty pricelist for source %q", source)
}
if err := s.repo.CreateItems(items); err != nil {
// Clean up the pricelist if items creation fails
s.repo.Delete(pricelist.ID)
@@ -262,6 +269,13 @@ func (s *Service) GetItems(pricelistID uint, page, perPage int, search string) (
return s.repo.GetItems(pricelistID, offset, perPage, search)
}
func (s *Service) GetLotNames(pricelistID uint) ([]string, error) {
if s.repo == nil {
return []string{}, nil
}
return s.repo.GetLotNames(pricelistID)
}
// Delete deletes a pricelist by ID
func (s *Service) Delete(id uint) error {
if s.repo == nil {

View File

@@ -2,6 +2,9 @@ package services
import (
"errors"
"fmt"
"sync"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
@@ -21,6 +24,9 @@ type QuoteService struct {
pricelistRepo *repository.PricelistRepository
localDB *localdb.LocalDB
pricingService *pricing.Service
cacheMu sync.RWMutex
priceCache map[string]cachedLotPrice
cacheTTL time.Duration
}
func NewQuoteService(
@@ -36,9 +42,16 @@ func NewQuoteService(
pricelistRepo: pricelistRepo,
localDB: localDB,
pricingService: pricingService,
priceCache: make(map[string]cachedLotPrice, 4096),
cacheTTL: 10 * time.Second,
}
}
type cachedLotPrice struct {
price *float64
expiresAt time.Time
}
type QuoteItem struct {
LotName string `json:"lot_name"`
Quantity int `json:"quantity"`
@@ -70,6 +83,7 @@ type PriceLevelsRequest struct {
Quantity int `json:"quantity"`
} `json:"items"`
PricelistIDs map[string]uint `json:"pricelist_ids,omitempty"`
NoCache bool `json:"no_cache,omitempty"`
}
type PriceLevelsItem struct {
@@ -170,11 +184,55 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve
return nil, ErrEmptyQuote
}
lotNames := make([]string, 0, len(req.Items))
seenLots := make(map[string]struct{}, len(req.Items))
for _, reqItem := range req.Items {
if _, ok := seenLots[reqItem.LotName]; ok {
continue
}
seenLots[reqItem.LotName] = struct{}{}
lotNames = append(lotNames, reqItem.LotName)
}
result := &PriceLevelsResult{
Items: make([]PriceLevelsItem, 0, len(req.Items)),
ResolvedPricelistIDs: map[string]uint{},
}
type levelState struct {
id uint
prices map[string]float64
}
levelBySource := map[models.PricelistSource]*levelState{
models.PricelistSourceEstimate: {prices: map[string]float64{}},
models.PricelistSourceWarehouse: {prices: map[string]float64{}},
models.PricelistSourceCompetitor: {prices: map[string]float64{}},
}
for source, st := range levelBySource {
sourceKey := string(source)
if req.PricelistIDs != nil {
if explicitID, ok := req.PricelistIDs[sourceKey]; ok && explicitID > 0 {
st.id = explicitID
result.ResolvedPricelistIDs[sourceKey] = explicitID
}
}
if st.id == 0 && s.pricelistRepo != nil {
latest, err := s.pricelistRepo.GetLatestActiveBySource(sourceKey)
if err == nil {
st.id = latest.ID
result.ResolvedPricelistIDs[sourceKey] = latest.ID
}
}
if st.id == 0 {
continue
}
prices, err := s.lookupPricesByPricelistID(st.id, lotNames, req.NoCache)
if err == nil {
st.prices = prices
}
}
for _, reqItem := range req.Items {
item := PriceLevelsItem{
LotName: reqItem.LotName,
@@ -182,22 +240,17 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve
PriceMissing: make([]string, 0, 3),
}
estimatePrice, estimateID := s.lookupLevelPrice(models.PricelistSourceEstimate, reqItem.LotName, req.PricelistIDs)
warehousePrice, warehouseID := s.lookupLevelPrice(models.PricelistSourceWarehouse, reqItem.LotName, req.PricelistIDs)
competitorPrice, competitorID := s.lookupLevelPrice(models.PricelistSourceCompetitor, reqItem.LotName, req.PricelistIDs)
item.EstimatePrice = estimatePrice
item.WarehousePrice = warehousePrice
item.CompetitorPrice = competitorPrice
if estimateID != 0 {
result.ResolvedPricelistIDs[string(models.PricelistSourceEstimate)] = estimateID
if p, ok := levelBySource[models.PricelistSourceEstimate].prices[reqItem.LotName]; ok && p > 0 {
price := p
item.EstimatePrice = &price
}
if warehouseID != 0 {
result.ResolvedPricelistIDs[string(models.PricelistSourceWarehouse)] = warehouseID
if p, ok := levelBySource[models.PricelistSourceWarehouse].prices[reqItem.LotName]; ok && p > 0 {
price := p
item.WarehousePrice = &price
}
if competitorID != 0 {
result.ResolvedPricelistIDs[string(models.PricelistSourceCompetitor)] = competitorID
if p, ok := levelBySource[models.PricelistSourceCompetitor].prices[reqItem.LotName]; ok && p > 0 {
price := p
item.CompetitorPrice = &price
}
if item.EstimatePrice == nil {
@@ -220,6 +273,93 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve
return result, nil
}
func (s *QuoteService) lookupPricesByPricelistID(pricelistID uint, lotNames []string, noCache bool) (map[string]float64, error) {
result := make(map[string]float64, len(lotNames))
if pricelistID == 0 || len(lotNames) == 0 {
return result, nil
}
missing := make([]string, 0, len(lotNames))
if noCache {
missing = append(missing, lotNames...)
} else {
now := time.Now()
s.cacheMu.RLock()
for _, lotName := range lotNames {
if entry, ok := s.priceCache[s.cacheKey(pricelistID, lotName)]; ok && entry.expiresAt.After(now) {
if entry.price != nil && *entry.price > 0 {
result[lotName] = *entry.price
}
continue
}
missing = append(missing, lotName)
}
s.cacheMu.RUnlock()
}
if len(missing) == 0 {
return result, nil
}
loaded := make(map[string]float64, len(missing))
if s.pricelistRepo != nil {
prices, err := s.pricelistRepo.GetPricesForLots(pricelistID, missing)
if err == nil {
for lotName, price := range prices {
if price > 0 {
result[lotName] = price
loaded[lotName] = price
}
}
s.updateCache(pricelistID, missing, loaded)
return result, nil
}
}
// Fallback path (usually offline): local per-lot lookup.
if s.localDB != nil {
for _, lotName := range missing {
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
if found && price > 0 {
result[lotName] = price
loaded[lotName] = price
}
}
s.updateCache(pricelistID, missing, loaded)
return result, nil
}
return result, fmt.Errorf("price lookup unavailable for pricelist %d", pricelistID)
}
func (s *QuoteService) updateCache(pricelistID uint, requested []string, loaded map[string]float64) {
if len(requested) == 0 {
return
}
expiresAt := time.Now().Add(s.cacheTTL)
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
for _, lotName := range requested {
if price, ok := loaded[lotName]; ok && price > 0 {
priceCopy := price
s.priceCache[s.cacheKey(pricelistID, lotName)] = cachedLotPrice{
price: &priceCopy,
expiresAt: expiresAt,
}
continue
}
s.priceCache[s.cacheKey(pricelistID, lotName)] = cachedLotPrice{
price: nil,
expiresAt: expiresAt,
}
}
}
func (s *QuoteService) cacheKey(pricelistID uint, lotName string) string {
return fmt.Sprintf("%d|%s", pricelistID, lotName)
}
func calculateDelta(target, base *float64) (*float64, *float64) {
if target == nil || base == nil {
return nil, nil

View File

@@ -33,6 +33,7 @@ type StockImportProgress struct {
Conflicts int `json:"conflicts,omitempty"`
FallbackMatches int `json:"fallback_matches,omitempty"`
ParseErrors int `json:"parse_errors,omitempty"`
QtyParseErrors int `json:"qty_parse_errors,omitempty"`
Ignored int `json:"ignored,omitempty"`
MappingSuggestions []StockMappingSuggestion `json:"mapping_suggestions,omitempty"`
ImportDate string `json:"import_date,omitempty"`
@@ -49,6 +50,7 @@ type StockImportResult struct {
Conflicts int
FallbackMatches int
ParseErrors int
QtyParseErrors int
Ignored int
MappingSuggestions []StockMappingSuggestion
ImportDate time.Time
@@ -87,6 +89,13 @@ type stockImportRow struct {
Vendor string
Price float64
Qty float64
QtyRaw string
QtyInvalid bool
}
type weightedPricePoint struct {
price float64
weight float64
}
func (s *StockImportService) Import(
@@ -128,7 +137,7 @@ func (s *StockImportService) Import(
Total: 100,
})
resolver, err := s.newLotResolver()
partnumberMappings, err := s.loadPartnumberMappings()
if err != nil {
return nil, err
}
@@ -139,6 +148,7 @@ func (s *StockImportService) Import(
conflicts int
fallbackMatches int
parseErrors int
qtyParseErrors int
ignored int
suggestionsByPN = make(map[string]StockMappingSuggestion)
)
@@ -152,41 +162,32 @@ func (s *StockImportService) Import(
parseErrors++
continue
}
if row.QtyInvalid {
qtyParseErrors++
parseErrors++
continue
}
if shouldIgnoreStockRow(row, ignoreRules) {
ignored++
continue
}
lot, matchType, resolveErr := resolver.resolve(row.Article)
if resolveErr != nil {
trimmedPN := strings.TrimSpace(row.Article)
if trimmedPN != "" {
key := normalizeKey(trimmedPN)
if key != "" {
reason := "unmapped"
if errors.Is(resolveErr, errResolveConflict) {
reason = "conflict"
}
candidate := StockMappingSuggestion{
Partnumber: trimmedPN,
Description: strings.TrimSpace(row.Description),
Reason: reason,
}
if prev, ok := suggestionsByPN[key]; !ok ||
(strings.TrimSpace(prev.Description) == "" && candidate.Description != "") ||
(prev.Reason != "conflict" && candidate.Reason == "conflict") {
suggestionsByPN[key] = candidate
}
}
}
if errors.Is(resolveErr, errResolveConflict) {
conflicts++
} else {
unmapped++
}
continue
}
if matchType == "article_exact" || matchType == "prefix" {
fallbackMatches++
partnumber := strings.TrimSpace(row.Article)
key := normalizeKey(partnumber)
mappedLots := partnumberMappings[key]
if len(mappedLots) == 0 {
unmapped++
suggestionsByPN[key] = upsertSuggestion(suggestionsByPN[key], StockMappingSuggestion{
Partnumber: partnumber,
Description: strings.TrimSpace(row.Description),
Reason: "unmapped",
})
} else if len(mappedLots) > 1 {
conflicts++
suggestionsByPN[key] = upsertSuggestion(suggestionsByPN[key], StockMappingSuggestion{
Partnumber: partnumber,
Description: strings.TrimSpace(row.Description),
Reason: "conflict",
})
}
var comments *string
@@ -199,30 +200,31 @@ func (s *StockImportService) Import(
}
qty := row.Qty
records = append(records, models.StockLog{
Lot: lot,
Date: importDate,
Price: row.Price,
Comments: comments,
Vendor: vendor,
Qty: &qty,
Partnumber: partnumber,
Date: importDate,
Price: row.Price,
Comments: comments,
Vendor: vendor,
Qty: &qty,
})
}
suggestions := collectSortedSuggestions(suggestionsByPN, 200)
if len(records) == 0 {
return nil, fmt.Errorf("no valid rows after mapping")
return nil, fmt.Errorf("no valid rows after filtering")
}
report(StockImportProgress{
Status: "mapping",
Message: "Сопоставление article -> lot завершено",
Message: "Валидация строк завершена",
RowsTotal: len(rows),
ValidRows: len(records),
Unmapped: unmapped,
Conflicts: conflicts,
FallbackMatches: fallbackMatches,
ParseErrors: parseErrors,
QtyParseErrors: qtyParseErrors,
Current: 40,
Total: 100,
})
@@ -261,10 +263,14 @@ func (s *StockImportService) Import(
return nil, fmt.Errorf("pricelist service unavailable")
}
pl, err := s.pricelistSvc.CreateForSourceWithProgress(createdBy, string(models.PricelistSourceWarehouse), items, func(p pricelistsvc.CreateProgress) {
current := 70 + int(float64(p.Current)*0.3)
if p.Status != "completed" && current >= 100 {
current = 99
}
report(StockImportProgress{
Status: "recalculating_warehouse",
Message: p.Message,
Current: 70 + int(float64(p.Current)*0.3),
Current: current,
Total: 100,
})
})
@@ -283,6 +289,7 @@ func (s *StockImportService) Import(
Conflicts: conflicts,
FallbackMatches: fallbackMatches,
ParseErrors: parseErrors,
QtyParseErrors: qtyParseErrors,
Ignored: ignored,
MappingSuggestions: suggestions,
ImportDate: importDate,
@@ -301,6 +308,7 @@ func (s *StockImportService) Import(
Conflicts: result.Conflicts,
FallbackMatches: result.FallbackMatches,
ParseErrors: result.ParseErrors,
QtyParseErrors: result.QtyParseErrors,
Ignored: result.Ignored,
MappingSuggestions: result.MappingSuggestions,
ImportDate: result.ImportDate.Format("2006-01-02"),
@@ -335,28 +343,45 @@ func (s *StockImportService) replaceStockLogs(records []models.StockLog) (int64,
func (s *StockImportService) buildWarehousePricelistItems() ([]pricelistsvc.CreateItemInput, error) {
var logs []models.StockLog
if err := s.db.Select("lot, price").Where("price > 0").Find(&logs).Error; err != nil {
if err := s.db.Select("partnumber, price, qty").Where("price > 0").Find(&logs).Error; err != nil {
return nil, err
}
grouped := make(map[string][]float64)
resolver, err := s.newLotResolver()
if err != nil {
return nil, err
}
grouped := make(map[string][]weightedPricePoint)
for _, l := range logs {
lot := strings.TrimSpace(l.Lot)
if lot == "" || l.Price <= 0 {
partnumber := strings.TrimSpace(l.Partnumber)
if partnumber == "" || l.Price <= 0 {
continue
}
grouped[lot] = append(grouped[lot], l.Price)
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, prices := range grouped {
price := median(prices)
for lot, values := range grouped {
price := weightedMedian(values)
if price <= 0 {
continue
}
items = append(items, pricelistsvc.CreateItemInput{
LotName: lot,
Price: price,
LotName: lot,
Price: price,
PriceMethod: "weighted_median",
})
}
sort.Slice(items, func(i, j int) bool {
@@ -365,6 +390,39 @@ func (s *StockImportService) buildWarehousePricelistItems() ([]pricelistsvc.Crea
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
}
if strings.TrimSpace(prev.Description) == "" && strings.TrimSpace(candidate.Description) != "" {
prev.Description = candidate.Description
}
if prev.Reason != "conflict" && candidate.Reason == "conflict" {
prev.Reason = "conflict"
}
return prev
}
func (s *StockImportService) ListMappings(page, perPage int, search string) ([]models.LotPartnumber, int64, error) {
if s.db == nil {
return nil, 0, fmt.Errorf("offline mode: mappings unavailable")
@@ -669,7 +727,8 @@ func parseMXLRows(content []byte) ([]stockImportRow, error) {
if err != nil {
continue
}
qty, err := parseLocalizedFloat(r[6])
qtyRaw := strings.TrimSpace(r[6])
qty, err := parseLocalizedQty(qtyRaw)
if err != nil {
qty = 0
}
@@ -680,6 +739,8 @@ func parseMXLRows(content []byte) ([]stockImportRow, error) {
Vendor: strings.TrimSpace(r[4]),
Price: price,
Qty: qty,
QtyRaw: qtyRaw,
QtyInvalid: err != nil,
})
}
return result, nil
@@ -767,6 +828,9 @@ func parseXLSXRows(content []byte) ([]stockImportRow, error) {
idxVendor, hasVendor := headers["вендор"]
idxPrice := headers["стоимость"]
idxQty, hasQty := headers["свободно"]
if !hasQty {
return nil, fmt.Errorf("xlsx parsing failed: qty column 'Свободно' not found")
}
for i := headerRow + 1; i < len(grid); i++ {
row := grid[i]
article := strings.TrimSpace(row[idxArticle])
@@ -778,10 +842,14 @@ func parseXLSXRows(content []byte) ([]stockImportRow, error) {
continue
}
qty := 0.0
qtyRaw := ""
qtyInvalid := false
if hasQty {
qty, err = parseLocalizedFloat(row[idxQty])
qtyRaw = strings.TrimSpace(row[idxQty])
qty, err = parseLocalizedQty(qtyRaw)
if err != nil {
qty = 0
qtyInvalid = true
}
}
@@ -805,6 +873,8 @@ func parseXLSXRows(content []byte) ([]stockImportRow, error) {
Vendor: vendor,
Price: price,
Qty: qty,
QtyRaw: qtyRaw,
QtyInvalid: qtyInvalid,
})
}
return result, nil
@@ -821,6 +891,23 @@ func parseLocalizedFloat(value string) (float64, error) {
return strconv.ParseFloat(clean, 64)
}
func parseLocalizedQty(value string) (float64, error) {
clean := strings.TrimSpace(value)
if clean == "" {
return 0, fmt.Errorf("empty qty")
}
if v, err := parseLocalizedFloat(clean); err == nil {
return v, nil
}
// Tolerate strings like "1 200 шт" by extracting the first numeric token.
re := regexp.MustCompile(`[-+]?\d[\d\s\u00a0]*(?:[.,]\d+)?`)
match := re.FindString(clean)
if strings.TrimSpace(match) == "" {
return 0, fmt.Errorf("invalid qty: %s", value)
}
return parseLocalizedFloat(match)
}
func detectImportDate(content []byte, filename string, fileModTime time.Time) time.Time {
if d, ok := extractDateFromText(string(content)); ok {
return d
@@ -885,6 +972,54 @@ func median(values []float64) float64 {
return c[n/2]
}
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)
w := v.weight
if w > 0 {
items = append(items, pair{price: v.price, weight: w})
totalWeight += w
}
}
// Fallback for rows without positive weights.
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
}
type lotResolver struct {
partnumberToLots map[string][]string
exactLots map[string]string

View File

@@ -47,6 +47,35 @@ func TestParseMXLRows(t *testing.T) {
}
}
func TestParseMXLRows_EmptyQtyMarkedInvalid(t *testing.T) {
content := strings.Join([]string{
`MOXCEL`,
`{16,2,{1,1,{"ru","Папка"}},0},1,`,
`{16,2,{1,1,{"ru","Артикул"}},0},2,`,
`{16,2,{1,1,{"ru","Описание"}},0},3,`,
`{16,2,{1,1,{"ru","Вендор"}},0},4,`,
`{16,2,{1,1,{"ru","Стоимость"}},0},5,`,
`{16,2,{1,1,{"ru","Свободно"}},0},6,`,
`{16,2,{1,1,{"ru","Серверы"}},0},1,`,
`{16,2,{1,1,{"ru","CPU_X"}},0},2,`,
`{16,2,{1,1,{"ru","Процессор"}},0},3,`,
`{16,2,{1,1,{"ru","AMD"}},0},4,`,
`{16,2,{1,1,{"ru","125,50"}},0},5,`,
`{16,2,{1,1,{"ru",""}},0},6,`,
}, "\n")
rows, err := parseMXLRows([]byte(content))
if err != nil {
t.Fatalf("parseMXLRows: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if !rows[0].QtyInvalid {
t.Fatalf("expected QtyInvalid=true for empty qty")
}
}
func TestParseXLSXRows(t *testing.T) {
xlsx := buildMinimalXLSX(t, []string{
"Папка", "Артикул", "Описание", "Вендор", "Стоимость", "Свободно",
@@ -114,9 +143,9 @@ func TestImportNoValidRowsKeepsStockLog(t *testing.T) {
}
existing := models.StockLog{
Lot: "CPU_A",
Date: time.Now(),
Price: 10,
Partnumber: "CPU_A",
Date: time.Now(),
Price: 10,
}
if err := db.Create(&existing).Error; err != nil {
t.Fatalf("seed stock_log: %v", err)
@@ -152,14 +181,14 @@ func TestReplaceStockLogs(t *testing.T) {
t.Fatalf("automigrate stock_log: %v", err)
}
if err := db.Create(&models.StockLog{Lot: "OLD", Date: time.Now(), Price: 1}).Error; err != nil {
if err := db.Create(&models.StockLog{Partnumber: "OLD", Date: time.Now(), Price: 1}).Error; err != nil {
t.Fatalf("seed old row: %v", err)
}
svc := NewStockImportService(db, nil)
records := []models.StockLog{
{Lot: "NEW_1", Date: time.Now(), Price: 2},
{Lot: "NEW_2", Date: time.Now(), Price: 3},
{Partnumber: "NEW_1", Date: time.Now(), Price: 2},
{Partnumber: "NEW_2", Date: time.Now(), Price: 3},
}
deleted, inserted, err := svc.replaceStockLogs(records)
@@ -171,14 +200,73 @@ func TestReplaceStockLogs(t *testing.T) {
}
var rows []models.StockLog
if err := db.Order("lot").Find(&rows).Error; err != nil {
if err := db.Order("partnumber").Find(&rows).Error; err != nil {
t.Fatalf("read rows: %v", err)
}
if len(rows) != 2 || rows[0].Lot != "NEW_1" || rows[1].Lot != "NEW_2" {
if len(rows) != 2 || rows[0].Partnumber != "NEW_1" || rows[1].Partnumber != "NEW_2" {
t.Fatalf("unexpected rows after replace: %#v", rows)
}
}
func TestWeightedMedian(t *testing.T) {
got := weightedMedian([]weightedPricePoint{
{price: 10, weight: 1},
{price: 20, weight: 3},
{price: 50, weight: 1},
})
if got != 20 {
t.Fatalf("expected weighted median 20, got %v", got)
}
}
func TestWeightedMedianFallbackToMedianWhenNoWeights(t *testing.T) {
got := weightedMedian([]weightedPricePoint{
{price: 10, weight: 0},
{price: 20, weight: 0},
{price: 30, weight: 0},
})
if got != 20 {
t.Fatalf("expected fallback median 20, got %v", got)
}
}
func TestBuildWarehousePricelistItems_UsesPrefixResolver(t *testing.T) {
db := openTestDB(t)
if err := db.AutoMigrate(&models.StockLog{}, &models.Lot{}, &models.LotPartnumber{}); err != nil {
t.Fatalf("automigrate: %v", err)
}
if err := db.Create(&models.Lot{LotName: "CPU_A"}).Error; err != nil {
t.Fatalf("seed lot: %v", err)
}
qty1 := 3.0
qty2 := 1.0
now := time.Now()
rows := []models.StockLog{
{Partnumber: "CPU_A-001", Date: now, Price: 100, Qty: &qty1},
{Partnumber: "CPU_A-XYZ", Date: now, Price: 120, Qty: &qty2},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatalf("seed stock_log: %v", err)
}
svc := NewStockImportService(db, nil)
items, err := svc.buildWarehousePricelistItems()
if err != nil {
t.Fatalf("buildWarehousePricelistItems: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
if items[0].LotName != "CPU_A" {
t.Fatalf("expected lot CPU_A, got %s", items[0].LotName)
}
if items[0].Price != 100 {
t.Fatalf("expected weighted median 100, got %v", items[0].Price)
}
}
func openTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})

View File

@@ -349,6 +349,10 @@ CREATE TABLE qt_configurations (
is_template INTEGER NOT NULL DEFAULT 0,
server_count INTEGER NOT NULL DEFAULT 1,
pricelist_id INTEGER NULL,
warehouse_pricelist_id INTEGER NULL,
competitor_pricelist_id INTEGER NULL,
disable_price_refresh INTEGER NOT NULL DEFAULT 0,
only_in_stock INTEGER NOT NULL DEFAULT 0,
price_updated_at DATETIME NULL,
created_at DATETIME
);`).Error; err != nil {