- BOM paste: auto-detect columns by content (price, qty, PN, description); handles $5,114.00 and European comma-decimal formats - LOT input: HTML5 datalist rebuilt on each renderBOMTable from allComponents; oninput updates data only (no re-render), onchange validates+resolves - BOM persistence: PUT handler explicitly marshals VendorSpec to JSON string (GORM Update does not reliably call driver.Valuer for custom types) - BOM autosave after every resolveBOM() call - Pricing tab: async renderPricingTab() calls /api/quote/price-levels for all resolved LOTs directly — Estimate prices shown even before cart apply - Unresolved PNs pushed to qt_vendor_partnumber_seen via POST /api/sync/partnumber-seen (fire-and-forget from JS) - sync.PushPartnumberSeen(): upsert with ON DUPLICATE KEY UPDATE last_seen_at - partnumber_books: pull ALL books (not only is_active=1); re-pull items when header exists but item count is 0; fallback for missing description column - partnumber_books UI: collapsible snapshot section (collapsed by default), pagination (10/page), sync button always visible in header - vendorSpec handlers: use GetConfigurationByUUID + IsActive check (removed original_username from WHERE — GetUsername returns "" without JWT) - bible/09-vendor-spec.md: updated with all architectural decisions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
128 lines
4.9 KiB
Go
128 lines
4.9 KiB
Go
package sync
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// PullPartnumberBooks synchronizes partnumber book snapshots from MariaDB to local SQLite.
|
|
// Append-only for headers; re-pulls items if a book header exists but has 0 items.
|
|
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())
|
|
|
|
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 ORDER BY created_at DESC, id DESC").Scan(&serverBooks).Error; err != nil {
|
|
return 0, fmt.Errorf("querying server partnumber books: %w", err)
|
|
}
|
|
slog.Info("partnumber books found on server", "count", len(serverBooks))
|
|
|
|
pulled := 0
|
|
for _, sb := range serverBooks {
|
|
var existing localdb.LocalPartnumberBook
|
|
err := s.localDB.DB().Where("server_id = ?", sb.ID).First(&existing).Error
|
|
if err == nil {
|
|
// Header exists — check whether items were saved
|
|
localItemCount := localBookRepo.CountBookItems(existing.ID)
|
|
if localItemCount > 0 {
|
|
slog.Debug("partnumber book already synced, skipping", "server_id", sb.ID, "version", sb.Version, "items", localItemCount)
|
|
continue
|
|
}
|
|
// Items missing — re-pull them
|
|
slog.Info("partnumber book header exists but has no items, re-pulling items", "server_id", sb.ID, "version", sb.Version)
|
|
n, err := pullBookItems(mariaDB, localBookRepo, sb.ID, existing.ID)
|
|
if err != nil {
|
|
slog.Error("failed to re-pull items for existing book", "server_id", sb.ID, "error", err)
|
|
} else {
|
|
slog.Info("re-pulled items for existing book", "server_id", sb.ID, "version", sb.Version, "items_saved", n)
|
|
pulled++
|
|
}
|
|
continue
|
|
}
|
|
|
|
slog.Info("pulling new partnumber book", "server_id", sb.ID, "version", sb.Version, "is_active", sb.IsActive)
|
|
|
|
localBook := &localdb.LocalPartnumberBook{
|
|
ServerID: sb.ID,
|
|
Version: sb.Version,
|
|
CreatedAt: sb.CreatedAt,
|
|
IsActive: sb.IsActive,
|
|
}
|
|
if err := localBookRepo.SaveBook(localBook); err != nil {
|
|
slog.Error("failed to save local partnumber book", "server_id", sb.ID, "error", err)
|
|
continue
|
|
}
|
|
|
|
n, err := pullBookItems(mariaDB, localBookRepo, sb.ID, localBook.ID)
|
|
if err != nil {
|
|
slog.Error("failed to pull items for new book", "server_id", sb.ID, "error", err)
|
|
continue
|
|
}
|
|
|
|
slog.Info("partnumber book saved locally", "server_id", sb.ID, "version", sb.Version, "items_saved", n)
|
|
pulled++
|
|
}
|
|
|
|
slog.Info("partnumber book pull completed", "new_books_pulled", pulled, "total_on_server", len(serverBooks))
|
|
return pulled, nil
|
|
}
|
|
|
|
// pullBookItems fetches items for a single book from MariaDB and saves them to SQLite.
|
|
// Returns the number of items saved.
|
|
func pullBookItems(mariaDB *gorm.DB, repo *repository.PartnumberBookRepository, serverBookID int, localBookID uint) (int, error) {
|
|
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"`
|
|
}
|
|
// description column may not exist yet on older server schemas — query without it first,
|
|
// then retry with it to populate descriptions if available.
|
|
var serverItems []serverItem
|
|
err := mariaDB.Raw("SELECT partnumber, lot_name, is_primary_pn, description FROM qt_partnumber_book_items WHERE book_id = ?", serverBookID).Scan(&serverItems).Error
|
|
if err != nil {
|
|
slog.Warn("description column not available on server, retrying without it", "server_book_id", serverBookID, "error", err)
|
|
if err2 := mariaDB.Raw("SELECT partnumber, lot_name, is_primary_pn FROM qt_partnumber_book_items WHERE book_id = ?", serverBookID).Scan(&serverItems).Error; err2 != nil {
|
|
return 0, fmt.Errorf("querying items from server: %w", err2)
|
|
}
|
|
}
|
|
slog.Info("partnumber book items fetched from server", "server_book_id", serverBookID, "count", len(serverItems))
|
|
|
|
if len(serverItems) == 0 {
|
|
slog.Warn("server returned 0 items for book — check qt_partnumber_book_items on server", "server_book_id", serverBookID)
|
|
return 0, nil
|
|
}
|
|
|
|
localItems := make([]localdb.LocalPartnumberBookItem, 0, len(serverItems))
|
|
for _, si := range serverItems {
|
|
localItems = append(localItems, localdb.LocalPartnumberBookItem{
|
|
BookID: localBookID,
|
|
Partnumber: si.Partnumber,
|
|
LotName: si.LotName,
|
|
IsPrimaryPN: si.IsPrimaryPN,
|
|
Description: si.Description,
|
|
})
|
|
}
|
|
if err := repo.SaveBookItems(localItems); err != nil {
|
|
return 0, fmt.Errorf("saving items to local db: %w", err)
|
|
}
|
|
return len(localItems), nil
|
|
}
|