Implement warehouse/lot pricing updates and configurator performance fixes
This commit is contained in:
@@ -720,6 +720,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
pricelists.GET("/latest", pricelistHandler.GetLatest)
|
pricelists.GET("/latest", pricelistHandler.GetLatest)
|
||||||
pricelists.GET("/:id", pricelistHandler.Get)
|
pricelists.GET("/:id", pricelistHandler.Get)
|
||||||
pricelists.GET("/:id/items", pricelistHandler.GetItems)
|
pricelists.GET("/:id/items", pricelistHandler.GetItems)
|
||||||
|
pricelists.GET("/:id/lots", pricelistHandler.GetLotNames)
|
||||||
pricelists.POST("", pricelistHandler.Create)
|
pricelists.POST("", pricelistHandler.Create)
|
||||||
pricelists.POST("/create-with-progress", pricelistHandler.CreateWithProgress)
|
pricelists.POST("/create-with-progress", pricelistHandler.CreateWithProgress)
|
||||||
pricelists.PATCH("/:id/active", pricelistHandler.SetActive)
|
pricelists.PATCH("/:id/active", pricelistHandler.SetActive)
|
||||||
@@ -1327,6 +1328,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
pricingAdmin.POST("/preview", pricingHandler.PreviewPrice)
|
pricingAdmin.POST("/preview", pricingHandler.PreviewPrice)
|
||||||
pricingAdmin.POST("/recalculate-all", pricingHandler.RecalculateAll)
|
pricingAdmin.POST("/recalculate-all", pricingHandler.RecalculateAll)
|
||||||
pricingAdmin.GET("/lots", pricingHandler.ListLots)
|
pricingAdmin.GET("/lots", pricingHandler.ListLots)
|
||||||
|
pricingAdmin.GET("/lots-table", pricingHandler.ListLotsTable)
|
||||||
pricingAdmin.POST("/stock/import", pricingHandler.ImportStockLog)
|
pricingAdmin.POST("/stock/import", pricingHandler.ImportStockLog)
|
||||||
pricingAdmin.GET("/stock/mappings", pricingHandler.ListStockMappings)
|
pricingAdmin.GET("/stock/mappings", pricingHandler.ListStockMappings)
|
||||||
pricingAdmin.POST("/stock/mappings", pricingHandler.UpsertStockMapping)
|
pricingAdmin.POST("/stock/mappings", pricingHandler.UpsertStockMapping)
|
||||||
|
|||||||
@@ -45,8 +45,15 @@ func (h *PricelistHandler) List(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If offline (empty list), fallback to local pricelists
|
isOffline := false
|
||||||
if total == 0 && h.localDB != nil {
|
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()
|
localPLs, err := h.localDB.GetLocalPricelists()
|
||||||
if err == nil && len(localPLs) > 0 {
|
if err == nil && len(localPLs) > 0 {
|
||||||
if source != "" {
|
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
|
// CanWrite returns whether the current user can create pricelists
|
||||||
func (h *PricelistHandler) CanWrite(c *gin.Context) {
|
func (h *PricelistHandler) CanWrite(c *gin.Context) {
|
||||||
canWrite, debugInfo := h.service.CanWriteDebug()
|
canWrite, debugInfo := h.service.CanWriteDebug()
|
||||||
|
|||||||
@@ -997,6 +997,7 @@ func (h *PricingHandler) ImportStockLog(c *gin.Context) {
|
|||||||
"conflicts": result.Conflicts,
|
"conflicts": result.Conflicts,
|
||||||
"fallback_matches": result.FallbackMatches,
|
"fallback_matches": result.FallbackMatches,
|
||||||
"parse_errors": result.ParseErrors,
|
"parse_errors": result.ParseErrors,
|
||||||
|
"qty_parse_errors": result.QtyParseErrors,
|
||||||
"ignored": result.Ignored,
|
"ignored": result.Ignored,
|
||||||
"mapping_suggestions": result.MappingSuggestions,
|
"mapping_suggestions": result.MappingSuggestions,
|
||||||
"import_date": result.ImportDate.Format("2006-01-02"),
|
"import_date": result.ImportDate.Format("2006-01-02"),
|
||||||
@@ -1031,6 +1032,7 @@ func (h *PricingHandler) ImportStockLog(c *gin.Context) {
|
|||||||
"conflicts": p.Conflicts,
|
"conflicts": p.Conflicts,
|
||||||
"fallback_matches": p.FallbackMatches,
|
"fallback_matches": p.FallbackMatches,
|
||||||
"parse_errors": p.ParseErrors,
|
"parse_errors": p.ParseErrors,
|
||||||
|
"qty_parse_errors": p.QtyParseErrors,
|
||||||
"ignored": p.Ignored,
|
"ignored": p.Ignored,
|
||||||
"mapping_suggestions": p.MappingSuggestions,
|
"mapping_suggestions": p.MappingSuggestions,
|
||||||
"import_date": p.ImportDate,
|
"import_date": p.ImportDate,
|
||||||
@@ -1183,6 +1185,231 @@ func (h *PricingHandler) DeleteStockIgnoreRule(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"deleted": deleted})
|
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) {
|
func (h *PricingHandler) ListLots(c *gin.Context) {
|
||||||
if h.db == nil {
|
if h.db == nil {
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"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 {
|
type SyncInfoResponse struct {
|
||||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
// Connection
|
||||||
|
DBHost string `json:"db_host"`
|
||||||
|
DBUser string `json:"db_user"`
|
||||||
|
DBName string `json:"db_name"`
|
||||||
|
|
||||||
|
// Status
|
||||||
IsOnline bool `json:"is_online"`
|
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"`
|
ErrorCount int `json:"error_count"`
|
||||||
Errors []SyncError `json:"errors,omitempty"`
|
Errors []SyncError `json:"errors,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -392,31 +410,44 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
|
|||||||
// Check online status by pinging MariaDB
|
// Check online status by pinging MariaDB
|
||||||
isOnline := h.checkOnline()
|
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
|
// Get sync times
|
||||||
lastPricelistSync := h.localDB.GetLastSyncTime()
|
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 != "")
|
// Get error count (only changes with LastError != "")
|
||||||
errorCount := int(h.localDB.CountErroredChanges())
|
errorCount := int(h.localDB.CountErroredChanges())
|
||||||
|
|
||||||
// Get recent errors (last 10)
|
// Get pending changes
|
||||||
changes, err := h.localDB.GetPendingChanges()
|
changes, err := h.localDB.GetPendingChanges()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to get pending changes for sync info", "error", err)
|
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
|
changes = []localdb.PendingChange{}
|
||||||
c.JSON(http.StatusOK, SyncInfoResponse{
|
|
||||||
LastSyncAt: lastPricelistSync,
|
|
||||||
IsOnline: isOnline,
|
|
||||||
ErrorCount: errorCount,
|
|
||||||
Errors: []SyncError{}, // Return empty errors list
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var errors []SyncError
|
var syncErrors []SyncError
|
||||||
for _, change := range changes {
|
for _, change := range changes {
|
||||||
// Check if there's a last error and it's not empty
|
|
||||||
if change.LastError != "" {
|
if change.LastError != "" {
|
||||||
errors = append(errors, SyncError{
|
syncErrors = append(syncErrors, SyncError{
|
||||||
Timestamp: change.CreatedAt,
|
Timestamp: change.CreatedAt,
|
||||||
Message: change.LastError,
|
Message: change.LastError,
|
||||||
})
|
})
|
||||||
@@ -424,15 +455,23 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Limit to last 10 errors
|
// Limit to last 10 errors
|
||||||
if len(errors) > 10 {
|
if len(syncErrors) > 10 {
|
||||||
errors = errors[:10]
|
syncErrors = syncErrors[:10]
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, SyncInfoResponse{
|
c.JSON(http.StatusOK, SyncInfoResponse{
|
||||||
LastSyncAt: lastPricelistSync,
|
DBHost: dbHost,
|
||||||
|
DBUser: dbUser,
|
||||||
|
DBName: dbName,
|
||||||
IsOnline: isOnline,
|
IsOnline: isOnline,
|
||||||
|
LastSyncAt: lastPricelistSync,
|
||||||
|
LotCount: lotCount,
|
||||||
|
LotLogCount: lotLogCount,
|
||||||
|
ConfigCount: configCount,
|
||||||
|
ProjectCount: projectCount,
|
||||||
|
PendingChanges: changes,
|
||||||
ErrorCount: errorCount,
|
ErrorCount: errorCount,
|
||||||
Errors: errors,
|
Errors: syncErrors,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
|||||||
IsTemplate: cfg.IsTemplate,
|
IsTemplate: cfg.IsTemplate,
|
||||||
ServerCount: cfg.ServerCount,
|
ServerCount: cfg.ServerCount,
|
||||||
PricelistID: cfg.PricelistID,
|
PricelistID: cfg.PricelistID,
|
||||||
|
OnlyInStock: cfg.OnlyInStock,
|
||||||
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
||||||
CreatedAt: cfg.CreatedAt,
|
CreatedAt: cfg.CreatedAt,
|
||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
@@ -72,6 +73,7 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
|||||||
IsTemplate: local.IsTemplate,
|
IsTemplate: local.IsTemplate,
|
||||||
ServerCount: local.ServerCount,
|
ServerCount: local.ServerCount,
|
||||||
PricelistID: local.PricelistID,
|
PricelistID: local.PricelistID,
|
||||||
|
OnlyInStock: local.OnlyInStock,
|
||||||
PriceUpdatedAt: local.PriceUpdatedAt,
|
PriceUpdatedAt: local.PriceUpdatedAt,
|
||||||
CreatedAt: local.CreatedAt,
|
CreatedAt: local.CreatedAt,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -483,6 +483,13 @@ func (l *LocalDB) CountConfigurations() int64 {
|
|||||||
return count
|
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
|
// Pricelist methods
|
||||||
|
|
||||||
// GetLastSyncTime returns the last sync timestamp
|
// GetLastSyncTime returns the last sync timestamp
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ type LocalConfiguration struct {
|
|||||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||||
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
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"`
|
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
|
|||||||
"is_template": localCfg.IsTemplate,
|
"is_template": localCfg.IsTemplate,
|
||||||
"server_count": localCfg.ServerCount,
|
"server_count": localCfg.ServerCount,
|
||||||
"pricelist_id": localCfg.PricelistID,
|
"pricelist_id": localCfg.PricelistID,
|
||||||
|
"only_in_stock": localCfg.OnlyInStock,
|
||||||
"price_updated_at": localCfg.PriceUpdatedAt,
|
"price_updated_at": localCfg.PriceUpdatedAt,
|
||||||
"created_at": localCfg.CreatedAt,
|
"created_at": localCfg.CreatedAt,
|
||||||
"updated_at": localCfg.UpdatedAt,
|
"updated_at": localCfg.UpdatedAt,
|
||||||
@@ -52,6 +53,7 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
|||||||
IsTemplate bool `json:"is_template"`
|
IsTemplate bool `json:"is_template"`
|
||||||
ServerCount int `json:"server_count"`
|
ServerCount int `json:"server_count"`
|
||||||
PricelistID *uint `json:"pricelist_id"`
|
PricelistID *uint `json:"pricelist_id"`
|
||||||
|
OnlyInStock bool `json:"only_in_stock"`
|
||||||
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
||||||
OriginalUserID uint `json:"original_user_id"`
|
OriginalUserID uint `json:"original_user_id"`
|
||||||
OriginalUsername string `json:"original_username"`
|
OriginalUsername string `json:"original_username"`
|
||||||
@@ -77,6 +79,7 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
|||||||
IsTemplate: snapshot.IsTemplate,
|
IsTemplate: snapshot.IsTemplate,
|
||||||
ServerCount: snapshot.ServerCount,
|
ServerCount: snapshot.ServerCount,
|
||||||
PricelistID: snapshot.PricelistID,
|
PricelistID: snapshot.PricelistID,
|
||||||
|
OnlyInStock: snapshot.OnlyInStock,
|
||||||
PriceUpdatedAt: snapshot.PriceUpdatedAt,
|
PriceUpdatedAt: snapshot.PriceUpdatedAt,
|
||||||
OriginalUserID: snapshot.OriginalUserID,
|
OriginalUserID: snapshot.OriginalUserID,
|
||||||
OriginalUsername: snapshot.OriginalUsername,
|
OriginalUsername: snapshot.OriginalUsername,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ type Configuration struct {
|
|||||||
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
||||||
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
||||||
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
|
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"`
|
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func (Supplier) TableName() string {
|
|||||||
// StockLog stores warehouse stock snapshots imported from external files.
|
// StockLog stores warehouse stock snapshots imported from external files.
|
||||||
type StockLog struct {
|
type StockLog struct {
|
||||||
StockLogID uint `gorm:"column:stock_log_id;primaryKey;autoIncrement"`
|
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"`
|
Supplier *string `gorm:"column:supplier;size:255"`
|
||||||
Date time.Time `gorm:"column:date;type:date;not null"`
|
Date time.Time `gorm:"column:date;type:date;not null"`
|
||||||
Price float64 `gorm:"column:price;not null"`
|
Price float64 `gorm:"column:price;not null"`
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -26,7 +27,8 @@ func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary
|
|||||||
|
|
||||||
// ListBySource returns pricelists filtered by source when provided.
|
// ListBySource returns pricelists filtered by source when provided.
|
||||||
func (r *PricelistRepository) ListBySource(source string, offset, limit int) ([]models.PricelistSummary, int64, error) {
|
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 != "" {
|
if source != "" {
|
||||||
query = query.Where("source = ?", 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.
|
// ListActiveBySource returns active pricelists filtered by source when provided.
|
||||||
func (r *PricelistRepository) ListActiveBySource(source string, offset, limit int) ([]models.PricelistSummary, int64, error) {
|
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 != "" {
|
if source != "" {
|
||||||
query = query.Where("source = ?", source)
|
query = query.Where("source = ?", source)
|
||||||
}
|
}
|
||||||
@@ -250,6 +254,19 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
|
|||||||
return items, total, nil
|
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 {
|
func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem) error {
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -271,21 +288,36 @@ func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type lotQty struct {
|
lotSet := make(map[string]struct{}, len(lots))
|
||||||
Lot string
|
for _, lot := range lots {
|
||||||
Qty float64
|
lotSet[lot] = struct{}{}
|
||||||
}
|
}
|
||||||
var qtyRows []lotQty
|
|
||||||
if err := r.db.Model(&models.StockLog{}).
|
resolver, err := r.newWarehouseLotResolver()
|
||||||
Select("lot, COALESCE(SUM(qty), 0) AS qty").
|
if err != nil {
|
||||||
Where("lot IN ?", lots).
|
|
||||||
Group("lot").
|
|
||||||
Scan(&qtyRows).Error; err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
qtyByLot := make(map[string]float64, len(qtyRows))
|
|
||||||
for _, row := range qtyRows {
|
var logs []struct {
|
||||||
qtyByLot[row.Lot] = row.Qty
|
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
|
var mappings []models.LotPartnumber
|
||||||
@@ -320,6 +352,131 @@ func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem)
|
|||||||
return nil
|
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.
|
// GetPriceForLot returns item price for a lot within a pricelist.
|
||||||
func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
|
func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
|
||||||
var item models.PricelistItem
|
var item models.PricelistItem
|
||||||
@@ -329,6 +486,28 @@ func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (
|
|||||||
return item.Price, nil
|
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.
|
// SetActive toggles active flag on a pricelist.
|
||||||
func (r *PricelistRepository) SetActive(id uint, isActive bool) error {
|
func (r *PricelistRepository) SetActive(id uint, isActive bool) error {
|
||||||
return r.db.Model(&models.Pricelist{}).Where("id = ?", id).Update("is_active", isActive).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 {
|
func newTestPricelistRepository(t *testing.T) *PricelistRepository {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -82,7 +133,7 @@ func newTestPricelistRepository(t *testing.T) *PricelistRepository {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open sqlite: %v", err)
|
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)
|
t.Fatalf("migrate: %v", err)
|
||||||
}
|
}
|
||||||
return NewPricelistRepository(db)
|
return NewPricelistRepository(db)
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ type CreateConfigRequest struct {
|
|||||||
IsTemplate bool `json:"is_template"`
|
IsTemplate bool `json:"is_template"`
|
||||||
ServerCount int `json:"server_count"`
|
ServerCount int `json:"server_count"`
|
||||||
PricelistID *uint `json:"pricelist_id,omitempty"`
|
PricelistID *uint `json:"pricelist_id,omitempty"`
|
||||||
|
OnlyInStock bool `json:"only_in_stock"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
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,
|
IsTemplate: req.IsTemplate,
|
||||||
ServerCount: req.ServerCount,
|
ServerCount: req.ServerCount,
|
||||||
PricelistID: pricelistID,
|
PricelistID: pricelistID,
|
||||||
|
OnlyInStock: req.OnlyInStock,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.configRepo.Create(config); err != nil {
|
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.IsTemplate = req.IsTemplate
|
||||||
config.ServerCount = req.ServerCount
|
config.ServerCount = req.ServerCount
|
||||||
config.PricelistID = pricelistID
|
config.PricelistID = pricelistID
|
||||||
|
config.OnlyInStock = req.OnlyInStock
|
||||||
|
|
||||||
if err := s.configRepo.Update(config); err != nil {
|
if err := s.configRepo.Update(config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -222,6 +225,7 @@ func (s *ConfigurationService) CloneToProject(configUUID string, ownerUsername s
|
|||||||
IsTemplate: false, // Clone is never a template
|
IsTemplate: false, // Clone is never a template
|
||||||
ServerCount: original.ServerCount,
|
ServerCount: original.ServerCount,
|
||||||
PricelistID: original.PricelistID,
|
PricelistID: original.PricelistID,
|
||||||
|
OnlyInStock: original.OnlyInStock,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.configRepo.Create(clone); err != nil {
|
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.IsTemplate = req.IsTemplate
|
||||||
config.ServerCount = req.ServerCount
|
config.ServerCount = req.ServerCount
|
||||||
config.PricelistID = pricelistID
|
config.PricelistID = pricelistID
|
||||||
|
config.OnlyInStock = req.OnlyInStock
|
||||||
|
|
||||||
if err := s.configRepo.Update(config); err != nil {
|
if err := s.configRepo.Update(config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -362,6 +367,7 @@ func (s *ConfigurationService) CloneNoAuthToProject(configUUID string, newName s
|
|||||||
IsTemplate: false,
|
IsTemplate: false,
|
||||||
ServerCount: original.ServerCount,
|
ServerCount: original.ServerCount,
|
||||||
PricelistID: original.PricelistID,
|
PricelistID: original.PricelistID,
|
||||||
|
OnlyInStock: original.OnlyInStock,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.configRepo.Create(clone); err != nil {
|
if err := s.configRepo.Create(clone); err != nil {
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
|||||||
IsTemplate: req.IsTemplate,
|
IsTemplate: req.IsTemplate,
|
||||||
ServerCount: req.ServerCount,
|
ServerCount: req.ServerCount,
|
||||||
PricelistID: pricelistID,
|
PricelistID: pricelistID,
|
||||||
|
OnlyInStock: req.OnlyInStock,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +164,7 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
|||||||
localCfg.IsTemplate = req.IsTemplate
|
localCfg.IsTemplate = req.IsTemplate
|
||||||
localCfg.ServerCount = req.ServerCount
|
localCfg.ServerCount = req.ServerCount
|
||||||
localCfg.PricelistID = pricelistID
|
localCfg.PricelistID = pricelistID
|
||||||
|
localCfg.OnlyInStock = req.OnlyInStock
|
||||||
localCfg.UpdatedAt = time.Now()
|
localCfg.UpdatedAt = time.Now()
|
||||||
localCfg.SyncStatus = "pending"
|
localCfg.SyncStatus = "pending"
|
||||||
|
|
||||||
@@ -268,6 +270,7 @@ func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsern
|
|||||||
IsTemplate: false,
|
IsTemplate: false,
|
||||||
ServerCount: original.ServerCount,
|
ServerCount: original.ServerCount,
|
||||||
PricelistID: original.PricelistID,
|
PricelistID: original.PricelistID,
|
||||||
|
OnlyInStock: original.OnlyInStock,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,6 +457,7 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
|||||||
localCfg.IsTemplate = req.IsTemplate
|
localCfg.IsTemplate = req.IsTemplate
|
||||||
localCfg.ServerCount = req.ServerCount
|
localCfg.ServerCount = req.ServerCount
|
||||||
localCfg.PricelistID = pricelistID
|
localCfg.PricelistID = pricelistID
|
||||||
|
localCfg.OnlyInStock = req.OnlyInStock
|
||||||
localCfg.UpdatedAt = time.Now()
|
localCfg.UpdatedAt = time.Now()
|
||||||
localCfg.SyncStatus = "pending"
|
localCfg.SyncStatus = "pending"
|
||||||
|
|
||||||
@@ -546,6 +550,7 @@ func (s *LocalConfigurationService) CloneNoAuthToProject(configUUID string, newN
|
|||||||
IsTemplate: false,
|
IsTemplate: false,
|
||||||
ServerCount: original.ServerCount,
|
ServerCount: original.ServerCount,
|
||||||
PricelistID: original.PricelistID,
|
PricelistID: original.PricelistID,
|
||||||
|
OnlyInStock: original.OnlyInStock,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1029,6 +1034,7 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
|
|||||||
current.IsTemplate = rollbackData.IsTemplate
|
current.IsTemplate = rollbackData.IsTemplate
|
||||||
current.ServerCount = rollbackData.ServerCount
|
current.ServerCount = rollbackData.ServerCount
|
||||||
current.PricelistID = rollbackData.PricelistID
|
current.PricelistID = rollbackData.PricelistID
|
||||||
|
current.OnlyInStock = rollbackData.OnlyInStock
|
||||||
current.PriceUpdatedAt = rollbackData.PriceUpdatedAt
|
current.PriceUpdatedAt = rollbackData.PriceUpdatedAt
|
||||||
current.UpdatedAt = time.Now()
|
current.UpdatedAt = time.Now()
|
||||||
current.SyncStatus = "pending"
|
current.SyncStatus = "pending"
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type CreateProgress struct {
|
|||||||
type CreateItemInput struct {
|
type CreateItemInput struct {
|
||||||
LotName string
|
LotName string
|
||||||
Price float64
|
Price float64
|
||||||
|
PriceMethod string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository, pricingSvc *pricing.Service) *Service {
|
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,
|
PricelistID: pricelist.ID,
|
||||||
LotName: strings.TrimSpace(srcItem.LotName),
|
LotName: strings.TrimSpace(srcItem.LotName),
|
||||||
Price: srcItem.Price,
|
Price: srcItem.Price,
|
||||||
|
PriceMethod: strings.TrimSpace(srcItem.PriceMethod),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} 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 {
|
if err := s.repo.CreateItems(items); err != nil {
|
||||||
// Clean up the pricelist if items creation fails
|
// Clean up the pricelist if items creation fails
|
||||||
s.repo.Delete(pricelist.ID)
|
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)
|
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
|
// Delete deletes a pricelist by ID
|
||||||
func (s *Service) Delete(id uint) error {
|
func (s *Service) Delete(id uint) error {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
@@ -21,6 +24,9 @@ type QuoteService struct {
|
|||||||
pricelistRepo *repository.PricelistRepository
|
pricelistRepo *repository.PricelistRepository
|
||||||
localDB *localdb.LocalDB
|
localDB *localdb.LocalDB
|
||||||
pricingService *pricing.Service
|
pricingService *pricing.Service
|
||||||
|
cacheMu sync.RWMutex
|
||||||
|
priceCache map[string]cachedLotPrice
|
||||||
|
cacheTTL time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewQuoteService(
|
func NewQuoteService(
|
||||||
@@ -36,9 +42,16 @@ func NewQuoteService(
|
|||||||
pricelistRepo: pricelistRepo,
|
pricelistRepo: pricelistRepo,
|
||||||
localDB: localDB,
|
localDB: localDB,
|
||||||
pricingService: pricingService,
|
pricingService: pricingService,
|
||||||
|
priceCache: make(map[string]cachedLotPrice, 4096),
|
||||||
|
cacheTTL: 10 * time.Second,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type cachedLotPrice struct {
|
||||||
|
price *float64
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type QuoteItem struct {
|
type QuoteItem struct {
|
||||||
LotName string `json:"lot_name"`
|
LotName string `json:"lot_name"`
|
||||||
Quantity int `json:"quantity"`
|
Quantity int `json:"quantity"`
|
||||||
@@ -70,6 +83,7 @@ type PriceLevelsRequest struct {
|
|||||||
Quantity int `json:"quantity"`
|
Quantity int `json:"quantity"`
|
||||||
} `json:"items"`
|
} `json:"items"`
|
||||||
PricelistIDs map[string]uint `json:"pricelist_ids,omitempty"`
|
PricelistIDs map[string]uint `json:"pricelist_ids,omitempty"`
|
||||||
|
NoCache bool `json:"no_cache,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PriceLevelsItem struct {
|
type PriceLevelsItem struct {
|
||||||
@@ -170,11 +184,55 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve
|
|||||||
return nil, ErrEmptyQuote
|
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{
|
result := &PriceLevelsResult{
|
||||||
Items: make([]PriceLevelsItem, 0, len(req.Items)),
|
Items: make([]PriceLevelsItem, 0, len(req.Items)),
|
||||||
ResolvedPricelistIDs: map[string]uint{},
|
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 {
|
for _, reqItem := range req.Items {
|
||||||
item := PriceLevelsItem{
|
item := PriceLevelsItem{
|
||||||
LotName: reqItem.LotName,
|
LotName: reqItem.LotName,
|
||||||
@@ -182,22 +240,17 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve
|
|||||||
PriceMissing: make([]string, 0, 3),
|
PriceMissing: make([]string, 0, 3),
|
||||||
}
|
}
|
||||||
|
|
||||||
estimatePrice, estimateID := s.lookupLevelPrice(models.PricelistSourceEstimate, reqItem.LotName, req.PricelistIDs)
|
if p, ok := levelBySource[models.PricelistSourceEstimate].prices[reqItem.LotName]; ok && p > 0 {
|
||||||
warehousePrice, warehouseID := s.lookupLevelPrice(models.PricelistSourceWarehouse, reqItem.LotName, req.PricelistIDs)
|
price := p
|
||||||
competitorPrice, competitorID := s.lookupLevelPrice(models.PricelistSourceCompetitor, reqItem.LotName, req.PricelistIDs)
|
item.EstimatePrice = &price
|
||||||
|
|
||||||
item.EstimatePrice = estimatePrice
|
|
||||||
item.WarehousePrice = warehousePrice
|
|
||||||
item.CompetitorPrice = competitorPrice
|
|
||||||
|
|
||||||
if estimateID != 0 {
|
|
||||||
result.ResolvedPricelistIDs[string(models.PricelistSourceEstimate)] = estimateID
|
|
||||||
}
|
}
|
||||||
if warehouseID != 0 {
|
if p, ok := levelBySource[models.PricelistSourceWarehouse].prices[reqItem.LotName]; ok && p > 0 {
|
||||||
result.ResolvedPricelistIDs[string(models.PricelistSourceWarehouse)] = warehouseID
|
price := p
|
||||||
|
item.WarehousePrice = &price
|
||||||
}
|
}
|
||||||
if competitorID != 0 {
|
if p, ok := levelBySource[models.PricelistSourceCompetitor].prices[reqItem.LotName]; ok && p > 0 {
|
||||||
result.ResolvedPricelistIDs[string(models.PricelistSourceCompetitor)] = competitorID
|
price := p
|
||||||
|
item.CompetitorPrice = &price
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.EstimatePrice == nil {
|
if item.EstimatePrice == nil {
|
||||||
@@ -220,6 +273,93 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve
|
|||||||
return result, nil
|
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) {
|
func calculateDelta(target, base *float64) (*float64, *float64) {
|
||||||
if target == nil || base == nil {
|
if target == nil || base == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type StockImportProgress struct {
|
|||||||
Conflicts int `json:"conflicts,omitempty"`
|
Conflicts int `json:"conflicts,omitempty"`
|
||||||
FallbackMatches int `json:"fallback_matches,omitempty"`
|
FallbackMatches int `json:"fallback_matches,omitempty"`
|
||||||
ParseErrors int `json:"parse_errors,omitempty"`
|
ParseErrors int `json:"parse_errors,omitempty"`
|
||||||
|
QtyParseErrors int `json:"qty_parse_errors,omitempty"`
|
||||||
Ignored int `json:"ignored,omitempty"`
|
Ignored int `json:"ignored,omitempty"`
|
||||||
MappingSuggestions []StockMappingSuggestion `json:"mapping_suggestions,omitempty"`
|
MappingSuggestions []StockMappingSuggestion `json:"mapping_suggestions,omitempty"`
|
||||||
ImportDate string `json:"import_date,omitempty"`
|
ImportDate string `json:"import_date,omitempty"`
|
||||||
@@ -49,6 +50,7 @@ type StockImportResult struct {
|
|||||||
Conflicts int
|
Conflicts int
|
||||||
FallbackMatches int
|
FallbackMatches int
|
||||||
ParseErrors int
|
ParseErrors int
|
||||||
|
QtyParseErrors int
|
||||||
Ignored int
|
Ignored int
|
||||||
MappingSuggestions []StockMappingSuggestion
|
MappingSuggestions []StockMappingSuggestion
|
||||||
ImportDate time.Time
|
ImportDate time.Time
|
||||||
@@ -87,6 +89,13 @@ type stockImportRow struct {
|
|||||||
Vendor string
|
Vendor string
|
||||||
Price float64
|
Price float64
|
||||||
Qty float64
|
Qty float64
|
||||||
|
QtyRaw string
|
||||||
|
QtyInvalid bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type weightedPricePoint struct {
|
||||||
|
price float64
|
||||||
|
weight float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *StockImportService) Import(
|
func (s *StockImportService) Import(
|
||||||
@@ -128,7 +137,7 @@ func (s *StockImportService) Import(
|
|||||||
Total: 100,
|
Total: 100,
|
||||||
})
|
})
|
||||||
|
|
||||||
resolver, err := s.newLotResolver()
|
partnumberMappings, err := s.loadPartnumberMappings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -139,6 +148,7 @@ func (s *StockImportService) Import(
|
|||||||
conflicts int
|
conflicts int
|
||||||
fallbackMatches int
|
fallbackMatches int
|
||||||
parseErrors int
|
parseErrors int
|
||||||
|
qtyParseErrors int
|
||||||
ignored int
|
ignored int
|
||||||
suggestionsByPN = make(map[string]StockMappingSuggestion)
|
suggestionsByPN = make(map[string]StockMappingSuggestion)
|
||||||
)
|
)
|
||||||
@@ -152,41 +162,32 @@ func (s *StockImportService) Import(
|
|||||||
parseErrors++
|
parseErrors++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if row.QtyInvalid {
|
||||||
|
qtyParseErrors++
|
||||||
|
parseErrors++
|
||||||
|
continue
|
||||||
|
}
|
||||||
if shouldIgnoreStockRow(row, ignoreRules) {
|
if shouldIgnoreStockRow(row, ignoreRules) {
|
||||||
ignored++
|
ignored++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
lot, matchType, resolveErr := resolver.resolve(row.Article)
|
partnumber := strings.TrimSpace(row.Article)
|
||||||
if resolveErr != nil {
|
key := normalizeKey(partnumber)
|
||||||
trimmedPN := strings.TrimSpace(row.Article)
|
mappedLots := partnumberMappings[key]
|
||||||
if trimmedPN != "" {
|
if len(mappedLots) == 0 {
|
||||||
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++
|
unmapped++
|
||||||
}
|
suggestionsByPN[key] = upsertSuggestion(suggestionsByPN[key], StockMappingSuggestion{
|
||||||
continue
|
Partnumber: partnumber,
|
||||||
}
|
Description: strings.TrimSpace(row.Description),
|
||||||
if matchType == "article_exact" || matchType == "prefix" {
|
Reason: "unmapped",
|
||||||
fallbackMatches++
|
})
|
||||||
|
} else if len(mappedLots) > 1 {
|
||||||
|
conflicts++
|
||||||
|
suggestionsByPN[key] = upsertSuggestion(suggestionsByPN[key], StockMappingSuggestion{
|
||||||
|
Partnumber: partnumber,
|
||||||
|
Description: strings.TrimSpace(row.Description),
|
||||||
|
Reason: "conflict",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
var comments *string
|
var comments *string
|
||||||
@@ -199,7 +200,7 @@ func (s *StockImportService) Import(
|
|||||||
}
|
}
|
||||||
qty := row.Qty
|
qty := row.Qty
|
||||||
records = append(records, models.StockLog{
|
records = append(records, models.StockLog{
|
||||||
Lot: lot,
|
Partnumber: partnumber,
|
||||||
Date: importDate,
|
Date: importDate,
|
||||||
Price: row.Price,
|
Price: row.Price,
|
||||||
Comments: comments,
|
Comments: comments,
|
||||||
@@ -211,18 +212,19 @@ func (s *StockImportService) Import(
|
|||||||
suggestions := collectSortedSuggestions(suggestionsByPN, 200)
|
suggestions := collectSortedSuggestions(suggestionsByPN, 200)
|
||||||
|
|
||||||
if len(records) == 0 {
|
if len(records) == 0 {
|
||||||
return nil, fmt.Errorf("no valid rows after mapping")
|
return nil, fmt.Errorf("no valid rows after filtering")
|
||||||
}
|
}
|
||||||
|
|
||||||
report(StockImportProgress{
|
report(StockImportProgress{
|
||||||
Status: "mapping",
|
Status: "mapping",
|
||||||
Message: "Сопоставление article -> lot завершено",
|
Message: "Валидация строк завершена",
|
||||||
RowsTotal: len(rows),
|
RowsTotal: len(rows),
|
||||||
ValidRows: len(records),
|
ValidRows: len(records),
|
||||||
Unmapped: unmapped,
|
Unmapped: unmapped,
|
||||||
Conflicts: conflicts,
|
Conflicts: conflicts,
|
||||||
FallbackMatches: fallbackMatches,
|
FallbackMatches: fallbackMatches,
|
||||||
ParseErrors: parseErrors,
|
ParseErrors: parseErrors,
|
||||||
|
QtyParseErrors: qtyParseErrors,
|
||||||
Current: 40,
|
Current: 40,
|
||||||
Total: 100,
|
Total: 100,
|
||||||
})
|
})
|
||||||
@@ -261,10 +263,14 @@ func (s *StockImportService) Import(
|
|||||||
return nil, fmt.Errorf("pricelist service unavailable")
|
return nil, fmt.Errorf("pricelist service unavailable")
|
||||||
}
|
}
|
||||||
pl, err := s.pricelistSvc.CreateForSourceWithProgress(createdBy, string(models.PricelistSourceWarehouse), items, func(p pricelistsvc.CreateProgress) {
|
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{
|
report(StockImportProgress{
|
||||||
Status: "recalculating_warehouse",
|
Status: "recalculating_warehouse",
|
||||||
Message: p.Message,
|
Message: p.Message,
|
||||||
Current: 70 + int(float64(p.Current)*0.3),
|
Current: current,
|
||||||
Total: 100,
|
Total: 100,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -283,6 +289,7 @@ func (s *StockImportService) Import(
|
|||||||
Conflicts: conflicts,
|
Conflicts: conflicts,
|
||||||
FallbackMatches: fallbackMatches,
|
FallbackMatches: fallbackMatches,
|
||||||
ParseErrors: parseErrors,
|
ParseErrors: parseErrors,
|
||||||
|
QtyParseErrors: qtyParseErrors,
|
||||||
Ignored: ignored,
|
Ignored: ignored,
|
||||||
MappingSuggestions: suggestions,
|
MappingSuggestions: suggestions,
|
||||||
ImportDate: importDate,
|
ImportDate: importDate,
|
||||||
@@ -301,6 +308,7 @@ func (s *StockImportService) Import(
|
|||||||
Conflicts: result.Conflicts,
|
Conflicts: result.Conflicts,
|
||||||
FallbackMatches: result.FallbackMatches,
|
FallbackMatches: result.FallbackMatches,
|
||||||
ParseErrors: result.ParseErrors,
|
ParseErrors: result.ParseErrors,
|
||||||
|
QtyParseErrors: result.QtyParseErrors,
|
||||||
Ignored: result.Ignored,
|
Ignored: result.Ignored,
|
||||||
MappingSuggestions: result.MappingSuggestions,
|
MappingSuggestions: result.MappingSuggestions,
|
||||||
ImportDate: result.ImportDate.Format("2006-01-02"),
|
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) {
|
func (s *StockImportService) buildWarehousePricelistItems() ([]pricelistsvc.CreateItemInput, error) {
|
||||||
var logs []models.StockLog
|
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
|
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 {
|
for _, l := range logs {
|
||||||
lot := strings.TrimSpace(l.Lot)
|
partnumber := strings.TrimSpace(l.Partnumber)
|
||||||
if lot == "" || l.Price <= 0 {
|
if partnumber == "" || l.Price <= 0 {
|
||||||
continue
|
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))
|
items := make([]pricelistsvc.CreateItemInput, 0, len(grouped))
|
||||||
for lot, prices := range grouped {
|
for lot, values := range grouped {
|
||||||
price := median(prices)
|
price := weightedMedian(values)
|
||||||
if price <= 0 {
|
if price <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
items = append(items, pricelistsvc.CreateItemInput{
|
items = append(items, pricelistsvc.CreateItemInput{
|
||||||
LotName: lot,
|
LotName: lot,
|
||||||
Price: price,
|
Price: price,
|
||||||
|
PriceMethod: "weighted_median",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
sort.Slice(items, func(i, j int) bool {
|
sort.Slice(items, func(i, j int) bool {
|
||||||
@@ -365,6 +390,39 @@ func (s *StockImportService) buildWarehousePricelistItems() ([]pricelistsvc.Crea
|
|||||||
return items, nil
|
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) {
|
func (s *StockImportService) ListMappings(page, perPage int, search string) ([]models.LotPartnumber, int64, error) {
|
||||||
if s.db == nil {
|
if s.db == nil {
|
||||||
return nil, 0, fmt.Errorf("offline mode: mappings unavailable")
|
return nil, 0, fmt.Errorf("offline mode: mappings unavailable")
|
||||||
@@ -669,7 +727,8 @@ func parseMXLRows(content []byte) ([]stockImportRow, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
qty, err := parseLocalizedFloat(r[6])
|
qtyRaw := strings.TrimSpace(r[6])
|
||||||
|
qty, err := parseLocalizedQty(qtyRaw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
qty = 0
|
qty = 0
|
||||||
}
|
}
|
||||||
@@ -680,6 +739,8 @@ func parseMXLRows(content []byte) ([]stockImportRow, error) {
|
|||||||
Vendor: strings.TrimSpace(r[4]),
|
Vendor: strings.TrimSpace(r[4]),
|
||||||
Price: price,
|
Price: price,
|
||||||
Qty: qty,
|
Qty: qty,
|
||||||
|
QtyRaw: qtyRaw,
|
||||||
|
QtyInvalid: err != nil,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -767,6 +828,9 @@ func parseXLSXRows(content []byte) ([]stockImportRow, error) {
|
|||||||
idxVendor, hasVendor := headers["вендор"]
|
idxVendor, hasVendor := headers["вендор"]
|
||||||
idxPrice := headers["стоимость"]
|
idxPrice := headers["стоимость"]
|
||||||
idxQty, hasQty := 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++ {
|
for i := headerRow + 1; i < len(grid); i++ {
|
||||||
row := grid[i]
|
row := grid[i]
|
||||||
article := strings.TrimSpace(row[idxArticle])
|
article := strings.TrimSpace(row[idxArticle])
|
||||||
@@ -778,10 +842,14 @@ func parseXLSXRows(content []byte) ([]stockImportRow, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
qty := 0.0
|
qty := 0.0
|
||||||
|
qtyRaw := ""
|
||||||
|
qtyInvalid := false
|
||||||
if hasQty {
|
if hasQty {
|
||||||
qty, err = parseLocalizedFloat(row[idxQty])
|
qtyRaw = strings.TrimSpace(row[idxQty])
|
||||||
|
qty, err = parseLocalizedQty(qtyRaw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
qty = 0
|
qty = 0
|
||||||
|
qtyInvalid = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -805,6 +873,8 @@ func parseXLSXRows(content []byte) ([]stockImportRow, error) {
|
|||||||
Vendor: vendor,
|
Vendor: vendor,
|
||||||
Price: price,
|
Price: price,
|
||||||
Qty: qty,
|
Qty: qty,
|
||||||
|
QtyRaw: qtyRaw,
|
||||||
|
QtyInvalid: qtyInvalid,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -821,6 +891,23 @@ func parseLocalizedFloat(value string) (float64, error) {
|
|||||||
return strconv.ParseFloat(clean, 64)
|
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 {
|
func detectImportDate(content []byte, filename string, fileModTime time.Time) time.Time {
|
||||||
if d, ok := extractDateFromText(string(content)); ok {
|
if d, ok := extractDateFromText(string(content)); ok {
|
||||||
return d
|
return d
|
||||||
@@ -885,6 +972,54 @@ func median(values []float64) float64 {
|
|||||||
return c[n/2]
|
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 {
|
type lotResolver struct {
|
||||||
partnumberToLots map[string][]string
|
partnumberToLots map[string][]string
|
||||||
exactLots 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) {
|
func TestParseXLSXRows(t *testing.T) {
|
||||||
xlsx := buildMinimalXLSX(t, []string{
|
xlsx := buildMinimalXLSX(t, []string{
|
||||||
"Папка", "Артикул", "Описание", "Вендор", "Стоимость", "Свободно",
|
"Папка", "Артикул", "Описание", "Вендор", "Стоимость", "Свободно",
|
||||||
@@ -114,7 +143,7 @@ func TestImportNoValidRowsKeepsStockLog(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
existing := models.StockLog{
|
existing := models.StockLog{
|
||||||
Lot: "CPU_A",
|
Partnumber: "CPU_A",
|
||||||
Date: time.Now(),
|
Date: time.Now(),
|
||||||
Price: 10,
|
Price: 10,
|
||||||
}
|
}
|
||||||
@@ -152,14 +181,14 @@ func TestReplaceStockLogs(t *testing.T) {
|
|||||||
t.Fatalf("automigrate stock_log: %v", err)
|
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)
|
t.Fatalf("seed old row: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := NewStockImportService(db, nil)
|
svc := NewStockImportService(db, nil)
|
||||||
records := []models.StockLog{
|
records := []models.StockLog{
|
||||||
{Lot: "NEW_1", Date: time.Now(), Price: 2},
|
{Partnumber: "NEW_1", Date: time.Now(), Price: 2},
|
||||||
{Lot: "NEW_2", Date: time.Now(), Price: 3},
|
{Partnumber: "NEW_2", Date: time.Now(), Price: 3},
|
||||||
}
|
}
|
||||||
|
|
||||||
deleted, inserted, err := svc.replaceStockLogs(records)
|
deleted, inserted, err := svc.replaceStockLogs(records)
|
||||||
@@ -171,14 +200,73 @@ func TestReplaceStockLogs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var rows []models.StockLog
|
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)
|
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)
|
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 {
|
func openTestDB(t *testing.T) *gorm.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
|||||||
@@ -349,6 +349,10 @@ CREATE TABLE qt_configurations (
|
|||||||
is_template INTEGER NOT NULL DEFAULT 0,
|
is_template INTEGER NOT NULL DEFAULT 0,
|
||||||
server_count INTEGER NOT NULL DEFAULT 1,
|
server_count INTEGER NOT NULL DEFAULT 1,
|
||||||
pricelist_id INTEGER NULL,
|
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,
|
price_updated_at DATETIME NULL,
|
||||||
created_at DATETIME
|
created_at DATETIME
|
||||||
);`).Error; err != nil {
|
);`).Error; err != nil {
|
||||||
|
|||||||
2
migrations/019_rename_stock_log_lot_to_partnumber.sql
Normal file
2
migrations/019_rename_stock_log_lot_to_partnumber.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE stock_log
|
||||||
|
CHANGE COLUMN lot partnumber VARCHAR(255) NOT NULL;
|
||||||
3
migrations/020_add_only_in_stock_to_configurations.sql
Normal file
3
migrations/020_add_only_in_stock_to_configurations.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- Add only_in_stock toggle to configuration settings persisted in MariaDB.
|
||||||
|
ALTER TABLE qt_configurations
|
||||||
|
ADD COLUMN IF NOT EXISTS only_in_stock BOOLEAN NOT NULL DEFAULT FALSE AFTER disable_price_refresh;
|
||||||
19
migrations/021_add_pricelist_items_pricelist_lot_index.sql
Normal file
19
migrations/021_add_pricelist_items_pricelist_lot_index.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
-- Ensure fast lookup for /api/quote/price-levels batched queries:
|
||||||
|
-- SELECT ... FROM qt_pricelist_items WHERE pricelist_id = ? AND lot_name IN (...)
|
||||||
|
SET @has_idx := (
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM information_schema.statistics
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'qt_pricelist_items'
|
||||||
|
AND index_name IN ('idx_qt_pricelist_items_pricelist_lot', 'idx_pricelist_lot')
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @ddl := IF(
|
||||||
|
@has_idx = 0,
|
||||||
|
'ALTER TABLE qt_pricelist_items ADD INDEX idx_qt_pricelist_items_pricelist_lot (pricelist_id, lot_name)',
|
||||||
|
'SELECT ''idx_qt_pricelist_items_pricelist_lot already exists, skip'''
|
||||||
|
);
|
||||||
|
|
||||||
|
PREPARE stmt FROM @ddl;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
@@ -7,7 +7,8 @@
|
|||||||
<div class="bg-white rounded-lg shadow p-4">
|
<div class="bg-white rounded-lg shadow p-4">
|
||||||
<div class="flex justify-between items-center border-b pb-4 mb-4">
|
<div class="flex justify-between items-center border-b pb-4 mb-4">
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<button onclick="loadTab('alerts')" id="btn-alerts" class="text-blue-600 font-medium">Алерты</button>
|
<button onclick="loadTab('lots')" id="btn-lots" class="text-blue-600 font-medium">LOT</button>
|
||||||
|
<button onclick="loadTab('pricelists')" id="btn-pricelists" class="text-gray-600">Прайслисты</button>
|
||||||
<button onclick="loadTab('estimate')" id="btn-estimate" class="text-gray-600">Estimate</button>
|
<button onclick="loadTab('estimate')" id="btn-estimate" class="text-gray-600">Estimate</button>
|
||||||
<button onclick="loadTab('warehouse')" id="btn-warehouse" class="text-gray-600">Склад</button>
|
<button onclick="loadTab('warehouse')" id="btn-warehouse" class="text-gray-600">Склад</button>
|
||||||
<button onclick="loadTab('competitor')" id="btn-competitor" class="text-gray-600">Конкуренты</button>
|
<button onclick="loadTab('competitor')" id="btn-competitor" class="text-gray-600">Конкуренты</button>
|
||||||
@@ -33,21 +34,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search and sort (only for components) -->
|
<!-- Search and sort (for LOT/component-settings) -->
|
||||||
<div id="search-bar" class="mb-4 hidden">
|
<div id="search-bar" class="mb-4 hidden">
|
||||||
<div class="flex gap-4 items-center">
|
<div class="flex gap-4 items-center">
|
||||||
<input type="text" id="search-input" placeholder="Поиск по артикулу..."
|
<input type="text" id="search-input" placeholder="Поиск..."
|
||||||
class="flex-1 px-3 py-2 border rounded"
|
class="flex-1 px-3 py-2 border rounded"
|
||||||
onkeyup="debounceSearch()">
|
onkeyup="debounceSearch()">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-sm text-gray-500">Сортировка:</span>
|
<span class="text-sm text-gray-500">Сортировка:</span>
|
||||||
<select id="sort-field" class="px-2 py-1 border rounded text-sm" onchange="changeSort()">
|
<select id="sort-field" class="px-2 py-1 border rounded text-sm" onchange="changeSort()">
|
||||||
<option value="lot_name">Артикул</option>
|
<option value="lot_name">Артикул</option>
|
||||||
<option value="popularity_score" selected>Популярность</option>
|
<option value="category">Категория</option>
|
||||||
<option value="quote_count">Кол-во котировок</option>
|
<option value="popularity_score">Популярность</option>
|
||||||
<option value="current_price">Цена</option>
|
<option value="estimate_count">Котировок</option>
|
||||||
|
<option value="stock_qty">На складе</option>
|
||||||
</select>
|
</select>
|
||||||
<button onclick="toggleSortDir()" id="sort-dir-btn" class="px-2 py-1 border rounded text-sm">↓</button>
|
<button onclick="toggleSortDir()" id="sort-dir-btn" class="px-2 py-1 border rounded text-sm">↑</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,6 +77,7 @@
|
|||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Версия</th>
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Версия</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Тип прайслиста</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>
|
||||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">Позиций</th>
|
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">Позиций</th>
|
||||||
@@ -85,7 +88,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody id="pricelists-body" class="bg-white divide-y divide-gray-200">
|
<tbody id="pricelists-body" class="bg-white divide-y divide-gray-200">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" class="px-6 py-4 text-center text-gray-500">Загрузка...</td>
|
<td colspan="8" class="px-6 py-4 text-center text-gray-500">Загрузка...</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -96,6 +99,7 @@
|
|||||||
<div id="stock-tools" class="mt-6 hidden space-y-6">
|
<div id="stock-tools" class="mt-6 hidden space-y-6">
|
||||||
<div class="border rounded-lg p-4">
|
<div class="border rounded-lg p-4">
|
||||||
<h3 class="text-lg font-semibold mb-3">Импорт stock_log</h3>
|
<h3 class="text-lg font-semibold mb-3">Импорт stock_log</h3>
|
||||||
|
<p class="text-xs text-gray-500 mb-3">В stock_log сохраняются все неотфильтрованные строки. Поле <span class="font-mono">partnumber</span> берется из файла; сопоставление с LOT используется только при расчете warehouse-прайслиста.</p>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<input type="file" id="stock-file-input" accept=".mxl,.xlsx" class="block w-full text-sm text-gray-700">
|
<input type="file" id="stock-file-input" accept=".mxl,.xlsx" class="block w-full text-sm text-gray-700">
|
||||||
<button onclick="importStockFile()" class="px-4 py-2 bg-emerald-600 text-white rounded hover:bg-emerald-700">Импортировать</button>
|
<button onclick="importStockFile()" class="px-4 py-2 bg-emerald-600 text-white rounded hover:bg-emerald-700">Импортировать</button>
|
||||||
@@ -148,7 +152,7 @@
|
|||||||
<div class="border rounded-lg p-4">
|
<div class="border rounded-lg p-4">
|
||||||
<h3 class="text-lg font-semibold mb-3">Сопоставление partnumber -> LOT</h3>
|
<h3 class="text-lg font-semibold mb-3">Сопоставление partnumber -> LOT</h3>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
<input id="stock-mappings-search" type="text" placeholder="Поиск по partnumber / описанию / lot" class="px-3 py-2 border rounded w-full">
|
<input id="stock-mappings-search" type="text" placeholder="Поиск по partnumber / описанию / LOT" class="px-3 py-2 border rounded w-full">
|
||||||
<datalist id="stock-lot-options"></datalist>
|
<datalist id="stock-lot-options"></datalist>
|
||||||
<button onclick="loadStockMappings(1)" class="px-3 py-2 border rounded hover:bg-gray-50">Обновить</button>
|
<button onclick="loadStockMappings(1)" class="px-3 py-2 border rounded hover:bg-gray-50">Обновить</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -358,7 +362,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let currentTab = 'alerts';
|
let currentTab = 'lots';
|
||||||
let currentPricelistSource = 'estimate';
|
let currentPricelistSource = 'estimate';
|
||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
let totalPages = 1;
|
let totalPages = 1;
|
||||||
@@ -366,8 +370,8 @@ let perPage = 50;
|
|||||||
let searchTimeout = null;
|
let searchTimeout = null;
|
||||||
let currentSearch = '';
|
let currentSearch = '';
|
||||||
let componentsCache = [];
|
let componentsCache = [];
|
||||||
let sortField = 'popularity_score';
|
let sortField = 'lot_name';
|
||||||
let sortDir = 'desc';
|
let sortDir = 'asc';
|
||||||
let pricelistsPage = 1;
|
let pricelistsPage = 1;
|
||||||
let pricelistsCanWrite = false;
|
let pricelistsCanWrite = false;
|
||||||
let isCreatingPricelist = false;
|
let isCreatingPricelist = false;
|
||||||
@@ -379,6 +383,82 @@ let stockMappingsSearch = '';
|
|||||||
let stockMappingsSearchTimer = null;
|
let stockMappingsSearchTimer = null;
|
||||||
let stockImportSuggestions = [];
|
let stockImportSuggestions = [];
|
||||||
|
|
||||||
|
function getPricelistTabConfig(tab) {
|
||||||
|
if (tab === 'estimate') {
|
||||||
|
return { source: 'estimate', title: 'Estimate', showEstimateSettings: true, showStockTools: false };
|
||||||
|
}
|
||||||
|
if (tab === 'warehouse') {
|
||||||
|
return { source: 'warehouse', title: 'Склад', showEstimateSettings: false, showStockTools: true };
|
||||||
|
}
|
||||||
|
if (tab === 'competitor') {
|
||||||
|
return { source: 'competitor', title: 'Конкуренты', showEstimateSettings: false, showStockTools: false };
|
||||||
|
}
|
||||||
|
return { source: '', title: 'Прайслисты', showEstimateSettings: false, showStockTools: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPricelistSourceLabel(source) {
|
||||||
|
if (source === 'warehouse') return 'Склад';
|
||||||
|
if (source === 'competitor') return 'Конкуренты';
|
||||||
|
return 'Estimate';
|
||||||
|
}
|
||||||
|
|
||||||
|
function canCreatePricelistForCurrentTab() {
|
||||||
|
return currentPricelistSource === 'estimate' || currentPricelistSource === 'warehouse' || currentPricelistSource === 'competitor';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePricelistsCreateButton() {
|
||||||
|
if (pricelistsCanWrite && canCreatePricelistForCurrentTab()) {
|
||||||
|
document.getElementById('pricelists-create-btn-container').innerHTML = `
|
||||||
|
<button onclick="openPricelistsCreateModal()" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
||||||
|
Создать прайслист
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
document.getElementById('pricelists-create-btn-container').innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SORT_OPTIONS = {
|
||||||
|
lots: [
|
||||||
|
{ value: 'lot_name', label: 'Артикул' },
|
||||||
|
{ value: 'category', label: 'Категория' },
|
||||||
|
{ value: 'popularity_score', label: 'Популярность' },
|
||||||
|
{ value: 'estimate_count', label: 'Котировок' },
|
||||||
|
{ value: 'stock_qty', label: 'На складе' }
|
||||||
|
],
|
||||||
|
'component-settings': [
|
||||||
|
{ value: 'lot_name', label: 'Артикул' },
|
||||||
|
{ value: 'popularity_score', label: 'Популярность' },
|
||||||
|
{ value: 'quote_count', label: 'Кол-во котировок' },
|
||||||
|
{ value: 'current_price', label: 'Цена' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
function setSortConfigForTab(tab) {
|
||||||
|
const searchInput = document.getElementById('search-input');
|
||||||
|
const sortSelect = document.getElementById('sort-field');
|
||||||
|
const sortDirBtn = document.getElementById('sort-dir-btn');
|
||||||
|
|
||||||
|
let options = SORT_OPTIONS.lots;
|
||||||
|
let defaultSort = 'lot_name';
|
||||||
|
let defaultDir = 'asc';
|
||||||
|
let placeholder = 'Поиск по LOT или описанию...';
|
||||||
|
|
||||||
|
if (tab === 'component-settings') {
|
||||||
|
options = SORT_OPTIONS['component-settings'];
|
||||||
|
defaultSort = 'popularity_score';
|
||||||
|
defaultDir = 'desc';
|
||||||
|
placeholder = 'Поиск по артикулу...';
|
||||||
|
}
|
||||||
|
|
||||||
|
sortSelect.innerHTML = options.map(o => `<option value="${o.value}">${o.label}</option>`).join('');
|
||||||
|
sortField = options.some(o => o.value === sortField) ? sortField : defaultSort;
|
||||||
|
sortDir = defaultDir;
|
||||||
|
sortSelect.value = sortField;
|
||||||
|
sortDirBtn.textContent = sortDir === 'asc' ? '↑' : '↓';
|
||||||
|
searchInput.placeholder = placeholder;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadTab(tab) {
|
async function loadTab(tab) {
|
||||||
currentTab = tab;
|
currentTab = tab;
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
@@ -388,32 +468,44 @@ async function loadTab(tab) {
|
|||||||
stopSyncUsersStatusRefresh();
|
stopSyncUsersStatusRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('btn-alerts').className = tab === 'alerts' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
document.getElementById('btn-lots').className = tab === 'lots' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
||||||
|
document.getElementById('btn-pricelists').className = tab === 'pricelists' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
||||||
document.getElementById('btn-estimate').className = (tab === 'estimate' || tab === 'component-settings') ? 'text-blue-600 font-medium' : 'text-gray-600';
|
document.getElementById('btn-estimate').className = (tab === 'estimate' || tab === 'component-settings') ? 'text-blue-600 font-medium' : 'text-gray-600';
|
||||||
document.getElementById('btn-warehouse').className = tab === 'warehouse' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
document.getElementById('btn-warehouse').className = tab === 'warehouse' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
||||||
document.getElementById('btn-competitor').className = tab === 'competitor' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
document.getElementById('btn-competitor').className = tab === 'competitor' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
||||||
document.getElementById('btn-sync-status').className = (tab === 'sync-status' ? 'text-blue-600 font-medium' : 'text-gray-600') + (pricelistsCanWrite ? '' : ' hidden');
|
document.getElementById('btn-sync-status').className = (tab === 'sync-status' ? 'text-blue-600 font-medium' : 'text-gray-600') + (pricelistsCanWrite ? '' : ' hidden');
|
||||||
document.getElementById('btn-all-configs').className = tab === 'all-configs' ? 'text-blue-600 font-medium' : 'text-gray-600 hidden';
|
document.getElementById('btn-all-configs').className = tab === 'all-configs' ? 'text-blue-600 font-medium' : 'text-gray-600 hidden';
|
||||||
|
|
||||||
if (tab === 'estimate' || tab === 'warehouse' || tab === 'competitor') {
|
if (tab === 'lots') {
|
||||||
currentPricelistSource = tab === 'estimate' ? 'estimate' : (tab === 'warehouse' ? 'warehouse' : 'competitor');
|
setSortConfigForTab('lots');
|
||||||
|
document.getElementById('search-bar').className = 'mb-4';
|
||||||
|
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t';
|
||||||
|
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
|
||||||
|
document.getElementById('pricelists-tab-content').className = 'hidden';
|
||||||
|
document.getElementById('sync-status-tab-content').className = 'hidden';
|
||||||
|
document.getElementById('tab-content').className = '';
|
||||||
|
await loadData();
|
||||||
|
} else if (tab === 'pricelists' || tab === 'estimate' || tab === 'warehouse' || tab === 'competitor') {
|
||||||
|
const pricelistTab = getPricelistTabConfig(tab);
|
||||||
|
currentPricelistSource = pricelistTab.source;
|
||||||
document.getElementById('search-bar').className = 'mb-4 hidden';
|
document.getElementById('search-bar').className = 'mb-4 hidden';
|
||||||
document.getElementById('pagination').className = 'hidden';
|
document.getElementById('pagination').className = 'hidden';
|
||||||
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
|
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
|
||||||
document.getElementById('pricelists-tab-content').className = '';
|
document.getElementById('pricelists-tab-content').className = '';
|
||||||
document.getElementById('sync-status-tab-content').className = 'hidden';
|
document.getElementById('sync-status-tab-content').className = 'hidden';
|
||||||
document.getElementById('tab-content').className = 'hidden';
|
document.getElementById('tab-content').className = 'hidden';
|
||||||
document.getElementById('pricelists-title').textContent = tab === 'estimate' ? 'Estimate' : (tab === 'warehouse' ? 'Склад' : 'Конкуренты');
|
document.getElementById('pricelists-title').textContent = pricelistTab.title;
|
||||||
document.getElementById('estimate-settings-btn').classList.toggle('hidden', tab !== 'estimate');
|
document.getElementById('estimate-settings-btn').classList.toggle('hidden', !pricelistTab.showEstimateSettings);
|
||||||
document.getElementById('stock-tools').classList.toggle('hidden', tab !== 'warehouse');
|
document.getElementById('stock-tools').classList.toggle('hidden', !pricelistTab.showStockTools);
|
||||||
await checkPricelistWritePermission();
|
await checkPricelistWritePermission();
|
||||||
await loadPricelists(1, currentPricelistSource);
|
await loadPricelists(1, currentPricelistSource);
|
||||||
if (tab === 'warehouse') {
|
if (pricelistTab.showStockTools) {
|
||||||
await loadStockMappings(1);
|
await loadStockMappings(1);
|
||||||
await loadStockIgnoreRules(1);
|
await loadStockIgnoreRules(1);
|
||||||
await loadStockLotOptions();
|
await loadStockLotOptions();
|
||||||
}
|
}
|
||||||
} else if (tab === 'component-settings') {
|
} else if (tab === 'component-settings') {
|
||||||
|
setSortConfigForTab('component-settings');
|
||||||
document.getElementById('search-bar').className = 'mb-4';
|
document.getElementById('search-bar').className = 'mb-4';
|
||||||
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t';
|
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t';
|
||||||
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
|
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
|
||||||
@@ -430,7 +522,7 @@ async function loadTab(tab) {
|
|||||||
document.getElementById('tab-content').className = 'hidden';
|
document.getElementById('tab-content').className = 'hidden';
|
||||||
await checkPricelistWritePermission();
|
await checkPricelistWritePermission();
|
||||||
if (!pricelistsCanWrite) {
|
if (!pricelistsCanWrite) {
|
||||||
await loadTab('alerts');
|
await loadTab('lots');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await loadUsersSyncStatus();
|
await loadUsersSyncStatus();
|
||||||
@@ -474,10 +566,22 @@ async function loadData() {
|
|||||||
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Загрузка...</div>';
|
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Загрузка...</div>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (currentTab === 'alerts') {
|
if (currentTab === 'lots') {
|
||||||
const resp = await fetch('/api/admin/pricing/alerts?per_page=100');
|
let url = '/api/admin/pricing/lots-table?page=' + currentPage + '&per_page=' + perPage;
|
||||||
|
if (currentSearch) {
|
||||||
|
url += '&search=' + encodeURIComponent(currentSearch);
|
||||||
|
}
|
||||||
|
if (sortField) {
|
||||||
|
url += '&sort=' + encodeURIComponent(sortField);
|
||||||
|
}
|
||||||
|
if (sortDir) {
|
||||||
|
url += '&dir=' + encodeURIComponent(sortDir);
|
||||||
|
}
|
||||||
|
const resp = await fetch(url);
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
renderAlerts(data.alerts || []);
|
totalPages = Math.max(1, Math.ceil((data.total || 0) / perPage));
|
||||||
|
renderLots(data.lots || [], data.total || 0);
|
||||||
|
updatePagination(data.total || 0);
|
||||||
} else if (currentTab === 'all-configs') {
|
} else if (currentTab === 'all-configs') {
|
||||||
// Load all configurations for all users
|
// Load all configurations for all users
|
||||||
let url = '/api/configs?page=' + currentPage + '&per_page=' + perPage;
|
let url = '/api/configs?page=' + currentPage + '&per_page=' + perPage;
|
||||||
@@ -544,21 +648,52 @@ function updatePagination(total) {
|
|||||||
document.getElementById('btn-next').disabled = currentPage >= totalPages;
|
document.getElementById('btn-next').disabled = currentPage >= totalPages;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAlerts(alerts) {
|
function renderLots(lots, total) {
|
||||||
if (alerts.length === 0) {
|
if (lots.length === 0) {
|
||||||
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-green-600">Нет активных алертов</div>';
|
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Нет данных</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = '<div class="space-y-2">';
|
let html = '<div class="text-sm text-gray-500 mb-2">Всего LOT: ' + total + '</div>';
|
||||||
alerts.forEach(a => {
|
html += '<div class="overflow-x-auto"><table class="w-full"><thead class="bg-gray-50"><tr>';
|
||||||
const colors = {critical: 'bg-red-100', high: 'bg-orange-100', medium: 'bg-yellow-100', low: 'bg-blue-100'};
|
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Категория</th>';
|
||||||
html += '<div class="' + (colors[a.severity] || 'bg-gray-100') + ' p-3 rounded">';
|
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>';
|
||||||
html += '<div class="flex justify-between"><span class="font-medium">' + escapeHtml(a.lot_name) + '</span>';
|
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">p/n</th>';
|
||||||
html += '<span class="text-xs uppercase">' + a.severity + '</span></div>';
|
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>';
|
||||||
html += '<p class="text-sm text-gray-600">' + escapeHtml(a.message) + '</p></div>';
|
html += '<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Популярность</th>';
|
||||||
|
html += '<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Котировок</th>';
|
||||||
|
html += '<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase">Конкуренты</th>';
|
||||||
|
html += '<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">На складе</th>';
|
||||||
|
html += '</tr></thead><tbody class="divide-y">';
|
||||||
|
|
||||||
|
lots.forEach(lot => {
|
||||||
|
const category = lot.category ? escapeHtml(lot.category) : '—';
|
||||||
|
const lotName = lot.lot_name ? escapeHtml(lot.lot_name) : '—';
|
||||||
|
const description = lot.lot_description ? escapeHtml(lot.lot_description) : '—';
|
||||||
|
const popularity = Number.isFinite(lot.popularity) ? Number(lot.popularity).toFixed(2) : '0.00';
|
||||||
|
const estimateCount = Number.isFinite(lot.estimate_count) ? lot.estimate_count.toLocaleString('ru-RU') : '0';
|
||||||
|
const stockQty = lot.stock_qty === null || lot.stock_qty === undefined
|
||||||
|
? '—'
|
||||||
|
: Number(lot.stock_qty).toLocaleString('ru-RU', { maximumFractionDigits: 2 });
|
||||||
|
const partnumbers = Array.isArray(lot.partnumbers) ? lot.partnumbers : [];
|
||||||
|
const firstPart = partnumbers.length > 0 ? escapeHtml(partnumbers[0]) : '—';
|
||||||
|
const more = partnumbers.length > 1
|
||||||
|
? `<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 text-xs">+${partnumbers.length - 1}</span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
html += '<tr class="hover:bg-gray-50">';
|
||||||
|
html += '<td class="px-3 py-2 text-sm">' + category + '</td>';
|
||||||
|
html += '<td class="px-3 py-2 text-sm font-medium">' + lotName + '</td>';
|
||||||
|
html += '<td class="px-3 py-2 text-sm">' + firstPart + more + '</td>';
|
||||||
|
html += '<td class="px-3 py-2 text-sm text-gray-600">' + description + '</td>';
|
||||||
|
html += '<td class="px-3 py-2 text-sm text-right">' + popularity + '</td>';
|
||||||
|
html += '<td class="px-3 py-2 text-sm text-right">' + estimateCount + '</td>';
|
||||||
|
html += '<td class="px-3 py-2 text-sm text-center">—</td>';
|
||||||
|
html += '<td class="px-3 py-2 text-sm text-right">' + stockQty + '</td>';
|
||||||
|
html += '</tr>';
|
||||||
});
|
});
|
||||||
html += '</div>';
|
|
||||||
|
html += '</tbody></table></div>';
|
||||||
document.getElementById('tab-content').innerHTML = html;
|
document.getElementById('tab-content').innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1124,6 +1259,30 @@ async function importStockFile() {
|
|||||||
percentEl.textContent = '0%';
|
percentEl.textContent = '0%';
|
||||||
barEl.style.width = '0%';
|
barEl.style.width = '0%';
|
||||||
statsEl.textContent = '';
|
statsEl.textContent = '';
|
||||||
|
const statsState = {
|
||||||
|
rowsTotal: 0,
|
||||||
|
validRows: 0,
|
||||||
|
inserted: 0,
|
||||||
|
unmapped: 0,
|
||||||
|
conflicts: 0,
|
||||||
|
ignored: 0,
|
||||||
|
parseErrors: 0,
|
||||||
|
qtyParseErrors: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const setIfNumber = (obj, key, value) => {
|
||||||
|
if (value === null || value === undefined || value === '') return;
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isNaN(n)) obj[key] = n;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStats = () => {
|
||||||
|
statsEl.textContent =
|
||||||
|
`Всего строк: ${statsState.rowsTotal} | Валидных: ${statsState.validRows} | ` +
|
||||||
|
`Вставлено: ${statsState.inserted} | Без сопоставления: ${statsState.unmapped} | ` +
|
||||||
|
`Конфликты: ${statsState.conflicts} | Игнор: ${statsState.ignored} | ` +
|
||||||
|
`Ошибки парсинга: ${statsState.parseErrors} | Ошибки количества: ${statsState.qtyParseErrors}`;
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/admin/pricing/stock/import', {
|
const resp = await fetch('/api/admin/pricing/stock/import', {
|
||||||
@@ -1138,36 +1297,53 @@ async function importStockFile() {
|
|||||||
|
|
||||||
const reader = resp.body.getReader();
|
const reader = resp.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
|
let sseBuffer = '';
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
const text = decoder.decode(value);
|
sseBuffer += decoder.decode(value, { stream: true });
|
||||||
const lines = text.split('\n');
|
const lines = sseBuffer.split('\n');
|
||||||
|
sseBuffer = lines.pop() || '';
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (!line.startsWith('data:')) continue;
|
if (!line.startsWith('data:')) continue;
|
||||||
let data;
|
let data;
|
||||||
try { data = JSON.parse(line.slice(5).trim()); } catch (_) { continue; }
|
try { data = JSON.parse(line.slice(5).trim()); } catch (_) { continue; }
|
||||||
|
setIfNumber(statsState, 'rowsTotal', data.rows_total);
|
||||||
|
setIfNumber(statsState, 'validRows', data.valid_rows);
|
||||||
|
setIfNumber(statsState, 'inserted', data.inserted);
|
||||||
|
setIfNumber(statsState, 'unmapped', data.unmapped);
|
||||||
|
setIfNumber(statsState, 'conflicts', data.conflicts);
|
||||||
|
setIfNumber(statsState, 'ignored', data.ignored);
|
||||||
|
setIfNumber(statsState, 'parseErrors', data.parse_errors);
|
||||||
|
setIfNumber(statsState, 'qtyParseErrors', data.qty_parse_errors);
|
||||||
|
renderStats();
|
||||||
|
|
||||||
const current = Number(data.current || 0);
|
const current = Number(data.current || 0);
|
||||||
const total = Number(data.total || 100);
|
const total = Number(data.total || 100);
|
||||||
const pct = total > 0 ? Math.round((current / total) * 100) : 0;
|
let pct = total > 0 ? Math.round((current / total) * 100) : 0;
|
||||||
|
if (data.status !== 'completed' && pct >= 100) {
|
||||||
|
pct = 99;
|
||||||
|
}
|
||||||
barEl.style.width = pct + '%';
|
barEl.style.width = pct + '%';
|
||||||
percentEl.textContent = pct + '%';
|
percentEl.textContent = pct + '%';
|
||||||
statusEl.textContent = data.message || data.status || 'Обработка';
|
statusEl.textContent = data.message || data.status || 'Обработка';
|
||||||
statsEl.textContent =
|
|
||||||
`Валидных: ${data.valid_rows || 0} | Вставлено: ${data.inserted || 0} | ` +
|
|
||||||
`Пропущено: ${(data.unmapped || 0) + (data.conflicts || 0) + (data.parse_errors || 0) + (data.ignored || 0)} | ` +
|
|
||||||
`Игнор: ${data.ignored || 0} | Конфликты: ${data.conflicts || 0}`;
|
|
||||||
|
|
||||||
if (data.status === 'error') {
|
if (data.status === 'error') {
|
||||||
throw new Error(data.message || 'Ошибка импорта');
|
throw new Error(data.message || 'Ошибка импорта');
|
||||||
}
|
}
|
||||||
if (data.status === 'completed') {
|
if (data.status === 'completed') {
|
||||||
|
barEl.style.width = '100%';
|
||||||
|
percentEl.textContent = '100%';
|
||||||
|
statusEl.textContent = 'Импорт завершен. Обновляю списки...';
|
||||||
stockImportSuggestions = data.mapping_suggestions || [];
|
stockImportSuggestions = data.mapping_suggestions || [];
|
||||||
renderStockImportSuggestions(stockImportSuggestions);
|
renderStockImportSuggestions(stockImportSuggestions);
|
||||||
showToast('Импорт stock_log завершен', 'success');
|
showToast('Импорт stock_log завершен', 'success');
|
||||||
await loadPricelists(1, 'warehouse');
|
await Promise.allSettled([
|
||||||
await loadStockMappings(stockMappingsPage);
|
loadPricelists(1, 'warehouse'),
|
||||||
await loadStockIgnoreRules(stockIgnoreRulesPage);
|
loadStockMappings(stockMappingsPage),
|
||||||
|
loadStockIgnoreRules(stockIgnoreRulesPage),
|
||||||
|
]);
|
||||||
|
statusEl.textContent = 'Импорт и обновление списков завершены';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1178,7 +1354,7 @@ async function importStockFile() {
|
|||||||
|
|
||||||
function formatSuggestionReason(reason) {
|
function formatSuggestionReason(reason) {
|
||||||
if (reason === 'conflict') return 'Конфликт';
|
if (reason === 'conflict') return 'Конфликт';
|
||||||
return 'Не найден LOT';
|
return 'Нет сопоставления с LOT';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStockImportSuggestions(items) {
|
function renderStockImportSuggestions(items) {
|
||||||
@@ -1563,8 +1739,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
await checkPricelistWritePermission();
|
await checkPricelistWritePermission();
|
||||||
// Check URL params for initial tab
|
// Check URL params for initial tab
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
let initialTab = urlParams.get('tab') || 'alerts';
|
let initialTab = urlParams.get('tab') || 'lots';
|
||||||
if (initialTab === 'pricelists') initialTab = 'estimate';
|
if (initialTab === 'alerts') initialTab = 'lots';
|
||||||
if (initialTab === 'components') initialTab = 'component-settings';
|
if (initialTab === 'components') initialTab = 'component-settings';
|
||||||
await loadTab(initialTab);
|
await loadTab(initialTab);
|
||||||
const stockMappingsSearchEl = document.getElementById('stock-mappings-search');
|
const stockMappingsSearchEl = document.getElementById('stock-mappings-search');
|
||||||
@@ -1579,9 +1755,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
document.getElementById('modal-meta-prices').addEventListener('input', debounceFetchPreview);
|
document.getElementById('modal-meta-prices').addEventListener('input', debounceFetchPreview);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pricelists functions
|
|
||||||
let canWrite = false;
|
|
||||||
|
|
||||||
async function checkPricelistWritePermission() {
|
async function checkPricelistWritePermission() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/pricelists/can-write');
|
const resp = await fetch('/api/pricelists/can-write');
|
||||||
@@ -1589,26 +1762,24 @@ async function checkPricelistWritePermission() {
|
|||||||
pricelistsCanWrite = data.can_write;
|
pricelistsCanWrite = data.can_write;
|
||||||
|
|
||||||
if (pricelistsCanWrite) {
|
if (pricelistsCanWrite) {
|
||||||
document.getElementById('pricelists-create-btn-container').innerHTML = `
|
updatePricelistsCreateButton();
|
||||||
<button onclick="openPricelistsCreateModal()" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
|
||||||
Создать прайслист
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
document.getElementById('btn-sync-status').classList.remove('hidden');
|
document.getElementById('btn-sync-status').classList.remove('hidden');
|
||||||
if (currentTab === 'sync-status') {
|
if (currentTab === 'sync-status') {
|
||||||
await loadUsersSyncStatus();
|
await loadUsersSyncStatus();
|
||||||
startSyncUsersStatusRefresh();
|
startSyncUsersStatusRefresh();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('pricelists-create-btn-container').innerHTML = '';
|
updatePricelistsCreateButton();
|
||||||
document.getElementById('btn-sync-status').classList.add('hidden');
|
document.getElementById('btn-sync-status').classList.add('hidden');
|
||||||
stopSyncUsersStatusRefresh();
|
stopSyncUsersStatusRefresh();
|
||||||
if (currentTab === 'sync-status') {
|
if (currentTab === 'sync-status') {
|
||||||
await loadTab('alerts');
|
await loadTab('lots');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to check pricelist write permission:', e);
|
console.error('Failed to check pricelist write permission:', e);
|
||||||
|
pricelistsCanWrite = false;
|
||||||
|
updatePricelistsCreateButton();
|
||||||
document.getElementById('btn-sync-status').classList.add('hidden');
|
document.getElementById('btn-sync-status').classList.add('hidden');
|
||||||
stopSyncUsersStatusRefresh();
|
stopSyncUsersStatusRefresh();
|
||||||
}
|
}
|
||||||
@@ -1685,13 +1856,16 @@ async function loadPricelists(page = 1, source = currentPricelistSource) {
|
|||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/pricelists?page=${page}&per_page=20&source=${encodeURIComponent(source)}`);
|
const resp = await fetch(`/api/pricelists?page=${page}&per_page=20&source=${encodeURIComponent(source)}`);
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(data.error || 'Ошибка загрузки прайслистов');
|
||||||
|
}
|
||||||
|
|
||||||
renderPricelists(data.pricelists || []);
|
renderPricelists(data.pricelists || []);
|
||||||
renderPricelistsPagination(data.total, data.page, data.per_page);
|
renderPricelistsPagination(data.total, data.page, data.per_page);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById('pricelists-body').innerHTML = `
|
document.getElementById('pricelists-body').innerHTML = `
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" class="px-6 py-4 text-center text-red-500">
|
<td colspan="8" class="px-6 py-4 text-center text-red-500">
|
||||||
Ошибка загрузки: ${e.message}
|
Ошибка загрузки: ${e.message}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1705,7 +1879,7 @@ function renderPricelists(pricelists) {
|
|||||||
if (pricelists.length === 0) {
|
if (pricelists.length === 0) {
|
||||||
document.getElementById('pricelists-body').innerHTML = `
|
document.getElementById('pricelists-body').innerHTML = `
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" class="px-6 py-4 text-center text-gray-500">
|
<td colspan="8" class="px-6 py-4 text-center text-gray-500">
|
||||||
Прайслисты не найдены. ${pricelistsCanWrite ? 'Создайте первый прайслист.' : ''}
|
Прайслисты не найдены. ${pricelistsCanWrite ? 'Создайте первый прайслист.' : ''}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1717,21 +1891,39 @@ function renderPricelists(pricelists) {
|
|||||||
const date = new Date(pl.created_at).toLocaleDateString('ru-RU');
|
const date = new Date(pl.created_at).toLocaleDateString('ru-RU');
|
||||||
const statusClass = pl.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800';
|
const statusClass = pl.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800';
|
||||||
const statusText = pl.is_active ? 'Активен' : 'Неактивен';
|
const statusText = pl.is_active ? 'Активен' : 'Неактивен';
|
||||||
|
const sourceLabel = formatPricelistSourceLabel(pl.source);
|
||||||
|
|
||||||
let actions = `<a href="/pricelists/${pl.id}" class="text-blue-600 hover:text-blue-800 text-sm">Просмотр</a>`;
|
let actions = '';
|
||||||
if (pricelistsCanWrite) {
|
if (pricelistsCanWrite) {
|
||||||
const toggleLabel = pl.is_active ? 'Деактивировать' : 'Активировать';
|
const toggleTitle = pl.is_active ? 'Деактивировать' : 'Активировать';
|
||||||
actions += ` <button onclick="togglePricelistActive(${pl.id}, ${pl.is_active ? 'false' : 'true'})" class="text-indigo-600 hover:text-indigo-800 text-sm ml-2">${toggleLabel}</button>`;
|
actions += ` <button onclick="togglePricelistActive(${pl.id}, ${pl.is_active ? 'false' : 'true'})" class="text-indigo-600 hover:text-indigo-800" title="${toggleTitle}">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14"></path>
|
||||||
|
</svg>
|
||||||
|
</button>`;
|
||||||
}
|
}
|
||||||
if (pricelistsCanWrite && pl.usage_count === 0) {
|
if (pricelistsCanWrite && pl.usage_count === 0) {
|
||||||
actions += ` <button onclick="deletePricelist(${pl.id})" class="text-red-600 hover:text-red-800 text-sm ml-2">Удалить</button>`;
|
actions += ` <button onclick="deletePricelist(${pl.id})" class="text-red-600 hover:text-red-800" title="Удалить">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 7h12M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2m-7 0v12m4-12v12m4-12v12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>`;
|
||||||
|
} else {
|
||||||
|
actions += ` <span class="text-gray-300" title="Удаление недоступно">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 7h12M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2m-7 0v12m4-12v12m4-12v12"></path>
|
||||||
|
</svg>
|
||||||
|
</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const versionCell = `<a href="/pricelists/${pl.id}" class="font-mono text-sm text-blue-600 hover:text-blue-800 hover:underline">${pl.version}</a>`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr class="hover:bg-gray-50">
|
<tr class="hover:bg-gray-50">
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<span class="font-mono text-sm">${pl.version}</span>
|
${versionCell}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${sourceLabel}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${date}</td>
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${date}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${pl.created_by || '-'}</td>
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${pl.created_by || '-'}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-center text-sm">${pl.item_count}</td>
|
<td class="px-6 py-4 whitespace-nowrap text-center text-sm">${pl.item_count}</td>
|
||||||
@@ -1739,7 +1931,9 @@ function renderPricelists(pricelists) {
|
|||||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
<span class="px-2 py-1 text-xs rounded-full ${statusClass}">${statusText}</span>
|
<span class="px-2 py-1 text-xs rounded-full ${statusClass}">${statusText}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right">${actions}</td>
|
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||||
|
<div class="inline-flex items-center justify-end gap-3">${actions}</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -1807,6 +2001,10 @@ async function loadPricelistsDbUsername() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openPricelistsCreateModal() {
|
function openPricelistsCreateModal() {
|
||||||
|
if (!canCreatePricelistForCurrentTab()) {
|
||||||
|
showToast('Создание доступно только на вкладках Estimate, Склад и Конкуренты', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
document.getElementById('pricelists-create-modal').classList.remove('hidden');
|
document.getElementById('pricelists-create-modal').classList.remove('hidden');
|
||||||
document.getElementById('pricelists-create-modal').classList.add('flex');
|
document.getElementById('pricelists-create-modal').classList.add('flex');
|
||||||
resetPricelistCreateProgress();
|
resetPricelistCreateProgress();
|
||||||
@@ -1829,6 +2027,9 @@ async function checkOnlineStatus() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createPricelist() {
|
async function createPricelist() {
|
||||||
|
if (!canCreatePricelistForCurrentTab()) {
|
||||||
|
throw new Error('Выберите вкладку Estimate, Склад или Конкуренты');
|
||||||
|
}
|
||||||
// Check if online before creating
|
// Check if online before creating
|
||||||
const isOnline = await checkOnlineStatus();
|
const isOnline = await checkOnlineStatus();
|
||||||
if (!isOnline) {
|
if (!isOnline) {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 pb-12">
|
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
{{template "content" .}}
|
{{template "content" .}}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
|
|
||||||
<!-- Sync Info Modal -->
|
<!-- Sync Info Modal -->
|
||||||
<div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
<div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||||||
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
|
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4">
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h3 class="text-lg font-medium text-gray-900">Информация о синхронизации</h3>
|
<h3 class="text-lg font-medium text-gray-900">Информация о синхронизации</h3>
|
||||||
@@ -57,27 +57,63 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-5">
|
||||||
|
<!-- Section 1: DB Connection -->
|
||||||
<div>
|
<div>
|
||||||
<h4 class="font-medium text-gray-900">Статус БД</h4>
|
<h4 class="font-medium text-gray-900 mb-2">Подключение к БД</h4>
|
||||||
<p id="modal-db-status" class="text-sm text-gray-600">Проверка...</p>
|
<div class="text-sm space-y-1">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500">Адрес:</span>
|
||||||
|
<span id="modal-db-host" class="text-gray-700 font-mono">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500">Пользователь:</span>
|
||||||
|
<span id="modal-db-user" class="text-gray-700">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500">Статус:</span>
|
||||||
|
<span id="modal-db-status" class="text-gray-700">Проверка...</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500">Последняя синхронизация:</span>
|
||||||
|
<span id="modal-last-sync" class="text-gray-700">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Section 2: Statistics -->
|
||||||
<div>
|
<div>
|
||||||
<h4 class="font-medium text-gray-900">Количество ошибок</h4>
|
<h4 class="font-medium text-gray-900 mb-2">Статистика</h4>
|
||||||
<p id="modal-error-count" class="text-sm text-gray-600">0</p>
|
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div class="flex justify-between bg-gray-50 rounded px-3 py-1.5">
|
||||||
|
<span class="text-gray-500">Компоненты (lot):</span>
|
||||||
|
<span id="modal-lot-count" class="font-medium text-gray-700">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between bg-gray-50 rounded px-3 py-1.5">
|
||||||
|
<span class="text-gray-500">Котировки:</span>
|
||||||
|
<span id="modal-lotlog-count" class="font-medium text-gray-700">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between bg-gray-50 rounded px-3 py-1.5">
|
||||||
|
<span class="text-gray-500">Конфигурации:</span>
|
||||||
|
<span id="modal-config-count" class="font-medium text-gray-700">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between bg-gray-50 rounded px-3 py-1.5">
|
||||||
|
<span class="text-gray-500">Проекты:</span>
|
||||||
|
<span id="modal-project-count" class="font-medium text-gray-700">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<!-- Section 3: Pending Changes (shown only if any) -->
|
||||||
<h4 class="font-medium text-gray-900">Последняя синхронизация</h4>
|
<div id="modal-pending-section" class="hidden">
|
||||||
<p id="modal-last-sync" class="text-sm text-gray-600">-</p>
|
<h4 class="font-medium text-gray-900 mb-2">Ожидающие синхронизации</h4>
|
||||||
|
<div id="modal-pending-list" class="text-sm max-h-40 overflow-y-auto space-y-1"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<!-- Section 4: Errors (shown only if any) -->
|
||||||
<h4 class="font-medium text-gray-900">Список ошибок</h4>
|
<div id="modal-errors-section" class="hidden">
|
||||||
<div id="modal-errors-list" class="mt-2 text-sm text-gray-600 max-h-40 overflow-y-auto">
|
<h4 class="font-medium text-gray-900 mb-2">Ошибки синхронизации</h4>
|
||||||
<p>Нет ошибок</p>
|
<div id="modal-errors-list" class="text-sm max-h-40 overflow-y-auto space-y-1"></div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -90,13 +126,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="fixed bottom-0 left-0 right-0 bg-gray-800 text-gray-300 text-xs py-1 px-4">
|
|
||||||
<div class="max-w-7xl mx-auto flex justify-between">
|
|
||||||
<span id="db-status">БД: проверка...</span>
|
|
||||||
<span id="db-counts"></span>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function showToast(msg, type) {
|
function showToast(msg, type) {
|
||||||
const colors = { success: 'bg-green-500', error: 'bg-red-500', info: 'bg-blue-500' };
|
const colors = { success: 'bg-green-500', error: 'bg-red-500', info: 'bg-blue-500' };
|
||||||
@@ -129,37 +158,75 @@
|
|||||||
const resp = await fetch('/api/sync/info');
|
const resp = await fetch('/api/sync/info');
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
|
|
||||||
document.getElementById('modal-db-status').textContent = data.is_online ? 'Подключено' : 'Отключено';
|
// Section 1: DB Connection
|
||||||
document.getElementById('modal-error-count').textContent = data.error_count;
|
document.getElementById('modal-db-host').textContent = data.db_host ? data.db_host + '/' + data.db_name : '—';
|
||||||
|
document.getElementById('modal-db-user').textContent = data.db_user || '—';
|
||||||
|
|
||||||
|
const statusEl = document.getElementById('modal-db-status');
|
||||||
|
if (data.is_online) {
|
||||||
|
statusEl.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-green-500 mr-1"></span>Online';
|
||||||
|
} else {
|
||||||
|
statusEl.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-red-500 mr-1"></span>Offline';
|
||||||
|
}
|
||||||
|
|
||||||
if (data.last_sync_at) {
|
if (data.last_sync_at) {
|
||||||
const date = new Date(data.last_sync_at);
|
const date = new Date(data.last_sync_at);
|
||||||
document.getElementById('modal-last-sync').textContent = date.toLocaleString('ru-RU');
|
document.getElementById('modal-last-sync').textContent = date.toLocaleString('ru-RU');
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('modal-last-sync').textContent = 'Нет данных';
|
document.getElementById('modal-last-sync').textContent = '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load error list
|
// Section 2: Statistics
|
||||||
|
document.getElementById('modal-lot-count').textContent = data.is_online ? data.lot_count.toLocaleString() : '—';
|
||||||
|
document.getElementById('modal-lotlog-count').textContent = data.is_online ? data.lot_log_count.toLocaleString() : '—';
|
||||||
|
document.getElementById('modal-config-count').textContent = data.config_count.toLocaleString();
|
||||||
|
document.getElementById('modal-project-count').textContent = data.project_count.toLocaleString();
|
||||||
|
|
||||||
|
// Section 3: Pending changes
|
||||||
|
const pendingSection = document.getElementById('modal-pending-section');
|
||||||
|
const pendingList = document.getElementById('modal-pending-list');
|
||||||
|
if (data.pending_changes && data.pending_changes.length > 0) {
|
||||||
|
pendingSection.classList.remove('hidden');
|
||||||
|
pendingList.innerHTML = data.pending_changes.map(ch => {
|
||||||
|
const shortUUID = ch.entity_uuid.substring(0, 8);
|
||||||
|
const time = new Date(ch.created_at).toLocaleString('ru-RU');
|
||||||
|
const hasError = ch.last_error ? ' border-l-2 border-red-400 pl-2' : '';
|
||||||
|
const errorLine = ch.last_error ? `<div class="text-red-500 text-xs mt-0.5">${ch.last_error}</div>` : '';
|
||||||
|
return `<div class="bg-gray-50 rounded px-3 py-1.5${hasError}">
|
||||||
|
<span class="font-medium">${ch.operation}</span>
|
||||||
|
<span class="text-gray-500">${ch.entity_type}</span>
|
||||||
|
<span class="font-mono text-xs text-gray-400">${shortUUID}</span>
|
||||||
|
<span class="text-gray-400 text-xs ml-1">${time}</span>
|
||||||
|
${errorLine}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
} else {
|
||||||
|
pendingSection.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section 4: Errors
|
||||||
|
const errorsSection = document.getElementById('modal-errors-section');
|
||||||
const errorsList = document.getElementById('modal-errors-list');
|
const errorsList = document.getElementById('modal-errors-list');
|
||||||
if (data.errors && data.errors.length > 0) {
|
if (data.errors && data.errors.length > 0) {
|
||||||
errorsList.innerHTML = data.errors.map(error =>
|
errorsSection.classList.remove('hidden');
|
||||||
`<div class="mb-1"><span class="font-medium">${error.timestamp}</span>: ${error.message}</div>`
|
errorsList.innerHTML = data.errors.map(error => {
|
||||||
).join('');
|
const time = new Date(error.timestamp).toLocaleString('ru-RU');
|
||||||
|
return `<div class="bg-red-50 text-red-700 rounded px-3 py-1.5">
|
||||||
|
<span class="text-xs text-red-400">${time}</span>: ${error.message}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
} else {
|
} else {
|
||||||
errorsList.innerHTML = '<p>Нет ошибок</p>';
|
errorsSection.classList.add('hidden');
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error('Failed to load sync info:', e);
|
console.error('Failed to load sync info:', e);
|
||||||
document.getElementById('modal-db-status').textContent = 'Ошибка загрузки';
|
document.getElementById('modal-db-status').textContent = 'Ошибка загрузки';
|
||||||
document.getElementById('modal-error-count').textContent = '0';
|
|
||||||
document.getElementById('modal-last-sync').textContent = '-';
|
|
||||||
document.getElementById('modal-errors-list').innerHTML = '<p>Ошибка загрузки данных</p>';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event delegation for sync dropdown and actions
|
// Event delegation for sync dropdown and actions
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
checkDbStatus();
|
loadDBUser();
|
||||||
checkWritePermission();
|
checkWritePermission();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -214,26 +281,16 @@
|
|||||||
syncAction('/api/sync/all', 'Полная синхронизация завершена', button, originalHTML);
|
syncAction('/api/sync/all', 'Полная синхронизация завершена', button, originalHTML);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkDbStatus() {
|
async function loadDBUser() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/db-status');
|
const resp = await fetch('/api/db-status');
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
const statusEl = document.getElementById('db-status');
|
|
||||||
const countsEl = document.getElementById('db-counts');
|
|
||||||
const userEl = document.getElementById('db-user');
|
const userEl = document.getElementById('db-user');
|
||||||
|
if (data.connected && data.db_user) {
|
||||||
if (data.connected) {
|
|
||||||
statusEl.innerHTML = '<span class="text-green-400">БД: подключено</span>';
|
|
||||||
if (data.db_user) {
|
|
||||||
userEl.innerHTML = '<span class="text-gray-500">@</span>' + data.db_user;
|
userEl.innerHTML = '<span class="text-gray-500">@</span>' + data.db_user;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
statusEl.innerHTML = '<span class="text-red-400">БД: ошибка - ' + data.error + '</span>';
|
|
||||||
}
|
|
||||||
|
|
||||||
countsEl.textContent = 'lot: ' + data.lot_count + ' | lot_log: ' + data.lot_log_count + ' | metadata: ' + data.metadata_count;
|
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
document.getElementById('db-status').innerHTML = '<span class="text-red-400">БД: нет связи</span>';
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,7 +319,7 @@
|
|||||||
|
|
||||||
// Call functions immediately to ensure they run even before DOMContentLoaded
|
// Call functions immediately to ensure they run even before DOMContentLoaded
|
||||||
// This ensures username and admin link are visible ASAP
|
// This ensures username and admin link are visible ASAP
|
||||||
checkDbStatus();
|
loadDBUser();
|
||||||
checkWritePermission();
|
checkWritePermission();
|
||||||
|
|
||||||
// Load last sync time - removed since dropdown is gone
|
// Load last sync time - removed since dropdown is gone
|
||||||
|
|||||||
@@ -246,6 +246,10 @@
|
|||||||
<input id="settings-disable-price-refresh" type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
<input id="settings-disable-price-refresh" type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||||
<span>Не обновлять цены</span>
|
<span>Не обновлять цены</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||||
|
<input id="settings-only-in-stock" type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||||
|
<span>Только наличие</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-5 py-4 border-t flex justify-end gap-2">
|
<div class="px-5 py-4 border-t flex justify-end gap-2">
|
||||||
<button type="button" onclick="closePriceSettingsModal()" class="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200">Отмена</button>
|
<button type="button" onclick="closePriceSettingsModal()" class="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200">Отмена</button>
|
||||||
@@ -334,12 +338,19 @@ let selectedPricelistIds = {
|
|||||||
competitor: null
|
competitor: null
|
||||||
};
|
};
|
||||||
let disablePriceRefresh = false;
|
let disablePriceRefresh = false;
|
||||||
|
let onlyInStock = false;
|
||||||
let activePricelistsBySource = {
|
let activePricelistsBySource = {
|
||||||
estimate: [],
|
estimate: [],
|
||||||
warehouse: [],
|
warehouse: [],
|
||||||
competitor: []
|
competitor: []
|
||||||
};
|
};
|
||||||
|
let activePricelistsLoadedAt = 0;
|
||||||
|
let activePricelistsLoadPromise = null;
|
||||||
let priceLevelsRequestSeq = 0;
|
let priceLevelsRequestSeq = 0;
|
||||||
|
let priceLevelsRefreshTimer = null;
|
||||||
|
let warehouseStockLotsByPricelist = new Map();
|
||||||
|
let warehouseStockLoadSeq = 0;
|
||||||
|
let warehouseStockLoadsByPricelist = new Map();
|
||||||
|
|
||||||
// Autocomplete state
|
// Autocomplete state
|
||||||
let autocompleteInput = null;
|
let autocompleteInput = null;
|
||||||
@@ -389,8 +400,10 @@ function formatDelta(abs, pct) {
|
|||||||
return sign + formatMoney(absValue) + ' (' + pctSign + Math.round(Math.abs(pct)) + '%)';
|
return sign + formatMoney(absValue) + ' (' + pctSign + Math.round(Math.abs(pct)) + '%)';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshPriceLevels() {
|
async function refreshPriceLevels(options = {}) {
|
||||||
if (!configUUID || cart.length === 0 || disablePriceRefresh) {
|
const force = options.force === true;
|
||||||
|
const noCache = options.noCache === true;
|
||||||
|
if (!configUUID || cart.length === 0 || (disablePriceRefresh && !force)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,6 +414,7 @@ async function refreshPriceLevels() {
|
|||||||
lot_name: item.lot_name,
|
lot_name: item.lot_name,
|
||||||
quantity: item.quantity
|
quantity: item.quantity
|
||||||
})),
|
})),
|
||||||
|
no_cache: noCache,
|
||||||
pricelist_ids: Object.fromEntries(
|
pricelist_ids: Object.fromEntries(
|
||||||
Object.entries(selectedPricelistIds)
|
Object.entries(selectedPricelistIds)
|
||||||
.filter(([, id]) => typeof id === 'number' && id > 0)
|
.filter(([, id]) => typeof id === 'number' && id > 0)
|
||||||
@@ -443,6 +457,99 @@ async function refreshPriceLevels() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function schedulePriceLevelsRefresh(options = {}) {
|
||||||
|
const delay = Number.isFinite(options.delay) ? options.delay : 120;
|
||||||
|
const rerender = options.rerender !== false;
|
||||||
|
const autosave = options.autosave === true;
|
||||||
|
const noCache = options.noCache === true;
|
||||||
|
const force = options.force === true;
|
||||||
|
|
||||||
|
if (priceLevelsRefreshTimer) {
|
||||||
|
clearTimeout(priceLevelsRefreshTimer);
|
||||||
|
priceLevelsRefreshTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
priceLevelsRefreshTimer = setTimeout(async () => {
|
||||||
|
priceLevelsRefreshTimer = null;
|
||||||
|
await refreshPriceLevels({ noCache, force });
|
||||||
|
if (rerender) {
|
||||||
|
renderTab();
|
||||||
|
updateCartUI();
|
||||||
|
}
|
||||||
|
if (autosave) {
|
||||||
|
triggerAutoSave();
|
||||||
|
}
|
||||||
|
}, Math.max(0, delay));
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentWarehousePricelistID() {
|
||||||
|
const id = selectedPricelistIds.warehouse;
|
||||||
|
if (Number.isFinite(id) && id > 0) return Number(id);
|
||||||
|
const fallback = activePricelistsBySource.warehouse?.[0]?.id;
|
||||||
|
if (Number.isFinite(fallback) && fallback > 0) return Number(fallback);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWarehouseInStockLots() {
|
||||||
|
const pricelistID = currentWarehousePricelistID();
|
||||||
|
if (!pricelistID) return new Set();
|
||||||
|
if (warehouseStockLotsByPricelist.has(pricelistID)) {
|
||||||
|
return warehouseStockLotsByPricelist.get(pricelistID);
|
||||||
|
}
|
||||||
|
const existingLoad = warehouseStockLoadsByPricelist.get(pricelistID);
|
||||||
|
if (existingLoad) {
|
||||||
|
return existingLoad;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPromise = (async () => {
|
||||||
|
const seq = ++warehouseStockLoadSeq;
|
||||||
|
const result = new Set();
|
||||||
|
const resp = await fetch(`/api/pricelists/${pricelistID}/lots`);
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`warehouse lots request failed: ${resp.status}`);
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
const lotNames = Array.isArray(data.lot_names) ? data.lot_names : [];
|
||||||
|
lotNames.forEach(lot => {
|
||||||
|
if (typeof lot === 'string' && lot.trim() !== '') {
|
||||||
|
result.add(lot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (seq === warehouseStockLoadSeq) {
|
||||||
|
warehouseStockLotsByPricelist.set(pricelistID, result);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
})();
|
||||||
|
|
||||||
|
warehouseStockLoadsByPricelist.set(pricelistID, loadPromise);
|
||||||
|
try {
|
||||||
|
return await loadPromise;
|
||||||
|
} finally {
|
||||||
|
warehouseStockLoadsByPricelist.delete(pricelistID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureWarehouseStockFilterLoaded() {
|
||||||
|
if (!onlyInStock) return;
|
||||||
|
try {
|
||||||
|
await loadWarehouseInStockLots();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load warehouse availability filter', e);
|
||||||
|
showToast('Не удалось загрузить наличие склада', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isComponentAllowedByStockFilter(comp) {
|
||||||
|
if (!onlyInStock) return true;
|
||||||
|
const pricelistID = currentWarehousePricelistID();
|
||||||
|
if (!pricelistID) return false;
|
||||||
|
const availableLots = warehouseStockLotsByPricelist.get(pricelistID);
|
||||||
|
// Don't block UI while stock set is being loaded.
|
||||||
|
if (!availableLots) return true;
|
||||||
|
return availableLots.has(comp.lot_name);
|
||||||
|
}
|
||||||
|
|
||||||
// Load categories from API and update tab configuration
|
// Load categories from API and update tab configuration
|
||||||
async function loadCategoriesFromAPI() {
|
async function loadCategoriesFromAPI() {
|
||||||
try {
|
try {
|
||||||
@@ -486,8 +593,8 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load categories first
|
// Load categories in background (defaults are usable immediately).
|
||||||
await loadCategoriesFromAPI();
|
const categoriesPromise = loadCategoriesFromAPI().catch(() => {});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/configs/' + configUUID);
|
const resp = await fetch('/api/configs/' + configUUID);
|
||||||
@@ -508,6 +615,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
document.getElementById('server-count').value = serverCount;
|
document.getElementById('server-count').value = serverCount;
|
||||||
document.getElementById('total-server-count').textContent = serverCount;
|
document.getElementById('total-server-count').textContent = serverCount;
|
||||||
selectedPricelistIds.estimate = config.pricelist_id || null;
|
selectedPricelistIds.estimate = config.pricelist_id || null;
|
||||||
|
onlyInStock = Boolean(config.only_in_stock);
|
||||||
|
|
||||||
if (config.items && config.items.length > 0) {
|
if (config.items && config.items.length > 0) {
|
||||||
cart = config.items.map(item => ({
|
cart = config.items.map(item => ({
|
||||||
@@ -538,14 +646,21 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
restoreLocalPriceSettings();
|
restoreLocalPriceSettings();
|
||||||
await loadActivePricelists();
|
await Promise.all([
|
||||||
|
loadActivePricelists(),
|
||||||
|
loadAllComponents(),
|
||||||
|
categoriesPromise,
|
||||||
|
]);
|
||||||
syncPriceSettingsControls();
|
syncPriceSettingsControls();
|
||||||
renderPricelistSettingsSummary();
|
renderPricelistSettingsSummary();
|
||||||
updateRefreshPricesButtonState();
|
updateRefreshPricesButtonState();
|
||||||
await loadAllComponents();
|
|
||||||
await refreshPriceLevels();
|
|
||||||
renderTab();
|
renderTab();
|
||||||
updateCartUI();
|
updateCartUI();
|
||||||
|
ensureWarehouseStockFilterLoaded().then(() => {
|
||||||
|
renderTab();
|
||||||
|
updateCartUI();
|
||||||
|
});
|
||||||
|
schedulePriceLevelsRefresh({ delay: 0, rerender: true, autosave: false });
|
||||||
|
|
||||||
// Close autocomplete on outside click
|
// Close autocomplete on outside click
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
@@ -582,8 +697,19 @@ function updateServerCount() {
|
|||||||
triggerAutoSave();
|
triggerAutoSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadActivePricelists() {
|
async function loadActivePricelists(force = false) {
|
||||||
|
const now = Date.now();
|
||||||
|
const isFresh = (now - activePricelistsLoadedAt) < 15000;
|
||||||
|
if (!force && isFresh) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activePricelistsLoadPromise) {
|
||||||
|
await activePricelistsLoadPromise;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const sources = ['estimate', 'warehouse', 'competitor'];
|
const sources = ['estimate', 'warehouse', 'competitor'];
|
||||||
|
activePricelistsLoadPromise = (async () => {
|
||||||
await Promise.all(sources.map(async source => {
|
await Promise.all(sources.map(async source => {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/pricelists?active_only=true&source=${source}&per_page=200`);
|
const resp = await fetch(`/api/pricelists?active_only=true&source=${source}&per_page=200`);
|
||||||
@@ -601,6 +727,14 @@ async function loadActivePricelists() {
|
|||||||
selectedPricelistIds[source] = null;
|
selectedPricelistIds[source] = null;
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
activePricelistsLoadedAt = Date.now();
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await activePricelistsLoadPromise;
|
||||||
|
} finally {
|
||||||
|
activePricelistsLoadPromise = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPricelistSelectOptions(selectId, source) {
|
function renderPricelistSelectOptions(selectId, source) {
|
||||||
@@ -627,6 +761,10 @@ function syncPriceSettingsControls() {
|
|||||||
if (disableCheckbox) {
|
if (disableCheckbox) {
|
||||||
disableCheckbox.checked = disablePriceRefresh;
|
disableCheckbox.checked = disablePriceRefresh;
|
||||||
}
|
}
|
||||||
|
const inStockCheckbox = document.getElementById('settings-only-in-stock');
|
||||||
|
if (inStockCheckbox) {
|
||||||
|
inStockCheckbox.checked = onlyInStock;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPricelistVersionById(source, id) {
|
function getPricelistVersionById(source, id) {
|
||||||
@@ -642,7 +780,8 @@ function renderPricelistSettingsSummary() {
|
|||||||
const warehouse = selectedPricelistIds.warehouse ? getPricelistVersionById('warehouse', selectedPricelistIds.warehouse) || `ID ${selectedPricelistIds.warehouse}` : 'авто';
|
const warehouse = selectedPricelistIds.warehouse ? getPricelistVersionById('warehouse', selectedPricelistIds.warehouse) || `ID ${selectedPricelistIds.warehouse}` : 'авто';
|
||||||
const competitor = selectedPricelistIds.competitor ? getPricelistVersionById('competitor', selectedPricelistIds.competitor) || `ID ${selectedPricelistIds.competitor}` : 'авто';
|
const competitor = selectedPricelistIds.competitor ? getPricelistVersionById('competitor', selectedPricelistIds.competitor) || `ID ${selectedPricelistIds.competitor}` : 'авто';
|
||||||
const refreshState = disablePriceRefresh ? ' | Обновление цен: выкл' : '';
|
const refreshState = disablePriceRefresh ? ' | Обновление цен: выкл' : '';
|
||||||
summary.textContent = `Estimate: ${estimate}, Склад: ${warehouse}, Конкуренты: ${competitor}${refreshState}`;
|
const stockFilterState = onlyInStock ? ' | Только наличие: вкл' : '';
|
||||||
|
summary.textContent = `Estimate: ${estimate}, Склад: ${warehouse}, Конкуренты: ${competitor}${refreshState}${stockFilterState}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateRefreshPricesButtonState() {
|
function updateRefreshPricesButtonState() {
|
||||||
@@ -693,11 +832,14 @@ function restoreLocalPriceSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openPriceSettingsModal() {
|
function openPriceSettingsModal() {
|
||||||
await loadActivePricelists();
|
|
||||||
syncPriceSettingsControls();
|
syncPriceSettingsControls();
|
||||||
renderPricelistSettingsSummary();
|
renderPricelistSettingsSummary();
|
||||||
document.getElementById('price-settings-modal')?.classList.remove('hidden');
|
document.getElementById('price-settings-modal')?.classList.remove('hidden');
|
||||||
|
loadActivePricelists().then(() => {
|
||||||
|
syncPriceSettingsControls();
|
||||||
|
renderPricelistSettingsSummary();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePriceSettingsModal() {
|
function closePriceSettingsModal() {
|
||||||
@@ -709,22 +851,31 @@ function applyPriceSettings() {
|
|||||||
const warehouseVal = parseInt(document.getElementById('settings-pricelist-warehouse')?.value || '');
|
const warehouseVal = parseInt(document.getElementById('settings-pricelist-warehouse')?.value || '');
|
||||||
const competitorVal = parseInt(document.getElementById('settings-pricelist-competitor')?.value || '');
|
const competitorVal = parseInt(document.getElementById('settings-pricelist-competitor')?.value || '');
|
||||||
const disableVal = Boolean(document.getElementById('settings-disable-price-refresh')?.checked);
|
const disableVal = Boolean(document.getElementById('settings-disable-price-refresh')?.checked);
|
||||||
|
const inStockVal = Boolean(document.getElementById('settings-only-in-stock')?.checked);
|
||||||
|
|
||||||
|
const prevWarehouseID = currentWarehousePricelistID();
|
||||||
selectedPricelistIds.estimate = Number.isFinite(estimateVal) && estimateVal > 0 ? estimateVal : null;
|
selectedPricelistIds.estimate = Number.isFinite(estimateVal) && estimateVal > 0 ? estimateVal : null;
|
||||||
selectedPricelistIds.warehouse = Number.isFinite(warehouseVal) && warehouseVal > 0 ? warehouseVal : null;
|
selectedPricelistIds.warehouse = Number.isFinite(warehouseVal) && warehouseVal > 0 ? warehouseVal : null;
|
||||||
selectedPricelistIds.competitor = Number.isFinite(competitorVal) && competitorVal > 0 ? competitorVal : null;
|
selectedPricelistIds.competitor = Number.isFinite(competitorVal) && competitorVal > 0 ? competitorVal : null;
|
||||||
disablePriceRefresh = disableVal;
|
disablePriceRefresh = disableVal;
|
||||||
|
onlyInStock = inStockVal;
|
||||||
|
|
||||||
|
const nextWarehouseID = currentWarehousePricelistID();
|
||||||
|
if (Number.isFinite(prevWarehouseID) && prevWarehouseID > 0 && prevWarehouseID !== nextWarehouseID) {
|
||||||
|
warehouseStockLotsByPricelist.delete(prevWarehouseID);
|
||||||
|
}
|
||||||
|
if (onlyInStock) {
|
||||||
|
ensureWarehouseStockFilterLoaded().then(() => {
|
||||||
|
renderTab();
|
||||||
|
updateCartUI();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
updateRefreshPricesButtonState();
|
updateRefreshPricesButtonState();
|
||||||
renderPricelistSettingsSummary();
|
renderPricelistSettingsSummary();
|
||||||
persistLocalPriceSettings();
|
persistLocalPriceSettings();
|
||||||
closePriceSettingsModal();
|
closePriceSettingsModal();
|
||||||
|
schedulePriceLevelsRefresh({ delay: 0, rerender: true, autosave: true });
|
||||||
refreshPriceLevels().then(() => {
|
|
||||||
renderTab();
|
|
||||||
updateCartUI();
|
|
||||||
triggerAutoSave();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCategoryFromLotName(lotName) {
|
function getCategoryFromLotName(lotName) {
|
||||||
@@ -1065,6 +1216,7 @@ function filterAutocomplete(category, search) {
|
|||||||
|
|
||||||
autocompleteFiltered = components.filter(c => {
|
autocompleteFiltered = components.filter(c => {
|
||||||
if (!c.current_price) return false;
|
if (!c.current_price) return false;
|
||||||
|
if (!isComponentAllowedByStockFilter(c)) return false;
|
||||||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||||||
return text.includes(searchLower);
|
return text.includes(searchLower);
|
||||||
})
|
})
|
||||||
@@ -1166,11 +1318,10 @@ function selectAutocompleteItem(index) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
hideAutocomplete();
|
hideAutocomplete();
|
||||||
refreshPriceLevels().then(() => {
|
|
||||||
renderTab();
|
renderTab();
|
||||||
updateCartUI();
|
updateCartUI();
|
||||||
triggerAutoSave();
|
triggerAutoSave();
|
||||||
});
|
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideAutocomplete() {
|
function hideAutocomplete() {
|
||||||
@@ -1200,6 +1351,7 @@ function filterAutocompleteMulti(search) {
|
|||||||
autocompleteFiltered = components.filter(c => {
|
autocompleteFiltered = components.filter(c => {
|
||||||
if (!c.current_price) return false;
|
if (!c.current_price) return false;
|
||||||
if (addedLots.has(c.lot_name)) return false;
|
if (addedLots.has(c.lot_name)) return false;
|
||||||
|
if (!isComponentAllowedByStockFilter(c)) return false;
|
||||||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||||||
return text.includes(searchLower);
|
return text.includes(searchLower);
|
||||||
})
|
})
|
||||||
@@ -1258,11 +1410,10 @@ function selectAutocompleteItemMulti(index) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
hideAutocomplete();
|
hideAutocomplete();
|
||||||
refreshPriceLevels().then(() => {
|
|
||||||
renderTab();
|
renderTab();
|
||||||
updateCartUI();
|
updateCartUI();
|
||||||
triggerAutoSave();
|
triggerAutoSave();
|
||||||
});
|
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Autocomplete for sectioned tabs (like storage with RAID and Disks sections)
|
// Autocomplete for sectioned tabs (like storage with RAID and Disks sections)
|
||||||
@@ -1299,6 +1450,7 @@ function filterAutocompleteSection(sectionId, search, inputElement) {
|
|||||||
autocompleteFiltered = sectionComponents.filter(c => {
|
autocompleteFiltered = sectionComponents.filter(c => {
|
||||||
if (!c.current_price) return false;
|
if (!c.current_price) return false;
|
||||||
if (addedLots.has(c.lot_name)) return false;
|
if (addedLots.has(c.lot_name)) return false;
|
||||||
|
if (!isComponentAllowedByStockFilter(c)) return false;
|
||||||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||||||
return text.includes(searchLower);
|
return text.includes(searchLower);
|
||||||
})
|
})
|
||||||
@@ -1364,12 +1516,10 @@ function selectAutocompleteItemSection(index, sectionId) {
|
|||||||
|
|
||||||
// Reset quantity to 1
|
// Reset quantity to 1
|
||||||
if (qtyInput) qtyInput.value = '1';
|
if (qtyInput) qtyInput.value = '1';
|
||||||
|
|
||||||
refreshPriceLevels().then(() => {
|
|
||||||
renderTab();
|
renderTab();
|
||||||
updateCartUI();
|
updateCartUI();
|
||||||
triggerAutoSave();
|
triggerAutoSave();
|
||||||
});
|
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSingleSelect(category) {
|
function clearSingleSelect(category) {
|
||||||
@@ -1517,6 +1667,12 @@ function triggerAutoSave() {
|
|||||||
async function saveConfig(showNotification = true) {
|
async function saveConfig(showNotification = true) {
|
||||||
// RBAC disabled - no token check required
|
// RBAC disabled - no token check required
|
||||||
if (!configUUID) return;
|
if (!configUUID) return;
|
||||||
|
if (priceLevelsRefreshTimer) {
|
||||||
|
clearTimeout(priceLevelsRefreshTimer);
|
||||||
|
priceLevelsRefreshTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshPriceLevels({ force: true, noCache: true });
|
||||||
|
|
||||||
// Get custom price if set
|
// Get custom price if set
|
||||||
const customPriceInput = document.getElementById('custom-price-input');
|
const customPriceInput = document.getElementById('custom-price-input');
|
||||||
@@ -1538,7 +1694,8 @@ async function saveConfig(showNotification = true) {
|
|||||||
custom_price: customPrice,
|
custom_price: customPrice,
|
||||||
notes: '',
|
notes: '',
|
||||||
server_count: serverCountValue,
|
server_count: serverCountValue,
|
||||||
pricelist_id: selectedPricelistIds.estimate
|
pricelist_id: selectedPricelistIds.estimate,
|
||||||
|
only_in_stock: onlyInStock
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1563,10 +1720,20 @@ async function exportCSV() {
|
|||||||
if (cart.length === 0) return;
|
if (cart.length === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (priceLevelsRefreshTimer) {
|
||||||
|
clearTimeout(priceLevelsRefreshTimer);
|
||||||
|
priceLevelsRefreshTimer = null;
|
||||||
|
}
|
||||||
|
await refreshPriceLevels({ force: true, noCache: true });
|
||||||
|
|
||||||
|
const exportItems = cart.map(item => ({
|
||||||
|
...item,
|
||||||
|
unit_price: getDisplayPrice(item),
|
||||||
|
}));
|
||||||
const resp = await fetch('/api/export/csv', {
|
const resp = await fetch('/api/export/csv', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({items: cart, name: configName})
|
body: JSON.stringify({items: exportItems, name: configName})
|
||||||
});
|
});
|
||||||
|
|
||||||
const blob = await resp.blob();
|
const blob = await resp.blob();
|
||||||
@@ -1793,6 +1960,11 @@ function clearCustomPrice() {
|
|||||||
|
|
||||||
async function exportCSVWithCustomPrice() {
|
async function exportCSVWithCustomPrice() {
|
||||||
if (cart.length === 0) return;
|
if (cart.length === 0) return;
|
||||||
|
if (priceLevelsRefreshTimer) {
|
||||||
|
clearTimeout(priceLevelsRefreshTimer);
|
||||||
|
priceLevelsRefreshTimer = null;
|
||||||
|
}
|
||||||
|
await refreshPriceLevels({ force: true, noCache: true });
|
||||||
|
|
||||||
const customPrice = parseFloat(document.getElementById('custom-price-input').value) || 0;
|
const customPrice = parseFloat(document.getElementById('custom-price-input').value) || 0;
|
||||||
const originalTotal = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
|
const originalTotal = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
|
||||||
|
|||||||
@@ -169,6 +169,16 @@
|
|||||||
return qty.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 3 });
|
return qty.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 3 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (text === null || text === undefined) return '';
|
||||||
|
return String(text)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
function formatPriceSettings(item) {
|
function formatPriceSettings(item) {
|
||||||
// Format price settings to match admin pricing interface style
|
// Format price settings to match admin pricing interface style
|
||||||
let settings = [];
|
let settings = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user