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 eb8ac34d83
commit 5f2969a85a
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

@@ -21,40 +21,51 @@ import (
) )
type StockImportProgress struct { type StockImportProgress struct {
Status string `json:"status"` Status string `json:"status"`
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
Current int `json:"current,omitempty"` Current int `json:"current,omitempty"`
Total int `json:"total,omitempty"` Total int `json:"total,omitempty"`
RowsTotal int `json:"rows_total,omitempty"` RowsTotal int `json:"rows_total,omitempty"`
ValidRows int `json:"valid_rows,omitempty"` ValidRows int `json:"valid_rows,omitempty"`
Inserted int `json:"inserted,omitempty"` Inserted int `json:"inserted,omitempty"`
Deleted int64 `json:"deleted,omitempty"` Deleted int64 `json:"deleted,omitempty"`
Unmapped int `json:"unmapped,omitempty"` Unmapped int `json:"unmapped,omitempty"`
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"`
ImportDate string `json:"import_date,omitempty"` Ignored int `json:"ignored,omitempty"`
PricelistID uint `json:"warehouse_pricelist_id,omitempty"` MappingSuggestions []StockMappingSuggestion `json:"mapping_suggestions,omitempty"`
PricelistVer string `json:"warehouse_pricelist_version,omitempty"` ImportDate string `json:"import_date,omitempty"`
PricelistID uint `json:"warehouse_pricelist_id,omitempty"`
PricelistVer string `json:"warehouse_pricelist_version,omitempty"`
} }
type StockImportResult struct { type StockImportResult struct {
RowsTotal int RowsTotal int
ValidRows int ValidRows int
Inserted int Inserted int
Deleted int64 Deleted int64
Unmapped int Unmapped int
Conflicts int Conflicts int
FallbackMatches int FallbackMatches int
ParseErrors int ParseErrors int
ImportDate time.Time Ignored int
WarehousePLID uint MappingSuggestions []StockMappingSuggestion
WarehousePLVer string ImportDate time.Time
WarehousePLID uint
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")
@@ -256,35 +275,39 @@ func (s *StockImportService) Import(
warehousePLVer = pl.Version warehousePLVer = pl.Version
result := &StockImportResult{ result := &StockImportResult{
RowsTotal: len(rows), RowsTotal: len(rows),
ValidRows: len(records), ValidRows: len(records),
Inserted: inserted, Inserted: inserted,
Deleted: deleted, Deleted: deleted,
Unmapped: unmapped, Unmapped: unmapped,
Conflicts: conflicts, Conflicts: conflicts,
FallbackMatches: fallbackMatches, FallbackMatches: fallbackMatches,
ParseErrors: parseErrors, ParseErrors: parseErrors,
ImportDate: importDate, Ignored: ignored,
WarehousePLID: warehousePLID, MappingSuggestions: suggestions,
WarehousePLVer: warehousePLVer, ImportDate: importDate,
WarehousePLID: warehousePLID,
WarehousePLVer: warehousePLVer,
} }
report(StockImportProgress{ report(StockImportProgress{
Status: "completed", Status: "completed",
Message: "Импорт завершен", Message: "Импорт завершен",
RowsTotal: result.RowsTotal, RowsTotal: result.RowsTotal,
ValidRows: result.ValidRows, ValidRows: result.ValidRows,
Inserted: result.Inserted, Inserted: result.Inserted,
Deleted: result.Deleted, Deleted: result.Deleted,
Unmapped: result.Unmapped, Unmapped: result.Unmapped,
Conflicts: result.Conflicts, Conflicts: result.Conflicts,
FallbackMatches: result.FallbackMatches, FallbackMatches: result.FallbackMatches,
ParseErrors: result.ParseErrors, ParseErrors: result.ParseErrors,
ImportDate: result.ImportDate.Format("2006-01-02"), Ignored: result.Ignored,
PricelistID: result.WarehousePLID, MappingSuggestions: result.MappingSuggestions,
PricelistVer: result.WarehousePLVer, ImportDate: result.ImportDate.Format("2006-01-02"),
Current: 100, PricelistID: result.WarehousePLID,
Total: 100, PricelistVer: result.WarehousePLVer,
Current: 100,
Total: 100,
}) })
return result, nil return result, nil
@@ -382,16 +405,18 @@ 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")
} }
var lotCount int64 if lotName != "" {
if err := s.db.Model(&models.Lot{}).Where("lot_name = ?", lotName).Count(&lotCount).Error; err != nil { var lotCount int64
return err 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 lotCount == 0 {
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 {
@@ -434,55 +459,153 @@ 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, 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 nil
} }
return s.db.Transaction(func(tx *gorm.DB) error { items := make([]StockMappingSuggestion, 0, len(src))
for _, row := range rows { for _, item := range src {
pn := strings.TrimSpace(row.Partnumber) items = append(items, item)
if pn == "" { }
continue 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) case "prefix":
var existing []models.LotPartnumber if strings.HasPrefix(candidate, rule.Pattern) {
if err := tx.Where("LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", pn).Find(&existing).Error; err != nil { return true
return err
} }
if len(existing) == 0 { case "suffix":
var descPtr *string if strings.HasSuffix(candidate, rule.Pattern) {
if desc != "" { return true
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 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 (

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