Files
QuoteForge/internal/services/sync/partnumber_books.go
Michael Chus 5e56f386cc 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>
2026-02-21 10:22:22 +03:00

93 lines
3.0 KiB
Go

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
}