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:
2026-02-21 22:21:13 +03:00
parent d3f1a838eb
commit d0400b18a3
12 changed files with 829 additions and 399 deletions

View File

@@ -679,3 +679,35 @@ func (h *SyncHandler) getReadinessCached(maxAge time.Duration) *sync.SyncReadine
h.readinessMu.Unlock()
return readiness
}
// ReportPartnumberSeen pushes unresolved vendor partnumbers to qt_vendor_partnumber_seen on MariaDB.
// POST /api/sync/partnumber-seen
func (h *SyncHandler) ReportPartnumberSeen(c *gin.Context) {
var body struct {
Items []struct {
Partnumber string `json:"partnumber"`
Description string `json:"description"`
} `json:"items"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
items := make([]sync.SeenPartnumber, 0, len(body.Items))
for _, it := range body.Items {
if it.Partnumber != "" {
items = append(items, sync.SeenPartnumber{
Partnumber: it.Partnumber,
Description: it.Description,
})
}
}
if err := h.syncService.PushPartnumberSeen(items); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"reported": len(items)})
}

View File

@@ -2,10 +2,10 @@ package handlers
import (
"encoding/json"
"errors"
"net/http"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/middleware"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
@@ -20,14 +20,23 @@ func NewVendorSpecHandler(localDB *localdb.LocalDB) *VendorSpecHandler {
return &VendorSpecHandler{localDB: localDB}
}
// lookupConfig finds an active configuration by UUID using the standard localDB method.
func (h *VendorSpecHandler) lookupConfig(uuid string) (*localdb.LocalConfiguration, error) {
cfg, err := h.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, err
}
if !cfg.IsActive {
return nil, errors.New("not active")
}
return cfg, nil
}
// GetVendorSpec returns the vendor spec (BOM) for a configuration.
// GET /api/configs/:uuid/vendor-spec
func (h *VendorSpecHandler) GetVendorSpec(c *gin.Context) {
uuid := c.Param("uuid")
username := middleware.GetUsername(c)
var cfg localdb.LocalConfiguration
if err := h.localDB.DB().Where("uuid = ? AND original_username = ? AND is_active = 1", uuid, username).First(&cfg).Error; err != nil {
cfg, err := h.lookupConfig(c.Param("uuid"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
return
}
@@ -42,8 +51,11 @@ func (h *VendorSpecHandler) GetVendorSpec(c *gin.Context) {
// PutVendorSpec saves (replaces) the vendor spec for a configuration.
// PUT /api/configs/:uuid/vendor-spec
func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
uuid := c.Param("uuid")
username := middleware.GetUsername(c)
cfg, err := h.lookupConfig(c.Param("uuid"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
return
}
var body struct {
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
@@ -53,13 +65,6 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
return
}
var cfg localdb.LocalConfiguration
if err := h.localDB.DB().Where("uuid = ? AND original_username = ? AND is_active = 1", uuid, username).First(&cfg).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
return
}
// Assign sort_order if not set
for i := range body.VendorSpec {
if body.VendorSpec[i].SortOrder == 0 {
body.VendorSpec[i].SortOrder = (i + 1) * 10
@@ -67,7 +72,12 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
}
spec := localdb.VendorSpec(body.VendorSpec)
if err := h.localDB.DB().Model(&cfg).Update("vendor_spec", spec).Error; err != nil {
specJSON, err := json.Marshal(spec)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := h.localDB.DB().Model(cfg).Update("vendor_spec", string(specJSON)).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -78,11 +88,7 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
// ResolveVendorSpec resolves vendor PN → LOT without modifying the cart.
// POST /api/configs/:uuid/vendor-spec/resolve
func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
uuid := c.Param("uuid")
username := middleware.GetUsername(c)
var cfg localdb.LocalConfiguration
if err := h.localDB.DB().Where("uuid = ? AND original_username = ? AND is_active = 1", uuid, username).First(&cfg).Error; err != nil {
if _, err := h.lookupConfig(c.Param("uuid")); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
return
}
@@ -104,7 +110,6 @@ func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
return
}
// Also compute aggregated LOTs
book, _ := bookRepo.GetActiveBook()
aggregated, err := services.AggregateLOTs(resolved, book, bookRepo)
if err != nil {
@@ -121,8 +126,11 @@ func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
// ApplyVendorSpec applies the resolved BOM to the cart (Estimate items).
// POST /api/configs/:uuid/vendor-spec/apply
func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
uuid := c.Param("uuid")
username := middleware.GetUsername(c)
cfg, err := h.lookupConfig(c.Param("uuid"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
return
}
var body struct {
Items []struct {
@@ -136,12 +144,6 @@ func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
return
}
var cfg localdb.LocalConfiguration
if err := h.localDB.DB().Where("uuid = ? AND original_username = ? AND is_active = 1", uuid, username).First(&cfg).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
return
}
newItems := make(localdb.LocalConfigItems, 0, len(body.Items))
for _, it := range body.Items {
newItems = append(newItems, localdb.LocalConfigItem{
@@ -157,7 +159,7 @@ func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
return
}
if err := h.localDB.DB().Model(&cfg).Update("items", string(itemsJSON)).Error; err != nil {
if err := h.localDB.DB().Model(cfg).Update("items", string(itemsJSON)).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}