# 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, "lot_mappings": [ { "lot_name": "LOT_A", "quantity_per_pn": 1 }, { "lot_name": "LOT_B", "quantity_per_pn": 2 } ] } ] ``` `lot_mappings[]` is the canonical persisted LOT mapping list for a BOM row. Each mapping entry stores: - `lot_name` - `quantity_per_pn` (how many units of this LOT are included in **one vendor PN**) ### PN → LOT Mapping Contract (single LOT, multiplier, bundle) QuoteForge expects the server to return/store BOM rows (`vendor_spec`) using a single canonical mapping list: - `lot_mappings[]` contains **all** LOT mappings for the PN row (single LOT and bundle cases alike) - the list stores exactly what the user sees in BOM (LOT + "LOT в 1 PN") - the DB contract does **not** split mappings into "base LOT" vs "bundle LOTs" #### Final quantity contribution to Estimate For one BOM row with vendor PN quantity `pn_qty`: - each mapping contribution: - `lot_qty = pn_qty * lot_mappings[i].quantity_per_pn` #### Example: one PN maps to multiple LOTs ```json { "vendor_partnumber": "SYS-821GE-TNHR", "quantity": 3, "lot_mappings": [ { "lot_name": "CHASSIS_X13_8GPU", "quantity_per_pn": 1 }, { "lot_name": "PS_3000W_Titanium", "quantity_per_pn": 2 }, { "lot_name": "RAILKIT_X13", "quantity_per_pn": 1 } ] } ``` This row contributes to Estimate: - `CHASSIS_X13_8GPU` → `3 * 1 = 3` - `PS_3000W_Titanium` → `3 * 2 = 6` - `RAILKIT_X13` → `3 * 1 = 3` --- ## 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, QuoteForge builds/updates UI-visible LOT mappings: 1. **Active book lookup** — query `local_partnumber_book_items WHERE book_id = activeBook.id AND partnumber = ?`. 2. **Populate BOM UI** — if a match exists, BOM row shows a LOT value (user can still edit it). 3. **Unresolved** — red row + inline LOT input with strict autocomplete. Persistence note: the application stores the final user-visible mappings in `lot_mappings[]` (not separate "resolved/manual" persisted fields). --- ## 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/import vendor BOM, manual column mapping, LOT matching, bundle decomposition (`1 PN -> multiple LOTs`), "Пересчитать эстимейт", "Очистить". 3. **Ценообразование** — pricing summary table + custom price input. BOM data is shared between tabs 2 and 3. ### BOM Import UI (raw table, manual column mapping) After paste (`Ctrl+V`) QuoteForge renders an editable raw table (not auto-detected parsing). - The pasted rows are shown **as-is** (including header rows, if present). - The user selects a type for each column manually: - `P/N` - `Кол-во` - `Цена` - `Описание` - `Не использовать` - Required mapping: - exactly one `P/N` - exactly one `Кол-во` - Optional mapping: - `Цена` (0..1) - `Описание` (0..1) - Rows can be: - ignored (UI-only, excluded from `vendor_spec`) - deleted - Raw cells are editable inline after paste. Notes: - There is **no auto column detection**. - There is **no auto header-row skip**. - Raw import layout itself is not stored on server; only normalized `vendor_spec` is stored. ### LOT matching in BOM table The BOM table adds service columns on the right: - `LOT` - `LOT в 1 PN` - actions (`+`, ignore, delete) `LOT` behavior: - The first LOT row shown in the BOM UI is the primary LOT mapping for the PN row. - Additional LOT rows are added via the `+` action. - inline LOT input is strict: - autocomplete source = full local components list (`/api/components?per_page=5000`) - free text that does not match an existing LOT is rejected `LOT в 1 PN` behavior: - quantity multiplier for each visible LOT row in BOM (`quantity_per_pn` in persisted `lot_mappings[]`) - default = `1` - editable inline ### Bundle mode (`1 PN -> multiple LOTs`) The `+` action in BOM rows adds an extra LOT mapping row for the same vendor PN row. - All visible LOT rows (first + added rows) are persisted uniformly in `lot_mappings[]` - Each mapping row has: - LOT - qty (`LOT in 1 PN` = `quantity_per_pn`) ### BOM restore on config open On config open, QuoteForge loads `vendor_spec` from server and reconstructs the editable BOM table in normalized form: - columns restored as: `Qty | P/N | Description | Price` - column mapping restored as: - `qty`, `pn`, `description`, `price` - LOT / `LOT в 1 PN` rows are restored from `vendor_spec.lot_mappings[]` This restores the BOM editing state, but not the original raw Excel layout (extra columns, ignored rows, original headers). ### 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`. ### Pricing Tab: BOM + Estimate merge behavior When BOM exists, the pricing tab renders: - BOM-based rows (including rows resolved via manual LOT and bundle mappings) - plus **Estimate-only LOTs** (rows currently in cart but not covered by BOM mappings) Estimate-only rows are shown as separate rows with: - `PN вендора = "—"` - vendor price = `—` - description from local components ### 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, QuoteForge pushes PN rows to `POST /api/sync/partnumber-seen` (fire-and-forget from JS — errors silently ignored): - unresolved BOM rows (`ignored = false`) - raw BOM rows explicitly marked as ignored in UI (`ignored = true`) — these rows are **not** saved to `vendor_spec`, but are reported for server-side tracking The handler calls `sync.PushPartnumberSeen()` which upserts into `qt_vendor_partnumber_seen`: ```sql INSERT INTO qt_vendor_partnumber_seen (source_type, vendor, partnumber, description, is_ignored, last_seen_at) VALUES ('manual', '', ?, ?, ?, NOW()) ON DUPLICATE KEY UPDATE last_seen_at = VALUES(last_seen_at), is_ignored = VALUES(is_ignored), 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`. - `GET` / `PUT` `vendor_spec` must preserve row-level mapping fields used by the UI: - `lot_mappings[]` - each item: `lot_name`, `quantity_per_pn` - `description` is persisted in each BOM row and is used by the Pricing tab when available. - Ignored raw rows are **not** persisted into `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 autosaved (debounced) after BOM-changing actions, including: - `resolveBOM()` - LOT row qty (`LOT в 1 PN`) changes - LOT row add/remove (`+` / delete in bundle context) - "Сохранить BOM" button triggers explicit save. ## Pricing Tab: Estimate Price Source `renderPricingTab()` is async. It calls `POST /api/quote/price-levels` with LOTs collected from: - `lot_mappings[]` from BOM rows - current Estimate/cart LOTs not covered by BOM mappings (to show estimate-only rows) This ensures Estimate prices appear for: - manually matched LOTs in the BOM tab - bundle LOTs - LOTs already present in Estimate but not mapped from BOM ### Apply to Estimate (`Пересчитать эстимейт`) When applying BOM to Estimate, QuoteForge builds cart rows from explicit UI mappings stored in `lot_mappings[]`. For a BOM row with PN qty = `Q`: - each mapped LOT contributes `Q * quantity_per_pn` Rows without any valid LOT mapping are skipped. ## Web Route | Route | Page | |-------|------| | `/partnumber-books` | Partnumber books — active book summary (unique LOTs, total PN, primary PN count), searchable items table, collapsible snapshot history |