food-market/docs/catalog-import-progress.md
nns 929d0b6a38
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
test(s32): stage-ui-catalog-import spec + финал в progress-doc
Playwright 1/1 passed против test.admin.food-market.kz после полной
заливки МойСклада в Демо-магазин:

- /catalog/products под demo-admin — 30 894 записей в шапке списка.
- Поиск по частичному названию «шоколад» → хотя бы одна строка.
- Поиск по штрихкоду 4820039807373 → хотя бы одна строка; клик →
  карточка товара в простом режиме (Название, Штрихкоды, Группа,
  Единица измерения, Розничная цена видны; «Типы цен» в sidebar нет).

Второй прогон импорта с тем же токеном в теле запроса → created=0,
updated=30894 (все существующие refresh'нуты). Идемпотентность по
MoySkladExternalId подтверждена.

Выборочная сверка 10 случайных товаров с МойСкладом (имя, штрихкоды,
цена продажи, закупочная) — 10/10 совпали ровно.

Token checks:
- Organizations.MoySkladToken IS NULL для «Демо-магазин» (токен
  никогда не сохранялся в БД, передавался только в теле запроса).
- /tmp/ms-token после импорта shred -u — локально ничего не осталось.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 23:46:11 +05:00

205 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 <demo-admin JWT>
{ "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` не трогались.