Files
QuoteForge/bible-local/09-vendor-spec.md
2026-03-07 21:03:40 +03:00

20 KiB
Raw Blame History

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_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

{
  "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_8GPU3 * 1 = 3
  • PS_3000W_Titanium3 * 2 = 6
  • RAILKIT_X133 * 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 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.
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_<uuid>.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:

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