package services import ( "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/repository" "math" ) // ResolvedBOMRow is the result of resolving a single vendor BOM row. type ResolvedBOMRow struct { localdb.VendorSpecItem // ResolutionSource already on VendorSpecItem: "book", "manual_suggestion", "unresolved" } // AggregatedLOT represents a LOT with its aggregated quantity from the BOM. type AggregatedLOT struct { LotName string Quantity int } // VendorSpecResolver resolves vendor BOM rows to LOT names using the active partnumber book. type VendorSpecResolver struct { bookRepo *repository.PartnumberBookRepository } func NewVendorSpecResolver(bookRepo *repository.PartnumberBookRepository) *VendorSpecResolver { return &VendorSpecResolver{bookRepo: bookRepo} } // Resolve resolves each vendor spec item's lot name using the 3-step algorithm. // It returns the resolved items. Manual lot suggestions from the input are preserved as pre-fill. func (r *VendorSpecResolver) Resolve(items []localdb.VendorSpecItem) ([]localdb.VendorSpecItem, error) { // Step 1: Get the active book book, err := r.bookRepo.GetActiveBook() if err != nil { // No book available — mark all as unresolved for i := range items { if items[i].ResolvedLotName == "" { items[i].ResolutionSource = "unresolved" } } return items, nil } for i, item := range items { pn := item.VendorPartnumber // Step 1: Look up in active book matches, err := r.bookRepo.FindLotByPartnumber(book.ID, pn) if err == nil && len(matches) > 0 { items[i].LotMappings = make([]localdb.VendorSpecLotMapping, 0, len(matches[0].LotsJSON)) for _, lot := range matches[0].LotsJSON { if lot.LotName == "" { continue } items[i].LotMappings = append(items[i].LotMappings, localdb.VendorSpecLotMapping{ LotName: lot.LotName, QuantityPerPN: lotQtyToInt(lot.Qty), }) } if len(items[i].LotMappings) > 0 { items[i].ResolvedLotName = items[i].LotMappings[0].LotName } items[i].ResolutionSource = "book" continue } // Step 2: Pre-fill from manual_lot_suggestion if provided if item.ManualLotSuggestion != "" { items[i].ResolvedLotName = item.ManualLotSuggestion items[i].ResolutionSource = "manual_suggestion" continue } // Step 3: Unresolved items[i].ResolvedLotName = "" items[i].ResolutionSource = "unresolved" } return items, nil } // AggregateLOTs applies qty from the resolved PN composition stored in lots_json. func AggregateLOTs(items []localdb.VendorSpecItem, book *localdb.LocalPartnumberBook, bookRepo *repository.PartnumberBookRepository) ([]AggregatedLOT, error) { lotTotals := make(map[string]int) if book != nil { for _, item := range items { if item.ResolvedLotName == "" { continue } lot := item.ResolvedLotName pn := item.VendorPartnumber matches, err := bookRepo.FindLotByPartnumber(book.ID, pn) if err != nil || len(matches) == 0 { lotTotals[lot] += item.Quantity continue } for _, m := range matches { for _, mappedLot := range m.LotsJSON { if mappedLot.LotName != lot { continue } lotTotals[lot] += item.Quantity * lotQtyToInt(mappedLot.Qty) } } } } else { // No book: all resolved rows contribute qty=1 per lot for _, item := range items { if item.ResolvedLotName != "" { lotTotals[item.ResolvedLotName] += item.Quantity } } } // Build aggregated list seen := make(map[string]bool) var result []AggregatedLOT for _, item := range items { lot := item.ResolvedLotName if lot == "" || seen[lot] { continue } seen[lot] = true qty := lotTotals[lot] if qty < 1 { qty = 1 } result = append(result, AggregatedLOT{LotName: lot, Quantity: qty}) } return result, nil } func lotQtyToInt(qty float64) int { if qty < 1 { return 1 } return int(math.Round(qty)) }