feat(vendor-spec): BOM import, LOT autocomplete, pricing, partnumber_seen push
- 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>
This commit is contained in:
@@ -7,10 +7,11 @@ import (
|
||||
|
||||
"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.
|
||||
// It only pulls books that don't exist locally yet (append-only).
|
||||
// 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")
|
||||
|
||||
@@ -21,7 +22,6 @@ func (s *Service) PullPartnumberBooks() (int, error) {
|
||||
|
||||
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"`
|
||||
@@ -29,21 +29,36 @@ func (s *Service) PullPartnumberBooks() (int, error) {
|
||||
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 {
|
||||
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 {
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
|
||||
// Save the book
|
||||
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,
|
||||
@@ -51,42 +66,62 @@ func (s *Service) PullPartnumberBooks() (int, error) {
|
||||
IsActive: sb.IsActive,
|
||||
}
|
||||
if err := localBookRepo.SaveBook(localBook); err != nil {
|
||||
slog.Warn("failed to save local partnumber book", "server_id", sb.ID, "error", err)
|
||||
slog.Error("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)
|
||||
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
|
||||
}
|
||||
|
||||
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))
|
||||
slog.Info("partnumber book saved locally", "server_id", sb.ID, "version", sb.Version, "items_saved", n)
|
||||
pulled++
|
||||
}
|
||||
|
||||
slog.Info("partnumber book pull completed", "pulled", 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
|
||||
}
|
||||
|
||||
49
internal/services/sync/partnumber_seen.go
Normal file
49
internal/services/sync/partnumber_seen.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SeenPartnumber represents an unresolved vendor partnumber to report.
|
||||
type SeenPartnumber struct {
|
||||
Partnumber string
|
||||
Description string
|
||||
}
|
||||
|
||||
// PushPartnumberSeen inserts unresolved vendor partnumbers into qt_vendor_partnumber_seen on MariaDB.
|
||||
// Uses INSERT ... ON DUPLICATE KEY UPDATE so existing rows are updated (last_seen_at) without error.
|
||||
func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("database not available: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
for _, item := range items {
|
||||
if item.Partnumber == "" {
|
||||
continue
|
||||
}
|
||||
err := mariaDB.Exec(`
|
||||
INSERT INTO qt_vendor_partnumber_seen
|
||||
(source_type, vendor, partnumber, description, last_seen_at)
|
||||
VALUES
|
||||
('manual', '', ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_seen_at = VALUES(last_seen_at),
|
||||
description = COALESCE(NULLIF(VALUES(description), ''), description)
|
||||
`, item.Partnumber, item.Description, now).Error
|
||||
if err != nil {
|
||||
slog.Error("failed to upsert partnumber_seen", "partnumber", item.Partnumber, "error", err)
|
||||
// Continue with remaining items
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("partnumber_seen pushed to server", "count", len(items))
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user