- 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>
437 lines
11 KiB
Go
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 ""
|
|
}
|
|
}
|