Implement warehouse/lot pricing updates and configurator performance fixes

This commit is contained in:
2026-02-07 05:20:35 +03:00
parent c1a31e5ee0
commit 7c741ff675
26 changed files with 1701 additions and 305 deletions

View File

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

View File

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

View File

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