diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go
index ebb653f..e3a42d9 100644
--- a/cmd/qfs/main.go
+++ b/cmd/qfs/main.go
@@ -1326,10 +1326,14 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
pricingAdmin.POST("/update", pricingHandler.UpdatePrice)
pricingAdmin.POST("/preview", pricingHandler.PreviewPrice)
pricingAdmin.POST("/recalculate-all", pricingHandler.RecalculateAll)
+ pricingAdmin.GET("/lots", pricingHandler.ListLots)
pricingAdmin.POST("/stock/import", pricingHandler.ImportStockLog)
pricingAdmin.GET("/stock/mappings", pricingHandler.ListStockMappings)
pricingAdmin.POST("/stock/mappings", pricingHandler.UpsertStockMapping)
pricingAdmin.DELETE("/stock/mappings/:partnumber", pricingHandler.DeleteStockMapping)
+ pricingAdmin.GET("/stock/ignore-rules", pricingHandler.ListStockIgnoreRules)
+ pricingAdmin.POST("/stock/ignore-rules", pricingHandler.UpsertStockIgnoreRule)
+ pricingAdmin.DELETE("/stock/ignore-rules/:id", pricingHandler.DeleteStockIgnoreRule)
pricingAdmin.GET("/alerts", pricingHandler.ListAlerts)
pricingAdmin.POST("/alerts/:id/acknowledge", pricingHandler.AcknowledgeAlert)
diff --git a/internal/handlers/pricing.go b/internal/handlers/pricing.go
index bece4ca..f114190 100644
--- a/internal/handlers/pricing.go
+++ b/internal/handlers/pricing.go
@@ -997,6 +997,8 @@ func (h *PricingHandler) ImportStockLog(c *gin.Context) {
"conflicts": result.Conflicts,
"fallback_matches": result.FallbackMatches,
"parse_errors": result.ParseErrors,
+ "ignored": result.Ignored,
+ "mapping_suggestions": result.MappingSuggestions,
"import_date": result.ImportDate.Format("2006-01-02"),
"warehouse_pricelist_id": result.WarehousePLID,
"warehouse_pricelist_version": result.WarehousePLVer,
@@ -1029,6 +1031,8 @@ func (h *PricingHandler) ImportStockLog(c *gin.Context) {
"conflicts": p.Conflicts,
"fallback_matches": p.FallbackMatches,
"parse_errors": p.ParseErrors,
+ "ignored": p.Ignored,
+ "mapping_suggestions": p.MappingSuggestions,
"import_date": p.ImportDate,
"warehouse_pricelist_id": p.PricelistID,
"warehouse_pricelist_version": p.PricelistVer,
@@ -1078,7 +1082,7 @@ func (h *PricingHandler) UpsertStockMapping(c *gin.Context) {
var req struct {
Partnumber string `json:"partnumber" binding:"required"`
- LotName string `json:"lot_name" binding:"required"`
+ LotName string `json:"lot_name"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -1109,3 +1113,107 @@ func (h *PricingHandler) DeleteStockMapping(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"deleted": deleted})
}
+
+func (h *PricingHandler) ListStockIgnoreRules(c *gin.Context) {
+ if h.stockImportService == nil {
+ c.JSON(http.StatusServiceUnavailable, gin.H{
+ "error": "Правила игнорирования доступны только в онлайн режиме",
+ "offline": true,
+ })
+ return
+ }
+
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
+ rows, total, err := h.stockImportService.ListIgnoreRules(page, perPage)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "items": rows,
+ "total": total,
+ "page": page,
+ "per_page": perPage,
+ })
+}
+
+func (h *PricingHandler) UpsertStockIgnoreRule(c *gin.Context) {
+ if h.stockImportService == nil {
+ c.JSON(http.StatusServiceUnavailable, gin.H{
+ "error": "Правила игнорирования доступны только в онлайн режиме",
+ "offline": true,
+ })
+ return
+ }
+ var req struct {
+ Target string `json:"target" binding:"required"`
+ MatchType string `json:"match_type" binding:"required"`
+ Pattern string `json:"pattern" binding:"required"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if err := h.stockImportService.UpsertIgnoreRule(req.Target, req.MatchType, req.Pattern); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "ignore rule saved"})
+}
+
+func (h *PricingHandler) DeleteStockIgnoreRule(c *gin.Context) {
+ if h.stockImportService == nil {
+ c.JSON(http.StatusServiceUnavailable, gin.H{
+ "error": "Правила игнорирования доступны только в онлайн режиме",
+ "offline": true,
+ })
+ return
+ }
+ id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+ if err != nil || id == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
+ return
+ }
+ deleted, err := h.stockImportService.DeleteIgnoreRule(uint(id))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"deleted": deleted})
+}
+
+func (h *PricingHandler) ListLots(c *gin.Context) {
+ if h.db == nil {
+ c.JSON(http.StatusServiceUnavailable, gin.H{
+ "error": "Список LOT доступен только в онлайн режиме",
+ "offline": true,
+ })
+ return
+ }
+ perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "500"))
+ if perPage < 1 {
+ perPage = 500
+ }
+ if perPage > 5000 {
+ perPage = 5000
+ }
+ search := strings.TrimSpace(c.Query("search"))
+ query := h.db.Model(&models.Lot{}).Select("lot_name")
+ if search != "" {
+ query = query.Where("lot_name LIKE ?", "%"+search+"%")
+ }
+ var lots []models.Lot
+ if err := query.Order("lot_name ASC").Limit(perPage).Find(&lots).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ items := make([]string, 0, len(lots))
+ for _, lot := range lots {
+ if strings.TrimSpace(lot.LotName) == "" {
+ continue
+ }
+ items = append(items, lot.LotName)
+ }
+ c.JSON(http.StatusOK, gin.H{"items": items})
+}
diff --git a/internal/models/lot.go b/internal/models/lot.go
index 7990f38..de27cdc 100644
--- a/internal/models/lot.go
+++ b/internal/models/lot.go
@@ -65,3 +65,16 @@ type LotPartnumber struct {
func (LotPartnumber) TableName() string {
return "lot_partnumbers"
}
+
+// StockIgnoreRule contains import ignore pattern rules.
+type StockIgnoreRule struct {
+ ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
+ Target string `gorm:"column:target;size:20;not null" json:"target"` // partnumber|description
+ MatchType string `gorm:"column:match_type;size:20;not null" json:"match_type"` // exact|prefix|suffix
+ Pattern string `gorm:"column:pattern;size:500;not null" json:"pattern"`
+ CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
+}
+
+func (StockIgnoreRule) TableName() string {
+ return "stock_ignore_rules"
+}
diff --git a/internal/services/stock_import.go b/internal/services/stock_import.go
index 61f34c9..6b23560 100644
--- a/internal/services/stock_import.go
+++ b/internal/services/stock_import.go
@@ -21,40 +21,51 @@ import (
)
type StockImportProgress struct {
- Status string `json:"status"`
- Message string `json:"message,omitempty"`
- Current int `json:"current,omitempty"`
- Total int `json:"total,omitempty"`
- RowsTotal int `json:"rows_total,omitempty"`
- ValidRows int `json:"valid_rows,omitempty"`
- Inserted int `json:"inserted,omitempty"`
- Deleted int64 `json:"deleted,omitempty"`
- Unmapped int `json:"unmapped,omitempty"`
- Conflicts int `json:"conflicts,omitempty"`
- FallbackMatches int `json:"fallback_matches,omitempty"`
- ParseErrors int `json:"parse_errors,omitempty"`
- ImportDate string `json:"import_date,omitempty"`
- PricelistID uint `json:"warehouse_pricelist_id,omitempty"`
- PricelistVer string `json:"warehouse_pricelist_version,omitempty"`
+ Status string `json:"status"`
+ Message string `json:"message,omitempty"`
+ Current int `json:"current,omitempty"`
+ Total int `json:"total,omitempty"`
+ RowsTotal int `json:"rows_total,omitempty"`
+ ValidRows int `json:"valid_rows,omitempty"`
+ Inserted int `json:"inserted,omitempty"`
+ Deleted int64 `json:"deleted,omitempty"`
+ Unmapped int `json:"unmapped,omitempty"`
+ Conflicts int `json:"conflicts,omitempty"`
+ FallbackMatches int `json:"fallback_matches,omitempty"`
+ ParseErrors int `json:"parse_errors,omitempty"`
+ Ignored int `json:"ignored,omitempty"`
+ MappingSuggestions []StockMappingSuggestion `json:"mapping_suggestions,omitempty"`
+ ImportDate string `json:"import_date,omitempty"`
+ PricelistID uint `json:"warehouse_pricelist_id,omitempty"`
+ PricelistVer string `json:"warehouse_pricelist_version,omitempty"`
}
type StockImportResult struct {
- RowsTotal int
- ValidRows int
- Inserted int
- Deleted int64
- Unmapped int
- Conflicts int
- FallbackMatches int
- ParseErrors int
- ImportDate time.Time
- WarehousePLID uint
- WarehousePLVer string
+ RowsTotal int
+ ValidRows int
+ Inserted int
+ Deleted int64
+ Unmapped int
+ Conflicts int
+ FallbackMatches int
+ ParseErrors int
+ Ignored int
+ MappingSuggestions []StockMappingSuggestion
+ ImportDate time.Time
+ WarehousePLID uint
+ WarehousePLVer string
}
-type pendingMapping struct {
- Partnumber string
- Description string
+type StockMappingSuggestion struct {
+ Partnumber string `json:"partnumber"`
+ Description string `json:"description,omitempty"`
+ Reason string `json:"reason,omitempty"`
+}
+
+type stockIgnoreRule struct {
+ Target string
+ MatchType string
+ Pattern string
}
type StockImportService struct {
@@ -128,26 +139,42 @@ func (s *StockImportService) Import(
conflicts int
fallbackMatches int
parseErrors int
- pendingByPN = make(map[string]pendingMapping)
+ ignored int
+ suggestionsByPN = make(map[string]StockMappingSuggestion)
)
+ ignoreRules, err := s.loadIgnoreRules()
+ if err != nil {
+ return nil, err
+ }
for _, row := range rows {
if strings.TrimSpace(row.Article) == "" {
parseErrors++
continue
}
+ if shouldIgnoreStockRow(row, ignoreRules) {
+ ignored++
+ continue
+ }
lot, matchType, resolveErr := resolver.resolve(row.Article)
if resolveErr != nil {
trimmedPN := strings.TrimSpace(row.Article)
if trimmedPN != "" {
key := normalizeKey(trimmedPN)
if key != "" {
- candidate := pendingMapping{
+ reason := "unmapped"
+ if errors.Is(resolveErr, errResolveConflict) {
+ reason = "conflict"
+ }
+ candidate := StockMappingSuggestion{
Partnumber: trimmedPN,
Description: strings.TrimSpace(row.Description),
+ Reason: reason,
}
- if prev, ok := pendingByPN[key]; !ok || (strings.TrimSpace(prev.Description) == "" && candidate.Description != "") {
- pendingByPN[key] = candidate
+ if prev, ok := suggestionsByPN[key]; !ok ||
+ (strings.TrimSpace(prev.Description) == "" && candidate.Description != "") ||
+ (prev.Reason != "conflict" && candidate.Reason == "conflict") {
+ suggestionsByPN[key] = candidate
}
}
}
@@ -181,15 +208,7 @@ func (s *StockImportService) Import(
})
}
- if len(pendingByPN) > 0 {
- pending := make([]pendingMapping, 0, len(pendingByPN))
- for _, m := range pendingByPN {
- pending = append(pending, m)
- }
- if err := s.upsertPendingMappings(pending); err != nil {
- return nil, err
- }
- }
+ suggestions := collectSortedSuggestions(suggestionsByPN, 200)
if len(records) == 0 {
return nil, fmt.Errorf("no valid rows after mapping")
@@ -256,35 +275,39 @@ func (s *StockImportService) Import(
warehousePLVer = pl.Version
result := &StockImportResult{
- RowsTotal: len(rows),
- ValidRows: len(records),
- Inserted: inserted,
- Deleted: deleted,
- Unmapped: unmapped,
- Conflicts: conflicts,
- FallbackMatches: fallbackMatches,
- ParseErrors: parseErrors,
- ImportDate: importDate,
- WarehousePLID: warehousePLID,
- WarehousePLVer: warehousePLVer,
+ RowsTotal: len(rows),
+ ValidRows: len(records),
+ Inserted: inserted,
+ Deleted: deleted,
+ Unmapped: unmapped,
+ Conflicts: conflicts,
+ FallbackMatches: fallbackMatches,
+ ParseErrors: parseErrors,
+ Ignored: ignored,
+ MappingSuggestions: suggestions,
+ ImportDate: importDate,
+ WarehousePLID: warehousePLID,
+ WarehousePLVer: warehousePLVer,
}
report(StockImportProgress{
- Status: "completed",
- Message: "Импорт завершен",
- RowsTotal: result.RowsTotal,
- ValidRows: result.ValidRows,
- Inserted: result.Inserted,
- Deleted: result.Deleted,
- Unmapped: result.Unmapped,
- Conflicts: result.Conflicts,
- FallbackMatches: result.FallbackMatches,
- ParseErrors: result.ParseErrors,
- ImportDate: result.ImportDate.Format("2006-01-02"),
- PricelistID: result.WarehousePLID,
- PricelistVer: result.WarehousePLVer,
- Current: 100,
- Total: 100,
+ Status: "completed",
+ Message: "Импорт завершен",
+ RowsTotal: result.RowsTotal,
+ ValidRows: result.ValidRows,
+ Inserted: result.Inserted,
+ Deleted: result.Deleted,
+ Unmapped: result.Unmapped,
+ Conflicts: result.Conflicts,
+ FallbackMatches: result.FallbackMatches,
+ ParseErrors: result.ParseErrors,
+ Ignored: result.Ignored,
+ MappingSuggestions: result.MappingSuggestions,
+ ImportDate: result.ImportDate.Format("2006-01-02"),
+ PricelistID: result.WarehousePLID,
+ PricelistVer: result.WarehousePLVer,
+ Current: 100,
+ Total: 100,
})
return result, nil
@@ -382,16 +405,18 @@ func (s *StockImportService) UpsertMapping(partnumber, lotName, description stri
partnumber = strings.TrimSpace(partnumber)
lotName = strings.TrimSpace(lotName)
description = strings.TrimSpace(description)
- if partnumber == "" || lotName == "" {
- return fmt.Errorf("partnumber and lot_name are required")
+ if partnumber == "" {
+ return fmt.Errorf("partnumber is required")
}
- var lotCount int64
- if err := s.db.Model(&models.Lot{}).Where("lot_name = ?", lotName).Count(&lotCount).Error; err != nil {
- return err
- }
- if lotCount == 0 {
- return fmt.Errorf("lot not found: %s", lotName)
+ if lotName != "" {
+ var lotCount int64
+ if err := s.db.Model(&models.Lot{}).Where("lot_name = ?", lotName).Count(&lotCount).Error; err != nil {
+ return err
+ }
+ if lotCount == 0 {
+ return fmt.Errorf("lot not found: %s", lotName)
+ }
}
return s.db.Transaction(func(tx *gorm.DB) error {
@@ -434,55 +459,153 @@ func (s *StockImportService) DeleteMapping(partnumber string) (int64, error) {
return res.RowsAffected, res.Error
}
-func (s *StockImportService) upsertPendingMappings(rows []pendingMapping) error {
- if s.db == nil || len(rows) == 0 {
+func (s *StockImportService) ListIgnoreRules(page, perPage int) ([]models.StockIgnoreRule, int64, error) {
+ if s.db == nil {
+ return nil, 0, fmt.Errorf("offline mode: ignore rules unavailable")
+ }
+ if page < 1 {
+ page = 1
+ }
+ if perPage < 1 {
+ perPage = 50
+ }
+ if perPage > 500 {
+ perPage = 500
+ }
+
+ offset := (page - 1) * perPage
+ query := s.db.Model(&models.StockIgnoreRule{})
+ var total int64
+ if err := query.Count(&total).Error; err != nil {
+ return nil, 0, err
+ }
+ var rows []models.StockIgnoreRule
+ if err := query.Order("id DESC").Offset(offset).Limit(perPage).Find(&rows).Error; err != nil {
+ return nil, 0, err
+ }
+ return rows, total, nil
+}
+
+func (s *StockImportService) UpsertIgnoreRule(target, matchType, pattern string) error {
+ if s.db == nil {
+ return fmt.Errorf("offline mode: ignore rules unavailable")
+ }
+ target = normalizeIgnoreTarget(target)
+ matchType = normalizeIgnoreMatchType(matchType)
+ pattern = strings.TrimSpace(pattern)
+ if target == "" || matchType == "" || pattern == "" {
+ return fmt.Errorf("target, match_type and pattern are required")
+ }
+ return s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&models.StockIgnoreRule{
+ Target: target,
+ MatchType: matchType,
+ Pattern: pattern,
+ }).Error
+}
+
+func (s *StockImportService) DeleteIgnoreRule(id uint) (int64, error) {
+ if s.db == nil {
+ return 0, fmt.Errorf("offline mode: ignore rules unavailable")
+ }
+ res := s.db.Delete(&models.StockIgnoreRule{}, id)
+ return res.RowsAffected, res.Error
+}
+
+func (s *StockImportService) loadIgnoreRules() ([]stockIgnoreRule, error) {
+ var rows []models.StockIgnoreRule
+ if err := s.db.Find(&rows).Error; err != nil {
+ return nil, err
+ }
+ rules := make([]stockIgnoreRule, 0, len(rows))
+ for _, row := range rows {
+ target := normalizeIgnoreTarget(row.Target)
+ matchType := normalizeIgnoreMatchType(row.MatchType)
+ pattern := normalizeKey(row.Pattern)
+ if target == "" || matchType == "" || pattern == "" {
+ continue
+ }
+ rules = append(rules, stockIgnoreRule{
+ Target: target,
+ MatchType: matchType,
+ Pattern: pattern,
+ })
+ }
+ return rules, nil
+}
+
+func collectSortedSuggestions(src map[string]StockMappingSuggestion, limit int) []StockMappingSuggestion {
+ if len(src) == 0 {
return nil
}
- return s.db.Transaction(func(tx *gorm.DB) error {
- for _, row := range rows {
- pn := strings.TrimSpace(row.Partnumber)
- if pn == "" {
- continue
+ items := make([]StockMappingSuggestion, 0, len(src))
+ for _, item := range src {
+ items = append(items, item)
+ }
+ sort.Slice(items, func(i, j int) bool {
+ return strings.ToLower(items[i].Partnumber) < strings.ToLower(items[j].Partnumber)
+ })
+ if limit > 0 && len(items) > limit {
+ return items[:limit]
+ }
+ return items
+}
+
+func shouldIgnoreStockRow(row stockImportRow, rules []stockIgnoreRule) bool {
+ if len(rules) == 0 {
+ return false
+ }
+ partnumber := normalizeKey(row.Article)
+ description := normalizeKey(row.Description)
+ for _, rule := range rules {
+ candidate := ""
+ if rule.Target == "partnumber" {
+ candidate = partnumber
+ } else {
+ candidate = description
+ }
+ if candidate == "" || rule.Pattern == "" {
+ continue
+ }
+ switch rule.MatchType {
+ case "exact":
+ if candidate == rule.Pattern {
+ return true
}
- desc := strings.TrimSpace(row.Description)
- var existing []models.LotPartnumber
- if err := tx.Where("LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", pn).Find(&existing).Error; err != nil {
- return err
+ case "prefix":
+ if strings.HasPrefix(candidate, rule.Pattern) {
+ return true
}
- if len(existing) == 0 {
- var descPtr *string
- if desc != "" {
- descPtr = &desc
- }
- if err := tx.Create(&models.LotPartnumber{
- Partnumber: pn,
- LotName: "",
- Description: descPtr,
- }).Error; err != nil {
- return err
- }
- continue
- }
- if desc == "" {
- continue
- }
- needsDescription := true
- for _, item := range existing {
- if item.Description != nil && strings.TrimSpace(*item.Description) != "" {
- needsDescription = false
- break
- }
- }
- if needsDescription {
- if err := tx.Model(&models.LotPartnumber{}).
- Where("LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", pn).
- Update("description", desc).Error; err != nil {
- return err
- }
+ case "suffix":
+ if strings.HasSuffix(candidate, rule.Pattern) {
+ return true
}
}
- return nil
- })
+ }
+ return false
+}
+
+func normalizeIgnoreTarget(v string) string {
+ switch strings.ToLower(strings.TrimSpace(v)) {
+ case "partnumber":
+ return "partnumber"
+ case "description":
+ return "description"
+ default:
+ return ""
+ }
+}
+
+func normalizeIgnoreMatchType(v string) string {
+ switch strings.ToLower(strings.TrimSpace(v)) {
+ case "exact":
+ return "exact"
+ case "prefix":
+ return "prefix"
+ case "suffix":
+ return "suffix"
+ default:
+ return ""
+ }
}
var (
diff --git a/migrations/018_add_stock_ignore_rules.sql b/migrations/018_add_stock_ignore_rules.sql
new file mode 100644
index 0000000..6171067
--- /dev/null
+++ b/migrations/018_add_stock_ignore_rules.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS stock_ignore_rules (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ target VARCHAR(20) NOT NULL,
+ match_type VARCHAR(20) NOT NULL,
+ pattern VARCHAR(500) NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uq_stock_ignore_rule (target, match_type, pattern),
+ KEY idx_stock_ignore_target (target)
+);
diff --git a/web/templates/admin_pricing.html b/web/templates/admin_pricing.html
index f64d742..24deb01 100644
--- a/web/templates/admin_pricing.html
+++ b/web/templates/admin_pricing.html
@@ -110,23 +110,55 @@
+
+
+
Новые партномера без сопоставления
+
+
+
+
+
+
+
+
+
+ | LOT |
+ Partnumber |
+ Описание |
+ Причина |
+ Действия |
+
+
+
+
+
+
Сопоставление partnumber -> LOT
-
-
-
+
+
- | Partnumber |
+ LOT |
+ Partnumber |
Описание |
- LOT |
Действия |
@@ -137,6 +169,40 @@
+
+
+
Игнорирование при импорте
+
+
+
+
+
+
+
+
+
+
+
+ | Поле |
+ Тип |
+ Шаблон |
+ Действия |
+
+
+
+ | Загрузка... |
+
+
+
+
+
@@ -309,6 +375,10 @@ let cachedDbUsername = null;
let syncUsersStatusTimer = null;
let stockMappingsPage = 1;
let stockMappingsCache = [];
+let stockIgnoreRulesPage = 1;
+let stockMappingsSearch = '';
+let stockMappingsSearchTimer = null;
+let stockImportSuggestions = [];
async function loadTab(tab) {
currentTab = tab;
@@ -341,6 +411,8 @@ async function loadTab(tab) {
await loadPricelists(1, currentPricelistSource);
if (tab === 'warehouse') {
await loadStockMappings(1);
+ await loadStockIgnoreRules(1);
+ await loadStockLotOptions();
}
} else if (tab === 'component-settings') {
document.getElementById('search-bar').className = 'mb-4';
@@ -1046,7 +1118,9 @@ async function importStockFile() {
const percentEl = document.getElementById('stock-import-percent');
const barEl = document.getElementById('stock-import-bar');
const statsEl = document.getElementById('stock-import-stats');
+ const suggestionsBox = document.getElementById('stock-import-suggestions');
box.classList.remove('hidden');
+ suggestionsBox.classList.add('hidden');
statusEl.textContent = 'Запуск импорта...';
percentEl.textContent = '0%';
barEl.style.width = '0%';
@@ -1082,16 +1156,19 @@ async function importStockFile() {
statusEl.textContent = data.message || data.status || 'Обработка';
statsEl.textContent =
`Валидных: ${data.valid_rows || 0} | Вставлено: ${data.inserted || 0} | ` +
- `Пропущено: ${(data.unmapped || 0) + (data.conflicts || 0) + (data.parse_errors || 0)} | ` +
- `Конфликты: ${data.conflicts || 0}`;
+ `Пропущено: ${(data.unmapped || 0) + (data.conflicts || 0) + (data.parse_errors || 0) + (data.ignored || 0)} | ` +
+ `Игнор: ${data.ignored || 0} | Конфликты: ${data.conflicts || 0}`;
if (data.status === 'error') {
throw new Error(data.message || 'Ошибка импорта');
}
if (data.status === 'completed') {
+ stockImportSuggestions = data.mapping_suggestions || [];
+ renderStockImportSuggestions(stockImportSuggestions);
showToast('Импорт stock_log завершен', 'success');
await loadPricelists(1, 'warehouse');
await loadStockMappings(stockMappingsPage);
+ await loadStockIgnoreRules(stockIgnoreRulesPage);
}
}
}
@@ -1100,13 +1177,151 @@ async function importStockFile() {
}
}
+function formatSuggestionReason(reason) {
+ if (reason === 'conflict') return 'Конфликт';
+ return 'Не найден LOT';
+}
+
+function renderStockImportSuggestions(items) {
+ const box = document.getElementById('stock-import-suggestions');
+ const body = document.getElementById('stock-import-suggestions-body');
+ if (!box || !body) return;
+ if (!items || items.length === 0) {
+ box.classList.add('hidden');
+ body.innerHTML = '';
+ return;
+ }
+ body.innerHTML = items.map(item => `
+
+ |
+
+ |
+ ${escapeHtml(item.partnumber || '')} |
+ ${escapeHtml(item.description || '—')} |
+ ${escapeHtml(formatSuggestionReason(item.reason || 'unmapped'))} |
+
+
+ |
+
+ `).join('');
+ box.classList.remove('hidden');
+}
+
+async function addSuggestionMapping(button) {
+ const partnumber = (button?.dataset?.partnumber || '').trim();
+ if (!partnumber) return;
+ const description = decodeURIComponent(button?.querySelector('svg')?.dataset?.description || '');
+ const input = document.querySelector(`input[data-role="suggestion-lot"][data-partnumber="${CSS.escape(partnumber)}"]`);
+ const lotName = (input?.value || '').trim();
+ try {
+ const resp = await fetch('/api/admin/pricing/stock/mappings', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ partnumber: partnumber, lot_name: lotName, description: description })
+ });
+ const data = await resp.json();
+ if (!resp.ok) throw new Error(data.error || 'Ошибка сохранения');
+ stockImportSuggestions = stockImportSuggestions.filter(item => (item.partnumber || '').trim().toLowerCase() !== partnumber.toLowerCase());
+ renderStockImportSuggestions(stockImportSuggestions);
+ await loadStockMappings(1);
+ showToast('Сопоставление добавлено', 'success');
+ } catch (e) {
+ showToast('Ошибка: ' + e.message, 'error');
+ }
+}
+
+async function ignoreSuggestion(button) {
+ const partnumber = (button?.dataset?.partnumber || '').trim();
+ if (!partnumber) return;
+ try {
+ const resp = await fetch('/api/admin/pricing/stock/ignore-rules', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ target: 'partnumber', match_type: 'exact', pattern: partnumber })
+ });
+ const data = await resp.json();
+ if (!resp.ok) throw new Error(data.error || 'Ошибка добавления в игнорирование');
+ stockImportSuggestions = stockImportSuggestions.filter(item => (item.partnumber || '').trim().toLowerCase() !== partnumber.toLowerCase());
+ renderStockImportSuggestions(stockImportSuggestions);
+ await loadStockIgnoreRules(1);
+ showToast('Добавлено в игнорирование', 'success');
+ } catch (e) {
+ showToast('Ошибка: ' + e.message, 'error');
+ }
+}
+
+async function addAllSuggestions() {
+ const descriptionByPartnumber = {};
+ for (const s of stockImportSuggestions) {
+ const pn = (s.partnumber || '').trim().toLowerCase();
+ if (!pn) continue;
+ descriptionByPartnumber[pn] = s.description || '';
+ }
+ const inputs = Array.from(document.querySelectorAll('input[data-role="suggestion-lot"]'));
+ const tasks = inputs
+ .map(input => ({
+ partnumber: (input.dataset.partnumber || '').trim(),
+ lot: (input.value || '').trim(),
+ description: descriptionByPartnumber[((input.dataset.partnumber || '').trim().toLowerCase())] || ''
+ }))
+ .filter(x => x.partnumber);
+ if (tasks.length === 0) {
+ showToast('Нет строк для добавления', 'error');
+ return;
+ }
+ let ok = 0;
+ for (const t of tasks) {
+ const resp = await fetch('/api/admin/pricing/stock/mappings', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ partnumber: t.partnumber, lot_name: t.lot, description: t.description })
+ });
+ if (resp.ok) ok++;
+ }
+ stockImportSuggestions = stockImportSuggestions.filter(item => !tasks.some(t => t.partnumber.toLowerCase() === (item.partnumber || '').toLowerCase()));
+ renderStockImportSuggestions(stockImportSuggestions);
+ await loadStockMappings(1);
+ showToast(`Добавлено: ${ok}`, 'success');
+}
+
+async function ignoreAllSuggestions() {
+ if (!stockImportSuggestions.length) return;
+ let ok = 0;
+ for (const s of stockImportSuggestions) {
+ const partnumber = (s.partnumber || '').trim();
+ if (!partnumber) continue;
+ const resp = await fetch('/api/admin/pricing/stock/ignore-rules', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ target: 'partnumber', match_type: 'exact', pattern: partnumber })
+ });
+ if (resp.ok) ok++;
+ }
+ stockImportSuggestions = [];
+ renderStockImportSuggestions(stockImportSuggestions);
+ await loadStockIgnoreRules(1);
+ showToast(`Добавлено в игнорирование: ${ok}`, 'success');
+}
+
async function loadStockMappings(page = 1) {
stockMappingsPage = page;
const body = document.getElementById('stock-mappings-body');
const pagination = document.getElementById('stock-mappings-pagination');
if (!body || !pagination) return;
try {
- const resp = await fetch(`/api/admin/pricing/stock/mappings?page=${page}&per_page=20`);
+ const query = encodeURIComponent(stockMappingsSearch);
+ const resp = await fetch(`/api/admin/pricing/stock/mappings?page=${page}&per_page=20&search=${query}`);
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Ошибка загрузки');
const items = data.items || [];
@@ -1115,12 +1330,30 @@ async function loadStockMappings(page = 1) {
body.innerHTML = '| Нет сопоставлений |
';
} else {
body.innerHTML = items.map(item => `
-
- | ${escapeHtml(item.partnumber || item.Partnumber || '—')} |
+
+ |
+
+ |
+ ${escapeHtml(item.partnumber || item.Partnumber || '—')} |
${escapeHtml(item.description || item.Description || '—')} |
- ${escapeHtml(item.lot_name || item.LotName || '—')} |
-
-
+ |
+
+
+
+
+
|
`).join('');
@@ -1143,19 +1376,23 @@ async function loadStockMappings(page = 1) {
}
}
-function selectStockMappingRow(partnumber) {
- const normalized = (partnumber || '').trim().toLowerCase();
- const row = stockMappingsCache.find(item => ((item.partnumber || item.Partnumber || '').trim().toLowerCase() === normalized));
- if (!row) return;
- document.getElementById('mapping-partnumber').value = (row.partnumber || row.Partnumber || '').trim();
- document.getElementById('mapping-lotname').value = (row.lot_name || row.LotName || '').trim();
+function applyStockMappingsSearch() {
+ stockMappingsSearch = (document.getElementById('stock-mappings-search')?.value || '').trim();
+ loadStockMappings(1);
}
-async function saveStockMapping() {
- const partnumber = document.getElementById('mapping-partnumber').value.trim();
- const lotName = document.getElementById('mapping-lotname').value.trim();
- if (!partnumber || !lotName) {
- showToast('Заполните partnumber и lot_name', 'error');
+function onStockMappingsSearchInput() {
+ clearTimeout(stockMappingsSearchTimer);
+ stockMappingsSearchTimer = setTimeout(applyStockMappingsSearch, 250);
+}
+
+async function saveInlineStockMapping(button) {
+ const partnumber = (button?.dataset?.partnumber || '').trim();
+ if (!partnumber) return;
+ const row = button.closest('tr');
+ const lotName = (row?.querySelector('input[data-role="lot"]')?.value || '').trim();
+ if (!lotName) {
+ showToast('LOT не может быть пустым', 'error');
return;
}
try {
@@ -1166,10 +1403,103 @@ async function saveStockMapping() {
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Ошибка сохранения');
- document.getElementById('mapping-partnumber').value = '';
- document.getElementById('mapping-lotname').value = '';
showToast('Сопоставление сохранено', 'success');
- await loadStockMappings(1);
+ await loadStockMappings(stockMappingsPage);
+ } catch (e) {
+ showToast('Ошибка: ' + e.message, 'error');
+ }
+}
+
+async function loadStockLotOptions() {
+ const datalist = document.getElementById('stock-lot-options');
+ if (!datalist) return;
+ try {
+ const resp = await fetch('/api/admin/pricing/lots?per_page=5000');
+ const data = await resp.json();
+ if (!resp.ok) throw new Error(data.error || 'Ошибка загрузки LOT');
+ const items = data.items || [];
+ datalist.innerHTML = items.map(lot => ``).join('');
+ } catch (_) {
+ datalist.innerHTML = '';
+ }
+}
+
+async function loadStockIgnoreRules(page = 1) {
+ stockIgnoreRulesPage = page;
+ const body = document.getElementById('stock-ignore-rules-body');
+ const pagination = document.getElementById('stock-ignore-rules-pagination');
+ if (!body || !pagination) return;
+ try {
+ const resp = await fetch(`/api/admin/pricing/stock/ignore-rules?page=${page}&per_page=20`);
+ const data = await resp.json();
+ if (!resp.ok) throw new Error(data.error || 'Ошибка загрузки');
+ const items = data.items || [];
+ if (items.length === 0) {
+ body.innerHTML = '| Нет правил |
';
+ } else {
+ const matchLabel = { exact: 'Равно', prefix: 'Начинается с', suffix: 'Заканчивается на' };
+ body.innerHTML = items.map(item => `
+
+ | ${escapeHtml(item.target)} |
+ ${escapeHtml(matchLabel[item.match_type] || item.match_type)} |
+ ${escapeHtml(item.pattern)} |
+
+
+ |
+
+ `).join('');
+ }
+ const totalPages = Math.ceil((data.total || 0) / (data.per_page || 20));
+ if (totalPages <= 1) {
+ pagination.innerHTML = '';
+ } else {
+ let html = '';
+ for (let i = 1; i <= totalPages; i++) {
+ const cls = i === page ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50';
+ html += ``;
+ }
+ pagination.innerHTML = html;
+ }
+ } catch (e) {
+ body.innerHTML = `| ${escapeHtml(e.message)} |
`;
+ pagination.innerHTML = '';
+ }
+}
+
+async function saveStockIgnoreRule() {
+ const target = document.getElementById('ignore-target').value;
+ const matchType = document.getElementById('ignore-match-type').value;
+ const pattern = document.getElementById('ignore-pattern').value.trim();
+ if (!pattern) {
+ showToast('Заполните шаблон', 'error');
+ return;
+ }
+ try {
+ const resp = await fetch('/api/admin/pricing/stock/ignore-rules', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ target: target, match_type: matchType, pattern: pattern })
+ });
+ const data = await resp.json();
+ if (!resp.ok) throw new Error(data.error || 'Ошибка сохранения');
+ document.getElementById('ignore-pattern').value = '';
+ showToast('Правило добавлено', 'success');
+ await loadStockIgnoreRules(1);
+ } catch (e) {
+ showToast('Ошибка: ' + e.message, 'error');
+ }
+}
+
+async function deleteStockIgnoreRule(id) {
+ if (!confirm('Удалить правило игнорирования?')) return;
+ try {
+ const resp = await fetch(`/api/admin/pricing/stock/ignore-rules/${id}`, {
+ method: 'DELETE'
+ });
+ const data = await resp.json();
+ if (!resp.ok) throw new Error(data.error || 'Ошибка удаления');
+ showToast('Правило удалено', 'success');
+ await loadStockIgnoreRules(stockIgnoreRulesPage);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
@@ -1190,6 +1520,33 @@ async function deleteStockMapping(partnumber) {
}
}
+async function ignoreStockMapping(partnumber) {
+ const pn = (partnumber || '').trim();
+ if (!pn) return;
+ if (!confirm('Добавить партномер в игнорирование и удалить из сопоставлений?')) return;
+ try {
+ const addResp = await fetch('/api/admin/pricing/stock/ignore-rules', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ target: 'partnumber', match_type: 'exact', pattern: pn })
+ });
+ const addData = await addResp.json();
+ if (!addResp.ok) throw new Error(addData.error || 'Ошибка добавления в игнорирование');
+
+ const delResp = await fetch(`/api/admin/pricing/stock/mappings/${encodeURIComponent(pn)}`, {
+ method: 'DELETE'
+ });
+ const delData = await delResp.json();
+ if (!delResp.ok) throw new Error(delData.error || 'Ошибка удаления сопоставления');
+
+ showToast('Партномер добавлен в игнорирование', 'success');
+ await loadStockMappings(stockMappingsPage);
+ await loadStockIgnoreRules(1);
+ } catch (e) {
+ showToast('Ошибка: ' + e.message, 'error');
+ }
+}
+
document.addEventListener('DOMContentLoaded', async () => {
await checkPricelistWritePermission();
// Check URL params for initial tab
@@ -1198,6 +1555,10 @@ document.addEventListener('DOMContentLoaded', async () => {
if (initialTab === 'pricelists') initialTab = 'estimate';
if (initialTab === 'components') initialTab = 'component-settings';
await loadTab(initialTab);
+ const stockMappingsSearchEl = document.getElementById('stock-mappings-search');
+ if (stockMappingsSearchEl) {
+ stockMappingsSearchEl.addEventListener('input', onStockMappingsSearchInput);
+ }
// Add event listeners for preview updates
document.getElementById('modal-period').addEventListener('change', fetchPreview);