Files
PriceForge/internal/services/parts_log_backfill.go
Mikhail Chusavitin 5f8aec456b Unified Quote Journal (parts_log) v3
- New unified append-only quote log table parts_log replaces three
  separate log tables (stock_log, partnumber_log_competitors, lot_log)
- Migrations 042-049: extend supplier, create parts_log/import_formats/
  ignore_rules, rework qt_lot_metadata composite PK, add lead_time_weeks
  to pricelist_items, backfill data, migrate ignore rules
- New services: PartsLogBackfillService, ImportFormatService,
  UnifiedImportService; new world pricelist type (all supplier types)
- qt_lot_metadata PK changed to (lot_name, pricelist_type); all queries
  now filter WHERE pricelist_type='estimate'
- Fix pre-existing bug: qt_component_usage_stats column names
  quotes_last30d/quotes_last7d (no underscore) — added explicit gorm tags
- Bible: full table inventory, baseline schema snapshot, updated pricelist/
  data-rules/api/history/architecture docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 17:25:54 +03:00

136 lines
3.6 KiB
Go

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
}