- BOM paste: auto-detect columns by content (price, qty, PN, description); handles $5,114.00 and European comma-decimal formats - LOT input: HTML5 datalist rebuilt on each renderBOMTable from allComponents; oninput updates data only (no re-render), onchange validates+resolves - BOM persistence: PUT handler explicitly marshals VendorSpec to JSON string (GORM Update does not reliably call driver.Valuer for custom types) - BOM autosave after every resolveBOM() call - Pricing tab: async renderPricingTab() calls /api/quote/price-levels for all resolved LOTs directly — Estimate prices shown even before cart apply - Unresolved PNs pushed to qt_vendor_partnumber_seen via POST /api/sync/partnumber-seen (fire-and-forget from JS) - sync.PushPartnumberSeen(): upsert with ON DUPLICATE KEY UPDATE last_seen_at - partnumber_books: pull ALL books (not only is_active=1); re-pull items when header exists but item count is 0; fallback for missing description column - partnumber_books UI: collapsible snapshot section (collapsed by default), pagination (10/page), sync button always visible in header - vendorSpec handlers: use GetConfigurationByUUID + IsActive check (removed original_username from WHERE — GetUsername returns "" without JWT) - bible/09-vendor-spec.md: updated with all architectural decisions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
9.8 KiB
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
[
{
"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)
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)
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):
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO '<DB_USER>'@'%';
is_primary_pn semantics
1= primary PN for thislot_name. Its quantity in the vendor BOM determinesqty(LOT).0= non-primary (trigger) PN. If no primary PN for the LOT is found in BOM, contributesqty=1.
Resolution Algorithm (3-step)
For each vendor_partnumber in the BOM:
- 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". - Manual suggestion — if
manual_lot_suggestionis non-empty (user typed it before) → pre-fill as grey suggestion,resolution_source = "manual_suggestion". - 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:
- Estimate — existing cart/component configurator (unchanged).
- BOM вендора — paste from Excel, auto-resolution, manual LOT input for unresolved rows, "Пересчитать эстимейт" button (confirmation dialog before overwriting cart), "Очистить" button.
- Ценообразование — 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.00and5 114,00formats) - 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 <input list="lot-autocomplete-list"> (HTML5 datalist). The datalist is rebuilt from allComponents on every renderBOMTable() call.
oninput— updatesbomRows[idx].manual_lotonly; no table re-render (prevents focus loss).onchange— validates via_bomLotValid(v)againstallComponents; rejects free-text not matching a known LOT (field reset). If valid, callsresolveBOM().
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_<uuid>.csvwith 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:
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_specis saved to server viaPUT /api/configs/:uuid/vendor-spec.- The PUT handler explicitly marshals
VendorSpecto JSON string before passing to GORMUpdate(GORM does not reliably calldriver.Valuerfor custom types inUpdate(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 |