Files
QuoteForge/internal/handlers/vendor_spec.go
Mikhail Chusavitin d204e337b5 feat: сохранять ручные PN→LOT маппинги как lot_suggestion в qt_vendor_partnumber_seen
При сохранении 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>
2026-06-16 15:39:53 +03:00

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})
}