package services import ( "context" "fmt" "log/slog" "git.mchus.pro/mchus/priceforge/internal/lotmatch" "git.mchus.pro/mchus/priceforge/internal/repository" "gorm.io/gorm" ) // PartsLogBackfillService resolves lot_name for unresolved parts_log rows. // Runs daily via the embedded scheduler and can be triggered manually via API. // The only permitted mutation on parts_log is filling lot_name/lot_category. type PartsLogBackfillService struct { db *gorm.DB partsLogRepo *repository.PartsLogRepository } func NewPartsLogBackfillService(db *gorm.DB) *PartsLogBackfillService { return &PartsLogBackfillService{ db: db, partsLogRepo: repository.NewPartsLogRepository(db), } } // RunBatch resolves up to limit unresolved parts_log rows. // Returns the number of rows resolved in this run. func (s *PartsLogBackfillService) RunBatch(ctx context.Context, limit int) (int, error) { if limit <= 0 { limit = 1000 } rows, err := s.partsLogRepo.FindUnresolved(limit) if err != nil { return 0, fmt.Errorf("backfill: find unresolved: %w", err) } if len(rows) == 0 { return 0, nil } // Load lot names for direct-match check (partnumber == lot_name) lotNames, err := s.loadAllLotNames() if err != nil { return 0, fmt.Errorf("backfill: load lot names: %w", err) } lotCategoryByName, err := s.loadLotCategories(lotNames) if err != nil { return 0, fmt.Errorf("backfill: load lot categories: %w", err) } // Load the partnumber→lot matcher from qt_partnumber_book_items matcher, err := lotmatch.NewMappingMatcherFromDB(s.db) if err != nil { return 0, fmt.Errorf("backfill: load matcher: %w", err) } var updates []repository.LotResolutionUpdate for _, row := range updates { _ = row // avoid unused warning — handled below } updates = updates[:0] for _, pl := range rows { if ctx.Err() != nil { break } var resolvedLot, resolvedCat string // Rule 1: if partnumber matches a known lot_name directly (lot_log pattern) if _, ok := lotCategoryByName[pl.Partnumber]; ok { resolvedLot = pl.Partnumber resolvedCat = lotCategoryByName[pl.Partnumber] } else { // Rule 2: resolve through qt_partnumber_book_items lots := matcher.MatchLotsWithVendor(pl.Partnumber, pl.Vendor) if len(lots) > 0 { resolvedLot = lots[0] resolvedCat = lotCategoryByName[resolvedLot] } } if resolvedLot != "" { updates = append(updates, repository.LotResolutionUpdate{ ID: pl.ID, LotName: resolvedLot, LotCategory: resolvedCat, }) } } if len(updates) == 0 { return 0, nil } if err := s.partsLogRepo.UpdateLotResolutionBatch(updates); err != nil { return 0, fmt.Errorf("backfill: update batch: %w", err) } slog.Info("parts_log backfill completed", "resolved", len(updates), "scanned", len(rows)) return len(updates), nil } func (s *PartsLogBackfillService) loadAllLotNames() ([]string, error) { var names []string err := s.db.Table("lot").Select("lot_name").Pluck("lot_name", &names).Error return names, err } func (s *PartsLogBackfillService) loadLotCategories(lotNames []string) (map[string]string, error) { if len(lotNames) == 0 { return map[string]string{}, nil } type row struct { LotName string `gorm:"column:lot_name"` LotCategory *string `gorm:"column:lot_category"` } var rows []row err := s.db.Table("lot").Select("lot_name, lot_category"). Where("lot_name IN ?", lotNames).Scan(&rows).Error if err != nil { return nil, err } result := make(map[string]string, len(rows)) for _, r := range rows { cat := "" if r.LotCategory != nil { cat = *r.LotCategory } result[r.LotName] = cat } return result, nil }