feat: implement vendor spec BOM import and PN→LOT resolution (Phase 1)

- 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>
This commit is contained in:
2026-02-21 10:22:22 +03:00
parent e5b6902c9e
commit 5e56f386cc
14 changed files with 1492 additions and 2 deletions

View File

@@ -0,0 +1,92 @@
package sync
import (
"fmt"
"log/slog"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/repository"
)
// PullPartnumberBooks synchronizes partnumber book snapshots from MariaDB to local SQLite.
// It only pulls books that don't exist locally yet (append-only).
func (s *Service) PullPartnumberBooks() (int, error) {
slog.Info("starting partnumber book pull")
mariaDB, err := s.getDB()
if err != nil {
return 0, fmt.Errorf("database not available: %w", err)
}
localBookRepo := repository.NewPartnumberBookRepository(s.localDB.DB())
// Query server for all active partnumber books
type serverBook struct {
ID int `gorm:"column:id"`
Version string `gorm:"column:version"`
CreatedAt time.Time `gorm:"column:created_at"`
IsActive bool `gorm:"column:is_active"`
}
var serverBooks []serverBook
if err := mariaDB.Raw("SELECT id, version, created_at, is_active FROM qt_partnumber_books WHERE is_active = 1 ORDER BY created_at DESC, id DESC").Scan(&serverBooks).Error; err != nil {
return 0, fmt.Errorf("querying server partnumber books: %w", err)
}
pulled := 0
for _, sb := range serverBooks {
// Check if already exists locally
var existing localdb.LocalPartnumberBook
err := s.localDB.DB().Where("server_id = ?", sb.ID).First(&existing).Error
if err == nil {
// Already exists
continue
}
// Save the book
localBook := &localdb.LocalPartnumberBook{
ServerID: sb.ID,
Version: sb.Version,
CreatedAt: sb.CreatedAt,
IsActive: sb.IsActive,
}
if err := localBookRepo.SaveBook(localBook); err != nil {
slog.Warn("failed to save local partnumber book", "server_id", sb.ID, "error", err)
continue
}
// Pull items for this book
type serverItem struct {
Partnumber string `gorm:"column:partnumber"`
LotName string `gorm:"column:lot_name"`
IsPrimaryPN bool `gorm:"column:is_primary_pn"`
Description string `gorm:"column:description"`
}
var serverItems []serverItem
if err := mariaDB.Raw("SELECT partnumber, lot_name, is_primary_pn, description FROM qt_partnumber_book_items WHERE book_id = ?", sb.ID).Scan(&serverItems).Error; err != nil {
slog.Warn("failed to query server partnumber book items", "book_id", sb.ID, "error", err)
continue
}
localItems := make([]localdb.LocalPartnumberBookItem, 0, len(serverItems))
for _, si := range serverItems {
localItems = append(localItems, localdb.LocalPartnumberBookItem{
BookID: localBook.ID,
Partnumber: si.Partnumber,
LotName: si.LotName,
IsPrimaryPN: si.IsPrimaryPN,
Description: si.Description,
})
}
if err := localBookRepo.SaveBookItems(localItems); err != nil {
slog.Warn("failed to save local partnumber book items", "book_id", localBook.ID, "error", err)
continue
}
slog.Info("pulled partnumber book", "version", sb.Version, "items", len(localItems))
pulled++
}
slog.Info("partnumber book pull completed", "pulled", pulled)
return pulled, nil
}

View File

@@ -0,0 +1,129 @@
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
}