Files
QuoteForge/bible/09-vendor-spec.md
Michael Chus 5e56f386cc feat: implement vendor spec BOM import and PN→LOT resolution (Phase 1)
- Migration 029: local_partnumber_books, local_partnumber_book_items,
  vendor_spec TEXT column on local_configurations
- Models: LocalPartnumberBook, LocalPartnumberBookItem, VendorSpec,
  VendorSpecItem with JSON Valuer/Scanner
- Repository: PartnumberBookRepository (GetActiveBook, FindLotByPartnumber,
  SaveBook/Items, ListBooks, CountBookItems)
- Service: VendorSpecResolver 3-step resolution (book → manual suggestion
  → unresolved) + AggregateLOTs with is_primary_pn qty logic
- Sync: PullPartnumberBooks append-only pull from qt_partnumber_books
- Handlers: VendorSpecHandler (GET/PUT/resolve/apply), PartnumberBooksHandler
- Routes: /api/configs/:uuid/vendor-spec*, /api/partnumber-books,
  /api/sync/partnumber-books, /partnumber-books page
- UI: 3 top-level tabs [Estimate][BOM вендора][Ценообразование]; Excel paste,
  PN resolution, inline LOT autocomplete, pricing table
- Bible: 03-database.md updated, 09-vendor-spec.md added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 10:22:22 +03:00

127 lines
4.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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) | 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
```json
[
{
"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.
---
## 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 mapping: LOT_A → x1[primary], x2, x3):
- BOM: x2×1, x3×2 → 1×LOT_A (no primary PN found)
- 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
3. **Ценообразование** — pricing summary table: vendor price | Estimate | Warehouse | Competitors; custom price input with `= сумма цен вендора` shortcut
BOM is shared between tabs 2 and 3. The `Пересчитать эстимейт` action shows a confirmation dialog before overwriting the cart.
### Excel Paste Detection
- 2 columns → `[PN, qty]`
- 3+ columns → first col=PN, first numeric col=qty, subsequent numeric cols=prices
- If row 1 has non-numeric qty → treat as header and skip
---
## 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 items |
| 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 server |
---
## MariaDB Schema (managed by PriceForge)
```sql
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 only reads (`SELECT`) from `qt_partnumber_books` and `qt_partnumber_book_items`. Writes are managed exclusively by PriceForge.