feat: PN code в импорте BOM, объединённый список LOT (world ∪ estimate)

Колонка «PN code» в таблице импорта BOM: HPE-спецификации хранят партномер
в двух столбцах, канонический вид — P52534-B21#B19. Склейка попадает
в vendor_partnumber и сравнивается с книгой партномеров как есть; при
загрузке разбирается обратно на две колонки. Схему БД менять не пришлось.

Список допустимых LOT собирается из world ∪ estimate (componentUniverse),
а не только из estimate. Прежняя область видимости молча теряла
lot_mappings: резолвер сопоставлял PN по книге, которая про прайслисты
не знает, а фронт отбрасывал LOT, которого нет в estimate — при этом
в корзину он всё равно попадал. World-only позиции отдают price_quality 0.

Кнопка «Пересопоставить» — перерешать BOM по актуальной книге, так как
при открытии конфигурации сопоставления остаются замороженными.

Итоги на вкладке «Ценообразование»: звёздочки убраны (переносились
на новую строку), все три суммы красные, при наведении — попап с долей
цен из прайслиста WORLD. Колонка «PN вендора» больше не переносится.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-07-24 17:01:31 +03:00
co-authored by Claude Opus 4.8
parent 0fa7b0b1b6
commit 905f9a4952
7 changed files with 547 additions and 119 deletions
+1 -1
View File
@@ -261,7 +261,7 @@ PK: lot_name
| price | decimal(12,2) NOT NULL | |
| price_quality | tinyint unsigned, nullable | added by migration 033. Set by the external pricelist-building tool, 0-9, based on quote recency/count per its own per-lot pricing-period settings — QF only reads and displays it, never computes it. |
`price_quality` is synced through `LocalPricelistItem` (pricelist detail page) and, separately, through `LocalComponent`/`services.ComponentView` (`/api/components`, used by the configurator's search dropdown and item table) — both read paths go through `pricelistItemRow`/`toLocalComponent()` in `internal/localdb/components.go`, still scoped to the currently selected pricelist. The color scale (red 0 → yellow 5 → green 9, gradient) lives in **one shared JS module**, `web/static/price-quality.js` (loaded by `base.html` for every page) — `priceQualityColor`/`qualityDotHtml`/`qualityBadgeHtml`/`qualityRowStyle`. Do not reimplement the color scale locally in a template; add a new helper to that module instead. Used in: pricelist detail "Качество" column, configurator search dropdown (colored dot), configurator table (leftmost quality-dot column), pricing tab (row background tint).
`price_quality` is synced through `LocalPricelistItem` (pricelist detail page) and, separately, through `LocalComponent`/`services.ComponentView` (`/api/components`, used by the configurator's search dropdown and item table) — both read paths go through `pricelistItemRow`/`toLocalComponent()` in `internal/localdb/components.go`. The `/api/components` path reads the component universe (`world` `estimate`, see [decisions/2026-07-24-component-universe-world-union.md](decisions/2026-07-24-component-universe-world-union.md)), where a world-only LOT is forced to `price_quality = 0` — the only place QF writes this field rather than reading it. The color scale (red 0 → yellow 5 → green 9, gradient) lives in **one shared JS module**, `web/static/price-quality.js` (loaded by `base.html` for every page) — `priceQualityColor`/`qualityDotHtml`/`qualityBadgeHtml`/`qualityRowStyle`. Do not reimplement the color scale locally in a template; add a new helper to that module instead. Used in: pricelist detail "Качество" column, configurator search dropdown (colored dot), configurator table (leftmost quality-dot column), pricing tab (row background tint).
The real table also has `price_method`, `price_period_days`, `price_coefficient`, `manual_price`, `meta_prices` and `lead_time_weeks` columns, owned and written by the external pricing engine that maintains `qt_pricelist_items`**keep them in the table for backward compatibility with that system; QF must never drop, rename, or write to them.** QF itself does not model, sync, or display any of them (tried once, removed as unused/dead in the client). Do not re-add them to `models.PricelistItem`/`LocalPricelistItem` without a concrete UI need.
+27
View File
@@ -25,6 +25,33 @@ Rules:
- QuoteForge does not use legacy BOM tables;
- apply flow rebuilds cart rows from `lot_mappings[]`.
## Split partnumbers (HPE option codes)
HPE specs carry the partnumber in two columns: base PN plus an option code
(`P52534-B21` + `B19`). The canonical partnumber is the concatenation
`P52534-B21#B19`, and it is what gets compared against the partnumber book.
Rules:
- the import grid has a `PN code` column type in addition to `P/N`; it is optional
and at most one column may carry it;
- an empty code cell keeps the bare PN — no trailing `#`;
- the composed value is stored in `vendor_partnumber`. There is **no separate
`pn_code` field**: no DDL and no new JSON key, and the stored string is exactly
the string the resolver looks up;
- the column layout of the import grid is not persisted. On load the grid is
re-derived from `vendor_spec`, and the `PN code` column reappears only when at
least one stored partnumber contains `#`, split on the first `#`.
## Re-resolving an existing BOM
Opening a configuration does **not** re-resolve its BOM: `loadVendorSpec` renders the
`lot_mappings[]` frozen at save time, so a configuration keeps the mapping it was
saved with even after the partnumber book changes.
Book entries added later are picked up only through the `Пересопоставить` button,
which calls the resolve endpoint for every row. Book matches win over the stored
mapping (resolver step 1 beats step 2); rows the book does not know keep theirs.
## Partnumber books
Partnumber books are pull-only snapshots from PriceForge.
@@ -0,0 +1,57 @@
# Decision: the component universe is `world` `estimate`, not `estimate` alone
**Date:** 2026-07-24
**Status:** active
## Context
Every component read path in `internal/localdb/components.go` was scoped to the latest
active `estimate` pricelist. That pricelist is therefore what defined the set of LOTs the
configurator considers to exist, and the frontend used it as its validation list
(`_bomLotValid` in `index.html`, fed by `/api/components`).
That silently broke BOM mappings. The resolver matches a vendor partnumber against the
partnumber book, which knows nothing about pricelists, so it legitimately returns LOTs the
estimate pricelist does not carry. The frontend then displayed such a row as mapped —
`_getRowBaseLot` does not validate `resolved_lot` — and `applyBOMToEstimate` pushed the LOT
into the cart, but `_getRowCanonicalLotMappings` filtered it out through `_bomLotValid`, so
the mapping vanished on save. Result: the LOT sat in the cart while its BOM row showed
"н/д", and the pricing tab listed it at the bottom as an orphan with an empty vendor PN.
Prices for such LOTs were already available — see
[2026-07-10-world-pricelist-fallback.md](2026-07-10-world-pricelist-fallback.md) — only
membership was missing.
## Decision
`componentUniverse()` in `internal/localdb/components.go` is the single source of the LOT
set, and every component read path goes through it: `ListComponents`,
`SearchLocalComponents`, `SearchLocalComponentsByCategory`, `GetLocalComponent`,
`GetLocalComponentCategories`, `GetLocalComponentCategoriesByLotNames`, `CountComponents`.
- The universe is the latest active `world` pricelist — the widest list available — plus
the latest active `estimate` pricelist as a safety net.
- Deduplication is on `UPPER(lot_name)`. The estimate row wins a collision, so its
category, description and `price_quality` stay authoritative and nothing changes for
LOTs that were already visible.
- A world-only row reports `price_quality = 0`, rendering at the red end of the shared
scale in `web/static/price-quality.js`. This is the **one** place QF sets that field
instead of only reading it (contrast `bible-local/03-database.md`), and it deliberately
overrides whatever the external pricing tool wrote on the world row.
- The helper always returns a derived table (`(?) AS c`), so callers apply their own
`Select`/`Count`/`Where` without knowing which pricelists exist.
- Either pricelist may be absent; only both missing is an error.
On the frontend, `_bomLotPersistable` additionally lets a LOT with
`resolution_source === 'book'` be persisted into `lot_mappings[]` even when it is in
neither pricelist. Hand-typed LOTs stay validated against the universe.
## Consequences
- The configurator's pickers, category list and search now surface world-only LOTs. They
are selectable and appear in the reddest quality colour.
- `CountComponents` — the "is there data" check — counts the union.
- A LOT present in neither pricelist (nothing to price it with) still resolves from the
book and now survives a save, instead of being dropped without a message.
- Covered by `internal/localdb/component_universe_test.go`: union and dedup, estimate
winning a collision, forced quality 0, and each single-source / empty case.