# Vendor Mapping (Сопоставление partnumber → LOT) > Решение зафиксировано: 2026-02-18 ## Концепция `qt_partnumber_book_items` — канонический контракт сопоставления для внешнего конфигуратора. Одна строка на `partnumber`; состав хранится в `lots_json` как список `{lot_name, qty}`. --- ## Правила ### 1. Единственная запись на PN Запрещено создавать несколько строк для одного `partnumber` в `qt_partnumber_book_items`. ### 2. Резолвинг - Сначала точное совпадение по `partnumber` в `qt_partnumber_book_items`. - Если состав PN содержит несколько LOT, резолвер возвращает весь список `{lot_name, qty}`. - Vendor хранится как metadata в `qt_vendor_partnumber_seen`; канонический vendor-aware mapping в БД отсутствует. ### 4. Ignore-логика - **Не использовать** `stock_ignore_rules` для новой логики. - Использовать `qt_vendor_partnumber_seen.is_ignored`. - `qt_vendor_partnumber_seen` хранится в формате **1 строка на partnumber** (vendor/source не участвуют в уникальности). - Ignore применяется по `partnumber` (одинаково для записей с vendor и без vendor). - Если внешняя система ошибочно записала в `qt_vendor_partnumber_seen.partnumber` значение, равное `lot.lot_name`, такая строка считается мусором и не должна попадать в Global Vendor Mappings UI. Такие seen-строки подлежат очистке, если для этого значения нет явной строки в `qt_partnumber_book_items`. --- ## Таблицы БД | Таблица | Назначение | |---------|------------| | `qt_vendor_partnumber_seen` | Реестр seen-записей (уникально по `partnumber`) + флаг `is_ignored` | | `qt_partnumber_books` | Версионированные книги PN; каждая книга хранит `partnumbers_json` — список PN, входящих в книгу | | `qt_partnumber_book_items` | Глобальный source-of-truth каталог `partnumber -> lots_json`; без дубликатов по `partnumber`; `lots_json` хранит список `{lot_name, qty}` | Миграции: - `migrations/023_vendor_partnumber_global_mapping.sql` (historical) - `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` - `migrations/031_drop_is_primary_pn.sql` - `migrations/032_drop_legacy_vendor_mapping_tables.sql` --- ## Partnumber Book — инварианты снимка При формировании partnumber book обязательно: 1. **`qt_partnumber_book_items` содержит одну строку на `partnumber`** — дубликаты по PN не допускаются. 2. **`lots_json`** содержит полный состав PN как JSON-массив объектов `{lot_name, qty}`. 3. **Ignored PN исключаются** — партномера с `qt_vendor_partnumber_seen.is_ignored = true` не попадают ни в каталог, ни в `partnumbers_json` книги. 4. **Многокомпонентные PN не разворачиваются в отдельные строки** — PN сохраняется одной записью, а состав компонентов уходит в `lots_json`. 5. **`description`** берётся из `qt_partnumber_book_items.description`. 6. **`qt_partnumber_books.partnumbers_json`** хранит отсортированный список PN, входящих в конкретную книгу. 7. **Конфликт разных составов для одного PN недопустим** — если при построении книги один и тот же PN даёт разные `lots_json`, создание snapshot должно завершиться ошибкой. ## QuoteForge Read Contract QuoteForge must read the active book header first: ```sql SELECT id, version, created_at, created_by, partnumbers_json FROM qt_partnumber_books WHERE is_active = 1 ORDER BY created_at DESC, id DESC LIMIT 1; ``` Then load item payloads from the catalog by PN list: ```sql SELECT partnumber, lots_json, description FROM qt_partnumber_book_items WHERE partnumber IN (...PNs from partnumbers_json...); ``` Contract notes: - `partnumbers_json` is the membership snapshot of the selected book. - `qt_partnumber_book_items` is the current canonical catalog of PN compositions. - `lots_json` format is JSON array: `[{"lot_name":"CPU_X","qty":2},{"lot_name":"RAM_X","qty":4}]` - QuoteForge must not expect `lot_name` or `is_primary_pn` columns in `qt_partnumber_book_items`. --- ## Связанные модули | Роль | Файл | |------|------| | Миграция | `migrations/025_dedup_vendor_seen_by_partnumber.sql` | | Миграции снимков | `migrations/026–032` | | Резолвер | `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` | --- ## CSV Import / Export (Global Vendor Mappings UI) - Формат CSV для Excel (RU locale): разделитель `;` - Кодировка: `UTF-8` (BOM допускается и поддерживается) - Колонки: `vendor;partnumber;lot_name;qty;description;ignore` - Допустим импорт как с заголовком, так и без заголовка (в фиксированном порядке колонок выше) - Пустые строки пропускаются - Для строки обязательны `partnumber` и (`lot_name` или заполненный `ignore`) - Если `ignore` заполнен и `lot_name` пустой, строка помечается как ignored в `qt_vendor_partnumber_seen` ### Маппинг один p/n → несколько lot Один p/n можно привязать к нескольким lot через несколько строк с одинаковым `partnumber`. Строки аккумулируются, затем сохраняются как один `lots_json` (upsert). ``` vendor;partnumber;lot_name;qty;description;ignore Intel;E-2336;Server_Intel;1;Процессор Intel; Intel;E-2336;Server_AMD;2;; Samsung;MZ7L3480HCHQ-00A07;SSD_480GB;;; ``` - `qty` необязательно; если пусто или 0 — считается `1` - `description` берётся из первой строки p/n - Каждый повторный импорт того же p/n **перезаписывает** весь `lots_json` ### Экспорт незамапленных p/n Кнопка «Выгрузить незамапленные» генерирует CSV в том же формате с пустыми `lot_name` и `qty` — для заполнения пользователем и последующего импорта.