Implement warehouse/lot pricing updates and configurator performance fixes
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -26,7 +27,8 @@ func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary
|
||||
|
||||
// ListBySource returns pricelists filtered by source when provided.
|
||||
func (r *PricelistRepository) ListBySource(source string, offset, limit int) ([]models.PricelistSummary, int64, error) {
|
||||
query := r.db.Model(&models.Pricelist{})
|
||||
query := r.db.Model(&models.Pricelist{}).
|
||||
Where("EXISTS (SELECT 1 FROM qt_pricelist_items WHERE qt_pricelist_items.pricelist_id = qt_pricelists.id)")
|
||||
if source != "" {
|
||||
query = query.Where("source = ?", source)
|
||||
}
|
||||
@@ -51,7 +53,9 @@ func (r *PricelistRepository) ListActive(offset, limit int) ([]models.PricelistS
|
||||
|
||||
// ListActiveBySource returns active pricelists filtered by source when provided.
|
||||
func (r *PricelistRepository) ListActiveBySource(source string, offset, limit int) ([]models.PricelistSummary, int64, error) {
|
||||
query := r.db.Model(&models.Pricelist{}).Where("is_active = ?", true)
|
||||
query := r.db.Model(&models.Pricelist{}).
|
||||
Where("is_active = ?", true).
|
||||
Where("EXISTS (SELECT 1 FROM qt_pricelist_items WHERE qt_pricelist_items.pricelist_id = qt_pricelists.id)")
|
||||
if source != "" {
|
||||
query = query.Where("source = ?", source)
|
||||
}
|
||||
@@ -250,6 +254,19 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// GetLotNames returns distinct lot names from pricelist items.
|
||||
func (r *PricelistRepository) GetLotNames(pricelistID uint) ([]string, error) {
|
||||
var lotNames []string
|
||||
if err := r.db.Model(&models.PricelistItem{}).
|
||||
Where("pricelist_id = ?", pricelistID).
|
||||
Distinct("lot_name").
|
||||
Order("lot_name ASC").
|
||||
Pluck("lot_name", &lotNames).Error; err != nil {
|
||||
return nil, fmt.Errorf("listing pricelist lot names: %w", err)
|
||||
}
|
||||
return lotNames, nil
|
||||
}
|
||||
|
||||
func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
@@ -271,21 +288,36 @@ func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem)
|
||||
return nil
|
||||
}
|
||||
|
||||
type lotQty struct {
|
||||
Lot string
|
||||
Qty float64
|
||||
lotSet := make(map[string]struct{}, len(lots))
|
||||
for _, lot := range lots {
|
||||
lotSet[lot] = struct{}{}
|
||||
}
|
||||
var qtyRows []lotQty
|
||||
if err := r.db.Model(&models.StockLog{}).
|
||||
Select("lot, COALESCE(SUM(qty), 0) AS qty").
|
||||
Where("lot IN ?", lots).
|
||||
Group("lot").
|
||||
Scan(&qtyRows).Error; err != nil {
|
||||
|
||||
resolver, err := r.newWarehouseLotResolver()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
qtyByLot := make(map[string]float64, len(qtyRows))
|
||||
for _, row := range qtyRows {
|
||||
qtyByLot[row.Lot] = row.Qty
|
||||
|
||||
var logs []struct {
|
||||
Partnumber string `gorm:"column:partnumber"`
|
||||
Qty *float64 `gorm:"column:qty"`
|
||||
}
|
||||
if err := r.db.Model(&models.StockLog{}).Select("partnumber, qty").Find(&logs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
qtyByLot := make(map[string]float64, len(lots))
|
||||
for _, row := range logs {
|
||||
if row.Qty == nil {
|
||||
continue
|
||||
}
|
||||
lot, err := resolver.resolve(row.Partnumber)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := lotSet[lot]; !ok {
|
||||
continue
|
||||
}
|
||||
qtyByLot[lot] += *row.Qty
|
||||
}
|
||||
|
||||
var mappings []models.LotPartnumber
|
||||
@@ -320,6 +352,131 @@ func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem)
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
errWarehouseResolveConflict = errors.New("multiple lot matches")
|
||||
errWarehouseResolveNotFound = errors.New("lot not found")
|
||||
)
|
||||
|
||||
type warehouseLotResolver struct {
|
||||
partnumberToLots map[string][]string
|
||||
exactLots map[string]string
|
||||
allLots []string
|
||||
}
|
||||
|
||||
func (r *PricelistRepository) newWarehouseLotResolver() (*warehouseLotResolver, error) {
|
||||
var mappings []models.LotPartnumber
|
||||
if err := r.db.Find(&mappings).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
partnumberToLots := make(map[string][]string, len(mappings))
|
||||
for _, m := range mappings {
|
||||
pn := normalizeWarehouseResolverKey(m.Partnumber)
|
||||
lot := strings.TrimSpace(m.LotName)
|
||||
if pn == "" || lot == "" {
|
||||
continue
|
||||
}
|
||||
partnumberToLots[pn] = append(partnumberToLots[pn], lot)
|
||||
}
|
||||
for key, vals := range partnumberToLots {
|
||||
partnumberToLots[key] = uniqueWarehouseStrings(vals)
|
||||
}
|
||||
|
||||
var allLotsRows []models.Lot
|
||||
if err := r.db.Select("lot_name").Find(&allLotsRows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
exactLots := make(map[string]string, len(allLotsRows))
|
||||
allLots := make([]string, 0, len(allLotsRows))
|
||||
for _, row := range allLotsRows {
|
||||
lot := strings.TrimSpace(row.LotName)
|
||||
if lot == "" {
|
||||
continue
|
||||
}
|
||||
exactLots[normalizeWarehouseResolverKey(lot)] = lot
|
||||
allLots = append(allLots, lot)
|
||||
}
|
||||
sort.Slice(allLots, func(i, j int) bool {
|
||||
li := len([]rune(allLots[i]))
|
||||
lj := len([]rune(allLots[j]))
|
||||
if li == lj {
|
||||
return allLots[i] < allLots[j]
|
||||
}
|
||||
return li > lj
|
||||
})
|
||||
|
||||
return &warehouseLotResolver{
|
||||
partnumberToLots: partnumberToLots,
|
||||
exactLots: exactLots,
|
||||
allLots: allLots,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *warehouseLotResolver) resolve(partnumber string) (string, error) {
|
||||
key := normalizeWarehouseResolverKey(partnumber)
|
||||
if key == "" {
|
||||
return "", errWarehouseResolveNotFound
|
||||
}
|
||||
|
||||
if mapped := r.partnumberToLots[key]; len(mapped) > 0 {
|
||||
if len(mapped) == 1 {
|
||||
return mapped[0], nil
|
||||
}
|
||||
return "", errWarehouseResolveConflict
|
||||
}
|
||||
if exact, ok := r.exactLots[key]; ok {
|
||||
return exact, nil
|
||||
}
|
||||
|
||||
best := ""
|
||||
bestLen := -1
|
||||
tie := false
|
||||
for _, lot := range r.allLots {
|
||||
lotKey := normalizeWarehouseResolverKey(lot)
|
||||
if lotKey == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(key, lotKey) {
|
||||
l := len([]rune(lotKey))
|
||||
if l > bestLen {
|
||||
best = lot
|
||||
bestLen = l
|
||||
tie = false
|
||||
} else if l == bestLen && !strings.EqualFold(best, lot) {
|
||||
tie = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if best == "" {
|
||||
return "", errWarehouseResolveNotFound
|
||||
}
|
||||
if tie {
|
||||
return "", errWarehouseResolveConflict
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
func normalizeWarehouseResolverKey(v string) string {
|
||||
return strings.ToLower(strings.TrimSpace(v))
|
||||
}
|
||||
|
||||
func uniqueWarehouseStrings(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
out := make([]string, 0, len(values))
|
||||
for _, v := range values {
|
||||
n := strings.TrimSpace(v)
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
k := strings.ToLower(n)
|
||||
if _, ok := seen[k]; ok {
|
||||
continue
|
||||
}
|
||||
seen[k] = struct{}{}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetPriceForLot returns item price for a lot within a pricelist.
|
||||
func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
|
||||
var item models.PricelistItem
|
||||
@@ -329,6 +486,28 @@ func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (
|
||||
return item.Price, nil
|
||||
}
|
||||
|
||||
// GetPricesForLots returns price map for given lots within a pricelist.
|
||||
func (r *PricelistRepository) GetPricesForLots(pricelistID uint, lotNames []string) (map[string]float64, error) {
|
||||
result := make(map[string]float64, len(lotNames))
|
||||
if pricelistID == 0 || len(lotNames) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var rows []models.PricelistItem
|
||||
if err := r.db.Select("lot_name, price").
|
||||
Where("pricelist_id = ? AND lot_name IN ?", pricelistID, lotNames).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
if row.Price > 0 {
|
||||
result[row.LotName] = row.Price
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SetActive toggles active flag on a pricelist.
|
||||
func (r *PricelistRepository) SetActive(id uint, isActive bool) error {
|
||||
return r.db.Model(&models.Pricelist{}).Where("id = ?", id).Update("is_active", isActive).Error
|
||||
|
||||
@@ -75,6 +75,57 @@ func TestGenerateVersion_IsolatedBySource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetItems_WarehouseAvailableQtyUsesPrefixResolver(t *testing.T) {
|
||||
repo := newTestPricelistRepository(t)
|
||||
db := repo.db
|
||||
|
||||
warehouse := models.Pricelist{
|
||||
Source: string(models.PricelistSourceWarehouse),
|
||||
Version: "S-2026-02-07-001",
|
||||
CreatedBy: "test",
|
||||
IsActive: true,
|
||||
}
|
||||
if err := db.Create(&warehouse).Error; err != nil {
|
||||
t.Fatalf("create pricelist: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.PricelistItem{
|
||||
PricelistID: warehouse.ID,
|
||||
LotName: "SSD_NVME_03.2T",
|
||||
Price: 100,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create pricelist item: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.Lot{LotName: "SSD_NVME_03.2T"}).Error; err != nil {
|
||||
t.Fatalf("create lot: %v", err)
|
||||
}
|
||||
qty := 5.0
|
||||
if err := db.Create(&models.StockLog{
|
||||
Partnumber: "SSD_NVME_03.2T_GEN3_P4610",
|
||||
Date: time.Now(),
|
||||
Price: 200,
|
||||
Qty: &qty,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create stock log: %v", err)
|
||||
}
|
||||
|
||||
items, total, err := repo.GetItems(warehouse.ID, 0, 20, "")
|
||||
if err != nil {
|
||||
t.Fatalf("GetItems: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Fatalf("expected total=1, got %d", total)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 item, got %d", len(items))
|
||||
}
|
||||
if items[0].AvailableQty == nil {
|
||||
t.Fatalf("expected available qty to be set")
|
||||
}
|
||||
if *items[0].AvailableQty != 5 {
|
||||
t.Fatalf("expected available qty=5, got %v", *items[0].AvailableQty)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestPricelistRepository(t *testing.T) *PricelistRepository {
|
||||
t.Helper()
|
||||
|
||||
@@ -82,7 +133,7 @@ func newTestPricelistRepository(t *testing.T) *PricelistRepository {
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Pricelist{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}, &models.Lot{}, &models.LotPartnumber{}, &models.StockLog{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return NewPricelistRepository(db)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user