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:
90
internal/handlers/partnumber_books.go
Normal file
90
internal/handlers/partnumber_books.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// PartnumberBooksHandler provides read-only access to local partnumber book snapshots.
|
||||
type PartnumberBooksHandler struct {
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
func NewPartnumberBooksHandler(localDB *localdb.LocalDB) *PartnumberBooksHandler {
|
||||
return &PartnumberBooksHandler{localDB: localDB}
|
||||
}
|
||||
|
||||
// List returns all local partnumber book snapshots.
|
||||
// GET /api/partnumber-books
|
||||
func (h *PartnumberBooksHandler) List(c *gin.Context) {
|
||||
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
|
||||
books, err := bookRepo.ListBooks()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
type bookSummary struct {
|
||||
ID uint `json:"id"`
|
||||
ServerID int `json:"server_id"`
|
||||
Version string `json:"version"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
IsActive bool `json:"is_active"`
|
||||
ItemCount int64 `json:"item_count"`
|
||||
}
|
||||
|
||||
summaries := make([]bookSummary, 0, len(books))
|
||||
for _, b := range books {
|
||||
summaries = append(summaries, bookSummary{
|
||||
ID: b.ID,
|
||||
ServerID: b.ServerID,
|
||||
Version: b.Version,
|
||||
CreatedAt: b.CreatedAt.Format("2006-01-02"),
|
||||
IsActive: b.IsActive,
|
||||
ItemCount: bookRepo.CountBookItems(b.ID),
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"books": summaries,
|
||||
"total": len(summaries),
|
||||
})
|
||||
}
|
||||
|
||||
// GetItems returns items for a partnumber book by server ID.
|
||||
// GET /api/partnumber-books/:id
|
||||
func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid book ID"})
|
||||
return
|
||||
}
|
||||
|
||||
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
|
||||
|
||||
// Find local book by server_id
|
||||
var book localdb.LocalPartnumberBook
|
||||
if err := h.localDB.DB().Where("server_id = ?", id).First(&book).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "partnumber book not found"})
|
||||
return
|
||||
}
|
||||
|
||||
items, err := bookRepo.GetBookItems(book.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"book_id": book.ServerID,
|
||||
"version": book.Version,
|
||||
"is_active": book.IsActive,
|
||||
"items": items,
|
||||
"total": len(items),
|
||||
})
|
||||
}
|
||||
@@ -243,6 +243,33 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
|
||||
h.syncService.RecordSyncHeartbeat()
|
||||
}
|
||||
|
||||
// SyncPartnumberBooks syncs partnumber book snapshots from MariaDB to local SQLite.
|
||||
// POST /api/sync/partnumber-books
|
||||
func (h *SyncHandler) SyncPartnumberBooks(c *gin.Context) {
|
||||
if !h.ensureSyncReadiness(c) {
|
||||
return
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
pulled, err := h.syncService.PullPartnumberBooks()
|
||||
if err != nil {
|
||||
slog.Error("partnumber books pull failed", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SyncResultResponse{
|
||||
Success: true,
|
||||
Message: "Partnumber books synced successfully",
|
||||
Synced: pulled,
|
||||
Duration: time.Since(startTime).String(),
|
||||
})
|
||||
h.syncService.RecordSyncHeartbeat()
|
||||
}
|
||||
|
||||
// SyncAllResponse represents result of full sync
|
||||
type SyncAllResponse struct {
|
||||
Success bool `json:"success"`
|
||||
|
||||
166
internal/handlers/vendor_spec.go
Normal file
166
internal/handlers/vendor_spec.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"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"
|
||||
)
|
||||
|
||||
// VendorSpecHandler handles vendor BOM spec operations for a configuration.
|
||||
type VendorSpecHandler struct {
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
func NewVendorSpecHandler(localDB *localdb.LocalDB) *VendorSpecHandler {
|
||||
return &VendorSpecHandler{localDB: localDB}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
|
||||
return
|
||||
}
|
||||
|
||||
spec := cfg.VendorSpec
|
||||
if spec == nil {
|
||||
spec = localdb.VendorSpec{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"vendor_spec": spec})
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
var body struct {
|
||||
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
spec := localdb.VendorSpec(body.VendorSpec)
|
||||
if err := h.localDB.DB().Model(&cfg).Update("vendor_spec", spec).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"vendor_spec": spec})
|
||||
}
|
||||
|
||||
// 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 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
|
||||
resolver := services.NewVendorSpecResolver(bookRepo)
|
||||
|
||||
resolved, err := resolver.Resolve(body.VendorSpec)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Also compute aggregated LOTs
|
||||
book, _ := bookRepo.GetActiveBook()
|
||||
aggregated, err := services.AggregateLOTs(resolved, book, bookRepo)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"resolved": resolved,
|
||||
"aggregated": aggregated,
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
var body struct {
|
||||
Items []struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Quantity int `json:"quantity"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
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{
|
||||
LotName: it.LotName,
|
||||
Quantity: it.Quantity,
|
||||
UnitPrice: it.UnitPrice,
|
||||
})
|
||||
}
|
||||
|
||||
itemsJSON, err := json.Marshal(newItems)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.localDB.DB().Model(&cfg).Update("items", string(itemsJSON)).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"items": newItems})
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
||||
}
|
||||
|
||||
// Load each page template with base
|
||||
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html", "config_revisions.html"}
|
||||
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html", "config_revisions.html", "partnumber_books.html"}
|
||||
for _, page := range simplePages {
|
||||
pagePath := filepath.Join(templatesPath, page)
|
||||
var tmpl *template.Template
|
||||
@@ -212,6 +212,10 @@ func (h *WebHandler) PricelistDetail(c *gin.Context) {
|
||||
h.render(c, "pricelist_detail.html", gin.H{"ActivePage": "pricelists"})
|
||||
}
|
||||
|
||||
func (h *WebHandler) PartnumberBooks(c *gin.Context) {
|
||||
h.render(c, "partnumber_books.html", gin.H{"ActivePage": "partnumber-books"})
|
||||
}
|
||||
|
||||
// Partials for htmx
|
||||
|
||||
func (h *WebHandler) ComponentsPartial(c *gin.Context) {
|
||||
|
||||
Reference in New Issue
Block a user