package services import ( "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/repository" ) // 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].ResolvedLotName = matches[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 the qty-logic to compute per-LOT quantities from the resolved BOM. // qty(lot) = SUM(qty of primary PN rows for this lot) if any primary PN exists, else 1. func AggregateLOTs(items []localdb.VendorSpecItem, book *localdb.LocalPartnumberBook, bookRepo *repository.PartnumberBookRepository) ([]AggregatedLOT, error) { // Gather all unique lot names that resolved lotPrimary := make(map[string]int) // lot_name → sum of primary PN quantities lotAny := make(map[string]bool) // lot_name → seen at least once (non-primary) lotHasPrimary := make(map[string]bool) // lot_name → has at least one primary PN in spec if book != nil { for _, item := range items { if item.ResolvedLotName == "" { continue } lot := item.ResolvedLotName pn := item.VendorPartnumber // Find if this pn is primary for its lot matches, err := bookRepo.FindLotByPartnumber(book.ID, pn) if err != nil || len(matches) == 0 { // manual/unresolved — treat as non-primary lotAny[lot] = true continue } for _, m := range matches { if m.LotName == lot { if m.IsPrimaryPN { lotPrimary[lot] += item.Quantity lotHasPrimary[lot] = true } else { lotAny[lot] = true } } } } } else { // No book: all resolved rows contribute qty=1 per lot for _, item := range items { if item.ResolvedLotName != "" { lotAny[item.ResolvedLotName] = true } } } // 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 := 1 if lotHasPrimary[lot] { qty = lotPrimary[lot] } result = append(result, AggregatedLOT{LotName: lot, Quantity: qty}) } return result, nil }