# 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). --- ## CFXML Workspace Import Contract QuoteForge may import a vendor configurator workspace in `CFXML` format as an existing project update path. This import path must convert one external workspace into one QuoteForge project containing multiple configurations. ### Import Unit Boundaries - One `CFXML` workspace file = one QuoteForge project import session. - One top-level configuration group inside the workspace = one QuoteForge configuration. - Software rows are **not** imported as standalone configurations. - All software rows must be attached to the configuration group they belong to. ### Configuration Grouping Top-level `ProductLineItem` rows are grouped by: - `ProprietaryGroupIdentifier` This field is the canonical boundary of one imported configuration. Rules: 1. Read all top-level `ProductLineItem` rows in document order. 2. Group them by `ProprietaryGroupIdentifier`. 3. Preserve document order of groups by the first encountered `ProductLineNumber`. 4. Import each group as exactly one QuoteForge configuration. `ConfigurationGroupLineNumberReference` is not sufficient for grouping imported configurations because multiple independent configuration groups may share the same value in one workspace. ### Primary Row Selection (no SKU hardcode) The importer must not hardcode vendor, model, or server SKU values. Within each `ProprietaryGroupIdentifier` group, the importer selects one primary top-level row using structural rules only: 1. Prefer rows with `ProductTypeCode = Hardware`. 2. If multiple rows match, prefer the row with the largest number of `ProductSubLineItem` children. 3. If there is still a tie, prefer the first row by `ProductLineNumber`. The primary row provides configuration-level metadata such as: - configuration name - server count - server model / description - article / support code candidate ### Software Inclusion Rule All top-level rows belonging to the same `ProprietaryGroupIdentifier` must be imported into the same QuoteForge configuration, including: - `Hardware` - `Software` - instruction / service rows represented as software-like items Effects: - a workspace never creates a separate configuration made only of software; - `software1`, `software2`, license rows, and instruction rows stay inside the related configuration; - the user sees one complete configuration instead of fragmented partial imports. ### Mapping to QuoteForge Project / Configuration For one imported configuration group: - QuoteForge configuration `name` <- primary row `ProductName` - QuoteForge configuration `server_count` <- primary row `Quantity` - QuoteForge configuration `server_model` <- primary row `ProductDescription` - QuoteForge configuration `article` or `support_code` <- primary row `ProprietaryProductIdentifier` - QuoteForge configuration `line` <- stable order by group appearance in the workspace Project-level fields such as QuoteForge `code`, `name`, and `variant` are not reliably defined by `CFXML` itself and should come from the existing target project context or explicit user input. ### Mapping to `vendor_spec` The importer must build one combined `vendor_spec` array per configuration group. Source rows: - all `ProductSubLineItem` rows from the primary top-level row; - all `ProductSubLineItem` rows from every non-primary top-level row in the same group; - if a top-level row has no `ProductSubLineItem`, the top-level row itself may be converted into one `vendor_spec` row so that software-only content is not lost. Each imported row maps into one `VendorSpecItem`: - `sort_order` <- stable sequence within the group - `vendor_partnumber` <- `ProprietaryProductIdentifier` - `quantity` <- `Quantity` - `description` <- `ProductDescription` - `unit_price` <- `UnitListPrice.FinancialAmount.MonetaryAmount` when present - `total_price` <- `quantity * unit_price` when unit price is present - `lot_mappings` <- resolved immediately from the active partnumber book when a unique match exists The importer stores vendor-native rows in `vendor_spec`, then immediately runs the same logical flow as BOM Resolve + Apply: - resolve vendor PN rows through the active partnumber book - persist canonical `lot_mappings[]` - build normalized configuration `items` from `row.quantity * quantity_per_pn` - fill `items.unit_price` from the latest local `estimate` pricelist - recalculate configuration `total_price` ### Import Pipeline Recommended parser pipeline: 1. Parse XML into top-level `ProductLineItem` rows. 2. Group rows by `ProprietaryGroupIdentifier`. 3. Select one primary row per group using structural rules. 4. Build one QuoteForge configuration DTO per group. 5. Merge all hardware/software rows of the group into one `vendor_spec`. 6. Resolve imported PN rows into canonical `lot_mappings[]` using the active partnumber book. 7. Build configuration `items` from resolved `lot_mappings[]`. 8. Price those `items` from the latest local `estimate` pricelist. 9. Save or update the QuoteForge configuration inside the existing project. ### Recommended Internal DTO ```go type ImportedProject struct { SourceFormat string SourceFilePath string SourceDocID string Code string Name string Variant string Configurations []ImportedConfiguration } type ImportedConfiguration struct { GroupID string Name string Line int ServerCount int ServerModel string Article string SupportCode string CurrencyCode string TopLevelRows []ImportedTopLevelRow VendorSpec []ImportedVendorRow } type ImportedTopLevelRow struct { ProductLineNumber string ItemNo string GroupID string ProductType string ProductCode string ProductName string Description string Quantity int UnitPrice *float64 IsPrimary bool SubRows []ImportedVendorRow } type ImportedVendorRow struct { SortOrder int SourceLineNumber string SourceParentLine string SourceProductType string VendorPartnumber string Description string Quantity int UnitPrice *float64 TotalPrice *float64 ProductCharacter string ProductCharPath string } ``` ### Current Product Assumption For QuoteForge product behavior, the correct user-facing interpretation is: - one external project/workspace contains several configurations; - each configuration contains both hardware and software rows that belong to it; - the importer must preserve that grouping exactly. --- ## 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 | | POST | `/api/projects/:uuid/vendor-import` | Import `CFXML` workspace into an existing project and create grouped configurations | | 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 inserts into `qt_vendor_partnumber_seen`. If a row with the same `partnumber` already exists, QuoteForge must leave it untouched: - do not update `last_seen_at` - do not update `is_ignored` - do not update `description` Canonical insert behavior: ```sql INSERT INTO qt_vendor_partnumber_seen (source_type, vendor, partnumber, description, is_ignored, last_seen_at) VALUES ('manual', '', ?, ?, ?, NOW()) ON DUPLICATE KEY UPDATE partnumber = partnumber ``` 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 |