- Add bible.git as submodule at bible/ - Rename bible/ → bible-local/ (project-specific architecture) - Update CLAUDE.md to reference both bible/ and bible-local/ - Add AGENTS.md for Codex with same structure Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
13 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,
"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_namequantity_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
{
"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 = 3PS_3000W_Titanium→3 * 2 = 6RAILKIT_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)
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, QuoteForge builds/updates UI-visible LOT mappings:
- Active book lookup — query
local_partnumber_book_items WHERE book_id = activeBook.id AND partnumber = ?. - Populate BOM UI — if a match exists, BOM row shows a LOT value (user can still edit it).
- 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:
- Estimate — existing cart/component configurator (unchanged).
- BOM — paste/import vendor BOM, manual column mapping, LOT matching, bundle decomposition (
1 PN -> multiple LOTs), "Пересчитать эстимейт", "Очистить". - Ценообразование — 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
Кол-во
- exactly one
- Optional mapping:
Цена(0..1)Описание(0..1)
- Rows can be:
- ignored (UI-only, excluded from
vendor_spec) - deleted
- ignored (UI-only, excluded from
- 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_specis stored.
LOT matching in BOM table
The BOM table adds service columns on the right:
LOTLOT в 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
- autocomplete source = full local components list (
LOT в 1 PN behavior:
- quantity multiplier for each visible LOT row in BOM (
quantity_per_pnin persistedlot_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 PNrows are restored fromvendor_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_<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, 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 tovendor_spec, but are reported for server-side tracking
The handler calls sync.PushPartnumberSeen() which upserts into qt_vendor_partnumber_seen:
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_specis saved to server viaPUT /api/configs/:uuid/vendor-spec.GET/PUTvendor_specmust preserve row-level mapping fields used by the UI:lot_mappings[]- each item:
lot_name,quantity_per_pn
descriptionis 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
VendorSpecto JSON string before passing to GORMUpdate(GORM does not reliably calldriver.Valuerfor custom types inUpdate(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 |