# 09 — Vendor Spec (BOM Import) ## Overview The vendor spec feature allows importing a vendor BOM (Bill of Materials) into a configuration. It maps vendor part numbers (PN) to internal LOT names using an active partnumber book (snapshot pulled from PriceForge), then aggregates quantities to populate the Estimate (cart). --- ## Architecture ### Storage | Data | Storage | Sync direction | |------|---------|---------------| | `vendor_spec` JSON | `local_configurations.vendor_spec` (TEXT, JSON-encoded) | Two-way via `pending_changes` | | Partnumber book snapshots | `local_partnumber_books` + `local_partnumber_book_items` | Pull-only from PriceForge | `vendor_spec` is a JSON array of `VendorSpecItem` objects stored inside the configuration row. It syncs to MariaDB `qt_configurations.vendor_spec` via the existing pending_changes mechanism. ### `vendor_spec` JSON Schema ```json [ { "sort_order": 10, "vendor_partnumber": "ABC-123", "quantity": 2, "description": "...", "unit_price": 4500.00, "total_price": 9000.00, "resolved_lot_name": "LOT_A", "resolution_source": "book", "manual_lot_suggestion": null } ] ``` `resolution_source` values: `"book"` | `"manual_suggestion"` | `"unresolved"` `manual_lot_suggestion` stores the user's inline LOT input — scoped to this configuration only, never written to the global book. --- ## Partnumber Books (Snapshots) Partnumber books are immutable versioned snapshots of the global PN→LOT mapping table, analogous to pricelists. PriceForge creates new snapshots; QuoteForge only pulls and reads them. ### SQLite (local mirror) ```sql CREATE TABLE local_partnumber_books ( id INTEGER PRIMARY KEY AUTOINCREMENT, server_id INTEGER UNIQUE NOT NULL, -- id from qt_partnumber_books version TEXT NOT NULL, -- format YYYY-MM-DD-NNN created_at DATETIME NOT NULL, is_active INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE local_partnumber_book_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, book_id INTEGER NOT NULL, -- FK → local_partnumber_books.id partnumber TEXT NOT NULL, lot_name TEXT NOT NULL, is_primary_pn INTEGER NOT NULL DEFAULT 0, description TEXT ); CREATE INDEX idx_local_book_pn ON local_partnumber_book_items(book_id, partnumber); ``` **Active book query:** `WHERE is_active=1 ORDER BY created_at DESC, id DESC LIMIT 1` **Schema creation:** GORM AutoMigrate (not `runLocalMigrations`). ### MariaDB (managed exclusively by PriceForge) ```sql CREATE TABLE qt_partnumber_books ( id INT AUTO_INCREMENT PRIMARY KEY, version VARCHAR(50) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, is_active TINYINT(1) NOT NULL DEFAULT 1 ); CREATE TABLE qt_partnumber_book_items ( id INT AUTO_INCREMENT PRIMARY KEY, book_id INT NOT NULL, partnumber VARCHAR(255) NOT NULL, lot_name VARCHAR(255) NOT NULL, is_primary_pn TINYINT(1) NOT NULL DEFAULT 0, description VARCHAR(10000) NULL, INDEX idx_book_pn (book_id, partnumber), FOREIGN KEY (book_id) REFERENCES qt_partnumber_books(id) ); ALTER TABLE qt_configurations ADD COLUMN vendor_spec JSON NULL; ``` QuoteForge has `SELECT` permission only on `qt_partnumber_books` and `qt_partnumber_book_items`. All writes are managed by PriceForge. **Grant (add to existing user setup):** ```sql GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO ''@'%'; GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO ''@'%'; ``` ### `is_primary_pn` semantics - `1` = primary PN for this `lot_name`. Its quantity in the vendor BOM determines `qty(LOT)`. - `0` = non-primary (trigger) PN. If no primary PN for the LOT is found in BOM, contributes `qty=1`. --- ## Resolution Algorithm (3-step) For each `vendor_partnumber` in the BOM: 1. **Active book lookup** — query `local_partnumber_book_items WHERE book_id = activeBook.id AND partnumber = ?`. If found → `resolved_lot_name = match.lot_name`, `resolution_source = "book"`. 2. **Manual suggestion** — if `manual_lot_suggestion` is non-empty (user typed it before) → pre-fill as grey suggestion, `resolution_source = "manual_suggestion"`. 3. **Unresolved** — red background, inline autocomplete input shown to user. --- ## Qty Aggregation Logic After resolution, qty per LOT is computed as: ``` qty(lot) = SUM(quantity of rows where is_primary_pn=1 AND resolved_lot=lot) if at least one primary PN for this lot was found in BOM = 1 if only non-primary PNs for this lot were found ``` Examples (book: LOT_A → x1[primary], x2, x3): - BOM: x2×1, x3×2 → 1×LOT_A (no primary PN) - BOM: x1×2, x2×1 → 2×LOT_A (primary qty=2) - BOM: x1×1, x2×3 → 1×LOT_A (primary qty=1) --- ## UI: Three Top-Level Tabs The configurator (`/configurator`) has three tabs: 1. **Estimate** — existing cart/component configurator (unchanged). 2. **BOM вендора** — paste from Excel, auto-resolution, manual LOT input for unresolved rows, "Пересчитать эстимейт" button (confirmation dialog before overwriting cart), "Очистить" button. 3. **Ценообразование** — pricing summary table + custom price input. BOM data is shared between tabs 2 and 3. ### BOM Paste Format (auto-detected, tab-separated from Excel) Columns are detected automatically by content analysis — any number of columns is supported: - **price column** — last column where ≥70% of values are parseable numbers (handles `$5,114.00` and `5 114,00` formats) - **qty column** — first column where all values are integers - **PN column** — last text column before qty - **description column** — longest text column after qty If the second column of the first row is non-numeric → treat as header and skip. Price parsing handles: `$` prefix, spaces as thousands separator, comma-as-decimal (European format). ### Pricing Tab: column order ``` LOT | PN вендора | Описание | Кол-во | Estimate | Цена вендора | Склад | Конк. ``` **If BOM is empty** — the pricing tab still renders, using cart items as rows (PN вендора = "—", Цена вендора = "—"). **Description source priority:** BOM row description → LOT description from `local_components`. ### Inline LOT input for unresolved rows Unresolved rows show a red background with an `` (HTML5 datalist). The datalist is rebuilt from `allComponents` on every `renderBOMTable()` call. - `oninput` — updates `bomRows[idx].manual_lot` only; no table re-render (prevents focus loss). - `onchange` — validates via `_bomLotValid(v)` against `allComponents`; rejects free-text not matching a known LOT (field reset). If valid, calls `resolveBOM()`. ### Pricing Tab: "Своя цена" input - Manual entry → proportionally redistributes custom price into "Цена вендора" cells (proportional to each row's Estimate share). Last row absorbs rounding remainder. - "Проставить цены BOM" button → restores per-row original BOM prices directly (no proportional redistribution). Sets "Своя цена" to their sum. - Both paths show "Скидка от Estimate: X%" info. - "Экспорт CSV" button → downloads `pricing_.csv` with UTF-8 BOM, same column order as table, plus Итого row. --- ## API Endpoints | Method | URL | Description | |--------|-----|-------------| | GET | `/api/configs/:uuid/vendor-spec` | Fetch stored BOM | | PUT | `/api/configs/:uuid/vendor-spec` | Replace BOM (full update) | | POST | `/api/configs/:uuid/vendor-spec/resolve` | Resolve PNs → LOTs (no cart mutation) | | POST | `/api/configs/:uuid/vendor-spec/apply` | Apply resolved LOTs to cart | | GET | `/api/partnumber-books` | List local book snapshots | | GET | `/api/partnumber-books/:id` | Items for a book by server_id | | POST | `/api/sync/partnumber-books` | Pull book snapshots from MariaDB | | POST | `/api/sync/partnumber-seen` | Push unresolved PNs to `qt_vendor_partnumber_seen` on MariaDB | ## Unresolved PN Tracking (`qt_vendor_partnumber_seen`) After each `resolveBOM()` call, all unresolved PNs (rows where `resolution_source === 'unresolved'`) are pushed to the server via `POST /api/sync/partnumber-seen` (fire-and-forget from JS — errors silently ignored). The handler calls `sync.PushPartnumberSeen()` which upserts into `qt_vendor_partnumber_seen`: ```sql INSERT INTO qt_vendor_partnumber_seen (source_type, vendor, partnumber, description, last_seen_at) VALUES ('manual', '', ?, ?, NOW()) ON DUPLICATE KEY UPDATE last_seen_at = VALUES(last_seen_at), description = COALESCE(NULLIF(VALUES(description), ''), description) ``` Uniqueness key: `partnumber` only (after PriceForge migration 025). PriceForge uses this table for populating the partnumber book. ## BOM Persistence - `vendor_spec` is saved to server via `PUT /api/configs/:uuid/vendor-spec`. - The PUT handler explicitly marshals `VendorSpec` to JSON string before passing to GORM `Update` (GORM does not reliably call `driver.Valuer` for custom types in `Update(column, value)`). - BOM is saved automatically after every `resolveBOM()` (which fires on paste and on manual LOT selection). - "Сохранить BOM" button triggers explicit save. ## Pricing Tab: Estimate Price Source `renderPricingTab()` is async. It calls `POST /api/quote/price-levels` with all resolved LOTs from BOM rows (regardless of whether those LOTs are in the cart). This ensures Estimate prices appear even for manually-resolved LOTs that have not yet been applied to the cart via "Пересчитать эстимейт". ## Web Route | Route | Page | |-------|------| | `/partnumber-books` | Partnumber books — active book summary (unique LOTs, total PN, primary PN count), searchable items table, collapsible snapshot history |