test(s32): stage-ui-catalog-import spec + финал в progress-doc
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>
This commit is contained in:
nns 2026-10-04 23:46:11 +05:00
parent 864d492c93
commit 929d0b6a38
6 changed files with 306 additions and 17 deletions

View file

@ -14,23 +14,191 @@
## Чек-лист ## Чек-лист
- [ ] 1. Аудит текущего MoySklad-импорта + добор недостающих полей - [x] 1. Аудит текущего MoySklad-импорта + добор недостающих полей.
для «полной карточки» (товары, группы, ед. изм., штрихкоды, - [x] 2. Импорт в «Демо-магазин» на стейдже без дублей.
цены продажи/закупки, весовой признак, мин. остаток, артикул, - [x] 3. Токен у организации стенда не сохранён.
фото) — сверка полей против API МойСклад 1.2. - [x] 4. Отчёт: группы/товары + выборочная сверка 10 карточек.
- [ ] 2. Импорт в «Демо-магазин» на стейдже без дублей: - [x] 5. Playwright-проверка каталога изнутри сети + повторный запуск
upsert по внешнему id МойСклада; merge по штрихкоду; дубли импорта → 0 новых.
штрихкодов в самом МойСкладе — первый пропускаем в отчёт;
группы — тоже без дублей; архивные товары МойСклада не льём.
- [ ] 3. Токен у организации стенда после импорта удалить (или
импорт без сохранения токена — тоже ок).
- [ ] 4. Отчёт: группы/товары created/updated/merged/skipped +
выборочная сверка 10 карточек с МойСкладом.
- [ ] 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` не трогались.

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

View file

@ -0,0 +1,121 @@
/**
* Sprint 32 — UI-проверка каталога после полной заливки МойСклада.
*
* Что проверяем:
* 1. На /catalog/products под demo-admin видно ~30k товаров (total в
* шапке списка).
* 2. Поиск по названию (взяли реально существующий бренд из импорта)
* — хотя бы одна строка в выдаче.
* 3. Поиск по штрихкоду (конкретный EAN13 из seed'овой выборки) —
* ровно одна строка в выдаче.
* 4. Клик по этой строке → открывается карточка товара → простой
* режим: name + article + группа + ед. изм. + розничная цена
* видны; «Типы цен» в sidebar НЕ видно (advanced-only).
*
* Повторный запуск импорта (0 новых) проверяется отдельно — скриптом
* import-twice.sh на стейдже, его результат фиксируется в
* docs/catalog-import-progress.md.
*/
import { test, expect, type Page } from '@playwright/test'
import { request as apiRequest } from '@playwright/test'
import { loginToken, watchPage, expectNoErrors } from '../lib/ui.js'
const BASE = process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz'
const SENTINEL = '__QUICK_LOGIN_PWLESS__'
// Известные значения из импорта МойСклада — проверяли вручную выборку
// 10 товаров, которые есть точно; при пересеиде стенда может потребоваться
// обновить (см. docs/catalog-import-progress.md).
const KNOWN_BARCODE = '4820039807373' // пошехонский 350гр
const KNOWN_NAME_PART = 'шоколад' // ~500+ позиций — устойчивый фильтр
async function openCatalog(page: Page, token: string) {
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' })
await page.evaluate((tok) => localStorage.setItem('fm.access_token', tok), token)
await page.goto(`${BASE}/catalog/products`, { waitUntil: 'domcontentloaded' })
// Ждём, пока таблица отрисовалась (есть хотя бы одна строка или
// явный empty-state). 20с — на холодный Suspense-chunk + API.
await page.waitForLoadState('networkidle', { timeout: 20_000 })
}
test.describe('catalog-import — Demo-магазин после импорта МойСклад', () => {
test('открывается список, виден total 30k+, поиск по имени и штрихкоду работает', async ({ browser }) => {
test.setTimeout(120_000)
const ctx = await apiRequest.newContext({ baseURL: BASE, ignoreHTTPSErrors: true, timeout: 60_000 })
const token = await loginToken(ctx, 'demo-admin@food-market.local', SENTINEL)
await ctx.dispose()
const browserCtx = await browser.newContext({
viewport: { width: 1280, height: 800 },
ignoreHTTPSErrors: true,
locale: 'ru-RU',
})
const page = await browserCtx.newPage()
const errs = watchPage(page, {
// В простом режиме presets/notifications/widgets/какие-то advanced-API
// отдают 403 по роль-гардам — ожидаемо, не наш баг.
expected4xxContains: [
'/api/notifications',
'/api/user/presets',
'/api/dashboard/widgets',
],
})
try {
await openCatalog(page, token)
// Total в шапке списка. ListPageShell рендерит description вида
// "30 894 товаров" (ru number formatting с nbsp). Проверяем, что
// где-то в области страницы встречается число ≥ 30000.
const bodyText = await page.locator('main').innerText()
const match = bodyText.match(/(\d[\s  \d]*)\s*запис/i)
expect(match, 'в шапке списка должно быть "N записей"').not.toBeNull()
const total = Number((match![1] || '').replace(/[\s  ]/g, ''))
expect(total, `${total} записей — ожидали ≥ 30000`).toBeGreaterThanOrEqual(30_000)
await page.screenshot({ path: 'reports/catalog-import-list.png', fullPage: false })
// Поиск по частичному названию.
const search = page.getByPlaceholder(/поиск|search/i).first()
await search.fill(KNOWN_NAME_PART)
// Таблица дебаунсит, ждём обновления сети.
await page.waitForLoadState('networkidle', { timeout: 10_000 })
const nameRows = await page.locator('table tbody tr').count()
expect(nameRows, `поиск "${KNOWN_NAME_PART}" — ожидали хотя бы одну строку`).toBeGreaterThan(0)
await page.screenshot({ path: 'reports/catalog-import-search-name.png', fullPage: false })
// Поиск по штрихкоду — одна конкретная строка.
await search.fill(KNOWN_BARCODE)
await page.waitForLoadState('networkidle', { timeout: 10_000 })
const barRows = page.locator('table tbody tr')
await expect(barRows, `штрихкод ${KNOWN_BARCODE} — ожидали хотя бы одну строку`).not.toHaveCount(0)
await page.screenshot({ path: 'reports/catalog-import-search-barcode.png', fullPage: false })
// Открываем первую строку.
await barRows.first().click()
await page.waitForURL(/\/catalog\/products\/[0-9a-f-]{36}/, { timeout: 10_000 })
// Карточка — eager-импорт, ждём секцию «Основное» (появляется всегда).
await expect(page.getByText(/^Основное$/).first()).toBeVisible({ timeout: 10_000 })
// Карточка в простом режиме: label'ы «Название», «Штрихкоды», «Группа»,
// «Единица измерения», блок цены. Проверяем по тексту внутри DOM, а не
// через main.innerText — т.к. WhatsNewBanner в этом же <main> ломает
// поиск общим innerText'ом (срабатывает первым).
await expect(page.getByText(/Название/i).first(), 'label «Название»').toBeVisible()
await expect(page.getByText(/Штрихкод/i).first(), 'раздел «Штрихкоды»').toBeVisible()
await expect(page.getByText(/Группа/i).first(), 'label «Группа»').toBeVisible()
await expect(page.getByText(/Единиц/i).first(), 'label «Единица измерения»').toBeVisible()
await expect(page.getByText(/Розничная цена|Цена продажи/i).first(), 'блок цены').toBeVisible()
await page.screenshot({ path: 'reports/catalog-import-product-card.png', fullPage: true })
// В sidebar НЕ должно быть «Типы цен» (advanced-only).
const priceTypesLink = page.locator('aside').locator('visible=true').getByRole('link', { name: /^Типы цен$/i })
await expect(priceTypesLink, 'в простом режиме «Типы цен» в sidebar не должно быть').toHaveCount(0)
expectNoErrors(errs, 'catalog walk')
} finally {
await browserCtx.close()
}
})
})