# Vendor Mapping (Сопоставление partnumber → LOT) > Решение зафиксировано: 2026-02-18 ## Концепция `lot_partnumbers` — канонический контракт сопоставления для внешнего конфигуратора. Маппинг строго 1:1 по ключу `(vendor, partnumber)` → `lot_name`. --- ## Правила ### 1. Единственная запись на ключ Запрещено создавать несколько строк для одного ключа `(vendor, partnumber)`. ### 2. Порядок резолвинга (фиксированный) ``` 1. Точное совпадение: vendor + partnumber → lot_name 2. Fallback: vendor='' + partnumber → lot_name ``` Резолвер: `internal/lotmatch/matcher.go` ### 3. Составные маппинги — через бандлы Если один внешний partnumber соответствует нескольким LOT — использовать внутренние бандл-таблицы: ``` lot_partnumbers: (vendor, partnumber) → bundle_lot_name qt_lot_bundles: bundle_lot_name → [item1, item2, ...] qt_lot_bundle_items: bundle_lot_name, lot_name, qty ``` - Bundle LOT — внутренний, скрыт в обычном UI LOT по умолчанию. - Bundle expansion происходит в PriceForge: при расчёте warehouse-прайслиста и при создании partnumber book snapshot. ### 4. Ignore-логика - **Не использовать** `stock_ignore_rules` для новой логики. - Использовать `qt_vendor_partnumber_seen.is_ignored`. - `qt_vendor_partnumber_seen` хранится в формате **1 строка на partnumber** (vendor/source не участвуют в уникальности). - Ignore применяется по `partnumber` (одинаково для записей с vendor и без vendor). ### 5. Клиентская совместимость - Клиент потребляет LOT-based прайслисты (как обычно). - Bundle expansion/allocation происходит только внутри PriceForge. --- ## Allocation при отсутствии estimate Если у bundle-компонента нет estimate-цены: 1. Fallback: взять из предыдущего активного warehouse-прайслиста. 2. Если нет предыдущего — цена `0`. --- ## Таблицы БД | Таблица | Назначение | |---------|------------| | `lot_partnumbers` | Канонические маппинги `(vendor, partnumber)` → `lot_name`; колонка `is_primary_pn` — ведущий PN для qty в BOM | | `qt_lot_bundles` | Определения бандлов (bundle LOT → описание) | | `qt_lot_bundle_items` | Состав бандла: `(bundle_lot_name, lot_name, qty)` | | `qt_vendor_partnumber_seen` | Реестр seen-записей (уникально по `partnumber`) + флаг `is_ignored` | | `qt_partnumber_books` | Версионированные снимки маппинга для QuoteForge (пишет PriceForge) | | `qt_partnumber_book_items` | Строки снимка: `(book_id, partnumber, lot_name, is_primary_pn, description)`; бандлы развёрнуты; пустые lot_name и ignored PN исключены | Миграции: - `migrations/023_vendor_partnumber_global_mapping.sql` - `migrations/025_dedup_vendor_seen_by_partnumber.sql` - `migrations/026_add_partnumber_books.sql` - `migrations/027_fix_partnumber_books_version_length.sql` - `migrations/028_add_description_to_partnumber_book_items.sql` --- ## Partnumber Book — инварианты снимка При формировании `qt_partnumber_book_items` обязательно: 1. **Пустой `lot_name` исключается** — `TRIM(lot_name) = ''` или NULL не попадают в снимок. 2. **Ignored PN исключаются** — партномера с `qt_vendor_partnumber_seen.is_ignored = true` не попадают в снимок. 3. **Бандлы разворачиваются** — каждая строка содержит конечный `lot_name` компонента, не имя бандла. 4. **`description`** берётся из `lot_partnumbers.description`; для развёрнутых бандлов — из родительской записи partnumber. 5. **Удаление items при retention** — явное (`DELETE WHERE book_id IN (...)`), не через FK CASCADE. --- ## Связанные модули | Роль | Файл | |------|------| | Миграция | `migrations/023_vendor_partnumber_global_mapping.sql` | | Миграция | `migrations/025_dedup_vendor_seen_by_partnumber.sql` | | Миграции снимков | `migrations/026–028` | | Резолвер | `internal/lotmatch/matcher.go` | | Сервис маппингов | `internal/services/vendor_mapping.go` | | Сервис снимков | `internal/services/partnumber_book.go` | | API | `internal/handlers/pricing.go` | | Warehouse calc | `internal/warehouse/snapshot.go` | | Stock import seen/ignore | `internal/services/stock_import.go` | | Модели | `internal/models/lot.go`, `internal/models/configuration.go` | | Роутинг | `cmd/pfs/main.go` |