package services import ( "errors" "fmt" "sort" "strings" "time" "git.mchus.pro/mchus/priceforge/internal/models" "gorm.io/gorm" ) type VendorMappingService struct { db *gorm.DB } type VendorMappingListRow struct { Vendor string `json:"vendor"` Partnumber string `json:"partnumber"` LotName string `json:"lot_name"` Description string `json:"description,omitempty"` Type string `json:"type"` Ignored bool `json:"ignored"` Sources []string `json:"sources,omitempty"` Unmapped bool `json:"unmapped"` HasCalcError bool `json:"has_calc_error"` BundleItemCnt int64 `json:"bundle_item_count"` LastSeenAt string `json:"last_seen_at,omitempty"` } type VendorMappingDetail struct { Vendor string `json:"vendor"` Partnumber string `json:"partnumber"` LotName string `json:"lot_name"` Description string `json:"description,omitempty"` Type string `json:"type"` Ignored bool `json:"ignored"` Sources []string `json:"sources,omitempty"` Items []models.LotBundleItem `json:"items,omitempty"` CalcErrors []string `json:"calc_errors,omitempty"` } func NewVendorMappingService(db *gorm.DB) *VendorMappingService { return &VendorMappingService{db: db} } func normalizeVendor(v string) string { return strings.TrimSpace(v) } func normalizePartnumber(v string) string { return strings.TrimSpace(v) } func (s *VendorMappingService) UpsertMapping(vendor, partnumber, lotName, description string, bundleItems []models.LotBundleItem) error { if s.db == nil { return fmt.Errorf("offline mode: vendor mappings unavailable") } vendor = normalizeVendor(vendor) partnumber = normalizePartnumber(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 prev models.LotPartnumber err := tx.Where("vendor = ? AND partnumber = ?", vendor, partnumber).First(&prev).Error if err == nil && description == "" && prev.Description != nil { description = strings.TrimSpace(*prev.Description) } if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err } var descPtr *string if description != "" { descPtr = &description } if err := tx.Where("vendor = ? AND partnumber = ?", vendor, partnumber).Delete(&models.LotPartnumber{}).Error; err != nil { return err } if err := tx.Create(&models.LotPartnumber{ Vendor: vendor, Partnumber: partnumber, LotName: lotName, Description: descPtr, }).Error; err != nil { return err } if strings.TrimSpace(lotName) != "" && len(bundleItems) > 0 { if err := tx.Where("bundle_lot_name = ?", lotName).Delete(&models.LotBundleItem{}).Error; err != nil { return err } if err := tx.Where("bundle_lot_name = ?", lotName).Delete(&models.LotBundle{}).Error; err != nil { return err } if err := tx.Create(&models.LotBundle{ BundleLotName: lotName, IsActive: true, }).Error; err != nil { return err } items := make([]models.LotBundleItem, 0, len(bundleItems)) for _, item := range bundleItems { ln := strings.TrimSpace(item.LotName) if ln == "" || item.Qty <= 0 { continue } items = append(items, models.LotBundleItem{ BundleLotName: lotName, LotName: ln, Qty: item.Qty, }) } if len(items) > 0 { if err := tx.Create(&items).Error; err != nil { return err } } } now := time.Now() 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, } if strings.TrimSpace(existing.SourceType) != "stock" { updates["source_type"] = "manual" } if strings.TrimSpace(existing.Vendor) == "" && vendor != "" { updates["vendor"] = vendor } if descPtr != nil { updates["description"] = *descPtr } return tx.Model(&models.VendorPartnumberSeen{}).Where("id = ?", existing.ID).Updates(updates).Error } if !errors.Is(err, gorm.ErrRecordNotFound) { return err } seen := models.VendorPartnumberSeen{ SourceType: "manual", Vendor: vendor, Partnumber: partnumber, LastSeenAt: now, Description: func() *string { if descPtr == nil { return nil } v := *descPtr return &v }(), } return tx.Create(&seen).Error }) } func (s *VendorMappingService) SetIgnore(vendor, partnumber, ignoredBy string, ignored bool) error { if s.db == nil { return fmt.Errorf("offline mode: vendor mappings unavailable") } vendor = normalizeVendor(vendor) partnumber = normalizePartnumber(partnumber) if partnumber == "" { return fmt.Errorf("partnumber is required") } now := time.Now() return s.db.Transaction(func(tx *gorm.DB) error { updates := map[string]any{"is_ignored": ignored} if ignored { updates["ignored_at"] = now if strings.TrimSpace(ignoredBy) != "" { updates["ignored_by"] = strings.TrimSpace(ignoredBy) } } else { updates["ignored_at"] = nil updates["ignored_by"] = nil } res := tx.Model(&models.VendorPartnumberSeen{}). Where("LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", partnumber). Updates(updates) if res.Error != nil { return res.Error } if res.RowsAffected > 0 { return nil } seen := models.VendorPartnumberSeen{ SourceType: "manual", Vendor: vendor, Partnumber: partnumber, LastSeenAt: now, IsIgnored: ignored, } if ignored { seen.IgnoredAt = &now if strings.TrimSpace(ignoredBy) != "" { v := strings.TrimSpace(ignoredBy) seen.IgnoredBy = &v } } return tx.Create(&seen).Error }) } func (s *VendorMappingService) DeleteMapping(vendor, partnumber string) (int64, error) { if s.db == nil { return 0, fmt.Errorf("offline mode: vendor mappings unavailable") } vendor = normalizeVendor(vendor) partnumber = normalizePartnumber(partnumber) if partnumber == "" { return 0, fmt.Errorf("partnumber is required") } // Delete exact mapping first. res := s.db.Where( "LOWER(TRIM(vendor)) = LOWER(TRIM(?)) AND LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", vendor, partnumber, ).Delete(&models.LotPartnumber{}) if res.Error != nil { return 0, res.Error } deleted := res.RowsAffected // If nothing deleted for vendor-specific key, remove fallback mapping too. if deleted == 0 && vendor != "" { fb := s.db.Where( "LOWER(TRIM(vendor)) = '' AND LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", partnumber, ).Delete(&models.LotPartnumber{}) if fb.Error != nil { return deleted, fb.Error } deleted += fb.RowsAffected } return deleted, nil } func (s *VendorMappingService) List(page, perPage int, search string, unmappedOnly, ignoredOnly bool) ([]VendorMappingListRow, int64, error) { if s.db == nil { return nil, 0, fmt.Errorf("offline mode: vendor mappings unavailable") } if page < 1 { page = 1 } if perPage < 1 { perPage = 50 } if perPage > 500 { perPage = 500 } offset := (page - 1) * perPage base := s.db.Table(`( SELECT TRIM(partnumber) AS partnumber FROM lot_partnumbers WHERE TRIM(COALESCE(partnumber, '')) <> '' UNION SELECT TRIM(partnumber) AS partnumber FROM qt_vendor_partnumber_seen WHERE TRIM(COALESCE(partnumber, '')) <> '' ) k`). Select(` COALESCE(NULLIF(sa.vendor, ''), NULLIF(lp.vendor, ''), '') as vendor, k.partnumber, COALESCE(lp.lot_name, '') as lot_name, COALESCE(lp.description, sa.description, '') as description, COALESCE(sa.ignored, 0) as ignored, COALESCE(sa.sources, '') as sources, sa.last_seen_at as last_seen_at, CASE WHEN COALESCE(lp.lot_name, '') = '' THEN 1 ELSE 0 END as unmapped, CASE WHEN b.bundle_lot_name IS NULL THEN 'single' ELSE 'bundle' END as mapping_type, COALESCE((SELECT COUNT(*) FROM qt_lot_bundle_items bi WHERE bi.bundle_lot_name = COALESCE(lp.lot_name, '')), 0) as bundle_item_count `). Joins(`LEFT JOIN ( SELECT partnumber, SUBSTRING_INDEX( GROUP_CONCAT(COALESCE(TRIM(vendor), '') ORDER BY (TRIM(COALESCE(vendor, '')) <> '') DESC, vendor ASC SEPARATOR '\n'), '\n', 1 ) AS vendor, SUBSTRING_INDEX( GROUP_CONCAT(COALESCE(TRIM(lot_name), '') ORDER BY (TRIM(COALESCE(vendor, '')) <> '') DESC, vendor ASC SEPARATOR '\n'), '\n', 1 ) AS lot_name, SUBSTRING_INDEX( GROUP_CONCAT(COALESCE(TRIM(description), '') ORDER BY (TRIM(COALESCE(vendor, '')) <> '') DESC, vendor ASC SEPARATOR '\n'), '\n', 1 ) AS description FROM lot_partnumbers WHERE TRIM(COALESCE(partnumber, '')) <> '' GROUP BY partnumber ) lp ON lp.partnumber = k.partnumber`). Joins(`LEFT JOIN ( SELECT partnumber, SUBSTRING_INDEX( GROUP_CONCAT(COALESCE(TRIM(vendor), '') ORDER BY (TRIM(COALESCE(vendor, '')) <> '') DESC, (source_type = 'stock') DESC, last_seen_at DESC, id DESC SEPARATOR '\n'), '\n', 1 ) AS vendor, MAX(is_ignored) AS ignored, GROUP_CONCAT(DISTINCT source_type ORDER BY source_type SEPARATOR ',') AS sources, MAX(last_seen_at) AS last_seen_at, MAX(description) AS description FROM qt_vendor_partnumber_seen GROUP BY partnumber ) sa ON sa.partnumber = k.partnumber`). Joins("LEFT JOIN qt_lot_bundles b ON b.bundle_lot_name = COALESCE(lp.lot_name, '')") if q := strings.TrimSpace(search); q != "" { like := "%" + q + "%" base = base.Where("COALESCE(NULLIF(sa.vendor, ''), NULLIF(lp.vendor, ''), '') LIKE ? OR k.partnumber LIKE ? OR COALESCE(lp.lot_name, '') LIKE ? OR COALESCE(lp.description, sa.description, '') LIKE ?", like, like, like, like) } if unmappedOnly { base = base.Where("COALESCE(lp.lot_name, '') = ''"). Where("COALESCE(sa.ignored, 0) = 0") } if ignoredOnly { base = base.Where("COALESCE(sa.ignored, 0) = 1") } else { base = base.Where("COALESCE(sa.ignored, 0) = 0") } type row struct { Vendor string Partnumber string LotName string Description string Ignored bool Sources string LastSeenAt *time.Time Unmapped bool MappingType string BundleItemCnt int64 } countQuery := s.db.Table("(") _ = countQuery var total int64 if err := s.db.Table("(?) as q", base).Count(&total).Error; err != nil { return nil, 0, err } var rows []row if err := base.Order("k.partnumber ASC").Offset(offset).Limit(perPage).Scan(&rows).Error; err != nil { return nil, 0, err } items := make([]VendorMappingListRow, 0, len(rows)) for _, r := range rows { var sources []string for _, s := range strings.Split(strings.TrimSpace(r.Sources), ",") { s = strings.TrimSpace(s) if s != "" { sources = append(sources, s) } } sort.Strings(sources) item := VendorMappingListRow{ Vendor: r.Vendor, Partnumber: r.Partnumber, LotName: r.LotName, Description: r.Description, Type: r.MappingType, Ignored: r.Ignored, Sources: sources, Unmapped: r.Unmapped, BundleItemCnt: r.BundleItemCnt, } if r.LastSeenAt != nil { item.LastSeenAt = r.LastSeenAt.Format(time.RFC3339) } items = append(items, item) } return items, total, nil } func (s *VendorMappingService) GetDetail(vendor, partnumber string) (*VendorMappingDetail, error) { if s.db == nil { return nil, fmt.Errorf("offline mode: vendor mappings unavailable") } vendor = normalizeVendor(vendor) partnumber = normalizePartnumber(partnumber) if partnumber == "" { return nil, fmt.Errorf("partnumber is required") } var mapping models.LotPartnumber if err := s.db.Where("vendor = ? AND partnumber = ?", vendor, partnumber).First(&mapping).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { mapping = models.LotPartnumber{Vendor: vendor, Partnumber: partnumber} if vendor != "" { var fallback models.LotPartnumber if fbErr := s.db.Where("vendor = '' AND partnumber = ?", partnumber).First(&fallback).Error; fbErr == nil { mapping = fallback } } } else { return nil, err } } var seenRows []models.VendorPartnumberSeen if err := s.db.Where("LOWER(TRIM(partnumber)) = LOWER(TRIM(?))", partnumber).Find(&seenRows).Error; err != nil { return nil, err } ignored := false sourceSet := make(map[string]struct{}) desc := "" for _, r := range seenRows { if r.IsIgnored { ignored = true } sourceSet[r.SourceType] = struct{}{} if desc == "" && r.Description != nil { desc = strings.TrimSpace(*r.Description) } } if desc == "" && mapping.Description != nil { desc = strings.TrimSpace(*mapping.Description) } sources := make([]string, 0, len(sourceSet)) for k := range sourceSet { sources = append(sources, k) } sort.Strings(sources) detail := &VendorMappingDetail{ Vendor: vendor, Partnumber: partnumber, LotName: mapping.LotName, Description: desc, Type: "single", Ignored: ignored, Sources: sources, } if strings.TrimSpace(mapping.LotName) != "" { var bundle models.LotBundle if err := s.db.Where("bundle_lot_name = ?", mapping.LotName).First(&bundle).Error; err == nil { detail.Type = "bundle" var items []models.LotBundleItem if err := s.db.Where("bundle_lot_name = ?", mapping.LotName).Order("lot_name ASC").Find(&items).Error; err != nil { return nil, err } detail.Items = items } } return detail, nil }