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>
This commit is contained in:
@@ -21,11 +21,22 @@ File: `qfs.db` in the user-state directory (see [05-config.md](05-config.md)).
|
||||
| `local_pricelists` | Pricelist headers | `id`, `server_id` (unique), `source`, `version`, `created_at` |
|
||||
| `local_pricelist_items` | Pricelist line items ← **sole source of prices** | `id`, `pricelist_id` (FK), `lot_name`, `price`, `lot_category` |
|
||||
|
||||
#### Partnumber Books (PN → LOT mapping, pull-only from PriceForge)
|
||||
|
||||
| Table | Purpose | Key Fields |
|
||||
|-------|---------|------------|
|
||||
| `local_partnumber_books` | Version snapshots of PN→LOT mappings | `id`, `server_id` (unique), `version`, `created_at`, `is_active` |
|
||||
| `local_partnumber_book_items` | PN→LOT mapping rows | `id`, `book_id` (FK), `partnumber`, `lot_name`, `is_primary_pn` |
|
||||
|
||||
Active book: `WHERE is_active=1 ORDER BY created_at DESC, id DESC LIMIT 1`
|
||||
|
||||
`is_primary_pn=1` means this PN's quantity in the vendor BOM determines qty(LOT). If only non-primary PNs are present for a LOT, qty defaults to 1.
|
||||
|
||||
#### Configurations and Projects
|
||||
|
||||
| Table | Purpose | Key Fields |
|
||||
|-------|---------|------------|
|
||||
| `local_configurations` | Saved configurations | `id`, `uuid` (unique), `items` (JSON), `line_no`, `pricelist_id`, `warehouse_pricelist_id`, `competitor_pricelist_id`, `current_version_id`, `sync_status` |
|
||||
| `local_configurations` | Saved configurations | `id`, `uuid` (unique), `items` (JSON), `vendor_spec` (JSON), `line_no`, `pricelist_id`, `warehouse_pricelist_id`, `competitor_pricelist_id`, `current_version_id`, `sync_status` |
|
||||
| `local_configuration_versions` | Immutable snapshots (revisions) | `id`, `configuration_id` (FK), `version_no`, `data` (JSON), `change_note`, `created_at` |
|
||||
| `local_projects` | Projects | `id`, `uuid` (unique), `name`, `code`, `sync_status` |
|
||||
|
||||
|
||||
126
bible/09-vendor-spec.md
Normal file
126
bible/09-vendor-spec.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user