Refine stock import UX with suggestions, ignore rules, and inline mapping controls
This commit is contained in:
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user