Refine stock import UX with suggestions, ignore rules, and inline mapping controls

This commit is contained in:
Mikhail Chusavitin
2026-02-06 19:58:42 +03:00
parent 236236b4ab
commit ad3c10504e
6 changed files with 766 additions and 147 deletions

View File

@@ -1326,10 +1326,14 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
pricingAdmin.POST("/update", pricingHandler.UpdatePrice) pricingAdmin.POST("/update", pricingHandler.UpdatePrice)
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.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)
pricingAdmin.DELETE("/stock/mappings/:partnumber", pricingHandler.DeleteStockMapping) 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.GET("/alerts", pricingHandler.ListAlerts)
pricingAdmin.POST("/alerts/:id/acknowledge", pricingHandler.AcknowledgeAlert) pricingAdmin.POST("/alerts/:id/acknowledge", pricingHandler.AcknowledgeAlert)

View File

@@ -997,6 +997,8 @@ 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,
"ignored": result.Ignored,
"mapping_suggestions": result.MappingSuggestions,
"import_date": result.ImportDate.Format("2006-01-02"), "import_date": result.ImportDate.Format("2006-01-02"),
"warehouse_pricelist_id": result.WarehousePLID, "warehouse_pricelist_id": result.WarehousePLID,
"warehouse_pricelist_version": result.WarehousePLVer, "warehouse_pricelist_version": result.WarehousePLVer,
@@ -1029,6 +1031,8 @@ 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,
"ignored": p.Ignored,
"mapping_suggestions": p.MappingSuggestions,
"import_date": p.ImportDate, "import_date": p.ImportDate,
"warehouse_pricelist_id": p.PricelistID, "warehouse_pricelist_id": p.PricelistID,
"warehouse_pricelist_version": p.PricelistVer, "warehouse_pricelist_version": p.PricelistVer,
@@ -1078,7 +1082,7 @@ func (h *PricingHandler) UpsertStockMapping(c *gin.Context) {
var req struct { var req struct {
Partnumber string `json:"partnumber" binding:"required"` Partnumber string `json:"partnumber" binding:"required"`
LotName string `json:"lot_name" binding:"required"` LotName string `json:"lot_name"`
Description string `json:"description"` Description string `json:"description"`
} }
if err := c.ShouldBindJSON(&req); err != nil { 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}) 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})
}

View File

@@ -65,3 +65,16 @@ type LotPartnumber struct {
func (LotPartnumber) TableName() string { func (LotPartnumber) TableName() string {
return "lot_partnumbers" 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"
}

View File

@@ -33,6 +33,8 @@ 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"`
Ignored int `json:"ignored,omitempty"`
MappingSuggestions []StockMappingSuggestion `json:"mapping_suggestions,omitempty"`
ImportDate string `json:"import_date,omitempty"` ImportDate string `json:"import_date,omitempty"`
PricelistID uint `json:"warehouse_pricelist_id,omitempty"` PricelistID uint `json:"warehouse_pricelist_id,omitempty"`
PricelistVer string `json:"warehouse_pricelist_version,omitempty"` PricelistVer string `json:"warehouse_pricelist_version,omitempty"`
@@ -47,14 +49,23 @@ type StockImportResult struct {
Conflicts int Conflicts int
FallbackMatches int FallbackMatches int
ParseErrors int ParseErrors int
Ignored int
MappingSuggestions []StockMappingSuggestion
ImportDate time.Time ImportDate time.Time
WarehousePLID uint WarehousePLID uint
WarehousePLVer string WarehousePLVer string
} }
type pendingMapping struct { type StockMappingSuggestion struct {
Partnumber string Partnumber string `json:"partnumber"`
Description string Description string `json:"description,omitempty"`
Reason string `json:"reason,omitempty"`
}
type stockIgnoreRule struct {
Target string
MatchType string
Pattern string
} }
type StockImportService struct { type StockImportService struct {
@@ -128,26 +139,42 @@ func (s *StockImportService) Import(
conflicts int conflicts int
fallbackMatches int fallbackMatches int
parseErrors 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 { for _, row := range rows {
if strings.TrimSpace(row.Article) == "" { if strings.TrimSpace(row.Article) == "" {
parseErrors++ parseErrors++
continue continue
} }
if shouldIgnoreStockRow(row, ignoreRules) {
ignored++
continue
}
lot, matchType, resolveErr := resolver.resolve(row.Article) lot, matchType, resolveErr := resolver.resolve(row.Article)
if resolveErr != nil { if resolveErr != nil {
trimmedPN := strings.TrimSpace(row.Article) trimmedPN := strings.TrimSpace(row.Article)
if trimmedPN != "" { if trimmedPN != "" {
key := normalizeKey(trimmedPN) key := normalizeKey(trimmedPN)
if key != "" { if key != "" {
candidate := pendingMapping{ reason := "unmapped"
if errors.Is(resolveErr, errResolveConflict) {
reason = "conflict"
}
candidate := StockMappingSuggestion{
Partnumber: trimmedPN, Partnumber: trimmedPN,
Description: strings.TrimSpace(row.Description), Description: strings.TrimSpace(row.Description),
Reason: reason,
} }
if prev, ok := pendingByPN[key]; !ok || (strings.TrimSpace(prev.Description) == "" && candidate.Description != "") { if prev, ok := suggestionsByPN[key]; !ok ||
pendingByPN[key] = candidate (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 { suggestions := collectSortedSuggestions(suggestionsByPN, 200)
pending := make([]pendingMapping, 0, len(pendingByPN))
for _, m := range pendingByPN {
pending = append(pending, m)
}
if err := s.upsertPendingMappings(pending); err != nil {
return nil, err
}
}
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 mapping")
@@ -264,6 +283,8 @@ func (s *StockImportService) Import(
Conflicts: conflicts, Conflicts: conflicts,
FallbackMatches: fallbackMatches, FallbackMatches: fallbackMatches,
ParseErrors: parseErrors, ParseErrors: parseErrors,
Ignored: ignored,
MappingSuggestions: suggestions,
ImportDate: importDate, ImportDate: importDate,
WarehousePLID: warehousePLID, WarehousePLID: warehousePLID,
WarehousePLVer: warehousePLVer, WarehousePLVer: warehousePLVer,
@@ -280,6 +301,8 @@ func (s *StockImportService) Import(
Conflicts: result.Conflicts, Conflicts: result.Conflicts,
FallbackMatches: result.FallbackMatches, FallbackMatches: result.FallbackMatches,
ParseErrors: result.ParseErrors, ParseErrors: result.ParseErrors,
Ignored: result.Ignored,
MappingSuggestions: result.MappingSuggestions,
ImportDate: result.ImportDate.Format("2006-01-02"), ImportDate: result.ImportDate.Format("2006-01-02"),
PricelistID: result.WarehousePLID, PricelistID: result.WarehousePLID,
PricelistVer: result.WarehousePLVer, PricelistVer: result.WarehousePLVer,
@@ -382,10 +405,11 @@ func (s *StockImportService) UpsertMapping(partnumber, lotName, description stri
partnumber = strings.TrimSpace(partnumber) partnumber = strings.TrimSpace(partnumber)
lotName = strings.TrimSpace(lotName) lotName = strings.TrimSpace(lotName)
description = strings.TrimSpace(description) description = strings.TrimSpace(description)
if partnumber == "" || lotName == "" { if partnumber == "" {
return fmt.Errorf("partnumber and lot_name are required") return fmt.Errorf("partnumber is required")
} }
if lotName != "" {
var lotCount int64 var lotCount int64
if err := s.db.Model(&models.Lot{}).Where("lot_name = ?", lotName).Count(&lotCount).Error; err != nil { if err := s.db.Model(&models.Lot{}).Where("lot_name = ?", lotName).Count(&lotCount).Error; err != nil {
return err return err
@@ -393,6 +417,7 @@ func (s *StockImportService) UpsertMapping(partnumber, lotName, description stri
if lotCount == 0 { if lotCount == 0 {
return fmt.Errorf("lot not found: %s", lotName) return fmt.Errorf("lot not found: %s", lotName)
} }
}
return s.db.Transaction(func(tx *gorm.DB) error { return s.db.Transaction(func(tx *gorm.DB) error {
var existing []models.LotPartnumber var existing []models.LotPartnumber
@@ -434,56 +459,154 @@ func (s *StockImportService) DeleteMapping(partnumber string) (int64, error) {
return res.RowsAffected, res.Error return res.RowsAffected, res.Error
} }
func (s *StockImportService) upsertPendingMappings(rows []pendingMapping) error { func (s *StockImportService) ListIgnoreRules(page, perPage int) ([]models.StockIgnoreRule, int64, error) {
if s.db == nil || len(rows) == 0 { if s.db == nil {
return nil return nil, 0, fmt.Errorf("offline mode: ignore rules unavailable")
} }
return s.db.Transaction(func(tx *gorm.DB) error { 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 { for _, row := range rows {
pn := strings.TrimSpace(row.Partnumber) target := normalizeIgnoreTarget(row.Target)
if pn == "" { matchType := normalizeIgnoreMatchType(row.MatchType)
pattern := normalizeKey(row.Pattern)
if target == "" || matchType == "" || pattern == "" {
continue continue
} }
desc := strings.TrimSpace(row.Description) rules = append(rules, stockIgnoreRule{
var existing []models.LotPartnumber Target: target,
if err := tx.Where("LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", pn).Find(&existing).Error; err != nil { MatchType: matchType,
return err Pattern: pattern,
}
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
}
}
}
return nil
}) })
} }
return rules, nil
}
func collectSortedSuggestions(src map[string]StockMappingSuggestion, limit int) []StockMappingSuggestion {
if len(src) == 0 {
return nil
}
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
}
case "prefix":
if strings.HasPrefix(candidate, rule.Pattern) {
return true
}
case "suffix":
if strings.HasSuffix(candidate, rule.Pattern) {
return true
}
}
}
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 ( var (
reISODate = regexp.MustCompile(`\b(20\d{2})-(\d{2})-(\d{2})\b`) reISODate = regexp.MustCompile(`\b(20\d{2})-(\d{2})-(\d{2})\b`)

View File

@@ -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)
);

View File

@@ -110,23 +110,55 @@
</div> </div>
<div id="stock-import-stats" class="text-xs text-gray-600 mt-2"></div> <div id="stock-import-stats" class="text-xs text-gray-600 mt-2"></div>
</div> </div>
<div id="stock-import-suggestions" class="hidden mt-4 p-3 bg-amber-50 border border-amber-200 rounded">
<div class="flex items-center justify-between mb-2">
<div class="text-sm font-medium text-amber-900">Новые партномера без сопоставления</div>
<div class="flex items-center gap-2">
<button onclick="addAllSuggestions()" class="inline-flex items-center gap-1 px-2 py-1 text-xs rounded border border-blue-200 text-blue-700 hover:bg-blue-50" title="Добавить все">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
<span>Добавить все</span>
</button>
<button onclick="ignoreAllSuggestions()" class="inline-flex items-center gap-1 px-2 py-1 text-xs rounded border border-amber-200 text-amber-700 hover:bg-amber-100" title="Игнорировать все">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636l-12.728 12.728M5.636 5.636l12.728 12.728"></path>
</svg>
<span>Игнорировать все</span>
</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-amber-200">
<thead class="bg-amber-100">
<tr>
<th class="px-3 py-2 w-1/4 text-left text-xs font-medium text-amber-800 uppercase">LOT</th>
<th class="px-3 py-2 w-1/4 text-left text-xs font-medium text-amber-800 uppercase">Partnumber</th>
<th class="px-3 py-2 text-left text-xs font-medium text-amber-800 uppercase">Описание</th>
<th class="px-3 py-2 text-left text-xs font-medium text-amber-800 uppercase">Причина</th>
<th class="px-3 py-2 text-right text-xs font-medium text-amber-800 uppercase">Действия</th>
</tr>
</thead>
<tbody id="stock-import-suggestions-body" class="bg-white divide-y divide-amber-100"></tbody>
</table>
</div>
</div>
</div> </div>
<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="mapping-partnumber" type="text" placeholder="partnumber" class="px-3 py-2 border rounded w-1/3"> <input id="stock-mappings-search" type="text" placeholder="Поиск по partnumber / описанию / lot" class="px-3 py-2 border rounded w-full">
<input id="mapping-lotname" type="text" placeholder="lot_name" class="px-3 py-2 border rounded w-1/3"> <datalist id="stock-lot-options"></datalist>
<button onclick="saveStockMapping()" class="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Сохранить</button>
<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>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Partnumber</th> <th class="px-4 py-2 w-1/4 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
<th class="px-4 py-2 w-1/4 text-left text-xs font-medium text-gray-500 uppercase">Partnumber</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th> <th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Действия</th> <th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>
</tr> </tr>
</thead> </thead>
@@ -137,6 +169,40 @@
</div> </div>
<div id="stock-mappings-pagination" class="flex justify-center space-x-2 mt-3"></div> <div id="stock-mappings-pagination" class="flex justify-center space-x-2 mt-3"></div>
</div> </div>
<div class="border rounded-lg p-4">
<h3 class="text-lg font-semibold mb-3">Игнорирование при импорте</h3>
<div class="flex items-center gap-2 mb-3">
<select id="ignore-target" class="px-3 py-2 border rounded">
<option value="partnumber">Partnumber</option>
<option value="description">Описание</option>
</select>
<select id="ignore-match-type" class="px-3 py-2 border rounded">
<option value="exact">Равно</option>
<option value="prefix">Начинается с</option>
<option value="suffix">Заканчивается на</option>
</select>
<input id="ignore-pattern" type="text" placeholder="Шаблон" class="px-3 py-2 border rounded w-1/3">
<button onclick="saveStockIgnoreRule()" class="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Добавить</button>
<button onclick="loadStockIgnoreRules(1)" class="px-3 py-2 border rounded hover:bg-gray-50">Обновить</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Поле</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Тип</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Шаблон</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>
</tr>
</thead>
<tbody id="stock-ignore-rules-body" class="bg-white divide-y divide-gray-200">
<tr><td colspan="4" class="px-4 py-3 text-sm text-gray-500">Загрузка...</td></tr>
</tbody>
</table>
</div>
<div id="stock-ignore-rules-pagination" class="flex justify-center space-x-2 mt-3"></div>
</div>
</div> </div>
</div> </div>
@@ -309,6 +375,10 @@ let cachedDbUsername = null;
let syncUsersStatusTimer = null; let syncUsersStatusTimer = null;
let stockMappingsPage = 1; let stockMappingsPage = 1;
let stockMappingsCache = []; let stockMappingsCache = [];
let stockIgnoreRulesPage = 1;
let stockMappingsSearch = '';
let stockMappingsSearchTimer = null;
let stockImportSuggestions = [];
async function loadTab(tab) { async function loadTab(tab) {
currentTab = tab; currentTab = tab;
@@ -341,6 +411,8 @@ async function loadTab(tab) {
await loadPricelists(1, currentPricelistSource); await loadPricelists(1, currentPricelistSource);
if (tab === 'warehouse') { if (tab === 'warehouse') {
await loadStockMappings(1); await loadStockMappings(1);
await loadStockIgnoreRules(1);
await loadStockLotOptions();
} }
} else if (tab === 'component-settings') { } else if (tab === 'component-settings') {
document.getElementById('search-bar').className = 'mb-4'; document.getElementById('search-bar').className = 'mb-4';
@@ -1046,7 +1118,9 @@ async function importStockFile() {
const percentEl = document.getElementById('stock-import-percent'); const percentEl = document.getElementById('stock-import-percent');
const barEl = document.getElementById('stock-import-bar'); const barEl = document.getElementById('stock-import-bar');
const statsEl = document.getElementById('stock-import-stats'); const statsEl = document.getElementById('stock-import-stats');
const suggestionsBox = document.getElementById('stock-import-suggestions');
box.classList.remove('hidden'); box.classList.remove('hidden');
suggestionsBox.classList.add('hidden');
statusEl.textContent = 'Запуск импорта...'; statusEl.textContent = 'Запуск импорта...';
percentEl.textContent = '0%'; percentEl.textContent = '0%';
barEl.style.width = '0%'; barEl.style.width = '0%';
@@ -1082,16 +1156,19 @@ async function importStockFile() {
statusEl.textContent = data.message || data.status || 'Обработка'; statusEl.textContent = data.message || data.status || 'Обработка';
statsEl.textContent = statsEl.textContent =
`Валидных: ${data.valid_rows || 0} | Вставлено: ${data.inserted || 0} | ` + `Валидных: ${data.valid_rows || 0} | Вставлено: ${data.inserted || 0} | ` +
`Пропущено: ${(data.unmapped || 0) + (data.conflicts || 0) + (data.parse_errors || 0)} | ` + `Пропущено: ${(data.unmapped || 0) + (data.conflicts || 0) + (data.parse_errors || 0) + (data.ignored || 0)} | ` +
`Конфликты: ${data.conflicts || 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') {
stockImportSuggestions = data.mapping_suggestions || [];
renderStockImportSuggestions(stockImportSuggestions);
showToast('Импорт stock_log завершен', 'success'); showToast('Импорт stock_log завершен', 'success');
await loadPricelists(1, 'warehouse'); await loadPricelists(1, 'warehouse');
await loadStockMappings(stockMappingsPage); 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 => `
<tr>
<td class="px-3 py-2 w-1/4 text-sm font-mono">
<input data-role="suggestion-lot" data-partnumber="${escapeHtml(item.partnumber || '')}" list="stock-lot-options" autocomplete="off" type="text" class="px-2 py-1 border rounded w-full font-mono" placeholder="Выберите LOT">
</td>
<td class="px-3 py-2 w-1/4 text-sm font-mono">${escapeHtml(item.partnumber || '')}</td>
<td class="px-3 py-2 text-sm text-gray-700">${escapeHtml(item.description || '—')}</td>
<td class="px-3 py-2 text-sm text-gray-700">${escapeHtml(formatSuggestionReason(item.reason || 'unmapped'))}</td>
<td class="px-3 py-2 text-right">
<div class="flex justify-end items-center gap-3">
<button data-partnumber="${escapeHtml(item.partnumber || '')}" onclick="addSuggestionMapping(this)" class="text-blue-600 hover:text-blue-800" title="Добавить">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" data-description="${encodeURIComponent(item.description || '')}">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</button>
<button data-partnumber="${escapeHtml(item.partnumber || '')}" onclick="ignoreSuggestion(this)" class="text-amber-600 hover:text-amber-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="M18.364 5.636l-12.728 12.728M5.636 5.636l12.728 12.728"></path>
</svg>
</button>
</div>
</td>
</tr>
`).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) { async function loadStockMappings(page = 1) {
stockMappingsPage = page; stockMappingsPage = page;
const body = document.getElementById('stock-mappings-body'); const body = document.getElementById('stock-mappings-body');
const pagination = document.getElementById('stock-mappings-pagination'); const pagination = document.getElementById('stock-mappings-pagination');
if (!body || !pagination) return; if (!body || !pagination) return;
try { 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(); const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Ошибка загрузки'); if (!resp.ok) throw new Error(data.error || 'Ошибка загрузки');
const items = data.items || []; const items = data.items || [];
@@ -1115,12 +1330,30 @@ async function loadStockMappings(page = 1) {
body.innerHTML = '<tr><td colspan="4" class="px-4 py-3 text-sm text-gray-500">Нет сопоставлений</td></tr>'; body.innerHTML = '<tr><td colspan="4" class="px-4 py-3 text-sm text-gray-500">Нет сопоставлений</td></tr>';
} else { } else {
body.innerHTML = items.map(item => ` body.innerHTML = items.map(item => `
<tr class="cursor-pointer hover:bg-gray-50" onclick="selectStockMappingRow('${escapeHtml((item.partnumber || item.Partnumber || '')).replace(/'/g, "\\'")}')"> <tr>
<td class="px-4 py-2 text-sm font-mono">${escapeHtml(item.partnumber || item.Partnumber || '—')}</td> <td class="px-4 py-2 w-1/4 text-sm font-mono">
<input data-role="lot" type="text" list="stock-lot-options" autocomplete="off" placeholder="Выберите LOT" value="${escapeHtml(item.lot_name || item.LotName || '')}" class="px-2 py-1 border rounded w-full font-mono">
</td>
<td class="px-4 py-2 w-1/4 text-sm font-mono">${escapeHtml(item.partnumber || item.Partnumber || '—')}</td>
<td class="px-4 py-2 text-sm text-gray-600">${escapeHtml(item.description || item.Description || '—')}</td> <td class="px-4 py-2 text-sm text-gray-600">${escapeHtml(item.description || item.Description || '—')}</td>
<td class="px-4 py-2 text-sm font-mono">${escapeHtml(item.lot_name || item.LotName || '—')}</td> <td class="px-4 py-2">
<td class="px-4 py-2 text-right"> <div class="flex justify-end items-center gap-3">
<button data-partnumber="${escapeHtml(item.partnumber || item.Partnumber || '')}" onclick="event.stopPropagation(); deleteStockMapping(this.dataset.partnumber)" class="text-red-600 hover:text-red-800 text-sm">Удалить</button> <button data-partnumber="${escapeHtml(item.partnumber || item.Partnumber || '')}" onclick="saveInlineStockMapping(this)" class="text-blue-600 hover:text-blue-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="M5 13l4 4L19 7"></path>
</svg>
</button>
<button data-partnumber="${escapeHtml(item.partnumber || item.Partnumber || '')}" onclick="event.stopPropagation(); ignoreStockMapping(this.dataset.partnumber)" class="text-amber-600 hover:text-amber-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="M18.364 5.636l-12.728 12.728M5.636 5.636l12.728 12.728"></path>
</svg>
</button>
<button data-partnumber="${escapeHtml(item.partnumber || item.Partnumber || '')}" onclick="event.stopPropagation(); deleteStockMapping(this.dataset.partnumber)" 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>
</div>
</td> </td>
</tr> </tr>
`).join(''); `).join('');
@@ -1143,19 +1376,23 @@ async function loadStockMappings(page = 1) {
} }
} }
function selectStockMappingRow(partnumber) { function applyStockMappingsSearch() {
const normalized = (partnumber || '').trim().toLowerCase(); stockMappingsSearch = (document.getElementById('stock-mappings-search')?.value || '').trim();
const row = stockMappingsCache.find(item => ((item.partnumber || item.Partnumber || '').trim().toLowerCase() === normalized)); loadStockMappings(1);
if (!row) return;
document.getElementById('mapping-partnumber').value = (row.partnumber || row.Partnumber || '').trim();
document.getElementById('mapping-lotname').value = (row.lot_name || row.LotName || '').trim();
} }
async function saveStockMapping() { function onStockMappingsSearchInput() {
const partnumber = document.getElementById('mapping-partnumber').value.trim(); clearTimeout(stockMappingsSearchTimer);
const lotName = document.getElementById('mapping-lotname').value.trim(); stockMappingsSearchTimer = setTimeout(applyStockMappingsSearch, 250);
if (!partnumber || !lotName) { }
showToast('Заполните partnumber и lot_name', 'error');
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; return;
} }
try { try {
@@ -1166,10 +1403,103 @@ async function saveStockMapping() {
}); });
const data = await resp.json(); const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Ошибка сохранения'); if (!resp.ok) throw new Error(data.error || 'Ошибка сохранения');
document.getElementById('mapping-partnumber').value = '';
document.getElementById('mapping-lotname').value = '';
showToast('Сопоставление сохранено', 'success'); 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 => `<option value="${escapeHtml(lot)}"></option>`).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 = '<tr><td colspan="4" class="px-4 py-3 text-sm text-gray-500">Нет правил</td></tr>';
} else {
const matchLabel = { exact: 'Равно', prefix: 'Начинается с', suffix: 'Заканчивается на' };
body.innerHTML = items.map(item => `
<tr>
<td class="px-4 py-2 text-sm">${escapeHtml(item.target)}</td>
<td class="px-4 py-2 text-sm">${escapeHtml(matchLabel[item.match_type] || item.match_type)}</td>
<td class="px-4 py-2 text-sm font-mono">${escapeHtml(item.pattern)}</td>
<td class="px-4 py-2 text-right">
<button onclick="deleteStockIgnoreRule(${item.id})" class="text-red-600 hover:text-red-800 text-sm">Удалить</button>
</td>
</tr>
`).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 += `<button onclick="loadStockIgnoreRules(${i})" class="px-3 py-1 rounded border ${cls}">${i}</button>`;
}
pagination.innerHTML = html;
}
} catch (e) {
body.innerHTML = `<tr><td colspan="4" class="px-4 py-3 text-sm text-red-600">${escapeHtml(e.message)}</td></tr>`;
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) { } catch (e) {
showToast('Ошибка: ' + e.message, 'error'); 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 () => { document.addEventListener('DOMContentLoaded', async () => {
await checkPricelistWritePermission(); await checkPricelistWritePermission();
// Check URL params for initial tab // Check URL params for initial tab
@@ -1198,6 +1555,10 @@ document.addEventListener('DOMContentLoaded', async () => {
if (initialTab === 'pricelists') initialTab = 'estimate'; if (initialTab === 'pricelists') initialTab = 'estimate';
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');
if (stockMappingsSearchEl) {
stockMappingsSearchEl.addEventListener('input', onStockMappingsSearchInput);
}
// Add event listeners for preview updates // Add event listeners for preview updates
document.getElementById('modal-period').addEventListener('change', fetchPreview); document.getElementById('modal-period').addEventListener('change', fetchPreview);