20 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).
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
CFXMLworkspace 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:
- Read all top-level
ProductLineItemrows in document order. - Group them by
ProprietaryGroupIdentifier. - Preserve document order of groups by the first encountered
ProductLineNumber. - 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:
- Prefer rows with
ProductTypeCode = Hardware. - If multiple rows match, prefer the row with the largest number of
ProductSubLineItemchildren. - 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:
HardwareSoftware- 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 rowProductName - QuoteForge configuration
server_count<- primary rowQuantity - QuoteForge configuration
server_model<- primary rowProductDescription - QuoteForge configuration
articleorsupport_code<- primary rowProprietaryProductIdentifier - 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
ProductSubLineItemrows from the primary top-level row; - all
ProductSubLineItemrows 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 onevendor_specrow so that software-only content is not lost.
Each imported row maps into one VendorSpecItem:
sort_order<- stable sequence within the groupvendor_partnumber<-ProprietaryProductIdentifierquantity<-Quantitydescription<-ProductDescriptionunit_price<-UnitListPrice.FinancialAmount.MonetaryAmountwhen presenttotal_price<-quantity * unit_pricewhen unit price is presentlot_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
itemsfromrow.quantity * quantity_per_pn - fill
items.unit_pricefrom the latest localestimatepricelist - recalculate configuration
total_price
Import Pipeline
Recommended parser pipeline:
- Parse XML into top-level
ProductLineItemrows. - Group rows by
ProprietaryGroupIdentifier. - Select one primary row per group using structural rules.
- Build one QuoteForge configuration DTO per group.
- Merge all hardware/software rows of the group into one
vendor_spec. - Resolve imported PN rows into canonical
lot_mappings[]using the active partnumber book. - Build configuration
itemsfrom resolvedlot_mappings[]. - Price those
itemsfrom the latest localestimatepricelist. - Save or update the QuoteForge configuration inside the existing project.
Recommended Internal DTO
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:
- 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 |
| 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 tovendor_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:
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_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 |