diff --git a/docs/catalog-import-progress.md b/docs/catalog-import-progress.md index 5580c4d..a884969 100644 --- a/docs/catalog-import-progress.md +++ b/docs/catalog-import-progress.md @@ -14,23 +14,191 @@ ## Чек-лист -- [ ] 1. Аудит текущего MoySklad-импорта + добор недостающих полей - для «полной карточки» (товары, группы, ед. изм., штрихкоды, - цены продажи/закупки, весовой признак, мин. остаток, артикул, - фото) — сверка полей против API МойСклад 1.2. -- [ ] 2. Импорт в «Демо-магазин» на стейдже без дублей: - upsert по внешнему id МойСклада; merge по штрихкоду; дубли - штрихкодов в самом МойСкладе — первый пропускаем в отчёт; - группы — тоже без дублей; архивные товары МойСклада не льём. -- [ ] 3. Токен у организации стенда после импорта удалить (или - импорт без сохранения токена — тоже ок). -- [ ] 4. Отчёт: группы/товары created/updated/merged/skipped + - выборочная сверка 10 карточек с МойСкладом. -- [ ] 5. Playwright-проверка каталога Демо-магазина изнутри сети: - открывается, поиск по названию и штрихкоду работает, карточка - показывает поля простого режима; повторный запуск импорта → 0 - новых. +- [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` не трогались. + diff --git a/tests/e2e/reports/catalog-import-list.png b/tests/e2e/reports/catalog-import-list.png new file mode 100644 index 0000000..adc2319 Binary files /dev/null and b/tests/e2e/reports/catalog-import-list.png differ diff --git a/tests/e2e/reports/catalog-import-product-card.png b/tests/e2e/reports/catalog-import-product-card.png new file mode 100644 index 0000000..35b9929 Binary files /dev/null and b/tests/e2e/reports/catalog-import-product-card.png differ diff --git a/tests/e2e/reports/catalog-import-search-barcode.png b/tests/e2e/reports/catalog-import-search-barcode.png new file mode 100644 index 0000000..bc782f2 Binary files /dev/null and b/tests/e2e/reports/catalog-import-search-barcode.png differ diff --git a/tests/e2e/reports/catalog-import-search-name.png b/tests/e2e/reports/catalog-import-search-name.png new file mode 100644 index 0000000..da5aee7 Binary files /dev/null and b/tests/e2e/reports/catalog-import-search-name.png differ diff --git a/tests/e2e/scenarios/stage-ui-catalog-import.spec.ts b/tests/e2e/scenarios/stage-ui-catalog-import.spec.ts new file mode 100644 index 0000000..f4e9b9c --- /dev/null +++ b/tests/e2e/scenarios/stage-ui-catalog-import.spec.ts @@ -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 в этом же
ломает + // поиск общим 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() + } + }) +})