- Migration 029: local_partnumber_books, local_partnumber_book_items, vendor_spec TEXT column on local_configurations - Models: LocalPartnumberBook, LocalPartnumberBookItem, VendorSpec, VendorSpecItem with JSON Valuer/Scanner - Repository: PartnumberBookRepository (GetActiveBook, FindLotByPartnumber, SaveBook/Items, ListBooks, CountBookItems) - Service: VendorSpecResolver 3-step resolution (book → manual suggestion → unresolved) + AggregateLOTs with is_primary_pn qty logic - Sync: PullPartnumberBooks append-only pull from qt_partnumber_books - Handlers: VendorSpecHandler (GET/PUT/resolve/apply), PartnumberBooksHandler - Routes: /api/configs/:uuid/vendor-spec*, /api/partnumber-books, /api/sync/partnumber-books, /partnumber-books page - UI: 3 top-level tabs [Estimate][BOM вендора][Ценообразование]; Excel paste, PN resolution, inline LOT autocomplete, pricing table - Bible: 03-database.md updated, 09-vendor-spec.md added Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
130 lines
3.8 KiB
Go
130 lines
3.8 KiB
Go
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
|
|
}
|