Files
QuoteForge/bible-local/09-vendor-spec.md
Michael Chus eb7c3739ce Add shared bible submodule, rename local bible to bible-local
- 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>
2026-03-01 16:41:14 +03:00

13 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).


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