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>
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -16,12 +17,14 @@ import (
|
||||
type VendorSpecHandler struct {
|
||||
localDB *localdb.LocalDB
|
||||
configService *services.LocalConfigurationService
|
||||
syncService *syncsvc.Service // optional; nil = no server push
|
||||
}
|
||||
|
||||
func NewVendorSpecHandler(localDB *localdb.LocalDB) *VendorSpecHandler {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,9 +114,57 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
|
||||
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
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SeenPartnumber represents an unresolved vendor partnumber to report.
|
||||
type SeenPartnumber struct {
|
||||
Partnumber string
|
||||
Description string
|
||||
Ignored bool
|
||||
Partnumber string
|
||||
Description string
|
||||
Ignored bool
|
||||
LotSuggestion []LotSuggestionEntry // optional; set when user manually mapped PN → LOT in UI
|
||||
}
|
||||
|
||||
// LotSuggestionEntry is one suggested LOT mapping for a vendor partnumber.
|
||||
// JSON shape mirrors qt_partnumber_book_items.lots_json: {"lot_name", "qty"}.
|
||||
type LotSuggestionEntry struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Qty int `json:"qty"`
|
||||
}
|
||||
|
||||
// PushPartnumberSeen inserts unresolved vendor partnumbers into qt_vendor_partnumber_seen on MariaDB.
|
||||
// Existing rows are left untouched: no updates to last_seen_at, is_ignored, or description.
|
||||
// When LotSuggestion is provided the column is updated too; if the column does not exist yet
|
||||
// (migration pending) the write is retried without it and a warning is logged — the app never panics.
|
||||
func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
@@ -30,7 +41,43 @@ func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
||||
if item.Partnumber == "" {
|
||||
continue
|
||||
}
|
||||
err := mariaDB.Exec(`
|
||||
|
||||
if len(item.LotSuggestion) > 0 {
|
||||
suggJSON, marshalErr := json.Marshal(item.LotSuggestion)
|
||||
if marshalErr != nil {
|
||||
slog.Error("partnumber_seen: failed to marshal lot_suggestion, skipping suggestion",
|
||||
"partnumber", item.Partnumber, "error", marshalErr)
|
||||
suggJSON = nil
|
||||
}
|
||||
|
||||
if suggJSON != nil {
|
||||
err = mariaDB.Exec(`
|
||||
INSERT INTO qt_vendor_partnumber_seen
|
||||
(source_type, vendor, partnumber, description, is_ignored, last_seen_at, lot_suggestion)
|
||||
VALUES
|
||||
('manual', '', ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
lot_suggestion = VALUES(lot_suggestion),
|
||||
last_seen_at = NOW(3)
|
||||
`, item.Partnumber, item.Description, item.Ignored, now, string(suggJSON)).Error
|
||||
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Column not yet migrated — fall through to insert without lot_suggestion.
|
||||
if !isUnknownColumnError(err) {
|
||||
slog.Error("partnumber_seen: failed to upsert with lot_suggestion",
|
||||
"partnumber", item.Partnumber, "error", err)
|
||||
continue
|
||||
}
|
||||
slog.Warn("partnumber_seen: lot_suggestion column missing (migration pending), inserting without it",
|
||||
"partnumber", item.Partnumber)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert without lot_suggestion (baseline behaviour or fallback).
|
||||
err = mariaDB.Exec(`
|
||||
INSERT INTO qt_vendor_partnumber_seen
|
||||
(source_type, vendor, partnumber, description, is_ignored, last_seen_at)
|
||||
VALUES
|
||||
@@ -40,10 +87,18 @@ func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
||||
`, item.Partnumber, item.Description, item.Ignored, now).Error
|
||||
if err != nil {
|
||||
slog.Error("failed to insert partnumber_seen", "partnumber", item.Partnumber, "error", err)
|
||||
// Continue with remaining items
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("partnumber_seen pushed to server", "count", len(items))
|
||||
return nil
|
||||
}
|
||||
|
||||
// isUnknownColumnError returns true when MariaDB reports that a column does not exist.
|
||||
func isUnknownColumnError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "unknown column") || strings.Contains(msg, "1054")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user