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

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