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
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>
205 lines
12 KiB
Markdown
205 lines
12 KiB
Markdown
# 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` не трогались.
|
||
|