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("/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)

View File

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

View File

@@ -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"
}

View File

@@ -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 (

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 id="stock-import-stats" class="text-xs text-gray-600 mt-2"></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 class="border rounded-lg p-4">
<h3 class="text-lg font-semibold mb-3">Сопоставление partnumber -> LOT</h3>
<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="mapping-lotname" type="text" placeholder="lot_name" class="px-3 py-2 border rounded w-1/3">
<button onclick="saveStockMapping()" class="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Сохранить</button>
<input id="stock-mappings-search" type="text" placeholder="Поиск по partnumber / описанию / lot" class="px-3 py-2 border rounded w-full">
<datalist id="stock-lot-options"></datalist>
<button onclick="loadStockMappings(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">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">LOT</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>
</tr>
</thead>
@@ -137,6 +169,40 @@
</div>
<div id="stock-mappings-pagination" class="flex justify-center space-x-2 mt-3"></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>
@@ -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 => `
<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) {
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 = '<tr><td colspan="4" class="px-4 py-3 text-sm text-gray-500">Нет сопоставлений</td></tr>';
} else {
body.innerHTML = items.map(item => `
<tr class="cursor-pointer hover:bg-gray-50" onclick="selectStockMappingRow('${escapeHtml((item.partnumber || item.Partnumber || '')).replace(/'/g, "\\'")}')">
<td class="px-4 py-2 text-sm font-mono">${escapeHtml(item.partnumber || item.Partnumber || '—')}</td>
<tr>
<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 font-mono">${escapeHtml(item.lot_name || item.LotName || '—')}</td>
<td class="px-4 py-2 text-right">
<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>
<td class="px-4 py-2">
<div class="flex justify-end items-center gap-3">
<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>
</tr>
`).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 => `<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) {
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);