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
@@ -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.