diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index ebb653f..e3a42d9 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -1326,10 +1326,14 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect pricingAdmin.POST("/update", pricingHandler.UpdatePrice) pricingAdmin.POST("/preview", pricingHandler.PreviewPrice) pricingAdmin.POST("/recalculate-all", pricingHandler.RecalculateAll) + pricingAdmin.GET("/lots", pricingHandler.ListLots) pricingAdmin.POST("/stock/import", pricingHandler.ImportStockLog) pricingAdmin.GET("/stock/mappings", pricingHandler.ListStockMappings) pricingAdmin.POST("/stock/mappings", pricingHandler.UpsertStockMapping) pricingAdmin.DELETE("/stock/mappings/:partnumber", pricingHandler.DeleteStockMapping) + pricingAdmin.GET("/stock/ignore-rules", pricingHandler.ListStockIgnoreRules) + pricingAdmin.POST("/stock/ignore-rules", pricingHandler.UpsertStockIgnoreRule) + pricingAdmin.DELETE("/stock/ignore-rules/:id", pricingHandler.DeleteStockIgnoreRule) pricingAdmin.GET("/alerts", pricingHandler.ListAlerts) pricingAdmin.POST("/alerts/:id/acknowledge", pricingHandler.AcknowledgeAlert) diff --git a/internal/handlers/pricing.go b/internal/handlers/pricing.go index bece4ca..f114190 100644 --- a/internal/handlers/pricing.go +++ b/internal/handlers/pricing.go @@ -997,6 +997,8 @@ func (h *PricingHandler) ImportStockLog(c *gin.Context) { "conflicts": result.Conflicts, "fallback_matches": result.FallbackMatches, "parse_errors": result.ParseErrors, + "ignored": result.Ignored, + "mapping_suggestions": result.MappingSuggestions, "import_date": result.ImportDate.Format("2006-01-02"), "warehouse_pricelist_id": result.WarehousePLID, "warehouse_pricelist_version": result.WarehousePLVer, @@ -1029,6 +1031,8 @@ func (h *PricingHandler) ImportStockLog(c *gin.Context) { "conflicts": p.Conflicts, "fallback_matches": p.FallbackMatches, "parse_errors": p.ParseErrors, + "ignored": p.Ignored, + "mapping_suggestions": p.MappingSuggestions, "import_date": p.ImportDate, "warehouse_pricelist_id": p.PricelistID, "warehouse_pricelist_version": p.PricelistVer, @@ -1078,7 +1082,7 @@ func (h *PricingHandler) UpsertStockMapping(c *gin.Context) { var req struct { Partnumber string `json:"partnumber" binding:"required"` - LotName string `json:"lot_name" binding:"required"` + LotName string `json:"lot_name"` Description string `json:"description"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -1109,3 +1113,107 @@ func (h *PricingHandler) DeleteStockMapping(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"deleted": deleted}) } + +func (h *PricingHandler) ListStockIgnoreRules(c *gin.Context) { + if h.stockImportService == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "Правила игнорирования доступны только в онлайн режиме", + "offline": true, + }) + return + } + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50")) + rows, total, err := h.stockImportService.ListIgnoreRules(page, perPage) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "items": rows, + "total": total, + "page": page, + "per_page": perPage, + }) +} + +func (h *PricingHandler) UpsertStockIgnoreRule(c *gin.Context) { + if h.stockImportService == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "Правила игнорирования доступны только в онлайн режиме", + "offline": true, + }) + return + } + var req struct { + Target string `json:"target" binding:"required"` + MatchType string `json:"match_type" binding:"required"` + Pattern string `json:"pattern" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := h.stockImportService.UpsertIgnoreRule(req.Target, req.MatchType, req.Pattern); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "ignore rule saved"}) +} + +func (h *PricingHandler) DeleteStockIgnoreRule(c *gin.Context) { + if h.stockImportService == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "Правила игнорирования доступны только в онлайн режиме", + "offline": true, + }) + return + } + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + deleted, err := h.stockImportService.DeleteIgnoreRule(uint(id)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"deleted": deleted}) +} + +func (h *PricingHandler) ListLots(c *gin.Context) { + if h.db == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "Список LOT доступен только в онлайн режиме", + "offline": true, + }) + return + } + perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "500")) + if perPage < 1 { + perPage = 500 + } + if perPage > 5000 { + perPage = 5000 + } + search := strings.TrimSpace(c.Query("search")) + query := h.db.Model(&models.Lot{}).Select("lot_name") + if search != "" { + query = query.Where("lot_name LIKE ?", "%"+search+"%") + } + var lots []models.Lot + if err := query.Order("lot_name ASC").Limit(perPage).Find(&lots).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + items := make([]string, 0, len(lots)) + for _, lot := range lots { + if strings.TrimSpace(lot.LotName) == "" { + continue + } + items = append(items, lot.LotName) + } + c.JSON(http.StatusOK, gin.H{"items": items}) +} diff --git a/internal/models/lot.go b/internal/models/lot.go index 7990f38..de27cdc 100644 --- a/internal/models/lot.go +++ b/internal/models/lot.go @@ -65,3 +65,16 @@ type LotPartnumber struct { func (LotPartnumber) TableName() string { return "lot_partnumbers" } + +// StockIgnoreRule contains import ignore pattern rules. +type StockIgnoreRule struct { + ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Target string `gorm:"column:target;size:20;not null" json:"target"` // partnumber|description + MatchType string `gorm:"column:match_type;size:20;not null" json:"match_type"` // exact|prefix|suffix + Pattern string `gorm:"column:pattern;size:500;not null" json:"pattern"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` +} + +func (StockIgnoreRule) TableName() string { + return "stock_ignore_rules" +} diff --git a/internal/services/stock_import.go b/internal/services/stock_import.go index 61f34c9..6b23560 100644 --- a/internal/services/stock_import.go +++ b/internal/services/stock_import.go @@ -21,40 +21,51 @@ import ( ) type StockImportProgress struct { - Status string `json:"status"` - Message string `json:"message,omitempty"` - Current int `json:"current,omitempty"` - Total int `json:"total,omitempty"` - RowsTotal int `json:"rows_total,omitempty"` - ValidRows int `json:"valid_rows,omitempty"` - Inserted int `json:"inserted,omitempty"` - Deleted int64 `json:"deleted,omitempty"` - Unmapped int `json:"unmapped,omitempty"` - Conflicts int `json:"conflicts,omitempty"` - FallbackMatches int `json:"fallback_matches,omitempty"` - ParseErrors int `json:"parse_errors,omitempty"` - ImportDate string `json:"import_date,omitempty"` - PricelistID uint `json:"warehouse_pricelist_id,omitempty"` - PricelistVer string `json:"warehouse_pricelist_version,omitempty"` + Status string `json:"status"` + Message string `json:"message,omitempty"` + Current int `json:"current,omitempty"` + Total int `json:"total,omitempty"` + RowsTotal int `json:"rows_total,omitempty"` + ValidRows int `json:"valid_rows,omitempty"` + Inserted int `json:"inserted,omitempty"` + Deleted int64 `json:"deleted,omitempty"` + Unmapped int `json:"unmapped,omitempty"` + Conflicts int `json:"conflicts,omitempty"` + FallbackMatches int `json:"fallback_matches,omitempty"` + ParseErrors int `json:"parse_errors,omitempty"` + Ignored int `json:"ignored,omitempty"` + MappingSuggestions []StockMappingSuggestion `json:"mapping_suggestions,omitempty"` + ImportDate string `json:"import_date,omitempty"` + PricelistID uint `json:"warehouse_pricelist_id,omitempty"` + PricelistVer string `json:"warehouse_pricelist_version,omitempty"` } type StockImportResult struct { - RowsTotal int - ValidRows int - Inserted int - Deleted int64 - Unmapped int - Conflicts int - FallbackMatches int - ParseErrors int - ImportDate time.Time - WarehousePLID uint - WarehousePLVer string + RowsTotal int + ValidRows int + Inserted int + Deleted int64 + Unmapped int + Conflicts int + FallbackMatches int + ParseErrors int + Ignored int + MappingSuggestions []StockMappingSuggestion + ImportDate time.Time + WarehousePLID uint + WarehousePLVer string } -type pendingMapping struct { - Partnumber string - Description string +type StockMappingSuggestion struct { + Partnumber string `json:"partnumber"` + Description string `json:"description,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type stockIgnoreRule struct { + Target string + MatchType string + Pattern string } type StockImportService struct { @@ -128,26 +139,42 @@ func (s *StockImportService) Import( conflicts int fallbackMatches int parseErrors int - pendingByPN = make(map[string]pendingMapping) + ignored int + suggestionsByPN = make(map[string]StockMappingSuggestion) ) + ignoreRules, err := s.loadIgnoreRules() + if err != nil { + return nil, err + } for _, row := range rows { if strings.TrimSpace(row.Article) == "" { parseErrors++ continue } + if shouldIgnoreStockRow(row, ignoreRules) { + ignored++ + continue + } lot, matchType, resolveErr := resolver.resolve(row.Article) if resolveErr != nil { trimmedPN := strings.TrimSpace(row.Article) if trimmedPN != "" { key := normalizeKey(trimmedPN) if key != "" { - candidate := pendingMapping{ + reason := "unmapped" + if errors.Is(resolveErr, errResolveConflict) { + reason = "conflict" + } + candidate := StockMappingSuggestion{ Partnumber: trimmedPN, Description: strings.TrimSpace(row.Description), + Reason: reason, } - if prev, ok := pendingByPN[key]; !ok || (strings.TrimSpace(prev.Description) == "" && candidate.Description != "") { - pendingByPN[key] = candidate + if prev, ok := suggestionsByPN[key]; !ok || + (strings.TrimSpace(prev.Description) == "" && candidate.Description != "") || + (prev.Reason != "conflict" && candidate.Reason == "conflict") { + suggestionsByPN[key] = candidate } } } @@ -181,15 +208,7 @@ func (s *StockImportService) Import( }) } - if len(pendingByPN) > 0 { - pending := make([]pendingMapping, 0, len(pendingByPN)) - for _, m := range pendingByPN { - pending = append(pending, m) - } - if err := s.upsertPendingMappings(pending); err != nil { - return nil, err - } - } + suggestions := collectSortedSuggestions(suggestionsByPN, 200) if len(records) == 0 { return nil, fmt.Errorf("no valid rows after mapping") @@ -256,35 +275,39 @@ func (s *StockImportService) Import( warehousePLVer = pl.Version result := &StockImportResult{ - RowsTotal: len(rows), - ValidRows: len(records), - Inserted: inserted, - Deleted: deleted, - Unmapped: unmapped, - Conflicts: conflicts, - FallbackMatches: fallbackMatches, - ParseErrors: parseErrors, - ImportDate: importDate, - WarehousePLID: warehousePLID, - WarehousePLVer: warehousePLVer, + RowsTotal: len(rows), + ValidRows: len(records), + Inserted: inserted, + Deleted: deleted, + Unmapped: unmapped, + Conflicts: conflicts, + FallbackMatches: fallbackMatches, + ParseErrors: parseErrors, + Ignored: ignored, + MappingSuggestions: suggestions, + ImportDate: importDate, + WarehousePLID: warehousePLID, + WarehousePLVer: warehousePLVer, } report(StockImportProgress{ - Status: "completed", - Message: "Импорт завершен", - RowsTotal: result.RowsTotal, - ValidRows: result.ValidRows, - Inserted: result.Inserted, - Deleted: result.Deleted, - Unmapped: result.Unmapped, - Conflicts: result.Conflicts, - FallbackMatches: result.FallbackMatches, - ParseErrors: result.ParseErrors, - ImportDate: result.ImportDate.Format("2006-01-02"), - PricelistID: result.WarehousePLID, - PricelistVer: result.WarehousePLVer, - Current: 100, - Total: 100, + Status: "completed", + Message: "Импорт завершен", + RowsTotal: result.RowsTotal, + ValidRows: result.ValidRows, + Inserted: result.Inserted, + Deleted: result.Deleted, + Unmapped: result.Unmapped, + Conflicts: result.Conflicts, + FallbackMatches: result.FallbackMatches, + ParseErrors: result.ParseErrors, + Ignored: result.Ignored, + MappingSuggestions: result.MappingSuggestions, + ImportDate: result.ImportDate.Format("2006-01-02"), + PricelistID: result.WarehousePLID, + PricelistVer: result.WarehousePLVer, + Current: 100, + Total: 100, }) return result, nil @@ -382,16 +405,18 @@ func (s *StockImportService) UpsertMapping(partnumber, lotName, description stri partnumber = strings.TrimSpace(partnumber) lotName = strings.TrimSpace(lotName) description = strings.TrimSpace(description) - if partnumber == "" || lotName == "" { - return fmt.Errorf("partnumber and lot_name are required") + if partnumber == "" { + return fmt.Errorf("partnumber is required") } - var lotCount int64 - if err := s.db.Model(&models.Lot{}).Where("lot_name = ?", lotName).Count(&lotCount).Error; err != nil { - return err - } - if lotCount == 0 { - return fmt.Errorf("lot not found: %s", lotName) + if lotName != "" { + var lotCount int64 + if err := s.db.Model(&models.Lot{}).Where("lot_name = ?", lotName).Count(&lotCount).Error; err != nil { + return err + } + if lotCount == 0 { + return fmt.Errorf("lot not found: %s", lotName) + } } return s.db.Transaction(func(tx *gorm.DB) error { @@ -434,55 +459,153 @@ func (s *StockImportService) DeleteMapping(partnumber string) (int64, error) { return res.RowsAffected, res.Error } -func (s *StockImportService) upsertPendingMappings(rows []pendingMapping) error { - if s.db == nil || len(rows) == 0 { +func (s *StockImportService) ListIgnoreRules(page, perPage int) ([]models.StockIgnoreRule, int64, error) { + if s.db == nil { + return nil, 0, fmt.Errorf("offline mode: ignore rules unavailable") + } + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 50 + } + if perPage > 500 { + perPage = 500 + } + + offset := (page - 1) * perPage + query := s.db.Model(&models.StockIgnoreRule{}) + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []models.StockIgnoreRule + if err := query.Order("id DESC").Offset(offset).Limit(perPage).Find(&rows).Error; err != nil { + return nil, 0, err + } + return rows, total, nil +} + +func (s *StockImportService) UpsertIgnoreRule(target, matchType, pattern string) error { + if s.db == nil { + return fmt.Errorf("offline mode: ignore rules unavailable") + } + target = normalizeIgnoreTarget(target) + matchType = normalizeIgnoreMatchType(matchType) + pattern = strings.TrimSpace(pattern) + if target == "" || matchType == "" || pattern == "" { + return fmt.Errorf("target, match_type and pattern are required") + } + return s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&models.StockIgnoreRule{ + Target: target, + MatchType: matchType, + Pattern: pattern, + }).Error +} + +func (s *StockImportService) DeleteIgnoreRule(id uint) (int64, error) { + if s.db == nil { + return 0, fmt.Errorf("offline mode: ignore rules unavailable") + } + res := s.db.Delete(&models.StockIgnoreRule{}, id) + return res.RowsAffected, res.Error +} + +func (s *StockImportService) loadIgnoreRules() ([]stockIgnoreRule, error) { + var rows []models.StockIgnoreRule + if err := s.db.Find(&rows).Error; err != nil { + return nil, err + } + rules := make([]stockIgnoreRule, 0, len(rows)) + for _, row := range rows { + target := normalizeIgnoreTarget(row.Target) + matchType := normalizeIgnoreMatchType(row.MatchType) + pattern := normalizeKey(row.Pattern) + if target == "" || matchType == "" || pattern == "" { + continue + } + rules = append(rules, stockIgnoreRule{ + Target: target, + MatchType: matchType, + Pattern: pattern, + }) + } + return rules, nil +} + +func collectSortedSuggestions(src map[string]StockMappingSuggestion, limit int) []StockMappingSuggestion { + if len(src) == 0 { return nil } - return s.db.Transaction(func(tx *gorm.DB) error { - for _, row := range rows { - pn := strings.TrimSpace(row.Partnumber) - if pn == "" { - continue + items := make([]StockMappingSuggestion, 0, len(src)) + for _, item := range src { + items = append(items, item) + } + sort.Slice(items, func(i, j int) bool { + return strings.ToLower(items[i].Partnumber) < strings.ToLower(items[j].Partnumber) + }) + if limit > 0 && len(items) > limit { + return items[:limit] + } + return items +} + +func shouldIgnoreStockRow(row stockImportRow, rules []stockIgnoreRule) bool { + if len(rules) == 0 { + return false + } + partnumber := normalizeKey(row.Article) + description := normalizeKey(row.Description) + for _, rule := range rules { + candidate := "" + if rule.Target == "partnumber" { + candidate = partnumber + } else { + candidate = description + } + if candidate == "" || rule.Pattern == "" { + continue + } + switch rule.MatchType { + case "exact": + if candidate == rule.Pattern { + return true } - desc := strings.TrimSpace(row.Description) - var existing []models.LotPartnumber - if err := tx.Where("LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", pn).Find(&existing).Error; err != nil { - return err + case "prefix": + if strings.HasPrefix(candidate, rule.Pattern) { + return true } - if len(existing) == 0 { - var descPtr *string - if desc != "" { - descPtr = &desc - } - if err := tx.Create(&models.LotPartnumber{ - Partnumber: pn, - LotName: "", - Description: descPtr, - }).Error; err != nil { - return err - } - continue - } - if desc == "" { - continue - } - needsDescription := true - for _, item := range existing { - if item.Description != nil && strings.TrimSpace(*item.Description) != "" { - needsDescription = false - break - } - } - if needsDescription { - if err := tx.Model(&models.LotPartnumber{}). - Where("LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", pn). - Update("description", desc).Error; err != nil { - return err - } + case "suffix": + if strings.HasSuffix(candidate, rule.Pattern) { + return true } } - return nil - }) + } + return false +} + +func normalizeIgnoreTarget(v string) string { + switch strings.ToLower(strings.TrimSpace(v)) { + case "partnumber": + return "partnumber" + case "description": + return "description" + default: + return "" + } +} + +func normalizeIgnoreMatchType(v string) string { + switch strings.ToLower(strings.TrimSpace(v)) { + case "exact": + return "exact" + case "prefix": + return "prefix" + case "suffix": + return "suffix" + default: + return "" + } } var ( diff --git a/migrations/018_add_stock_ignore_rules.sql b/migrations/018_add_stock_ignore_rules.sql new file mode 100644 index 0000000..6171067 --- /dev/null +++ b/migrations/018_add_stock_ignore_rules.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS stock_ignore_rules ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + target VARCHAR(20) NOT NULL, + match_type VARCHAR(20) NOT NULL, + pattern VARCHAR(500) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_stock_ignore_rule (target, match_type, pattern), + KEY idx_stock_ignore_target (target) +); diff --git a/web/templates/admin_pricing.html b/web/templates/admin_pricing.html index f64d742..24deb01 100644 --- a/web/templates/admin_pricing.html +++ b/web/templates/admin_pricing.html @@ -110,23 +110,55 @@
+

Сопоставление partnumber -> LOT

- - - + +
- + + - @@ -137,6 +169,40 @@
+ +
+

Игнорирование при импорте

+
+ + + + + +
+
+
PartnumberLOTPartnumber ОписаниеLOT Действия
+ + + + + + + + + + + +
ПолеТипШаблонДействия
Загрузка...
+
+
+
@@ -309,6 +375,10 @@ let cachedDbUsername = null; let syncUsersStatusTimer = null; let stockMappingsPage = 1; let stockMappingsCache = []; +let stockIgnoreRulesPage = 1; +let stockMappingsSearch = ''; +let stockMappingsSearchTimer = null; +let stockImportSuggestions = []; async function loadTab(tab) { currentTab = tab; @@ -341,6 +411,8 @@ async function loadTab(tab) { await loadPricelists(1, currentPricelistSource); if (tab === 'warehouse') { await loadStockMappings(1); + await loadStockIgnoreRules(1); + await loadStockLotOptions(); } } else if (tab === 'component-settings') { document.getElementById('search-bar').className = 'mb-4'; @@ -1046,7 +1118,9 @@ async function importStockFile() { const percentEl = document.getElementById('stock-import-percent'); const barEl = document.getElementById('stock-import-bar'); const statsEl = document.getElementById('stock-import-stats'); + const suggestionsBox = document.getElementById('stock-import-suggestions'); box.classList.remove('hidden'); + suggestionsBox.classList.add('hidden'); statusEl.textContent = 'Запуск импорта...'; percentEl.textContent = '0%'; barEl.style.width = '0%'; @@ -1082,16 +1156,19 @@ async function importStockFile() { 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.conflicts || 0}`; + `Пропущено: ${(data.unmapped || 0) + (data.conflicts || 0) + (data.parse_errors || 0) + (data.ignored || 0)} | ` + + `Игнор: ${data.ignored || 0} | Конфликты: ${data.conflicts || 0}`; if (data.status === 'error') { throw new Error(data.message || 'Ошибка импорта'); } if (data.status === 'completed') { + stockImportSuggestions = data.mapping_suggestions || []; + renderStockImportSuggestions(stockImportSuggestions); showToast('Импорт stock_log завершен', 'success'); await loadPricelists(1, 'warehouse'); await loadStockMappings(stockMappingsPage); + await loadStockIgnoreRules(stockIgnoreRulesPage); } } } @@ -1100,13 +1177,151 @@ async function importStockFile() { } } +function formatSuggestionReason(reason) { + if (reason === 'conflict') return 'Конфликт'; + return 'Не найден LOT'; +} + +function renderStockImportSuggestions(items) { + const box = document.getElementById('stock-import-suggestions'); + const body = document.getElementById('stock-import-suggestions-body'); + if (!box || !body) return; + if (!items || items.length === 0) { + box.classList.add('hidden'); + body.innerHTML = ''; + return; + } + body.innerHTML = items.map(item => ` + + + + + ${escapeHtml(item.partnumber || '')} + ${escapeHtml(item.description || '—')} + ${escapeHtml(formatSuggestionReason(item.reason || 'unmapped'))} + +
+ + +
+ + + `).join(''); + box.classList.remove('hidden'); +} + +async function addSuggestionMapping(button) { + const partnumber = (button?.dataset?.partnumber || '').trim(); + if (!partnumber) return; + const description = decodeURIComponent(button?.querySelector('svg')?.dataset?.description || ''); + const input = document.querySelector(`input[data-role="suggestion-lot"][data-partnumber="${CSS.escape(partnumber)}"]`); + const lotName = (input?.value || '').trim(); + try { + const resp = await fetch('/api/admin/pricing/stock/mappings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ partnumber: partnumber, lot_name: lotName, description: description }) + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || 'Ошибка сохранения'); + stockImportSuggestions = stockImportSuggestions.filter(item => (item.partnumber || '').trim().toLowerCase() !== partnumber.toLowerCase()); + renderStockImportSuggestions(stockImportSuggestions); + await loadStockMappings(1); + showToast('Сопоставление добавлено', 'success'); + } catch (e) { + showToast('Ошибка: ' + e.message, 'error'); + } +} + +async function ignoreSuggestion(button) { + const partnumber = (button?.dataset?.partnumber || '').trim(); + if (!partnumber) return; + try { + const resp = await fetch('/api/admin/pricing/stock/ignore-rules', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target: 'partnumber', match_type: 'exact', pattern: partnumber }) + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || 'Ошибка добавления в игнорирование'); + stockImportSuggestions = stockImportSuggestions.filter(item => (item.partnumber || '').trim().toLowerCase() !== partnumber.toLowerCase()); + renderStockImportSuggestions(stockImportSuggestions); + await loadStockIgnoreRules(1); + showToast('Добавлено в игнорирование', 'success'); + } catch (e) { + showToast('Ошибка: ' + e.message, 'error'); + } +} + +async function addAllSuggestions() { + const descriptionByPartnumber = {}; + for (const s of stockImportSuggestions) { + const pn = (s.partnumber || '').trim().toLowerCase(); + if (!pn) continue; + descriptionByPartnumber[pn] = s.description || ''; + } + const inputs = Array.from(document.querySelectorAll('input[data-role="suggestion-lot"]')); + const tasks = inputs + .map(input => ({ + partnumber: (input.dataset.partnumber || '').trim(), + lot: (input.value || '').trim(), + description: descriptionByPartnumber[((input.dataset.partnumber || '').trim().toLowerCase())] || '' + })) + .filter(x => x.partnumber); + if (tasks.length === 0) { + showToast('Нет строк для добавления', 'error'); + return; + } + let ok = 0; + for (const t of tasks) { + const resp = await fetch('/api/admin/pricing/stock/mappings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ partnumber: t.partnumber, lot_name: t.lot, description: t.description }) + }); + if (resp.ok) ok++; + } + stockImportSuggestions = stockImportSuggestions.filter(item => !tasks.some(t => t.partnumber.toLowerCase() === (item.partnumber || '').toLowerCase())); + renderStockImportSuggestions(stockImportSuggestions); + await loadStockMappings(1); + showToast(`Добавлено: ${ok}`, 'success'); +} + +async function ignoreAllSuggestions() { + if (!stockImportSuggestions.length) return; + let ok = 0; + for (const s of stockImportSuggestions) { + const partnumber = (s.partnumber || '').trim(); + if (!partnumber) continue; + const resp = await fetch('/api/admin/pricing/stock/ignore-rules', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target: 'partnumber', match_type: 'exact', pattern: partnumber }) + }); + if (resp.ok) ok++; + } + stockImportSuggestions = []; + renderStockImportSuggestions(stockImportSuggestions); + await loadStockIgnoreRules(1); + showToast(`Добавлено в игнорирование: ${ok}`, 'success'); +} + async function loadStockMappings(page = 1) { stockMappingsPage = page; const body = document.getElementById('stock-mappings-body'); const pagination = document.getElementById('stock-mappings-pagination'); if (!body || !pagination) return; try { - const resp = await fetch(`/api/admin/pricing/stock/mappings?page=${page}&per_page=20`); + const query = encodeURIComponent(stockMappingsSearch); + const resp = await fetch(`/api/admin/pricing/stock/mappings?page=${page}&per_page=20&search=${query}`); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || 'Ошибка загрузки'); const items = data.items || []; @@ -1115,12 +1330,30 @@ async function loadStockMappings(page = 1) { body.innerHTML = 'Нет сопоставлений'; } else { body.innerHTML = items.map(item => ` - - ${escapeHtml(item.partnumber || item.Partnumber || '—')} + + + + + ${escapeHtml(item.partnumber || item.Partnumber || '—')} ${escapeHtml(item.description || item.Description || '—')} - ${escapeHtml(item.lot_name || item.LotName || '—')} - - + +
+ + + +
`).join(''); @@ -1143,19 +1376,23 @@ async function loadStockMappings(page = 1) { } } -function selectStockMappingRow(partnumber) { - const normalized = (partnumber || '').trim().toLowerCase(); - const row = stockMappingsCache.find(item => ((item.partnumber || item.Partnumber || '').trim().toLowerCase() === normalized)); - if (!row) return; - document.getElementById('mapping-partnumber').value = (row.partnumber || row.Partnumber || '').trim(); - document.getElementById('mapping-lotname').value = (row.lot_name || row.LotName || '').trim(); +function applyStockMappingsSearch() { + stockMappingsSearch = (document.getElementById('stock-mappings-search')?.value || '').trim(); + loadStockMappings(1); } -async function saveStockMapping() { - const partnumber = document.getElementById('mapping-partnumber').value.trim(); - const lotName = document.getElementById('mapping-lotname').value.trim(); - if (!partnumber || !lotName) { - showToast('Заполните partnumber и lot_name', 'error'); +function onStockMappingsSearchInput() { + clearTimeout(stockMappingsSearchTimer); + stockMappingsSearchTimer = setTimeout(applyStockMappingsSearch, 250); +} + +async function saveInlineStockMapping(button) { + const partnumber = (button?.dataset?.partnumber || '').trim(); + if (!partnumber) return; + const row = button.closest('tr'); + const lotName = (row?.querySelector('input[data-role="lot"]')?.value || '').trim(); + if (!lotName) { + showToast('LOT не может быть пустым', 'error'); return; } try { @@ -1166,10 +1403,103 @@ async function saveStockMapping() { }); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || 'Ошибка сохранения'); - document.getElementById('mapping-partnumber').value = ''; - document.getElementById('mapping-lotname').value = ''; showToast('Сопоставление сохранено', 'success'); - await loadStockMappings(1); + await loadStockMappings(stockMappingsPage); + } catch (e) { + showToast('Ошибка: ' + e.message, 'error'); + } +} + +async function loadStockLotOptions() { + const datalist = document.getElementById('stock-lot-options'); + if (!datalist) return; + try { + const resp = await fetch('/api/admin/pricing/lots?per_page=5000'); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || 'Ошибка загрузки LOT'); + const items = data.items || []; + datalist.innerHTML = items.map(lot => ``).join(''); + } catch (_) { + datalist.innerHTML = ''; + } +} + +async function loadStockIgnoreRules(page = 1) { + stockIgnoreRulesPage = page; + const body = document.getElementById('stock-ignore-rules-body'); + const pagination = document.getElementById('stock-ignore-rules-pagination'); + if (!body || !pagination) return; + try { + const resp = await fetch(`/api/admin/pricing/stock/ignore-rules?page=${page}&per_page=20`); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || 'Ошибка загрузки'); + const items = data.items || []; + if (items.length === 0) { + body.innerHTML = 'Нет правил'; + } else { + const matchLabel = { exact: 'Равно', prefix: 'Начинается с', suffix: 'Заканчивается на' }; + body.innerHTML = items.map(item => ` + + ${escapeHtml(item.target)} + ${escapeHtml(matchLabel[item.match_type] || item.match_type)} + ${escapeHtml(item.pattern)} + + + + + `).join(''); + } + const totalPages = Math.ceil((data.total || 0) / (data.per_page || 20)); + if (totalPages <= 1) { + pagination.innerHTML = ''; + } else { + let html = ''; + for (let i = 1; i <= totalPages; i++) { + const cls = i === page ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'; + html += ``; + } + pagination.innerHTML = html; + } + } catch (e) { + body.innerHTML = `${escapeHtml(e.message)}`; + pagination.innerHTML = ''; + } +} + +async function saveStockIgnoreRule() { + const target = document.getElementById('ignore-target').value; + const matchType = document.getElementById('ignore-match-type').value; + const pattern = document.getElementById('ignore-pattern').value.trim(); + if (!pattern) { + showToast('Заполните шаблон', 'error'); + return; + } + try { + const resp = await fetch('/api/admin/pricing/stock/ignore-rules', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target: target, match_type: matchType, pattern: pattern }) + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || 'Ошибка сохранения'); + document.getElementById('ignore-pattern').value = ''; + showToast('Правило добавлено', 'success'); + await loadStockIgnoreRules(1); + } catch (e) { + showToast('Ошибка: ' + e.message, 'error'); + } +} + +async function deleteStockIgnoreRule(id) { + if (!confirm('Удалить правило игнорирования?')) return; + try { + const resp = await fetch(`/api/admin/pricing/stock/ignore-rules/${id}`, { + method: 'DELETE' + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || 'Ошибка удаления'); + showToast('Правило удалено', 'success'); + await loadStockIgnoreRules(stockIgnoreRulesPage); } catch (e) { showToast('Ошибка: ' + e.message, 'error'); } @@ -1190,6 +1520,33 @@ async function deleteStockMapping(partnumber) { } } +async function ignoreStockMapping(partnumber) { + const pn = (partnumber || '').trim(); + if (!pn) return; + if (!confirm('Добавить партномер в игнорирование и удалить из сопоставлений?')) return; + try { + const addResp = await fetch('/api/admin/pricing/stock/ignore-rules', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target: 'partnumber', match_type: 'exact', pattern: pn }) + }); + const addData = await addResp.json(); + if (!addResp.ok) throw new Error(addData.error || 'Ошибка добавления в игнорирование'); + + const delResp = await fetch(`/api/admin/pricing/stock/mappings/${encodeURIComponent(pn)}`, { + method: 'DELETE' + }); + const delData = await delResp.json(); + if (!delResp.ok) throw new Error(delData.error || 'Ошибка удаления сопоставления'); + + showToast('Партномер добавлен в игнорирование', 'success'); + await loadStockMappings(stockMappingsPage); + await loadStockIgnoreRules(1); + } catch (e) { + showToast('Ошибка: ' + e.message, 'error'); + } +} + document.addEventListener('DOMContentLoaded', async () => { await checkPricelistWritePermission(); // Check URL params for initial tab @@ -1198,6 +1555,10 @@ document.addEventListener('DOMContentLoaded', async () => { if (initialTab === 'pricelists') initialTab = 'estimate'; if (initialTab === 'components') initialTab = 'component-settings'; await loadTab(initialTab); + const stockMappingsSearchEl = document.getElementById('stock-mappings-search'); + if (stockMappingsSearchEl) { + stockMappingsSearchEl.addEventListener('input', onStockMappingsSearchInput); + } // Add event listeners for preview updates document.getElementById('modal-period').addEventListener('change', fetchPreview);