Files
QuoteForge/bible/09-vendor-spec.md
Michael Chus d0400b18a3 feat(vendor-spec): BOM import, LOT autocomplete, pricing, partnumber_seen push
- 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>
2026-02-21 22:21:13 +03:00

9.8 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,
    "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 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:

  1. 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".
  2. Manual suggestion — if manual_lot_suggestion is non-empty (user typed it before) → pre-fill as grey suggestion, resolution_source = "manual_suggestion".
  3. 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:

  1. Estimate — existing cart/component configurator (unchanged).
  2. BOM вендора — paste from Excel, auto-resolution, manual LOT input for unresolved rows, "Пересчитать эстимейт" button (confirmation dialog before overwriting cart), "Очистить" button.
  3. Ценообразование — 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.00 and 5 114,00 formats)
  • 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 — updates bomRows[idx].manual_lot only; no table re-render (prevents focus loss).
  • onchange — validates via _bomLotValid(v) against allComponents; rejects free-text not matching a known LOT (field reset). If valid, calls resolveBOM().

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, 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_spec is saved to server via PUT /api/configs/:uuid/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 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