From d204e337b5719b60623c5f0123dc8afaa2d1ae63 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Tue, 16 Jun 2026 15:39:53 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20=D1=80=D1=83=D1=87=D0=BD=D1=8B=D0=B5=20PN?= =?UTF-8?q?=E2=86=92LOT=20=D0=BC=D0=B0=D0=BF=D0=BF=D0=B8=D0=BD=D0=B3=D0=B8?= =?UTF-8?q?=20=D0=BA=D0=B0=D0=BA=20lot=5Fsuggestion=20=D0=B2=20qt=5Fvendor?= =?UTF-8?q?=5Fpartnumber=5Fseen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При сохранении 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 --- bible-local/03-database.md | 3 +- bible-local/11-lot-suggestions.md | 161 ++++++++++++++++++++++ bible-local/README.md | 1 + cmd/qfs/main.go | 2 +- internal/handlers/vendor_spec.go | 53 ++++++- internal/services/sync/partnumber_seen.go | 67 ++++++++- 6 files changed, 278 insertions(+), 9 deletions(-) create mode 100644 bible-local/11-lot-suggestions.md diff --git a/bible-local/03-database.md b/bible-local/03-database.md index df2df58..0437da4 100644 --- a/bible-local/03-database.md +++ b/bible-local/03-database.md @@ -48,7 +48,7 @@ Runtime read/write: - `qt_pricelist_sync_status` — pricelist sync timestamps per user Insert-only tracking: -- `qt_vendor_partnumber_seen` — vendor partnumbers encountered during sync +- `qt_vendor_partnumber_seen` — vendor partnumbers encountered during sync; `lot_suggestion` column updated when user manually maps PN → LOT in vendor-spec UI Server-side only (not queried by client runtime): - `qt_component_usage_stats` — aggregated component popularity stats (written by server jobs) @@ -312,6 +312,7 @@ PK: job_name | ignored_by | varchar(100) | | | created_at | datetime(3) | | | updated_at | datetime(3) | | +| lot_suggestion | longtext (JSON) | nullable; set when user manually maps PN → LOT in vendor-spec UI; same format as `qt_partnumber_book_items.lots_json`; see [11-lot-suggestions.md](11-lot-suggestions.md) | ### stock_ignore_rules | Column | Type | Notes | diff --git a/bible-local/11-lot-suggestions.md b/bible-local/11-lot-suggestions.md new file mode 100644 index 0000000..dea895b --- /dev/null +++ b/bible-local/11-lot-suggestions.md @@ -0,0 +1,161 @@ +# 11 - Lot Suggestions (qt_vendor_partnumber_seen) + +## Purpose + +`qt_vendor_partnumber_seen` records vendor partnumbers encountered during import +that have no mapping in the active partnumber book. When a user manually maps +such a partnumber to one or more LOT names in the QuoteForge UI, those mappings +are written back to the server as **lot suggestions** — hints for the team that +maintains `qt_partnumber_book_items`. + +## Schema Extension + +Add one nullable column to `qt_vendor_partnumber_seen`: + +```sql +ALTER TABLE `qt_vendor_partnumber_seen` + ADD COLUMN `lot_suggestion` longtext DEFAULT NULL + COMMENT 'JSON array [{lot_name, qty}] — user-entered LOT mappings from the UI'; +``` + +### Updated table contract (relevant columns only) + +| Column | Type | Notes | +|--------|------|-------| +| `partnumber` | varchar(255) UNIQUE NOT NULL | natural key | +| `lot_suggestion` | longtext (JSON) | nullable; set when user maps the PN manually | + +`lot_suggestion` contains the same JSON shape as `qt_partnumber_book_items.lots_json`: + +```json +[ + { "lot_name": "LOT_A", "qty": 1 }, + { "lot_name": "LOT_B", "qty": 2 } +] +``` + +Rules: +- `null` or absent means no suggestion has been entered yet; +- an empty array `[]` is not a valid value — use `null` instead; +- a single PN may map to multiple lots (`lot_name` entries), each with its own `qty`; +- the array is ordered — the order reflects the order of `lot_mappings[]` in the + vendor spec row at the time of last user save; +- `qty` must be a positive integer (≥ 1). + +## Write Contract (QuoteForge → MariaDB) + +QuoteForge writes `lot_suggestion` when all of the following are true: + +1. The user saves a vendor BOM via `PUT /api/configs/:uuid/vendor-spec`. +2. At least one `vendor_spec` row has a non-empty `lot_mappings[]` array (manually + entered or confirmed by the user — not auto-resolved from a partnumber book). +3. The MariaDB connection is available at the time of save. + +For each such row: + +```sql +INSERT INTO qt_vendor_partnumber_seen + (source_type, vendor, partnumber, description, is_ignored, last_seen_at, lot_suggestion) +VALUES + ('manual', '', ?, ?, 0, NOW(3), ?) +ON DUPLICATE KEY UPDATE + lot_suggestion = VALUES(lot_suggestion), + last_seen_at = IF(lot_suggestion IS NULL, last_seen_at, NOW(3)) +``` + +- `lot_suggestion` value = JSON-marshalled `lot_mappings[]` from the vendor spec item, + reusing the same `{lot_name, qty}` shape. +- If the PN row already exists and `lot_suggestion` is already set, it is **overwritten** + with the latest user input (the user is assumed to have corrected it). +- If the user **clears** all lot_mappings for a PN (sets to empty), no update is sent — + the existing `lot_suggestion` on the server is left untouched. +- Rows where `lot_mappings[]` is empty or nil are skipped entirely (no insert, no update). +- Writes are best-effort: a MariaDB error for one row is logged and skipped; remaining + rows continue. A write failure does not fail the vendor-spec save. + +## Read Contract (Partnumber-Book Creation Tool → MariaDB) + +The tool that maintains `qt_partnumber_book_items` reads `qt_vendor_partnumber_seen` +to discover new partnumbers and their suggested mappings. + +### Discovery query + +```sql +SELECT + s.id, + s.partnumber, + s.description, + s.vendor, + s.lot_suggestion, + s.last_seen_at, + b.lots_json AS book_lots_json +FROM qt_vendor_partnumber_seen s +LEFT JOIN qt_partnumber_book_items b ON b.partnumber = s.partnumber +WHERE s.is_ignored = 0 + AND s.lot_suggestion IS NOT NULL +ORDER BY s.last_seen_at DESC; +``` + +### Interpretation rules + +| Condition | Meaning | Suggested action | +|-----------|---------|-----------------| +| `book_lots_json IS NULL` AND `lot_suggestion IS NOT NULL` | No book entry yet; user suggested mapping | Create new `qt_partnumber_book_items` row with `lots_json = lot_suggestion` | +| `book_lots_json IS NOT NULL` AND `lot_suggestion IS NOT NULL` AND they differ | User corrected or extended the existing mapping | Review diff and decide whether to update `qt_partnumber_book_items` | +| `book_lots_json IS NOT NULL` AND `lot_suggestion IS NOT NULL` AND they match | Suggestion already applied | No action needed | + +### Suggestion format + +`lot_suggestion` is valid JSON (or `null`). Parse it as an array of objects: + +```json +[ + { "lot_name": "LOT_A", "qty": 1 }, + { "lot_name": "LOT_B", "qty": 2 } +] +``` + +Map directly to `qt_partnumber_book_items.lots_json` — the formats are identical. + +### Multiple lots per PN + +One PN may have multiple suggestion entries (e.g., a bundle). The array carries +all of them. The book-creation tool must preserve the full array when writing +`lots_json`, not just the first element. + +### Qty semantics + +`qty` in a lot suggestion means "how many of this LOT per one occurrence of the +vendor PN". This matches `qt_partnumber_book_items.lots_json` exactly. Example: +a server platform that comes with 4 PSUs would produce +`[{"lot_name": "PS_1300W_Titanium", "qty": 4}]`. + +## Permissions + +The existing `qfs_user` grant covers this column — no new permission is required: + +```sql +GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO 'qfs_user'@'%'; +``` + +The book-creation tool connects with its own credentials and needs at minimum: + +```sql +GRANT SELECT ON RFQ_LOG.qt_vendor_partnumber_seen TO ''@'%'; +GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_partnumber_book_items TO ''@'%'; +``` + +## Migration + +Migration is applied outside this repo (server-side DDL): + +```sql +ALTER TABLE `qt_vendor_partnumber_seen` + ADD COLUMN IF NOT EXISTS `lot_suggestion` longtext DEFAULT NULL + COMMENT 'JSON [{lot_name, qty}] — user LOT suggestions from QuoteForge UI'; +``` + +QuoteForge handles a missing column gracefully: if the migration has not run yet, +the write with `lot_suggestion` fails with "Unknown column" (MariaDB 1054), a warning +is logged, and the row is re-inserted without the column. The app never crashes on +migration lag. diff --git a/bible-local/README.md b/bible-local/README.md index 052fb04..2b1b8bc 100644 --- a/bible-local/README.md +++ b/bible-local/README.md @@ -15,6 +15,7 @@ Project-specific architecture and operational contracts. | [07-dev.md](07-dev.md) | Development commands and guardrails | | [09-vendor-spec.md](09-vendor-spec.md) | Vendor BOM and CFXML import contract | | [10-agent-api-guide.md](10-agent-api-guide.md) | End-to-end API guide for agents pricing servers from a TZ | +| [11-lot-suggestions.md](11-lot-suggestions.md) | lot_suggestion column in qt_vendor_partnumber_seen — write/read contract for manual UI mappings | ## Rules diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index b5613aa..d31f873 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -779,7 +779,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect quoteHandler := handlers.NewQuoteHandler(quoteService) exportHandler := handlers.NewExportHandler(exportService, configService, projectService, dbUsername) pricelistHandler := handlers.NewPricelistHandler(local) - vendorSpecHandler := handlers.NewVendorSpecHandler(local) + vendorSpecHandler := handlers.NewVendorSpecHandler(local, syncService) partnumberBooksHandler := handlers.NewPartnumberBooksHandler(local) respondError := handlers.RespondError syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval) diff --git a/internal/handlers/vendor_spec.go b/internal/handlers/vendor_spec.go index 53bb8b9..cf26334 100644 --- a/internal/handlers/vendor_spec.go +++ b/internal/handlers/vendor_spec.go @@ -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 diff --git a/internal/services/sync/partnumber_seen.go b/internal/services/sync/partnumber_seen.go index f940c30..13df901 100644 --- a/internal/services/sync/partnumber_seen.go +++ b/internal/services/sync/partnumber_seen.go @@ -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") +}