package services import ( "errors" "fmt" "sort" "strings" "time" "git.mchus.pro/mchus/priceforge/internal/models" "gorm.io/gorm" "gorm.io/gorm/clause" ) func upsertSuggestion(prev StockMappingSuggestion, candidate StockMappingSuggestion) StockMappingSuggestion { if strings.TrimSpace(prev.Partnumber) == "" { return candidate } if strings.TrimSpace(prev.Description) == "" && strings.TrimSpace(candidate.Description) != "" { prev.Description = candidate.Description } if prev.Reason != "conflict" && candidate.Reason == "conflict" { prev.Reason = "conflict" } return prev } func (s *StockImportService) ListMappings(page, perPage int, search string) ([]StockMappingRow, int64, error) { if s.db == nil { return nil, 0, fmt.Errorf("offline mode: mappings 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.PartnumberBookItem{}) if search = strings.TrimSpace(search); search != "" { like := "%" + search + "%" query = query.Where("partnumber LIKE ? OR lots_json LIKE ? OR description LIKE ?", like, like, like) } var total int64 if err := query.Count(&total).Error; err != nil { return nil, 0, err } var items []models.PartnumberBookItem if err := query.Order("partnumber ASC").Offset(offset).Limit(perPage).Find(&items).Error; err != nil { return nil, 0, err } rows := make([]StockMappingRow, 0, len(items)) for _, item := range items { lotName := "" for _, lot := range parseSnapshotLots(item.LotsJSON) { lotName = lot.LotName break } rows = append(rows, StockMappingRow{ Partnumber: item.Partnumber, LotName: lotName, Description: item.Description, }) } return rows, total, nil } func (s *StockImportService) UpsertMapping(partnumber, lotName, description string) error { if s.db == nil { return fmt.Errorf("offline mode: mappings unavailable") } partnumber = strings.TrimSpace(partnumber) lotName = strings.TrimSpace(lotName) description = strings.TrimSpace(description) if partnumber == "" { return fmt.Errorf("partnumber is required") } 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 { var existing models.PartnumberBookItem err := tx.Where("LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", partnumber).First(&existing).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err } if description == "" { if existing.Description != nil && strings.TrimSpace(*existing.Description) != "" { description = strings.TrimSpace(*existing.Description) } } var descPtr *string if description != "" { descPtr = &description } return tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "partnumber"}}, DoUpdates: clause.AssignmentColumns([]string{"lots_json", "description"}), }).Create(&models.PartnumberBookItem{ Partnumber: partnumber, LotsJSON: fmt.Sprintf(`[{"lot_name":%q,"qty":1}]`, lotName), Description: descPtr, }).Error }) } func (s *StockImportService) DeleteMapping(partnumber string) (int64, error) { if s.db == nil { return 0, fmt.Errorf("offline mode: mappings unavailable") } partnumber = strings.TrimSpace(partnumber) if partnumber == "" { return 0, fmt.Errorf("partnumber is required") } res := s.db.Where("LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", partnumber).Delete(&models.PartnumberBookItem{}) return res.RowsAffected, res.Error } 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.VendorPartnumberSeen{}).Where("is_ignored = ?", true) var total int64 if err := query.Count(&total).Error; err != nil { return nil, 0, err } var seenRows []models.VendorPartnumberSeen if err := query.Order("id DESC").Offset(offset).Limit(perPage).Find(&seenRows).Error; err != nil { return nil, 0, err } rows := make([]models.StockIgnoreRule, 0, len(seenRows)) for _, row := range seenRows { pattern := strings.TrimSpace(row.Partnumber) if vendor := strings.TrimSpace(row.Vendor); vendor != "" { pattern = vendor + "|" + pattern } rows = append(rows, models.StockIgnoreRule{ ID: uint(row.ID), Target: "partnumber", MatchType: "exact", Pattern: pattern, CreatedAt: row.CreatedAt, }) } 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") } if normalizeIgnoreTarget(target) != "partnumber" || normalizeIgnoreMatchType(matchType) != "exact" { return fmt.Errorf("only exact partnumber ignore is supported") } pattern = strings.TrimSpace(pattern) if pattern == "" { return fmt.Errorf("pattern is required") } vendor := "" partnumber := pattern if parts := strings.SplitN(pattern, "|", 2); len(parts) == 2 { vendor = strings.TrimSpace(parts[0]) partnumber = strings.TrimSpace(parts[1]) } if partnumber == "" { return fmt.Errorf("partnumber is required") } now := time.Now() return s.db.Transaction(func(tx *gorm.DB) error { var existing models.VendorPartnumberSeen err := tx.Where("LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", partnumber).First(&existing).Error if err == nil { updates := map[string]any{ "last_seen_at": now, "is_ignored": true, "ignored_at": now, } if strings.TrimSpace(existing.SourceType) != "stock" { updates["source_type"] = "manual" } if strings.TrimSpace(existing.Vendor) == "" && vendor != "" { updates["vendor"] = vendor } return tx.Model(&models.VendorPartnumberSeen{}). Where("id = ?", existing.ID). Updates(updates).Error } if !errors.Is(err, gorm.ErrRecordNotFound) { return err } return tx.Create(&models.VendorPartnumberSeen{ SourceType: "manual", Vendor: vendor, Partnumber: partnumber, LastSeenAt: now, IsIgnored: true, IgnoredAt: &now, }).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.Model(&models.VendorPartnumberSeen{}). Where("id = ?", id). Updates(map[string]any{"is_ignored": false, "ignored_at": nil, "ignored_by": nil}) 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 } 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 (s *StockImportService) loadIgnoredSeenIndex() (*ignoreIndex, error) { var rows []struct { Partnumber string IsPattern bool } if err := s.db.Model(&models.VendorPartnumberSeen{}). Select("partnumber, is_pattern"). Where("is_ignored = ?", true). Scan(&rows).Error; err != nil { return nil, err } idx := &ignoreIndex{exact: make(map[string]struct{}, len(rows))} for _, row := range rows { k := normalizeKey(row.Partnumber) if k == "" { continue } if row.IsPattern { idx.patterns = append(idx.patterns, k) } else { idx.exact[k] = struct{}{} } } return idx, nil } func isIgnoredBySeenIndex(idx *ignoreIndex, _ string, partnumber string) bool { if idx == nil { return false } return idx.isIgnored(normalizeKey(partnumber)) } func (s *StockImportService) upsertSeenRows(rows map[string]models.VendorPartnumberSeen) error { if len(rows) == 0 { return nil } now := time.Now() return s.db.Transaction(func(tx *gorm.DB) error { for _, row := range rows { vendor := strings.TrimSpace(row.Vendor) partnumber := strings.TrimSpace(row.Partnumber) if partnumber == "" { continue } trimmedDesc := func() *string { if row.Description == nil { return nil } v := strings.TrimSpace(*row.Description) if v == "" { return nil } return &v }() var existing models.VendorPartnumberSeen err := tx.Where("LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", partnumber).First(&existing).Error if err == nil { updates := map[string]any{ "last_seen_at": now, "updated_at": now, } // Preserve stock provenance for unmapped analysis. if strings.TrimSpace(existing.SourceType) != "stock" && strings.TrimSpace(row.SourceType) != "" { updates["source_type"] = strings.TrimSpace(row.SourceType) } if strings.TrimSpace(existing.Vendor) == "" && vendor != "" { updates["vendor"] = vendor } if trimmedDesc != nil { updates["description"] = *trimmedDesc } if err := tx.Model(&models.VendorPartnumberSeen{}). Where("id = ?", existing.ID). Updates(updates).Error; err != nil { return err } continue } if !errors.Is(err, gorm.ErrRecordNotFound) { return err } if err := tx.Create(&models.VendorPartnumberSeen{ SourceType: row.SourceType, Vendor: vendor, Partnumber: partnumber, LastSeenAt: now, Description: func() *string { if trimmedDesc == nil { return nil } v := *trimmedDesc return &v }(), }).Error; err != nil { return err } } return nil }) } 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 } case "prefix": if strings.HasPrefix(candidate, rule.Pattern) { return true } case "suffix": if strings.HasSuffix(candidate, rule.Pattern) { return true } } } 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 "" } }