- 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>
136 lines
3.6 KiB
Go
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
|
|
}
|