Files
PriceForge/internal/services/stock_mappings.go
Mikhail Chusavitin f73e3d144d Vendor mapping: wildcard ignore patterns, bulk CSV import, multi-lot qty
- Add glob pattern support (* and ?) for ignore rules stored in
  qt_vendor_partnumber_seen (is_pattern flag, migration 041)
- Pattern matching applied in stock/competitor import, partnumber book
  snapshot, and vendor mappings list (Go-side via NormalizeKey)
- BulkUpsertMappings: replace N+1 loop with two batch SQL upserts,
  validating all lots in a single query (~1500 queries → 3-4)
- CSV import: multi-lot per PN via repeated rows, optional qty column
- CSV export: updated column format vendor;partnumber;lot_name;qty;description;ignore;notes
- UI: ignore patterns section with add/delete, import progress feedback
- Update bible-local/vendor-mapping.md with new CSV format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 09:41:48 +03:00

437 lines
11 KiB
Go

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