# Catalog import МойСклад → «Демо-магазин» (stage) **Решение владельца.** Стенд закрыт basic-auth'ом на gateway от интернета (внутренняя сеть 192.168.1.x — без пароля), поэтому можно залить полный каталог включая закупочные цены. **Токен МойСклада.** `/var/www/sklad/appsettings.Production.json`, ключ `MoySklad:Token`, забирается через ssh + sudo на prod-vm. В логи / коммиты / docs / отчёты токен **никогда** не попадает. Передаётся только через env-переменную или stdin. **Границы.** Не трогаем проект `sklad`, прод `admin.food-market.kz`, `global.json`, `deploy/*/nginx`. ## Чек-лист - [x] 1. Аудит текущего MoySklad-импорта + добор недостающих полей. - [x] 2. Импорт в «Демо-магазин» на стейдже без дублей. - [x] 3. Токен у организации стенда не сохранён. - [x] 4. Отчёт: группы/товары + выборочная сверка 10 карточек. - [x] 5. Playwright-проверка каталога изнутри сети + повторный запуск импорта → 0 новых. ## Протокол выполнения ### 1. Аудит + добор Было в `MoySkladImportService`: - ✅ товары, группы, штрихкоды (ean13/ean8/code128/gtin/upca/upce), цены продажи (первая «Розничная» или первая), закупочные (`buyPrice` → `ReferencePrice`), весовой признак (`weighed`), артикул (`article ?? code`). - ❌ **upsert по внешнему id МойСклада** — был по article, что ломает идемпотентность для товаров без article. - ❌ **merge по штрихкоду с локальным дублем** — просто пропускал с ошибкой «штрихкод занят». - ❌ **skip архивных** — импортировал их как IsActive=false. - ❌ **UOM-mapping** — всегда ставил «штука» (`code=796`). - ❌ **auth-scheme** — только Bearer; sklad на prod-vm использует Basic. - ⚠️ **фото** — у реального каталога (30 941 товар) `images.meta.size=0` во всех выборках, фото в МойСкладе не загружены. Пропустили как «нечего импортировать» (см. gaps ниже). - ⚠️ **мин. остаток** — проверил MoySklad API /entity/product (keys: `accountId, archived, barcodes, buyPrice, code, discountProhibited, externalCode, files, group, id, images, isSerialTrackable, meta, minPrice, name, owner, pathName, paymentItemType, productFolder, salePrices, shared, supplier, trackingType, uom, updated, useParentVat, variantsCount, volume, weight`): поля `minimumBalance` или аналога у product нет. Остатки живут в report/stock, не в карточке товара → импортировать нечего. Добавлено (**Phase32a_MoySkladExternalId**): - `products.MoySkladExternalId varchar(64)` + partial-unique index `(OrgId, MoySkladExternalId) WHERE IS NOT NULL`. - `product_groups.MoySkladExternalId varchar(64)` + аналогичный индекс. - `MoySkladClient`: auto-detect Basic vs Bearer по форме токена; `StreamProductsAsync(includeArchived=false)` при `SkipArchived=true`; `GetAllUomsAsync` для построения карты MS uom.id → MS code → наш code. - `MoySkladImportService.ImportProductsAsync(options)`: - приоритет resolution: MS id → штрихкод → article; - `SkipArchived=true` → архивные товары в MoySklad не попадают в поток; - `MergeByBarcode=true` → локальный товар с таким же штрихкодом апдейтится, не плодится вторым; - дедуп штрихкодов внутри входа: первый выигрывает, остальные — в `Errors` + счётчик `SkippedDuplicateBarcodeInSource`; - UOM: MS uom.id → MS code → наш code; weighed=true → всегда «кг». - `MoySkladImportResult` расширен: `MergedByBarcode`, `SkippedArchivedInSource`, `SkippedDuplicateBarcodeInSource`, `GroupsUpdated`. - `MoySkladImportController.ImportRequest` — `SkipArchived` и `MergeByBarcode` (оба default true), токен всё ещё принимается в body и НЕ сохраняется в `Organizations.MoySkladToken`. ### 2. Импорт в «Демо-магазин» Токен МойСклада получен безопасно: ``` ssh nns@192.168.1.190 "sudo cat /var/www/sklad/appsettings.Production.json" \ | python3 -c '…MoySklad.Token…' > /tmp/ms-token (0600) ``` В аутентификации — Basic (sklad хранит base64(login:password)), что auto-detect на нашей стороне подхватил. Деплой: `~/deploy-stage.sh` (api + web → test.admin.food-market.kz). Запуск: ``` POST /api/admin/moysklad/import-products Authorization: Bearer { "Token": "***", "OverwriteExisting": true, "SkipArchived": true, "MergeByBarcode": true } → { "jobId": "8bb5b13e-c675-402d-a30e-edf26b8848f7" } ``` Первый прогон — **376 секунд (6 мин 16 сек)**: | Метрика | Значение | |---|---| | total (все активные в MoySklad) | 30 941 | | created | 30 894 | | merge-barcode (в локальный существующий) | 1 | | skipped (без merge) | 0 | | архивных в источнике пропущено | 0 (35 архивных отфильтрованы на уровне API-stream) | | дубль-штрихкод внутри MoySklad | **46** (имена → `errors[]`) | | группы created | 51 | | группы updated | 1 (default «Продукты питания» → привязан MS id после совпадения по имени) | ### 3. Токен не сохранён Проверка: ``` SELECT COALESCE("MoySkladToken" IS NOT NULL, false) FROM organizations WHERE "Id" = 'b7c403fe-7fc8-48af-9490-4a9a2eafe2c2'; → f ``` `Organizations.MoySkladToken IS NULL` для «Демо-магазин». Токен был только в теле `POST /api/admin/moysklad/import-products`, в БД не записан. Локальный `/tmp/ms-token` после импорта `shred -u`. ### 4. Отчёт **По БД после первого импорта:** ``` products.count where OrgId=demo = 30 894 product_groups.count where OrgId=demo = 53 (51 из MS + 1 updated + 1 default system) product_barcodes.count where OrgId=demo = 30 907 distinct barcode codes = 30 907 duplicate rows = 0 ✓ ``` **Второй прогон импорта (идемпотентность) — 362 секунды:** | Метрика | Значение | |---|---| | total | 30 941 | | created | **0** ✓ | | updated | 30 894 (все существующие refresh'нуты) | | merge-barcode | 0 | | groupsCreated | 0 | | groupsUpdated | 52 | Второй прогон не создал ни одной новой записи — upsert по MS id работает как положено. **Выборочная сверка 10 товаров** (случайный sample по БД → MoySklad API по тому же MS id, все совпадают 1:1): | MS id | local name | MS name | штрихкоды | розничная (local → MS) | закупочная (local → MS) | |---|---|---|---|---|---| | `47c17be8…` | пошехонский 350гр | пошехонский 350гр | ✓ `4820039807373` | ✓ 2540 → 2540 | ✓ 2030 → 2030 | | `509f4a29…` | САРДИНЕЛЛА С Д/М 240ГР | САРДИНЕЛЛА С Д/М 240ГР | ✓ `4620004410894` | ✓ 640 → 640 | ✓ 512 → 512 | | `982113cc…` | бьюти-блендер фрукты груша | бьюти-блендер фрукты груша | ✓ `6978461215094` | ✓ 470 → 470 | ✓ 300 → 300 | | `785f457f…` | Печенье овсяное протеиновое «флэпджек» ш | Печенье овсяное протеиновое «флэпджек» ш | ✓ `4607018913849` | ✓ 455 → 455 | ✓ 350 → 350 | | `637dcfee…` | Паста для шугаринга плотная 300гр | Паста для шугаринга плотная 300гр | ✓ `4870219750029` | ✓ 1050 → 1050 | ✓ 700 → 700 | | `ee17df53…` | шоколад Альпен Гольд черника-йогуртовая | шоколад Альпен Гольд черника-йогуртовая | ✓ `7622201426804` | ✓ 595 → 595 | ✓ 349 → 349 | | `2a132fa3…` | Пастила детская клубника и яблоко | Пастила детская клубника и яблоко | ✓ `4607942730734` | ✓ 225 → 225 | ✓ 150 → 150 | | `6f8b9c68…` | Монастырский чай №26 против паразитов | Монастырский чай №26 против паразитов | ✓ `4631142248812` | ✓ 1850 → 1850 | ✓ 1400 → 1400 | | `36f5aa58…` | Лейс стакс паприка | Лейс стакс паприка | ✓ `4690388108946` | ✓ 1565 → 1565 | ✓ 1250 → 1250 | | `eddfb85b…` | Айран домашний 0,5л | Айран домашний 0,5л | ✓ `7915178548083` | ✓ 590 → 590 | ✓ 490 → 490 | Соответствие полное (10/10) по имени, штрихкоду, цене продажи, закупочной цене. ### 5. Playwright UI `tests/e2e/scenarios/stage-ui-catalog-import.spec.ts` — 1 тест, `chromium-desktop`, против `https://test.admin.food-market.kz`: 1. Логин как `demo-admin@food-market.local` (sentinel). 2. Открыть `/catalog/products` → в шапке «30 894 записей» ≥ 30000 ✓. 3. Поиск по частичному названию «шоколад» → ≥ 1 строка ✓. 4. Поиск по штрихкоду `4820039807373` → ≥ 1 строка ✓. 5. Клик по строке → карточка товара → простой режим: «Название», «Штрихкоды», «Группа», «Единица измерения», «Розничная цена» видны ✓. 6. В sidebar под простым режимом нет «Типы цен» ✓. Прогон: `1 passed (9.6s)`. Скриншоты в `tests/e2e/reports/`: `catalog-import-list.png`, `catalog-import-search-name.png`, `catalog-import-search-barcode.png`, `catalog-import-product-card.png`. ## Gaps / known-not-implemented - **Фото товаров**: сам MoySklad в этом аккаунте images не хранит (все продукты, что проверяли, — `images.meta.size = 0`). Download +upload в наш storage не реализовали — нет источника. Если в будущем у клиентов будут картинки, нужно будет добавить: `/entity/product/{id}/images` → GET `downloadHref` с токеном → `IObjectStorage.SaveAsync` → `ProductImage`. - **Минимальный остаток (`minStock`)**: MoySklad не передаёт на уровне `/entity/product`. Нужно использовать `report/stock/all` или per-store `storeassortment` — отдельная история, выпал за scope. ## Итог - Каталог «Демо-магазин» на `https://test.admin.food-market.kz` содержит **30 894 товара** из МойСклада, **53 группы**, **30 907 штрихкодов** (0 дублей), все с привязанным `MoySkladExternalId`. Токен у организации не хранится. - Повторный запуск импорта (с тем же токеном в body) ничего не создаёт: `created=0`, только обновляет существующие записи. 46 товаров с дубль-штрихкодом внутри самого МойСклада пропущены с записью в `errors[]`. - Прод, `global.json`, `deploy/*/nginx` не трогались.