При сохранении vendor-spec строки с заполненным lot_mappings автоматически
отправляются на сервер и пишутся в новый столбец lot_suggestion. Столбец
хранит JSON-массив [{lot_name, qty}] — тот же формат, что qt_partnumber_book_items.lots_json.
Если миграция ещё не прошла (столбец отсутствует), приложение логирует WARN
и записывает строку без столбца; сбоя нет.
Контракт для инструмента создания partnumber-books описан в bible-local/11-lot-suggestions.md.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
280 lines
8.0 KiB
Go
280 lines
8.0 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
|
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// VendorSpecHandler handles vendor BOM spec operations for a configuration.
|
|
type VendorSpecHandler struct {
|
|
localDB *localdb.LocalDB
|
|
configService *services.LocalConfigurationService
|
|
syncService *syncsvc.Service // optional; nil = no server push
|
|
}
|
|
|
|
func NewVendorSpecHandler(localDB *localdb.LocalDB, syncService *syncsvc.Service) *VendorSpecHandler {
|
|
return &VendorSpecHandler{
|
|
localDB: localDB,
|
|
configService: services.NewLocalConfigurationService(localDB, nil, nil, func() bool { return false }),
|
|
syncService: syncService,
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// ParseText parses a pasted single-column text BOM (Inspur or Russian text BOM)
|
|
// using the same parsers as the vendor file-import path. It is stateless: no
|
|
// configuration is required. Returns the parsed rows and the detected format, or
|
|
// an empty result when the text is not a recognized single-column format (the
|
|
// client then falls back to manual column mapping).
|
|
// POST /api/vendor-spec/parse-text
|
|
func (h *VendorSpecHandler) ParseText(c *gin.Context) {
|
|
var body struct {
|
|
Text string `json:"text"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
|
return
|
|
}
|
|
|
|
rows, format := services.ParsePastedBOMText(body.Text)
|
|
if rows == nil {
|
|
rows = []localdb.VendorSpecItem{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"rows": rows, "format": format})
|
|
}
|
|
|
|
// GetVendorSpec returns the vendor spec (BOM) for a configuration.
|
|
// GET /api/configs/:uuid/vendor-spec
|
|
func (h *VendorSpecHandler) GetVendorSpec(c *gin.Context) {
|
|
cfg, err := h.lookupConfig(c.Param("uuid"))
|
|
if 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) {
|
|
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"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
|
return
|
|
}
|
|
|
|
for i := range body.VendorSpec {
|
|
if body.VendorSpec[i].SortOrder == 0 {
|
|
body.VendorSpec[i].SortOrder = (i + 1) * 10
|
|
}
|
|
// Persist canonical LOT mapping only.
|
|
body.VendorSpec[i].LotMappings = normalizeLotMappings(body.VendorSpec[i].LotMappings)
|
|
body.VendorSpec[i].ResolvedLotName = ""
|
|
body.VendorSpec[i].ResolutionSource = ""
|
|
body.VendorSpec[i].ManualLotSuggestion = ""
|
|
body.VendorSpec[i].LotQtyPerPN = 0
|
|
body.VendorSpec[i].LotAllocations = nil
|
|
}
|
|
|
|
spec := localdb.VendorSpec(body.VendorSpec)
|
|
if _, err := h.configService.UpdateVendorSpecNoAuth(cfg.UUID, spec); err != nil {
|
|
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
|
return
|
|
}
|
|
|
|
// Push manual lot mappings as suggestions to the server (best-effort, non-blocking).
|
|
h.pushLotSuggestions(body.VendorSpec)
|
|
|
|
c.JSON(http.StatusOK, gin.H{"vendor_spec": spec})
|
|
}
|
|
|
|
// pushLotSuggestions sends manual PN→LOT mappings to qt_vendor_partnumber_seen.lot_suggestion.
|
|
// Errors are logged and silently dropped — they must not affect the HTTP response.
|
|
func (h *VendorSpecHandler) pushLotSuggestions(spec []localdb.VendorSpecItem) {
|
|
if h.syncService == nil {
|
|
return
|
|
}
|
|
|
|
var items []syncsvc.SeenPartnumber
|
|
for _, row := range spec {
|
|
if row.VendorPartnumber == "" || len(row.LotMappings) == 0 {
|
|
continue
|
|
}
|
|
suggestion := make([]syncsvc.LotSuggestionEntry, 0, len(row.LotMappings))
|
|
for _, m := range row.LotMappings {
|
|
if m.LotName == "" {
|
|
continue
|
|
}
|
|
qty := m.QuantityPerPN
|
|
if qty < 1 {
|
|
qty = 1
|
|
}
|
|
suggestion = append(suggestion, syncsvc.LotSuggestionEntry{
|
|
LotName: m.LotName,
|
|
Qty: qty,
|
|
})
|
|
}
|
|
if len(suggestion) == 0 {
|
|
continue
|
|
}
|
|
items = append(items, syncsvc.SeenPartnumber{
|
|
Partnumber: row.VendorPartnumber,
|
|
Description: row.Description,
|
|
LotSuggestion: suggestion,
|
|
})
|
|
}
|
|
|
|
if len(items) == 0 {
|
|
return
|
|
}
|
|
|
|
if err := h.syncService.PushPartnumberSeen(items); err != nil {
|
|
slog.Warn("vendor_spec: failed to push lot suggestions to server", "error", err)
|
|
}
|
|
}
|
|
|
|
func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
merged := make(map[string]int, len(in))
|
|
order := make([]string, 0, len(in))
|
|
for _, m := range in {
|
|
lot := strings.TrimSpace(m.LotName)
|
|
if lot == "" {
|
|
continue
|
|
}
|
|
qty := m.QuantityPerPN
|
|
if qty < 1 {
|
|
qty = 1
|
|
}
|
|
if _, exists := merged[lot]; !exists {
|
|
order = append(order, lot)
|
|
}
|
|
merged[lot] += qty
|
|
}
|
|
out := make([]localdb.VendorSpecLotMapping, 0, len(order))
|
|
for _, lot := range order {
|
|
out = append(out, localdb.VendorSpecLotMapping{
|
|
LotName: lot,
|
|
QuantityPerPN: merged[lot],
|
|
})
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ResolveVendorSpec resolves vendor PN → LOT without modifying the cart.
|
|
// POST /api/configs/:uuid/vendor-spec/resolve
|
|
func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
|
|
if _, err := h.lookupConfig(c.Param("uuid")); 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 {
|
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
|
return
|
|
}
|
|
|
|
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
|
|
resolver := services.NewVendorSpecResolver(bookRepo)
|
|
|
|
resolved, err := resolver.Resolve(body.VendorSpec)
|
|
if err != nil {
|
|
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
|
return
|
|
}
|
|
|
|
book, err := bookRepo.GetActiveBook()
|
|
if err != nil {
|
|
slog.Warn("vendor spec resolve: no active partnumber book", "err", err)
|
|
book = nil
|
|
}
|
|
aggregated, err := services.AggregateLOTs(resolved, book, bookRepo)
|
|
if err != nil {
|
|
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
|
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) {
|
|
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 {
|
|
LotName string `json:"lot_name"`
|
|
Quantity int `json:"quantity"`
|
|
UnitPrice float64 `json:"unit_price"`
|
|
} `json:"items"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
|
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,
|
|
})
|
|
}
|
|
|
|
if _, err := h.configService.ApplyVendorSpecItemsNoAuth(cfg.UUID, newItems); err != nil {
|
|
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"items": newItems})
|
|
}
|