diff --git a/docs/full-chain-e2e-progress.md b/docs/full-chain-e2e-progress.md new file mode 100644 index 0000000..2e9d925 --- /dev/null +++ b/docs/full-chain-e2e-progress.md @@ -0,0 +1,212 @@ +# Full-chain E2E — прогресс + +Автономная задача владельца (07.10.2026): один запускаемый сценарий «жизнь магазина +от регистрации до отчётов», проходящий ВСЕ процессы простого режима через настоящий +стенд `https://test.admin.food-market.kz`, сверяющий ЧИСЛА и инварианты, печатающий +отчёт; повторяемый и включённый в ночной прогон. + +Поправка владельца (07.10, применяется сразу): +- **A.** Шаг 0 — резервная копия stage-БД, затем очистка «Демо-магазина» от всего кроме + каталога/сотрудников/точек продаж. +- **B.** Полная имитация дня (пп.1–13) ОДИН раз «как в жизни» прямо в «Демо-магазине» на + настоящих товарах (вход demo-admin/keeper/cashier, регистрацию пропустить). +- **C.** Повторяемый ночной тест — свежая временная организация каждый прогон, «Демо» не трогает. + +## Топология (выяснено 07.10) + +- Стенд `test.admin.food-market.kz` крутится на **prod-vm `192.168.1.190`**. + - DB: контейнер `food-market-stage-postgres` (127.0.0.1:5435 на prod-vm), db/user `food_market`. + - API: `food-market-stage-api` (порт 8085), web: `food-market-stage-web`. + - Доступ: `ssh nns@192.168.1.190 "docker exec -i food-market-stage-postgres psql -U food_market -d food_market ..."`. + - uploads на диске: `/opt/food-market-stage-data/uploads` (prod-vm). +- Я на **dev-vm `192.168.1.192`**. Локальный контейнер `food-market-postgres` (127.0.0.1:5434) — + это DEV-БД, НЕ стенд. Поэтому `lib/db.ts` (`docker exec food-market-postgres`) для стенда НЕ годится. + **Решение:** все инварианты в полном сценарии считаем через API; для Демо-очистки и ночной + уборки ходим в stage-БД по ssh. +- Деплой фикса на стенд: `~/deploy-stage.sh` (билд на dev-vm → push в registry 192.168.1.193:5001 → + ssh prod-vm `docker compose -p food-market-stage pull/up`). Есть guard stage↔prod isolation. + +## Орги на стенде + +- «Демо-магазин» = `b7c403fe-7fc8-48af-9490-4a9a2eafe2c2` — 30894 товара (ЦЕЛЬ очистки A/B). +- «Demo Market» = `f406452c-3d33-4c1d-84bd-e1d445a132f7` — 50 товаров, НЕ трогать. +- Всего 453 орги (много autotest-мусора Reg-*, чистится prune-логикой). + +## Классификация таблиц для очистки Демо (A) + +KEEP (каталог + сотрудники + точки продаж + reference): +`products, product_prices, product_barcodes, product_groups, product_images, +org_units_of_measure, price_types, employees, employee_roles, users, roles, stores, +retail_points, employee_retail_point_assignments, expense_items, user_presets`. + +DELETE (org-scoped, по OrganizationId, retry-loop для FK): +`supplies, supply_lines, enters, enter_lines, losses, loss_lines, inventories, +inventory_lines, transfers, transfer_lines, demands, demand_lines, supplier_returns, +supplier_return_lines, retail_sales, retail_sale_lines, cash_shifts, cash_drawer_operations, +pos_batch_acks, stocks, stock_movements, money_accounts, money_transactions, +money_transaction_links, counterparties, counterparty_adjustments, counterparty_tags, +loyalty_cards, loyalty_programs, promotions, import_jobs, invoice_uploads, invoice_aliases, +org_exports, org_audit_log`. +Плюс файлы invoice_uploads с диска `/opt/food-market-stage-data/uploads`. + +Проверка после A: products=30894, и документы/остатки=0 (supplies/retail_sales/enters/losses/ +inventories/transfers/demands/supplier_returns/stocks/stock_movements/money_*/cash_*=0). + +## Чек-лист + +### Шаг 0 (A) — backup + очистка Демо ✅ ВЫПОЛНЕНО 08.10 +- [x] pg_dump stage → `~/backups/stage-before-reset-20261008-000016.dump` (17M, заголовок PGDMP ок) +- [x] scoped clean Демо через `deploy/stage-reset-demo.sql` (dry-run в транзакции → commit); удалено 100740 строк +- [x] verify: products=30894 ✓, product_prices=30894, product_barcodes=30907, product_groups=53, + employees=3, users=3 (demo-admin/keeper/cashier), stores=1, retail_points=2; + supplies/retail_sales/enters/losses/inventories/stocks/stock_movements/money_*/cash_*/ + counterparties/invoice_uploads/org_audit_log = 0 ✓ +- [x] disk: удалён demo export-каталог; invoice StoredPath=NULL (снимки уже не на диске) + +### Поправка 08.10 (ночь) — скриншоты каждого процесса ✅ +- [x] Реальные Playwright-скриншоты каждого шага (вкл. 2а): форма/результат/документ/отчёт с числами +- [x] 1920×1080 @2x PNG (файлы 3840×2216), fullPage для отчётов; мобильные 390×844 @3x (инвент./фото-приёмка) +- [x] Имена `NN-шаг-что.png` в `reports/full-chain-/screenshots/`; B — всегда (~34 кадра), C — только при ✗ +- [x] В отчёте каждый скрин рядом со строкой шага; `index.html` галерея (рус. подписи, превью→оригинал) +- [x] Без паролей/токенов (вход токеном в localStorage, поле пароля пустое); ключевые экраны — реальный браузер + +### Шаг 2а (поправка 08.10) — сотрудники и точки продаж ✅ +- [x] 2 точки (каждая своя касса + эквайринг), правка, деактивация/активация; смена на неактивной → отказ (400) +- [x] Сотрудники кассир/кладовщик/админ; правка ФИО/тел/роль; смена пароля (новый работает, старый нет); + блокировка → ни token (invalid_grant), ни refresh (400) +- [x] Привязка кассира: на точке 1 (закреплён) → успех; на точке 2 → отказ (retail-point-not-assigned); + две смены на одной точке → 409; параллельные смены т.1/т.2 не смешивают деньги (свои «должно быть») +- [x] Права API(403: /money/accounts,/transactions,/employees) + UI (в меню кассира нет модуля «Деньги»/ + «Настроек», есть только своя касса; кладовщик без денег); чужую орг никто (изоляция) +- [x] Отчёты по точкам (register) и кассирам (cashier): сумма = общей выручке дня (3300=3300) +- [x] В Демо (B): новые с меткой «(e2e)»/`fc-*`, в конце деактивированы (не удалены) + +### Сценарий ✅ (все 16 шагов зелёные; C — temp-org, B — Демо на реальных товарах) +1. [x] Регистрация → вход → простой режим; скрытые модули 403 (demands/transfers/promo/abc) +2. [x] Группа, 5 товаров (разные ед.изм., 1 весовой 12.5) + цены + штрихкоды; поставщик (БИН+тел); права кассира/кладовщика +3. [x] Касса(Cash,50000)+банк(Bank,номер,200000)+эквайринг(Acquiring); balances = начальным +4. [x] Долг через корректировку WeOwe 100000 → долг=100000 +5. [x] Приёмка №1 (кладовщик) → остатки +кол-во ровно; себестоимость=цене приёмки +6. [x] Приёмка №2 из табличной накладной (CSV по шаблону) → «Поставщик не указан» → провести; остатки +7. [x] Приёмка №3 по фото (OCR Claude CLI, до 3 попыток) → ручное сопоставление → провести; снимки с диска удалены (404) +8. [x] Оплата №1 частично нал из кассы, №2 полностью с банка; PayedSum; долг=начальный+приёмки−оплаты (точно) +9. [x] Смена: размен 5000, 3 продажи (нал/карта/смеш), oversell→409, возврат по чеку, внесение/выплата, + закрытие с расхождением 500 → expectedCash/difference + Z-отчёт; продажа без смены→409 +10. [x] Оприходование +8, списание −2, инвентаризация CSV (часть): расхождение применено, непосчитанные НЕ обнулены +11. [x] Отчёты: продажи/прибыль(=выручка−cost)/остатки на дату/«Деньги»(=начальный+движения)/долги; по точкам/кассирам +12. [x] Инварианты: товар остаток≡Σдвижений; счёт≡начальный+Σопераций; долг≡формуле; изоляция (demo не видит temp) +13. [x] Отмена приёмки с проданным товаром→отказ; отмена платежа→долг вернулся (точно) +14. [x] Отчёт `reports/full-chain-.md` (ожид/факт/✓✗/длит + скрины) + галерея; в `~/nightly-verify.sh` + (04:00, C+UI) и `~/quality-watchdog.sh` step 11 (HR=03, api-only) +15. [x] 3 бага найдено и починено (repro→fix→deploy→rerun зелёный); прогоны зелёные (см. ниже) + +## Найденные и исправленные баги + +1. **EmployeesController.Update → 500** (DbUpdateConcurrencyException «0 rows affected») при правке + сотрудника, у которого есть привязка к точке продаж. Причина: EF8 путается на nav-collection + `RemoveRange+Clear+Add` (тот же баг, что чинили в Supplies/Enters/Losses/Demands.Update). + Фикс: `ExecuteDeleteAsync` по EmployeeId + `DbSet.Add` (без Clear nav). **Исправлено в коде, ждёт deploy.** + Воспроизведение: создать сотрудника с retailPointIds=[точка], затем PUT любой правки → было 500. + Фикс в `EmployeesController.Update`. ✅ Задеплоено, прогон зелёный. +2. **Кассир видел модуль «Деньги»** (GET /api/money/accounts → 200): роль «Кассир» имела `MoneyView`. + Фикс: дефолт кассира `MoneyView=false` (DevDataSeeder) + backfill существующих орг (вкл. демо) в + `EnsureMoneyDefaultsAsync` (снимает MoneyView, оставляет CashShiftOperate). Эндпоинты своей смены/Z + (`CashShiftsController` List/Get/Report) → `RequiresPermission("MoneyView,CashShiftOperate")`; добавлена + ИЛИ-семантика прав в `PermissionRequirement`/`PermissionAuthorizationHandler`. Веб уже корректно прячет + модуль «Деньги»/«Настройки» у кассира (оставляет только «Смены»). ✅ Задеплоено, кассир→403 на деньги, 200 на свою смену. +3. **Смена открывалась на неактивной точке; кассир мог открыть смену на непривязанной точке.** + Фикс в `MoneyService.OpenShiftAsync`: проверка `RetailPoint.IsActive` (`retail-point-inactive`) и + привязки кассира (`retail-point-not-assigned`; у кого привязок нет — на любой). ✅ Задеплоено, прогон зелёный. + +Прочие найденные дефекты оказались **в самом тесте** (не в продукте): неуникальный EAN13-генератор, +`r.data.employee.id` вместо `r.data.id`, groupBy `register` (а не `retailPoint`), пароль quick-login +`__QUICK_LOGIN_PWLESS__` (а не 123456), слишком строгая UI-проверка меню кассира (/money/shifts — его касса). Все поправлены. + +### Баги из тестирования iOS-приложения (поправка владельца 08.10) +4. **Кладовщик без прав на склад.** Роль «Кладовщик» не имела InventoryEdit/EnterEdit/LossEdit → API 403 + на инвентаризацию/оприходование/списание, хотя веб их показывал (рассинхрон). Фикс: добавлены права в + дефолт роли (DevDataSeeder) + backfill существующих орг. Репро в шаге 10 (кладовщик→оприходование/списание не 403). ✅ +5. **Инвентаризация обнуляла непосчитанное.** Create с пустыми строками авто-подтягивал ВСЕ товары склада с + ActualQty=0 (Diff=−stock) → проведение обнуляло всё непересчитанное (и всё вне выбранной группы). Фикс: + авто-подтянутые строки создаются как ActualQty=BookQty (Diff=0) → проведение их не трогает; кладовщик меняет + ActualQty только у пересчитанных (истинный ноль вводится явно). Репро в шаге 10 (пустая инвентаризация → + непосчитанный не обнулён). ✅ (без миграции/правок веба) +6. **Внутренний EAN-13 с неверной контрольной цифрой.** Веб-генератор (`lib/barcode.ts` digitsChecksum) считал + веса слева, а для 12-значного тела чётность переворачивается. Фикс: веса справа налево (EAN-8/UPC-A не изменились). + Уже выданные коды починены скриптом `deploy/fix-internal-ean13.sql`: исправлен **41** код (0 конфликтов, 0 осталось); + список — `docs/ean13-fixed-20261008.txt`. Прод НЕ трогали (скрипт ручной, не auto-migration). ✅ +7. **«Приёмка сканированием» не сохранялась.** SupplyScanPage читал валюты как массив + (`Array.isArray(r.data)`), а `/api/catalog/currencies` отдаёт PagedResult `{items}` → список валют пуст, + нет KZT по умолчанию. Фикс: `r.data.items`. ✅ +8. **Поиск товара на экране накладной не работал.** InvoiceReviewPage слал `?q=`, а API ждёт `?search=` + (PagedRequest.Search). Фикс: `search`. ✅ +9. **«Сегодня» считалось по UTC, а не Asia/Almaty (UTC+5).** Дашборд (RevenueToday/Week/Month + график), + `/api/reports/sales` и `/profit` группировка `period:day` — все считали день по UTC. Фикс: хелпер + `BusinessTime` (UTC+5) + применён в RetailSalesController /stats и report day-bucket'ах. ✅ + +## API-карта (выверено по контроллерам, для сценария) + +Base: `E2E_ADMIN_URL=https://test.admin.food-market.kz`. Логин: `POST /connect/token` +(password grant, client_id=food-market-web). Демо-трио (режим B): username +`demo-admin|demo-keeper|demo-cashier@food-market.local`, пароль-sentinel `__QUICK_LOGIN_PWLESS__` (quick-login беспарольный вход на стенде; не `123456`). + +- Регистрация (C): `POST /api/auth/signup {email,password,organizationName,phone,plan:'start'}` → 200; орг по умолчанию простой режим. +- Простой режим: `[RequiresAdvancedMode]` → 403 code=`advanced-mode-required`. Закрыты: demands(`/api/sales/demands`), + transfers(`/api/inventory/transfers`), promotions, loyalty, abc-report, telegram, audit-log, price-types мутации, user-presets. +- Каталог: units `/api/catalog/units-of-measure`; groups `/api/catalog/product-groups` {name,parentId,sortOrder}; + price-types `/api/catalog/price-types` (isRetail); currencies `/api/catalog/currencies` (KZT); + products `POST /api/catalog/products` {name,article,unitOfMeasureId,vat,vatEnabled,productGroupId,isService,packaging, + referencePrice,purchaseCurrencyId,prices:[{priceTypeId,amount,currencyId}],barcodes:[{code,type,isPrimary}]}. +- Контрагент: `POST /api/catalog/counterparties` {name,legalName,type(0=ЮЛ),bin,countryId,...}. +- Магазин/точки: `/api/catalog/stores` {name,code,isMain,isActive}; `/api/catalog/retail-points` {name,code,storeId,isActive}. +- Сотрудники: `/api/organization/employees` POST {lastName,firstName,position,email,phone,roleId,isActive,retailPointIds,createAccount} → generatedPassword; + PUT/DELETE требуют роли Admin; роли `/api/organization/employee-roles` (системные «Кассир»,«Кладовщик»,Admin). +- Деньги (perm MoneyView/MoneyEdit/CashShiftOperate): + - Счета `POST /api/money/accounts` {Name,Kind(Cash=1,Safe=2,Bank=3,Acquiring=4),BankName?,AccountNumber?,StoreId?(обяз.для Cash),CurrencyId,OpeningBalance,OpeningDate,IsActive}. + Баланс = OpeningBalance + Σ(PaymentIn/CashIn/TransferIn) − Σ(PaymentOut/CashOut/TransferOut) по Posted. Report `GET /api/money/reports/balances`. + - Корректировка долга `POST /api/money/adjustments` {Date,CounterpartyId,Direction(WeOwe=1,TheyOwe=2),Amount,Notes} + `/{id}/post`. + - Транзакция `POST /api/money/transactions` {Date,Kind(PaymentIn=1,PaymentOut=2,CashIn=3,CashOut=4),AccountId,CounterpartyId?,ExpenseItemId?,CurrencyId,Amount,Links:[{SupplyId?,SupplierReturnId?,LinkedSum}]} + `/{id}/post`. + Оплата приёмки = PaymentOut/CashOut с Links[{SupplyId,LinkedSum}] → поднимает `supply.PayedSum` (инвариант 0≤PayedSum≤Total). + - Смена `POST /api/money/shifts/open` {RetailPointId,CashAccountId,AcquiringAccountId?,OpeningFloat}; `/{id}/drawer` {Direction(In=1,Out=2),Amount,Reason}; + `/{id}/close` {ActualCash,Notes} → {expectedCash,difference}; `/{id}/report` (Z). 409 ShiftAlreadyOpen. + ExpectedCash = OpeningFloat + salesCash − returnsCash + drawerIn − drawerOut; salesCash=Σ PaidCash non-return по CashShiftId; CashDifference = Actual − Expected. +- Приёмка `POST /api/purchases/supplies` {date,supplierId,storeId,currencyId,notes,lines:[{productId,quantity,unitPrice}]} + `/{id}/post` (perm SuppliesPost). Повторный post → 409. +- Инвойсы `POST /api/purchases/invoices/upload` (form `files`, xlsx/csv/pdf); `/upload-photos` (form `files`,`codes`); фон InvoiceWorker: pending→parsing→needs_review|failed; + `PUT /{id}/lines` {Lines,SupplierName,...} (ручной матч ProductId+MatchedBy=manual); `POST /{id}/apply` {SupplierId?(null→«Поставщик не указан»),StoreId,CurrencyId,Notes} → создаёт Supply Draft; снимки с диска удаляются. +- Продажа `POST /api/sales/retail` {date,storeId,retailPointId,customerId,currencyId,payment(Cash=0,Card=1,Mixed=99),paidCash,paidCard,notes,lines:[{productId,quantity,unitPrice,discount,vatPercent}],isReturn,referenceSaleId} + + `/{id}/post` (требует откр.смену на точке иначе 409 code=cash-shift-required; oversell→409; недоплата→400). Возврат: `/{id}/create-return`→draft(isReturn)→post. `/{id}/unpost` (при наличии проведённых возвратов→409). +- Склад: enters `POST /api/inventory/enters` {date,storeId,currencyId,notes,lines:[{productId,quantity,unitCost}]}+post (оприходование, +qty); + losses `POST /api/inventory/losses` {date,storeId,currencyId,reason,notes,lines:[{productId,quantity,unitCost}]}+post (−qty); + инвентаризация `POST /api/inventory/inventories/import` (form file csv/xlsx: «Штрихкод»/«Количество»→{found,unknown}); `POST /api/inventory/inventories` {date,storeId,notes,lines:[{productId,actualQty}]}(частичная=только эти товары)+post (двигает только Diff≠0, непосчитанные не трогаются). + stock `GET /api/inventory/stock?productId&storeId`; movements `GET /api/inventory/movements`. +- Отчёты: sales `GET /api/reports/sales?from&to&groupBy=period:day|retailPoint|cashier|product&storeId` →[{Key,Label,Revenue,Discount,Transactions,Quantity}]; + profit `GET /api/reports/profit?from&to` →[{..,Revenue,Cost,Profit,MarginPercent}]; stock `GET /api/reports/stock?date&storeId&includeZero` →[{..,Quantity,Cost,Value}] (остатки на дату); + деньги balances/movement/supplier-debts (долг = (Σприёмки−Σоплачено)−(Σвозвраты−Σполучено)+(WeOwe−TheyOwe)). + +Фикстура фото-накладной: `tests/fixtures/invoice-sample.jpg`. + +## Журнал + +- 07.10 — старт; выяснена топология стенда; классификация таблиц; план зафиксирован. +- 08.10 — Шаг 0 (A) выполнен (backup+clean Демо). Выверена полная API-карта по контроллерам. Приступаю к сценарию. +- 08.10 — сценарий зелёный (16/16); 3 бага (EF8 nav, кассир-MoneyView, привязка/неактивная точка) + 2а строгие + проверки; скриншоты+галерея; B-прогон в Демо зелёный; в nightly+watchdog. 2 зелёных прогона C подряд. +- 08.10 — iOS-cleanup Демо: транзакционные ios-документы удалены ещё pre-B reset'ом (reset-demo.sql: приёмки/ + оприходования/списания/инвентаризации/контрагенты в DELETE-set). Остаточный ios-товар «Тестовый товар (ios-тест)» + (83b8254c, создан после reset) + его 3 штрихкода + 1 цена — удалены. Реальных правок цены после reset не + найдено (ios-правка цены была на этом удалённом товаре). «Gerlax D2 iOS» — реальный товар каталога (04.10), оставлен. +- 08.10 — поправка: 6 багов из iOS-тестирования (см. список выше, пп.4–9) — репро в сценарии, фикс, деплой, + EAN-13 скрипт (41 код). Все репро после деплоя зелёные (кладовщик 201; пустая инвентаризация: непосчитанное + 35→35; EAN-13 валиден). Дашборд revenueToday по Алматы верен (10650, 6 чеков сегодня). +- 08.10 — ✅ ФИНАЛ: B-showcase v2 на чистом Демо — 16/16 зелёный (36 скринов, галерея). Два прогона C подряд — + оба 16/16 ЗЕЛЁНЫЙ. 9 багов (3 + 6) исправлены и задеплоены. touch ~/.fm-watchdog/DONE. + +## Статус: ГОТОВО ✅ + +- Сценарий `full-chain`: 16/16 зелёный (режимы C и B). В `~/nightly-verify.sh` (04:00, C+UI) и + `~/quality-watchdog.sh` step 11 (HR=03, api-only). +- 9 исправленных багов (3 из разработки теста + 6 из iOS): все с репро в сценарии, задеплоены, повторный прогон зелёный. +- Демо-магазин: backup сохранён; очищен (A); проведён показательный день (B) на реальном каталоге; e2e-сущности + деактивированы; ios-мусор удалён. +- Код-правки — в working tree (задеплоены на stage через ~/deploy-stage.sh), в git НЕ закоммичены + (policy «коммит только по просьбе»). Готов закоммитить по команде. diff --git a/tests/e2e/full-chain.sh b/tests/e2e/full-chain.sh new file mode 100755 index 0000000..d798b35 --- /dev/null +++ b/tests/e2e/full-chain.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Запускаемый сквозной тест «жизнь магазина от регистрации до отчётов». +# Один прогон = свои свежие данные против настоящего стенда, сверка ЧИСЕЛ и +# инвариантов, markdown-отчёт со скриншотами. Выход != 0 если есть падения — +# чтобы ночной прогон/quality-watchdog ловил регресс. +# +# Использование: +# tests/e2e/full-chain.sh # режим C: свежая temp-орг (для ночного прогона) +# tests/e2e/full-chain.sh --api-only # без браузера (быстрее, только API+инварианты) +# FULLCHAIN_TARGET=demo tests/e2e/full-chain.sh # режим B: прогон в «Демо-магазине» +# +# Env: +# E2E_ADMIN_URL — стенд (default https://test.admin.food-market.kz) +# FULLCHAIN_TARGET — temp (default) | demo +set -uo pipefail +cd "$(dirname "$0")" + +export E2E_ADMIN_URL="${E2E_ADMIN_URL:-https://test.admin.food-market.kz}" + +if [ ! -d node_modules ]; then + pnpm install --silent +fi +# chromium для UI-шагов (если не ставили) +if [ "${1:-}" != "--api-only" ]; then + pnpm exec playwright install chromium >/dev/null 2>&1 || true +fi + +echo "[full-chain] стенд=$E2E_ADMIN_URL режим=${FULLCHAIN_TARGET:-temp} $*" +pnpm exec tsx runner.ts full-chain "$@" +RC=$? + +REPORT="$(ls -t reports/full-chain-*.md 2>/dev/null | head -1)" +if [ -z "$REPORT" ]; then + echo "[full-chain] ОТЧЁТ НЕ НАЙДЕН — прогон не состоялся" + exit 2 +fi +echo "[full-chain] отчёт: $REPORT" + +# Итоговая строка: «**Итог:** N ✓ / M ✗ / ...» +SUMMARY="$(grep -m1 '\*\*Итог:\*\*' "$REPORT" || true)" +echo "[full-chain] $SUMMARY" +FAILS="$(echo "$SUMMARY" | grep -oE '[0-9]+ ✗' | grep -oE '[0-9]+' || echo 0)" +if [ "${FAILS:-0}" -gt 0 ] || [ "$RC" -ne 0 ]; then + echo "[full-chain] РЕЗУЛЬТАТ: КРАСНЫЙ (падений: ${FAILS:-?})" + exit 1 +fi +echo "[full-chain] РЕЗУЛЬТАТ: ЗЕЛЁНЫЙ" +exit 0 diff --git a/tests/e2e/lib/fc-ui.ts b/tests/e2e/lib/fc-ui.ts new file mode 100644 index 0000000..bddd6ed --- /dev/null +++ b/tests/e2e/lib/fc-ui.ts @@ -0,0 +1,213 @@ +/** + * UI-харнесс full-chain: реальный headless-браузер (Playwright chromium). + * Делает скриншоты КАЖДОГО процесса в хорошем разрешении (поправка владельца + * 08.10): десктоп 1920×1080 @2x (Retina) + мобильный 390×844 @3x для экранов, + * которыми пользуются с телефона. fullPage для длинных отчётов. Имена файлов + * `NN-шаг-что.png`, папка reports/full-chain-/screenshots/. + * + * На каждой странице слушаем console.error / сетевые 5xx / error-тосты — + * шаги проверяют, что их 0. Логин кладём токеном в localStorage, поле пароля + * на экранах не светится (нет паролей/токенов на скринах). + */ +import { chromium, type Browser, type BrowserContext, type Page } from 'playwright' +import { mkdirSync, writeFileSync } from 'node:fs' +import { resolve, basename } from 'node:path' +import { BASE } from './fc.js' + +export interface UiErrors { console: string[]; network: string[]; toast: string[] } +export interface Shot { file: string; rel: string; caption: string; step: string; mobile: boolean } + +function attachErrorListeners(page: Page, errors: UiErrors) { + page.on('console', (m) => { + if (m.type() !== 'error') return + const t = m.text() + if (/^Failed to load resource: the server responded with a status of \d+/i.test(t)) return + if (/net::(ERR_NETWORK_CHANGED|ERR_INTERNET_DISCONNECTED|ERR_CONNECTION_RESET|ERR_NAME_NOT_RESOLVED|ERR_CONNECTION_REFUSED|ERR_TIMED_OUT|ERR_ABORTED)/i.test(t)) return + errors.console.push(t) + }) + page.on('response', (r) => { + const s = r.status() + if (s < 400) return + const url = r.url() + if (s === 401 && /\/(api|connect)\//.test(url)) return + errors.network.push(`${s} ${r.request().method()} ${url}`) + }) +} + +export class Ui { + browser!: Browser + deskCtx!: BrowserContext + mobCtx?: BrowserContext + page!: Page // desktop + mobPage?: Page + errors: UiErrors = { console: [], network: [], toast: [] } + shots: Shot[] = [] + dir!: string + private token = '' + + static async launch(runId: string): Promise { + const u = new Ui() + u.dir = resolve(import.meta.dirname, '..', 'reports', `full-chain-${runId}`, 'screenshots') + mkdirSync(u.dir, { recursive: true }) + u.browser = await chromium.launch({ headless: true, args: ['--ignore-certificate-errors'] }) + u.deskCtx = await u.browser.newContext({ + ignoreHTTPSErrors: true, locale: 'ru-RU', + viewport: { width: 1920, height: 1080 }, deviceScaleFactor: 2, baseURL: BASE, + }) + u.page = await u.deskCtx.newPage() + attachErrorListeners(u.page, u.errors) + return u + } + + private async ensureMobile(): Promise { + if (!this.mobPage) { + this.mobCtx = await this.browser.newContext({ + ignoreHTTPSErrors: true, locale: 'ru-RU', isMobile: true, hasTouch: true, + viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, baseURL: BASE, + }) + this.mobPage = await this.mobCtx.newPage() + attachErrorListeners(this.mobPage, this.errors) + if (this.token) { + await this.mobPage.goto('/login', { waitUntil: 'domcontentloaded' }) + await this.mobPage.evaluate((t) => localStorage.setItem('fm.access_token', t), this.token) + } + } + return this.mobPage + } + + /** Залогинить браузер токеном (оба контекста). */ + async attach(token: string, path = '/dashboard') { + this.token = token + await this.page.goto('/login', { waitUntil: 'domcontentloaded' }) + await this.page.evaluate((t) => localStorage.setItem('fm.access_token', t), token) + await this.page.goto(path, { waitUntil: 'domcontentloaded' }) + await this.page.waitForTimeout(500) + } + + async goto(path: string, waitMs = 600) { + await this.page.goto(path, { waitUntil: 'domcontentloaded' }) + await this.page.waitForTimeout(waitMs) + } + async gotoMobile(path: string, waitMs = 700): Promise { + const m = await this.ensureMobile() + await m.goto(path, { waitUntil: 'domcontentloaded' }) + await m.waitForTimeout(waitMs) + return m + } + + async collectToasts() { + for (const p of [this.page, this.mobPage]) { + if (!p) continue + try { + const texts = await p.$$eval('[data-toast-kind="error"]', (els) => els.map((e) => (e.textContent ?? '').trim())) + for (const t of texts) if (t && !this.errors.toast.includes(t)) this.errors.toast.push(t) + } catch { /* навигация */ } + } + } + + /** Снимок десктопа. name → NN-шаг-что (без .png). caption — рус. подпись для галереи/отчёта. */ + async shot(step: string, name: string, caption: string, opts?: { fullPage?: boolean }): Promise { + const file = resolve(this.dir, `${name}.png`) + try { await this.page.screenshot({ path: file, fullPage: opts?.fullPage ?? false }) } catch { /* ignore */ } + const s: Shot = { file, rel: this.relFor(name), caption, step, mobile: false } + this.shots.push(s) + return s + } + + /** Мобильный снимок (экран, которым пользуются с телефона). Навигирует mobile-страницу сам. */ + async shotMobile(step: string, name: string, caption: string, path: string, opts?: { fullPage?: boolean }): Promise { + const m = await this.gotoMobile(path) + const file = resolve(this.dir, `${name}.png`) + try { await m.screenshot({ path: file, fullPage: opts?.fullPage ?? true }) } catch { /* ignore */ } + const s: Shot = { file, rel: this.relFor(name), caption, step, mobile: true } + this.shots.push(s) + return s + } + + private relFor(name: string): string { + // относительный путь от reports/.md к скрину + return `${basename(resolve(this.dir, '..'))}/screenshots/${name}.png` + } + + errorSummary(): string { + const parts: string[] = [] + if (this.errors.console.length) parts.push(`console: ${this.errors.console.slice(0, 3).join(' | ')}`) + if (this.errors.network.length) parts.push(`net: ${this.errors.network.slice(0, 3).join(' | ')}`) + if (this.errors.toast.length) parts.push(`toast: ${this.errors.toast.slice(0, 3).join(' | ')}`) + return parts.join('; ') + } + resetErrors() { this.errors = { console: [], network: [], toast: [] } } + + /** HTML-галерея со всеми скриншотами (крупные превью, клик → оригинал). */ + writeGallery(runId: string, meta: { target: string; when: string }) { + const byStep = new Map() + for (const s of this.shots) { if (!byStep.has(s.step)) byStep.set(s.step, []); byStep.get(s.step)!.push(s) } + const sections = [...byStep.entries()].map(([stepName, shots]) => { + const cards = shots.map((s) => ` +
+ + ${esc(s.caption)} + +
${s.mobile ? '📱 ' : '🖥 '}${esc(s.caption)}
${basename(s.file)}
+
`).join('') + return `

${esc(stepName)}

${cards}
` + }).join('\n') + const html = ` + +Full-chain e2e — скриншоты процессов (${esc(meta.target)}) + +

Жизнь магазина — скриншоты процессов

+
Прогон: ${esc(meta.target)} · ${esc(meta.when)} · всего кадров: ${this.shots.length}
+${sections} +` + const out = resolve(this.dir, '..', 'index.html') + writeFileSync(out, html, 'utf8') + return out + } + + /** Ссылки сайдбара для пользователя с данным токеном (эфемерный контекст). */ + async sidebarHrefs(token: string): Promise { + const c = await this.browser.newContext({ ignoreHTTPSErrors: true, locale: 'ru-RU', viewport: { width: 1920, height: 1080 }, baseURL: BASE }) + const p = await c.newPage() + try { + await p.goto('/login', { waitUntil: 'domcontentloaded' }) + await p.evaluate((t) => localStorage.setItem('fm.access_token', t), token) + await p.goto('/dashboard', { waitUntil: 'domcontentloaded' }) + await p.waitForTimeout(900) + return (await p.$$eval('a[href^="/"]', (els) => els.map((e) => e.getAttribute('href') ?? ''))).filter(Boolean) + } finally { await c.close() } + } + + /** Скрин экрана под чужим токеном (напр. кабинет кассира — без пунктов «Деньги»/«Настройки»). */ + async shotAs(step: string, token: string, path: string, name: string, caption: string): Promise { + const c = await this.browser.newContext({ ignoreHTTPSErrors: true, locale: 'ru-RU', viewport: { width: 1920, height: 1080 }, deviceScaleFactor: 2, baseURL: BASE }) + const p = await c.newPage() + const file = resolve(this.dir, `${name}.png`) + try { + await p.goto('/login', { waitUntil: 'domcontentloaded' }) + await p.evaluate((t) => localStorage.setItem('fm.access_token', t), token) + await p.goto(path, { waitUntil: 'domcontentloaded' }) + await p.waitForTimeout(900) + await p.screenshot({ path: file, fullPage: false }) + } catch { /* ignore */ } finally { await c.close() } + const s: Shot = { file, rel: this.relFor(name), caption, step, mobile: false } + this.shots.push(s) + return s + } + + async close() { try { await this.browser.close() } catch { /* ignore */ } } +} + +function esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} diff --git a/tests/e2e/lib/fc.ts b/tests/e2e/lib/fc.ts new file mode 100644 index 0000000..a6d2530 --- /dev/null +++ b/tests/e2e/lib/fc.ts @@ -0,0 +1,131 @@ +/** + * Хелперы для сценария full-chain (жизнь магазина от регистрации до отчётов). + * + * Работает против НАСТОЯЩЕГО стенда (E2E_ADMIN_URL, по умолчанию + * https://test.admin.food-market.kz). Без моков. Инварианты считаем через API + * (не через БД: stage-БД живёт на prod-vm, docker exec сюда не достаёт). + * + * Два режима запуска: + * - C (по умолчанию): своя свежая организация через /api/auth/signup, + * имя `e2e-fullchain-`. Чистится ночной уборкой (>3 дней). + * - B (FULLCHAIN_TARGET=demo): вход в «Демо-магазин» demo-admin/keeper/cashier, + * работа на реальном каталоге; новые сущности с меткой «(e2e)». + */ +import axios, { AxiosInstance } from 'axios' +import { Agent as HttpsAgent } from 'node:https' + +export const BASE = process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz' +export const TARGET = (process.env.FULLCHAIN_TARGET ?? 'temp').toLowerCase() // 'temp' | 'demo' +export const IS_DEMO = TARGET === 'demo' + +const httpsAgent = new HttpsAgent({ rejectUnauthorized: false }) + +export function makeClient(token?: string): AxiosInstance { + return axios.create({ + baseURL: BASE, + httpsAgent, + timeout: 60_000, + headers: token ? { Authorization: `Bearer ${token}` } : {}, + validateStatus: () => true, + }) +} + +export interface Session { + email: string + password: string + token: string + orgId: string + roles: string[] +} + +async function tokenFor(email: string, password: string): Promise { + const body = new URLSearchParams({ + grant_type: 'password', username: email, password, + client_id: 'food-market-web', scope: 'openid profile email roles api offline_access', + }) + const r = await makeClient().post('/connect/token', body, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + if (r.status !== 200) throw new Error(`token ${email}: ${r.status} ${JSON.stringify(r.data).slice(0, 200)}`) + return r.data.access_token as string +} + +export async function me(token: string): Promise<{ orgId: string; roles: string[]; advancedMode: boolean; sub: string }> { + const r = await makeClient(token).get('/api/me') + if (r.status !== 200) throw new Error(`/api/me: ${r.status}`) + return { orgId: r.data.orgId, roles: r.data.roles ?? [], advancedMode: !!r.data.advancedMode, sub: r.data.sub } +} + +/** Регистрация свежей организации (режим C). Имя с меткой e2e-fullchain-. */ +export async function signupOrg(): Promise { + const ts = Date.now() + const email = `e2e-fullchain-${ts}@food-market.local` + const password = 'UiTest12345!' + const orgName = `e2e-fullchain-${ts}` + const api = makeClient() + let r = await api.post('/api/auth/signup', { email, password, organizationName: orgName, phone: '+77011190001', plan: 'start' }) + for (let i = 0; i < 6 && r.status === 429; i++) { + await sleep(12_000) + r = await api.post('/api/auth/signup', { email, password, organizationName: orgName, phone: '+77011190001', plan: 'start' }) + } + if (r.status !== 200) throw new Error(`signup: ${r.status} ${JSON.stringify(r.data).slice(0, 200)}`) + const token = await tokenFor(email, password) + const m = await me(token) + return { email, password, token, orgId: m.orgId, roles: m.roles } +} + +/** Вход в «Демо-магазин» одним из demo-* (режим B); пароль — quick-login master. */ +export async function demoLogin(which: 'admin' | 'keeper' | 'cashier'): Promise { + const email = `demo-${which}@food-market.local` + // На стенде demo-трио логинится quick-login sentinel-паролем (беспарольный + // вход). Реальный пароль demo-юзеров неизвестен; sentinel работает пока + // QuickLogin:Enabled=true и Issuer не прод. + const password = process.env.FULLCHAIN_DEMO_PASS ?? '__QUICK_LOGIN_PWLESS__' + const token = await tokenFor(email, password) + const m = await me(token) + return { email, password, token, orgId: m.orgId, roles: m.roles } +} + +/** Логин по известным кредам (для проверки блокировки, повторных входов). */ +export async function loginCreds(email: string, password: string): Promise<{ ok: boolean; status: number; token?: string; refresh?: string }> { + const body = new URLSearchParams({ + grant_type: 'password', username: email, password, + client_id: 'food-market-web', scope: 'openid profile email roles api offline_access', + }) + const r = await makeClient().post('/connect/token', body, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }) + return { ok: r.status === 200, status: r.status, token: r.data?.access_token, refresh: r.data?.refresh_token } +} + +export async function refreshToken(refresh: string): Promise<{ ok: boolean; status: number }> { + const body = new URLSearchParams({ + grant_type: 'refresh_token', refresh_token: refresh, client_id: 'food-market-web', + }) + const r = await makeClient().post('/connect/token', body, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }) + return { ok: r.status === 200, status: r.status } +} + +// ── числовые помощники ───────────────────────────────────────────────────── +export const round2 = (x: number) => Math.round((x + Number.EPSILON) * 100) / 100 +export const approxEq = (a: number, b: number, eps = 0.01) => Math.abs(a - b) < eps +export const sum = (xs: number[]) => xs.reduce((a, b) => a + b, 0) +export const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)) + +/** EAN-13 с валидной контрольной цифрой. idx (0..999) кодируется в младших + * разрядах 12-значного тела ДО контрольной цифры, время прогона — в середине, + * поэтому коды уникальны и между прогонами, и между товарами (idx). */ +export function ean13(idx: number): string { + const timePart = String(Date.now() % 100_000_000).padStart(8, '0') // 8 цифр + const base = ('2' + timePart + String(idx % 1000).padStart(3, '0')).slice(0, 12) // 1+8+3=12 + let s = 0 + for (let i = 0; i < 12; i++) s += Number(base[i]) * (i % 2 === 0 ? 1 : 3) + const check = (10 - (s % 10)) % 10 + return base + check +} + +/** Проверка контрольной цифры EAN-13 (справа налево, веса 3,1,3,1…). */ +export function isValidEan13(code: string): boolean { + if (!/^\d{13}$/.test(code)) return false + let s = 0, w = 3 + for (let i = 11; i >= 0; i--) { s += Number(code[i]) * w; w = w === 3 ? 1 : 3 } + return (10 - (s % 10)) % 10 === Number(code[12]) +} diff --git a/tests/e2e/lib/report.ts b/tests/e2e/lib/report.ts index cdb430c..836f7cc 100644 --- a/tests/e2e/lib/report.ts +++ b/tests/e2e/lib/report.ts @@ -23,6 +23,7 @@ export interface Step { durationMs: number notes: string[] screenshot?: string + screenshots?: { rel: string; caption: string }[] } export interface Bug { @@ -112,6 +113,17 @@ export class Report { lines.push('') lines.push(`Скриншот: \`${step.screenshot}\``) } + if (step.screenshots && step.screenshots.length > 0) { + lines.push('') + lines.push('Скриншоты процесса:') + lines.push('') + for (const sh of step.screenshots) { + lines.push(`[${sh.caption}](${sh.rel})`) + lines.push('') + lines.push(`![${sh.caption}](${sh.rel})`) + lines.push('') + } + } lines.push('') } diff --git a/tests/e2e/runner.ts b/tests/e2e/runner.ts index 574f24b..dc21741 100644 --- a/tests/e2e/runner.ts +++ b/tests/e2e/runner.ts @@ -83,7 +83,9 @@ async function main() { console.error(`[${stepDecl.id}] ${step.status} (${step.durationMs}ms)`) } - const ts = new Date().toISOString().replace(/[:.]/g, '-') + // FULLCHAIN_RUN_ID — чтобы имя отчёта совпадало с папкой скриншотов + // (reports/full-chain-/screenshots/). Если не задан — timestamp. + const ts = process.env.FULLCHAIN_RUN_ID ?? new Date().toISOString().replace(/[:.]/g, '-') const reportPath = resolve(import.meta.dirname, 'reports', `${name}-${ts}.md`) report.finalize(reportPath) console.error(`[done] report → ${reportPath}`) diff --git a/tests/e2e/scenarios/full-chain.steps.ts b/tests/e2e/scenarios/full-chain.steps.ts new file mode 100644 index 0000000..8f53def --- /dev/null +++ b/tests/e2e/scenarios/full-chain.steps.ts @@ -0,0 +1,1319 @@ +/** + * Шаги сценария full-chain. Каждый шаг сверяет конкретные числа (ожидаемое + * считает сам тест) и инварианты против настоящего стенда. См. full-chain.yml. + * + * Запуск: E2E_ADMIN_URL=https://test.admin.food-market.kz tests/e2e/run.sh full-chain + * режим B: FULLCHAIN_TARGET=demo ... (вход demo-*, реальный каталог). + */ +import type { CheckResult, Step, Report } from '../lib/report.js' +import { + BASE, IS_DEMO, makeClient, signupOrg, demoLogin, loginCreds, refreshToken, + me as apiMe, round2, approxEq, sum, sleep, ean13, isValidEan13, type Session, +} from '../lib/fc.js' +import { Ui } from '../lib/fc-ui.js' +import type { AxiosInstance } from 'axios' + +interface Ctx { + apiOnly: boolean + ui?: Ui + owner?: Session + ownerApi?: AxiosInstance + tag: string + storeId?: string + currencyId?: string + retailPtId?: string + groupId?: string + products: Prod[] + supplierId?: string + placeholderId?: string + emp: Record + point1Id?: string + point2Id?: string + cash1Id?: string + acq1Id?: string + cash2Id?: string + acq2Id?: string + bankId?: string + cashOpening: number + bankOpening: number + acqOpening: number + initialDebt: number + supplies: SupplyRec[] + pay: PayRec[] + shift?: ShiftRec + reportFrom?: string + reportTo?: string +} +interface Prod { id: string; name: string; price: number; cost: number; unitId: string; unitName: string; barcode: string; weighted: boolean } +interface EmpRec { id: string; email: string; password: string; role: string; token?: string } +interface SupplyRec { id: string; number?: string; total: number; supplierId: string; lines: { productId: string; quantity: number; unitPrice: number }[]; paid: number } +interface PayRec { txId: string; supplyIdx: number; amount: number; account: string } +interface ShiftRec { + id: string; pointId: string; cashId: string; openingFloat: number + salesCash: number; salesCard: number; returnsCash: number; drawerIn: number; drawerOut: number + expected: number; actual: number +} + +interface StepCtx { ctx: Ctx; step: Step; report: Report } + +const TS = Date.now() +const RUN_ID = process.env.FULLCHAIN_RUN_ID ?? new Date().toISOString().replace(/[:.]/g, '-') +// Режим B (демо) — снимаем всё; режим C — только при падении шага. +const SHOTS = IS_DEMO || process.env.FULLCHAIN_SHOTS === 'always' + +// ── запись проверки «ожидаемое/фактическое» ───────────────────────────────── +function rec(step: Step, kind: CheckResult['kind'], desc: string, ok: boolean, expected?: unknown, actual?: unknown) { + let detail = '' + if (expected !== undefined || actual !== undefined) detail = `ожид=${fmt(expected)} факт=${fmt(actual)}` + step.checks.push({ kind, description: desc, ok, detail }) +} +function fmt(x: unknown): string { + if (x == null) return '—' + if (typeof x === 'number') return String(round2(x)) + if (typeof x === 'string') return x.length > 60 ? x.slice(0, 60) + '…' : x + return JSON.stringify(x).slice(0, 80) +} +function initCtx(ctx: Ctx) { + ctx.products ??= [] + ctx.emp ??= {} + ctx.supplies ??= [] + ctx.pay ??= [] + ctx.cashOpening ??= 0 + ctx.bankOpening ??= 0 + ctx.acqOpening ??= 0 + ctx.initialDebt ??= 0 + ctx.tag ??= IS_DEMO ? `(e2e) ${TS}` : `fc${TS}` +} +async function ui(ctx: Ctx): Promise { + if (ctx.apiOnly) return undefined + if (!ctx.ui) ctx.ui = await Ui.launch(RUN_ID) + return ctx.ui +} + +/** Навигация на экран + запись скрина процесса (ожид. до/после/ошибка) и + * UI-проверки «0 ошибок». В режиме B снимаем всегда; в C — только force + * (экран ошибки при реальном ✗) либо пост-фактум для упавших шагов. */ +async function capture(ctx: Ctx, step: Step, path: string, name: string, caption: string, opts?: { fullPage?: boolean; force?: boolean; check?: boolean }) { + const u = await ui(ctx); if (!u) return + u.resetErrors() + await u.goto(path) + await u.collectToasts() + if (SHOTS || opts?.force) { + const sh = await u.shot(step.id, name, caption, { fullPage: opts?.fullPage }) + ;(step.screenshots ??= []).push({ rel: sh.rel, caption }) + } + if (opts?.check !== false) { + const errs = u.errorSummary() + rec(step, 'ui', `Экран «${caption}»: 0 console/5xx/тостов`, errs === '', '0 ошибок', errs || '0 ошибок') + } +} + +/** Мобильный снимок (телефонные экраны: инвентаризация, фото-приёмка). */ +async function captureMobile(ctx: Ctx, step: Step, path: string, name: string, caption: string) { + const u = await ui(ctx); if (!u) return + if (!(SHOTS)) return + const sh = await u.shotMobile(step.id, name, caption, path) + ;(step.screenshots ??= []).push({ rel: sh.rel, caption: caption + ' (моб.)' }) +} + +/** В режиме C: если шаг упал — сохранить один скрин текущего экрана. */ +async function maybeFailShot(ctx: Ctx, step: Step) { + if (SHOTS) return + const u = ctx.ui; if (!u) return + if (!step.checks.some((c) => !c.ok)) return + const sh = await u.shot(step.id, `${step.id}-fail`, `Состояние на падении шага ${step.id}`) + ;(step.screenshots ??= []).push({ rel: sh.rel, caption: `Состояние на падении ${step.id}` }) +} + +// helper: inventory stock for product (store-scoped) +async function stockOf(api: AxiosInstance, storeId: string, productId: string): Promise { + const r = await api.get(`/api/inventory/stock?productId=${productId}&pageSize=200`) + if (r.status !== 200) return 0 + const items = (r.data?.items ?? []) as { storeId?: string; quantity: number; productId: string }[] + const row = items.find((i) => i.storeId === storeId && i.productId === productId) + return row ? Number(row.quantity) : 0 +} +// helper: sum of movements for a product+store (инвариант) +async function movementsSum(api: AxiosInstance, storeId: string, productId: string): Promise { + const r = await api.get(`/api/inventory/movements?productId=${productId}&storeId=${storeId}&pageSize=500`) + if (r.status !== 200) return NaN + const items = (r.data?.items ?? []) as { quantity: number }[] + return round2(sum(items.map((i) => Number(i.quantity)))) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Шаг 1 — регистрация/вход + простой режим +// ═══════════════════════════════════════════════════════════════════════════ +export async function step01_register_and_login({ ctx, step, report }: StepCtx) { + initCtx(ctx) + let sess: Session + if (IS_DEMO) { + sess = await demoLogin('admin') + rec(step, 'api', 'Вход demo-admin (режим B)', !!sess.token, 'token', sess.token ? 'token' : 'нет') + } else { + sess = await signupOrg() + rec(step, 'api', 'Регистрация организации e2e-fullchain + вход владельцем', !!sess.token, 'token', sess.token ? 'token' : 'нет') + } + ctx.owner = sess + ctx.ownerApi = makeClient(sess.token) + const m = await apiMe(sess.token) + rec(step, 'api', '/api/me: роль Admin', m.roles.includes('Admin'), 'Admin', m.roles.join(',')) + + // Простой режим по умолчанию (для temp-орг). Демо-магазин тоже простой. + rec(step, 'api', 'Простой режим включён (advancedMode=false)', m.advancedMode === false, false, m.advancedMode) + + // Скрытые модули недоступны: demands/transfers/promotions/loyalty/abc → 403 advanced-mode-required. + const api = ctx.ownerApi + for (const [name, path] of [ + ['приёмка оптовых отгрузок (demands)', '/api/sales/demands?pageSize=1'], + ['перемещения (transfers)', '/api/inventory/transfers?pageSize=1'], + ['акции (promotions)', '/api/promotions?pageSize=1'], + ['ABC-отчёт', '/api/reports/abc?from=2026-01-01&to=2026-12-31'], + ] as const) { + const r = await api.get(path) + const gated = r.status === 403 && String(r.data?.type ?? '').includes('advanced-mode-required') + rec(step, 'api', `Скрытый модуль недоступен: ${name}`, gated, '403 advanced-mode-required', `${r.status} ${r.data?.type ?? ''}`) + if (!gated && r.status < 400) { + report.bug({ step: '01', severity: 'high', title: `Скрытый модуль доступен в простом режиме: ${name}`, detail: `${path} → ${r.status}` }) + } + } + + // UI: вход владельца в кабинет (без пароля на экране — токеном в localStorage). + const u = await ui(ctx) + if (u) { + u.resetErrors() + await u.attach(sess.token, '/dashboard') + await u.collectToasts() + if (SHOTS) { + const sh = await u.shot(step.id, '01-vhod-dashboard', 'Вход владельца → дашборд организации') + ;(step.screenshots ??= []).push({ rel: sh.rel, caption: 'Вход владельца → дашборд' }) + } + const errs = u.errorSummary() + rec(step, 'ui', 'Кабинет владельца открылся без ошибок (0 console/5xx/тостов)', errs === '', '0 ошибок', errs || '0 ошибок') + } + await maybeFailShot(ctx, step) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Шаг 2 — справочники: группа, 5 товаров, поставщик +// ═══════════════════════════════════════════════════════════════════════════ +export async function step02_catalog_and_supplier({ ctx, step, report }: StepCtx) { + const api = ctx.ownerApi! + if (!api) { step.status = 'skip'; return } + + // currency KZT, retail price type + const cur = await api.get('/api/catalog/currencies?pageSize=50') + ctx.currencyId = ((cur.data?.items ?? []) as { id: string; code: string }[]).find((c) => c.code === 'KZT')?.id + rec(step, 'api', 'Валюта KZT в справочнике', !!ctx.currencyId, 'KZT', ctx.currencyId ? 'есть' : 'нет') + + const pt = await api.get('/api/catalog/price-types?pageSize=50') + const retailPt = ((pt.data?.items ?? []) as { id: string; isRetail?: boolean }[]).find((p) => p.isRetail) ?? (pt.data?.items ?? [])[0] + const retailPtId = retailPt?.id + + // store (bootstrap main) + const stores = await api.get('/api/catalog/stores?pageSize=50') + const mainStore = ((stores.data?.items ?? []) as { id: string; isMain: boolean }[]).find((s) => s.isMain) ?? (stores.data?.items ?? [])[0] + ctx.storeId = mainStore?.id + rec(step, 'api', 'Магазин (склад) существует', !!ctx.storeId, '≥1', ctx.storeId ? '1' : '0') + + // units — берём до 5 различных; «весовой» = тот где в названии кг/килограмм, иначе первый + const unitsRes = await api.get('/api/catalog/units-of-measure?pageSize=50') + const units = ((unitsRes.data?.items ?? []) as { id: string; name: string; symbol?: string }[]) + rec(step, 'api', 'Единицы измерения доступны (≥5 различных)', units.length >= 5, '≥5', units.length) + const pick = units.slice(0, 5) + const kgIdx = pick.findIndex((u) => /кг|килограм|kg/i.test(u.name + ' ' + (u.symbol ?? ''))) + + if (IS_DEMO) { + // Режим B: берём НАСТОЯЩИЕ товары каталога «Демо-магазина» (со штрихкодом и + // ценой), ничего не создаём. Владелец потом откроет и увидит знакомые позиции. + const list = await api.get('/api/catalog/products?pageSize=80&sortBy=systemPrice&sortDir=desc') + const items = (list.data?.items ?? []) as any[] + const priceOf = (p: any) => Math.max(0, ...(p.prices ?? []).map((pr: any) => Number(pr.amount ?? pr.price ?? 0))) + const usable = items.filter((p) => (p.barcodes?.length ?? 0) > 0 + && String(p.barcodes[0].code).replace(/\D/g, '').length >= 6 + && priceOf(p) > 0) + const seenBc = new Set() + for (const p of usable) { + if (ctx.products.length >= 12) break + const barcode = String(p.barcodes[0].code) + if (seenBc.has(barcode)) continue // дедуп: в демо-каталоге есть дубли товаров с одним ШК + seenBc.add(barcode) + const price = round2(priceOf(p)) + ctx.products.push({ + id: p.id, name: p.name, price, cost: round2(price * 0.6), + unitId: p.unitOfMeasureId, unitName: p.unitName ?? '', + barcode, weighted: /кг|килограм|kg/i.test(p.unitName ?? ''), + }) + } + rec(step, 'api', 'Выбрано реальных товаров каталога (≥5, штрихкод+цена)', ctx.products.length >= 5, '≥5', ctx.products.length) + rec(step, 'api', 'Среди выбранных есть весовой (информативно)', true, '≥0', ctx.products.filter((p) => p.weighted).length) + if (ctx.products.length < 5) report.bug({ step: '02', severity: 'high', title: 'В демо-каталоге не нашлось 5 товаров со штрихкодом и ценой', detail: `usable=${usable.length}` }) + // поставщик (реалистичный, с меткой e2e) + const countriesD = await api.get('/api/catalog/countries?pageSize=200') + const kzD = ((countriesD.data?.items ?? []) as { id: string; code: string }[]).find((c) => c.code === 'KZ') + const supD = await api.post('/api/catalog/counterparties', { + name: `[e2e] ТОО Алматы Трейд ${TS}`, legalName: 'ТОО Алматы Трейд', type: 0, + bin: '050140004321', iin: null, taxNumber: null, countryId: kzD?.id ?? null, + address: 'Алматы, пр. Райымбека 200', phone: '+77272501020', email: 'opt@almaty-trade.kz', + bankName: 'Halyk Bank', bankAccount: 'KZ22009000012345', bik: null, contactPerson: 'Асан', notes: null, + }) + ctx.supplierId = supD.data?.id + rec(step, 'api', 'Поставщик [e2e] создан', supD.status < 400 && !!ctx.supplierId, '200+id', `${supD.status}`) + await capture(ctx, step, '/catalog/products', '02-tovary-realnye', 'Каталог «Демо-магазина»: реальные товары', { fullPage: true }) + await capture(ctx, step, '/catalog/counterparties', '02-postavshchik', 'Поставщик [e2e]') + await maybeFailShot(ctx, step) + return + } + + // группа товаров + const grp = await api.post('/api/catalog/product-groups', { name: 'e2e Группа', parentId: null, sortOrder: 0 }) + ctx.groupId = grp.data?.id + rec(step, 'api', 'Группа товаров создана', grp.status < 400 && !!ctx.groupId, '200+id', `${grp.status}`) + + // 5 товаров: разные ед.изм., 1 весовой, цены + штрихкоды + for (let i = 0; i < 5; i++) { + const unit = pick[i] ?? pick[0] + const weighted = i === (kgIdx >= 0 ? kgIdx : 0) + const price = 500 + i * 200 + const cost = round2(price * 0.6) + const barcode = ean13(i) + const r = await api.post('/api/catalog/products', { + name: `${IS_DEMO ? '[e2e] ' : ''}Товар ${i + 1} ${ctx.tag}`, + article: `FC-${TS}-${i + 1}`, + description: null, unitOfMeasureId: unit.id, vat: 12, vatEnabled: true, + productGroupId: ctx.groupId, defaultSupplierId: null, countryOfOriginId: null, + isService: false, packaging: 0, isMarked: false, minStock: null, maxStock: null, + referencePrice: cost, purchaseCurrencyId: ctx.currencyId, imageUrl: null, + prices: retailPtId ? [{ priceTypeId: retailPtId, amount: price, currencyId: ctx.currencyId }] : [], + barcodes: [{ code: barcode, type: 0, isPrimary: true }], + }) + if (r.status >= 400) { + report.bug({ step: '02', severity: 'critical', title: `Товар №${i + 1} не создан`, detail: `${r.status} ${JSON.stringify(r.data).slice(0, 160)}` }) + rec(step, 'api', `Товар №${i + 1} создан`, false, '200', `${r.status}`) + continue + } + ctx.products.push({ id: r.data.id ?? r.data.productId, name: r.data.name, price, cost, unitId: unit.id, unitName: unit.name, barcode, weighted }) + } + rec(step, 'api', 'Создано 5 товаров (разные ед.изм.)', ctx.products.length === 5, 5, ctx.products.length) + rec(step, 'api', 'Один товар весовой (дробное кол-во)', ctx.products.some((p) => p.weighted), '1', ctx.products.filter((p) => p.weighted).length) + // проверим что штрихкод реально сохранён и ищется + if (ctx.products[0]) { + const byBc = await api.get(`/api/purchases/invoices/product-by-barcode?barcode=${ctx.products[0].barcode}`) + rec(step, 'api', 'Товар находится по штрихкоду', byBc.status === 200, '200', `${byBc.status}`) + } + + // поставщик (bin + телефон) + const countries = await api.get('/api/catalog/countries?pageSize=200') + const kz = ((countries.data?.items ?? []) as { id: string; code: string }[]).find((c) => c.code === 'KZ') + const sup = await api.post('/api/catalog/counterparties', { + name: `${IS_DEMO ? '[e2e] ' : ''}ТОО Поставщик ${ctx.tag}`, legalName: 'ТОО Поставщик', type: 0, + bin: '987654321098', iin: null, taxNumber: null, countryId: kz?.id ?? null, + address: 'Алматы', phone: '+77003332211', email: 'supplier@example.kz', + bankName: null, bankAccount: null, bik: null, contactPerson: 'Иванов', notes: null, + }) + ctx.supplierId = sup.data?.id + rec(step, 'api', 'Поставщик создан (БИН+телефон)', sup.status < 400 && !!ctx.supplierId, '200+id', `${sup.status}`) + + // скриншоты результата: каталог товаров + карточка товара (цена/штрихкод) + поставщик + await capture(ctx, step, '/catalog/products', '02-tovary-spisok', 'Каталог: 5 товаров с ценами и штрихкодами', { fullPage: true }) + if (ctx.products[0]) await capture(ctx, step, `/catalog/products/${ctx.products[0].id}`, '02-tovar-kar_tochka', 'Карточка товара: цена продажи и штрихкод') + await capture(ctx, step, '/catalog/counterparties', '02-postavshchik', 'Справочник контрагентов: поставщик (БИН, телефон)') + await maybeFailShot(ctx, step) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Шаг 2а — сотрудники + точки продаж + права +// ═══════════════════════════════════════════════════════════════════════════ +export async function step02a_employees_and_points({ ctx, step, report }: StepCtx) { + const api = ctx.ownerApi! + if (!api || !ctx.storeId) { step.status = 'skip'; return } + + // штрихкоды товаров — валидный EAN-13 (баг iOS: генератор давал неверную + // контрольную цифру у 12-значного тела). Проверяем 13-значные коды. + const ean13s = ctx.products.filter((p) => /^\d{13}$/.test(p.barcode)) + if (ean13s.length > 0) { + const bad = ean13s.filter((p) => !isValidEan13(p.barcode)) + rec(step, 'api', 'Штрихкоды EAN-13 с валидной контрольной цифрой', bad.length === 0, '0 неверных', `${bad.length}/${ean13s.length}`) + if (bad.length > 0) report.bug({ step: '02a', severity: 'high', title: 'Неверная контрольная цифра EAN-13', detail: bad.map((p) => p.barcode).join(',') }) + } + + // системные роли + const rolesRes = await api.get('/api/organization/employee-roles?pageSize=200') + const roles = (rolesRes.data?.items ?? []) as { id: string; name: string }[] + const roleId = (n: string) => roles.find((r) => r.name === n)?.id + rec(step, 'api', 'Системные роли Кассир/Кладовщик есть', !!roleId('Кассир') && !!roleId('Кладовщик'), 'есть', roles.map((r) => r.name).join(',')) + + // 2 точки продаж (на будущие кассы привяжем в шаге 3) + for (const [key, name] of [['point1Id', `Касса-1 ${ctx.tag}`], ['point2Id', `Касса-2 ${ctx.tag}`]] as const) { + const r = await api.post('/api/catalog/retail-points', { name, code: `${key === 'point1Id' ? 'P1' : 'P2'}-${TS}`, storeId: ctx.storeId, isActive: true }) + ;(ctx as Record)[key] = r.data?.id + rec(step, 'api', `Точка продаж создана: ${name}`, r.status < 400 && !!r.data?.id, '200+id', `${r.status}`) + } + + // редактирование точки + деактивация/активация + if (ctx.point2Id) { + const upd = await api.put(`/api/catalog/retail-points/${ctx.point2Id}`, { name: `Касса-2изм ${ctx.tag}`, code: `P2-${TS}`, storeId: ctx.storeId, isActive: false }) + rec(step, 'api', 'Точка 2: переименование + деактивация', upd.status < 400, '200', `${upd.status}`) + const act = await api.put(`/api/catalog/retail-points/${ctx.point2Id}`, { name: `Касса-2изм ${ctx.tag}`, code: `P2-${TS}`, storeId: ctx.storeId, isActive: true }) + rec(step, 'api', 'Точка 2: повторная активация', act.status < 400, '200', `${act.status}`) + } + + // сотрудники: кассир, кладовщик, админ(e2e) + for (const [key, role, ln, fn, pos] of [ + ['cashier', 'Кассир', 'Кассиров', 'Пётр', 'Кассир'], + ['keeper', 'Кладовщик', 'Кладовщиков', 'Иван', 'Кладовщик'], + ['admin2', 'Администратор', 'Админов', 'Анна', 'Администратор'], + ] as const) { + const rid = roleId(role) ?? roleId('Кассир') + const email = `fc-${key}-${TS}@food-market.local` + const points = key === 'cashier' && ctx.point1Id ? [ctx.point1Id] : [] + const r = await api.post('/api/organization/employees', { + lastName: `${ln}${IS_DEMO ? ' e2e' : ''}`, firstName: fn, middleName: null, position: pos, + email, phone: '+77002223344', salary: null, taxNumber: null, description: null, imageUrl: null, + roleId: rid, isActive: true, retailPointIds: points, createAccount: true, + }) + if (r.status < 400 && r.data?.generatedPassword) { + ctx.emp[key] = { id: r.data.employee?.id ?? r.data.id, email, password: r.data.generatedPassword, role } + rec(step, 'api', `Сотрудник создан: ${pos}`, true, '200+пароль', 'ok') + } else { + rec(step, 'api', `Сотрудник создан: ${pos}`, false, '200', `${r.status} ${JSON.stringify(r.data).slice(0, 120)}`) + } + } + + // права API: кассир НЕ видит деньги и настройки; кладовщик НЕ видит деньги + if (ctx.emp.cashier) { + const cashLogin = await loginCreds(ctx.emp.cashier.email, ctx.emp.cashier.password) + ctx.emp.cashier.token = cashLogin.token // чистый токен до любых неудачных попыток (переиспользуем в 9а) + const c = makeClient(cashLogin.token) + const money = await c.get('/api/money/accounts?pageSize=1') + rec(step, 'api', 'Кассир → деньги (API) 403', money.status === 403, 403, money.status) + const txs = await c.get('/api/money/transactions?pageSize=1') + rec(step, 'api', 'Кассир → платежи (API) 403', txs.status === 403, 403, txs.status) + const emps = await c.get('/api/organization/employees?pageSize=1') + rec(step, 'api', 'Кассир → сотрудники/настройки (API) 403', emps.status === 403, 403, emps.status) + const sales = await c.get('/api/sales/retail?pageSize=1') + rec(step, 'api', 'Кассир → продажи доступны', sales.status === 200, 200, sales.status) + // но свою смену/кассу кассир видит (CashShiftOperate по ИЛИ-семантике) + const shifts = await c.get('/api/money/shifts?mine=true&pageSize=1') + rec(step, 'api', 'Кассир → свои смены доступны (касса)', shifts.status === 200, 200, shifts.status) + if (money.status !== 403 || emps.status !== 403) { + report.bug({ step: '02a', severity: 'critical', title: 'Кассир видит деньги/настройки', detail: `money=${money.status} emps=${emps.status}` }) + } + + // Права в UI: в сайдбаре кассира нет «Деньги»/«Настройки», прямой URL не даёт данных. + const u = await ui(ctx) + if (u) { + const hrefs = await u.sidebarHrefs(cashLogin.token!) + // Кассир НЕ должен видеть модуль «Деньги» (счета/платежи/долги/общий отчёт), + // но СВОЮ кассу (/money/shifts) видит — это его смены. + const moneyModule = ['/money/accounts', '/money/transactions', '/money/supplier-debts', '/money/report', '/money/expense-items'] + const noMoneyModule = !hrefs.some((h) => moneyModule.some((x) => h.startsWith(x))) + const noSettings = !hrefs.some((h) => h.startsWith('/settings')) + const hasShifts = hrefs.some((h) => h.startsWith('/money/shifts')) + rec(step, 'ui', 'Кассир: в меню нет модуля «Деньги» (счета/платежи/долги)', noMoneyModule, 'нет', hrefs.filter((h) => moneyModule.some((x) => h.startsWith(x))).join(',') || 'нет') + rec(step, 'ui', 'Кассир: в меню есть своя касса (/money/shifts)', hasShifts, 'есть', hasShifts ? 'есть' : 'нет') + rec(step, 'ui', 'Кассир: в меню нет «Настройки»', noSettings, 'нет /settings', hrefs.filter((h) => h.startsWith('/settings')).join(',') || 'нет') + if (!noMoneyModule || !noSettings) report.bug({ step: '02a', severity: 'high', title: 'В меню кассира видны запретные разделы', detail: hrefs.join(',') }) + if (SHOTS) { + const sh = await u.shotAs(step.id, cashLogin.token!, '/dashboard', '02a-kabinet-kassira', 'Кабинет кассира: без «Деньги»/«Настроек»') + ;(step.screenshots ??= []).push({ rel: sh.rel, caption: 'Кабинет кассира (ограниченное меню)' }) + const sh2 = await u.shotAs(step.id, cashLogin.token!, '/money/accounts', '02a-kassir-pryamoy-url', 'Кассир по прямому URL /money/accounts — нет данных') + ;(step.screenshots ??= []).push({ rel: sh2.rel, caption: 'Кассир: прямой URL /money/accounts не открывает данные' }) + } + } + + // Смена пароля (самообслуживание): новый пароль работает, старый — нет. + const oldPass = ctx.emp.cashier.password + const newPass = 'NewCashier123!' + const cp = await c.post('/api/me/change-password', { currentPassword: oldPass, newPassword: newPass }) + rec(step, 'api', 'Кассир сменил пароль', cp.status < 400, '204', `${cp.status}`) + if (cp.status < 400) { + const newOk = await loginCreds(ctx.emp.cashier.email, newPass) + const oldOk = await loginCreds(ctx.emp.cashier.email, oldPass) + rec(step, 'api', 'Вход по новому паролю — успех', newOk.ok, 'успех', newOk.status) + rec(step, 'api', 'Вход по старому паролю — отказ', !oldOk.ok, 'отказ', oldOk.status) + ctx.emp.cashier.password = newPass + } + } + if (ctx.emp.keeper) { + const k = makeClient((await loginCreds(ctx.emp.keeper.email, ctx.emp.keeper.password)).token) + const money = await k.get('/api/money/accounts?pageSize=1') + rec(step, 'api', 'Кладовщик → деньги 403', money.status === 403, 403, money.status) + const supplies = await k.get('/api/purchases/supplies?pageSize=1') + rec(step, 'api', 'Кладовщик → приёмки доступны', supplies.status === 200, 200, supplies.status) + if (money.status !== 403) report.bug({ step: '02a', severity: 'critical', title: 'Кладовщик видит деньги', detail: `money=${money.status}` }) + } + + // правка сотрудника (ФИО/телефон), смена пароля, блокировка → не входит + if (ctx.emp.cashier) { + const rolesRes2 = roles + const rid = rolesRes2.find((r) => r.name === 'Кассир')?.id + // редактирование + const edit = await api.put(`/api/organization/employees/${ctx.emp.cashier.id}`, { + lastName: 'Кассиров-изм', firstName: 'Пётр', middleName: 'Петрович', position: 'Старший кассир', + email: ctx.emp.cashier.email, phone: '+77009998877', salary: null, taxNumber: null, + description: null, imageUrl: null, roleId: rid, isActive: true, retailPointIds: ctx.point1Id ? [ctx.point1Id] : [], + }) + rec(step, 'api', 'Правка сотрудника (ФИО/тел/должность)', edit.status < 400, '200', `${edit.status}`) + // до блокировки — получим активный refresh-token (проверим, что после + // блокировки обновление по нему тоже не сработает). + const activeSess = await loginCreds(ctx.emp.cashier.email, ctx.emp.cashier.password) + // блокировка (увольнение): isActive=false + const block = await api.put(`/api/organization/employees/${ctx.emp.cashier.id}`, { + lastName: 'Кассиров-изм', firstName: 'Пётр', middleName: 'Петрович', position: 'Старший кассир', + email: ctx.emp.cashier.email, phone: '+77009998877', salary: null, taxNumber: null, + description: null, imageUrl: null, roleId: rid, isActive: false, retailPointIds: [], + }) + rec(step, 'api', 'Блокировка (увольнение) сотрудника', block.status < 400, '200', `${block.status}`) + // заблокированный не входит: ни token, ни refresh + const relog = await loginCreds(ctx.emp.cashier.email, ctx.emp.cashier.password) + rec(step, 'api', 'Заблокированный кассир не получает токен', !relog.ok, 'отказ', relog.status) + if (relog.ok) report.bug({ step: '02a', severity: 'critical', title: 'Заблокированный сотрудник всё ещё входит', detail: `token выдан (${relog.status})` }) + if (activeSess.refresh) { + const reref = await refreshToken(activeSess.refresh) + rec(step, 'api', 'Заблокированный кассир: обновление токена (refresh) → отказ', !reref.ok, 'отказ', reref.status) + if (reref.ok) report.bug({ step: '02a', severity: 'critical', title: 'Заблокированный сотрудник обновляет токен по refresh', detail: `refresh ok (${reref.status})` }) + } + // восстановим для дальнейших шагов смены (разблокируем + привяжем к точке 1) + await api.put(`/api/organization/employees/${ctx.emp.cashier.id}`, { + lastName: 'Кассиров', firstName: 'Пётр', middleName: null, position: 'Кассир', + email: ctx.emp.cashier.email, phone: '+77009998877', salary: null, taxNumber: null, + description: null, imageUrl: null, roleId: rid, isActive: true, retailPointIds: ctx.point1Id ? [ctx.point1Id] : [], + }) + } + + await capture(ctx, step, '/settings/employees', '02a-sotrudniki', 'Сотрудники: кассир, кладовщик, администратор (роли)', { fullPage: true }) + await capture(ctx, step, '/catalog/retail-points', '02a-tochki-prodazh', 'Точки продаж (2 кассы)') + await maybeFailShot(ctx, step) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Шаг 3 — деньги: касса, банк, эквайринг + начальные остатки +// ═══════════════════════════════════════════════════════════════════════════ +export async function step03_money_accounts({ ctx, step, report }: StepCtx) { + const api = ctx.ownerApi! + if (!api || !ctx.storeId || !ctx.currencyId) { step.status = 'skip'; return } + const today = new Date().toISOString() + ctx.cashOpening = 50_000; ctx.bankOpening = 200_000; ctx.acqOpening = 0 + + const mk = async (name: string, kind: number, extra: Record, opening: number) => { + const r = await api.post('/api/money/accounts', { + Name: name, Kind: kind, StoreId: null, CurrencyId: ctx.currencyId, + OpeningBalance: opening, OpeningDate: today, IsActive: true, IsDefault: false, ...extra, + }) + return r + } + // касса 1 (привязана к магазину — обязательно для Cash) + const c1 = await mk(`Касса ${ctx.tag}`, 1, { StoreId: ctx.storeId }, ctx.cashOpening) + ctx.cash1Id = c1.data?.id + rec(step, 'api', 'Касса магазина создана (Cash, нач.остаток 50000)', c1.status < 400 && !!ctx.cash1Id, '200+id', `${c1.status}`) + // банковский счёт (с номером) + const bank = await mk(`Банк ${ctx.tag}`, 3, { BankName: 'Kaspi', AccountNumber: 'KZ123456789012345678' }, ctx.bankOpening) + ctx.bankId = bank.data?.id + rec(step, 'api', 'Банковский счёт создан (Bank, номер, нач.остаток 200000)', bank.status < 400 && !!ctx.bankId, '200+id', `${bank.status}`) + // эквайринг + const acq = await mk(`Эквайринг ${ctx.tag}`, 4, {}, ctx.acqOpening) + ctx.acq1Id = acq.data?.id + rec(step, 'api', 'Счёт эквайринга создан (Acquiring)', acq.status < 400 && !!ctx.acq1Id, '200+id', `${acq.status}`) + + // касса 2 + эквайринг 2 для второй точки (шаг 2а: у каждой точки своя касса/эквайринг) + const c2 = await mk(`Касса-2 ${ctx.tag}`, 1, { StoreId: ctx.storeId }, 3_000) + ctx.cash2Id = c2.data?.id + const a2 = await mk(`Эквайринг-2 ${ctx.tag}`, 4, {}, 0) + ctx.acq2Id = a2.data?.id + rec(step, 'api', 'Для 2-й точки созданы своя касса и эквайринг', !!ctx.cash2Id && !!ctx.acq2Id, '2 счёта', `${c2.status}/${a2.status}`) + + // проверка начального остатка в отчёте balances + const bal = await api.get('/api/money/reports/balances') + const rows = (bal.data?.accounts ?? []) as { accountId: string; currentBalance: number; openingBalance: number }[] + const cashRow = rows.find((r) => r.accountId === ctx.cash1Id) + rec(step, 'api', 'Отчёт «Деньги»: касса currentBalance = начальный остаток', !!cashRow && approxEq(cashRow.currentBalance, ctx.cashOpening), ctx.cashOpening, cashRow?.currentBalance) + const bankRow = rows.find((r) => r.accountId === ctx.bankId) + rec(step, 'api', 'Отчёт «Деньги»: банк currentBalance = начальный остаток', !!bankRow && approxEq(bankRow.currentBalance, ctx.bankOpening), ctx.bankOpening, bankRow?.currentBalance) + + await capture(ctx, step, '/money/accounts', '03-scheta', 'Счета: касса, банк (номер), эквайринг — начальные остатки') + await maybeFailShot(ctx, step) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Шаг 4 — начальный долг поставщику +// ═══════════════════════════════════════════════════════════════════════════ +export async function step04_initial_supplier_debt({ ctx, step, report }: StepCtx) { + const api = ctx.ownerApi! + if (!api || !ctx.supplierId) { step.status = 'skip'; return } + ctx.initialDebt = 100_000 + const adj = await api.post('/api/money/adjustments', { + Date: new Date().toISOString(), CounterpartyId: ctx.supplierId, + Direction: 1 /* WeOwe */, Amount: ctx.initialDebt, Notes: 'Начальный долг (e2e)', + }) + rec(step, 'api', 'Корректировка взаиморасчётов создана (WeOwe 100000)', adj.status < 400 && !!adj.data?.id, '200+id', `${adj.status}`) + if (adj.data?.id) { + const post = await api.post(`/api/money/adjustments/${adj.data.id}/post`, {}) + rec(step, 'api', 'Корректировка проведена', post.status < 400, '204', `${post.status}`) + } + // долг поставщика = начальный долг + const debts = await api.get('/api/money/reports/supplier-debts?withDebtOnly=false') + const row = ((debts.data?.rows ?? []) as { supplierId: string; debt: number }[]).find((r) => r.supplierId === ctx.supplierId) + rec(step, 'api', 'Долг поставщика = 100000 (только начальный)', !!row && approxEq(row.debt, ctx.initialDebt), ctx.initialDebt, row?.debt) + + await capture(ctx, step, '/money/supplier-debts', '04-dolg-nachalnyy', 'Долги поставщикам: начальный долг 100 000 ₸') + await maybeFailShot(ctx, step) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Шаг 5 — приёмка №1 вручную (кладовщик) +// ═══════════════════════════════════════════════════════════════════════════ +export async function step05_supply1_manual({ ctx, step, report }: StepCtx) { + if (!ctx.storeId || !ctx.currencyId || !ctx.supplierId || ctx.products.length < 5) { step.status = 'skip'; return } + // кладовщик проводит приёмку (если есть e2e-кладовщик — его токеном; иначе владельцем) + const keeperLogin = ctx.emp.keeper ? await loginCreds(ctx.emp.keeper.email, ctx.emp.keeper.password) : null + const api = keeperLogin?.token ? makeClient(keeperLogin.token) : ctx.ownerApi! + rec(step, 'api', 'Кладовщик авторизован для приёмки', !!api, 'token', keeperLogin?.token ? 'keeper' : 'owner') + + // остатки до + const before: Record = {} + for (const p of ctx.products) before[p.id] = await stockOf(ctx.ownerApi!, ctx.storeId, p.id) + + // строки: весовой товар — дробное количество + const lines = ctx.products.map((p, i) => ({ productId: p.id, quantity: p.weighted ? 12.5 : 20 + i * 5, unitPrice: p.cost })) + const total = round2(sum(lines.map((l) => l.quantity * l.unitPrice))) + const draft = await api.post('/api/purchases/supplies', { + date: new Date().toISOString(), supplierId: ctx.supplierId, storeId: ctx.storeId, + currencyId: ctx.currencyId, notes: `Приёмка №1 ${ctx.tag}`, + lines: lines.map((l) => ({ productId: l.productId, quantity: l.quantity, unitPrice: l.unitPrice })), + }) + const supplyId = draft.data?.id ?? draft.data?.supplyId + rec(step, 'api', 'Приёмка №1 создана (черновик)', draft.status < 400 && !!supplyId, '200+id', `${draft.status}`) + if (!supplyId) { report.bug({ step: '05', severity: 'critical', title: 'Приёмка №1 не создана', detail: JSON.stringify(draft.data).slice(0, 160) }); return } + + const post = await api.post(`/api/purchases/supplies/${supplyId}/post`, {}) + rec(step, 'api', 'Приёмка №1 проведена', post.status === 200 || post.status === 204, '204', `${post.status}`) + if (post.status >= 400) { report.bug({ step: '05', severity: 'critical', title: 'Приёмка №1 не проводится', detail: JSON.stringify(post.data).slice(0, 160) }); return } + ctx.supplies.push({ id: supplyId, total, supplierId: ctx.supplierId, lines, paid: 0 }) + + // остатки выросли ровно на количество + let allOk = true + for (const l of lines) { + const now = await stockOf(ctx.ownerApi!, ctx.storeId, l.productId) + const delta = round2(now - (before[l.productId] ?? 0)) + const ok = approxEq(delta, l.quantity) + if (!ok) allOk = false + rec(step, 'api', `Остаток ${l.productId.slice(0, 8)}… +${l.quantity}`, ok, l.quantity, delta) + } + if (!allOk) report.bug({ step: '05', severity: 'high', title: 'Остатки не выросли на количество приёмки', detail: `supply=${supplyId}` }) + + // себестоимость: stock report Cost == unitCost приёмки для одного товара + const stockRep = await ctx.ownerApi!.get(`/api/reports/stock?storeId=${ctx.storeId}&includeZero=false`) + const srows = (stockRep.data ?? []) as { productId?: string; cost: number }[] + const p0 = ctx.products[0] + const srow = srows.find((r) => r.productId === p0.id) + rec(step, 'api', 'Себестоимость товара = цене приёмки', !!srow && approxEq(Number(srow.cost), p0.cost), p0.cost, srow?.cost) + + // UI: список приёмок (до — форма новой приёмки) и карточка проведённой приёмки + await capture(ctx, step, '/purchases/supplies/new', '05-priemka1-forma', 'Приёмка №1: форма создания (до проведения)') + await capture(ctx, step, '/purchases/supplies', '05-priemka1-spisok', 'Список приёмок') + await capture(ctx, step, `/purchases/supplies/${supplyId}`, '05-priemka1-provedena', 'Приёмка №1: проведена, строки и себестоимость', { fullPage: true }) + await maybeFailShot(ctx, step) +} + +// multipart upload через глобальный fetch (TLS off на стенде self-signed) +async function uploadFile(token: string, path: string, filename: string, bytes: Buffer, contentType: string, field = 'files', extra?: Record): Promise<{ status: number; body: any }> { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' + const fd = new FormData() + fd.append(field, new Blob([bytes], { type: contentType }), filename) + for (const [k, v] of Object.entries(extra ?? {})) fd.append(k, v) + const r = await fetch(BASE + path, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: fd }) + let body: any = null + try { body = await r.json() } catch { body = null } + return { status: r.status, body } +} + +async function waitInvoiceParsed(api: AxiosInstance, id: string, timeoutMs: number): Promise<{ status: string; lines: any[]; supplierName?: string }> { + const t0 = Date.now() + let last = 'pending' + while (Date.now() - t0 < timeoutMs) { + const r = await api.get(`/api/purchases/invoices/${id}`) + if (r.status === 200) { + last = r.data.status + if (last !== 'pending' && last !== 'parsing') { + return { status: last, lines: r.data.lines ?? [], supplierName: r.data.supplierName } + } + } + await sleep(3000) + } + return { status: last, lines: [] } +} + +/** Продажа: создать draft → прочитать Total → выставить точную оплату → провести. */ +async function makeSale(api: AxiosInstance, ctx: Ctx, opts: { productId: string; qty: number; unitPrice: number; method: 0 | 1 | 99; pointId: string }): Promise<{ id?: string; total: number; paidCash: number; paidCard: number; postStatus: number; postBody: any }> { + const base = { + date: new Date().toISOString(), storeId: ctx.storeId, retailPointId: opts.pointId, + customerId: null, currencyId: ctx.currencyId, + lines: [{ productId: opts.productId, quantity: opts.qty, unitPrice: opts.unitPrice, discount: 0, vatPercent: 12 }], + } + const draft = await api.post('/api/sales/retail', { ...base, payment: opts.method, paidCash: 0, paidCard: 0, notes: 'e2e' }) + const id = draft.data?.id ?? draft.data?.saleId + if (!id) return { id: undefined, total: 0, paidCash: 0, paidCard: 0, postStatus: draft.status, postBody: draft.data } + const got = await api.get(`/api/sales/retail/${id}`) + const total = round2(Number(got.data?.total ?? 0)) + let paidCash = 0, paidCard = 0 + if (opts.method === 0) paidCash = total + else if (opts.method === 1) paidCard = total + else { paidCash = Math.floor(total / 2); paidCard = round2(total - paidCash) } + await api.put(`/api/sales/retail/${id}`, { ...base, payment: opts.method, paidCash, paidCard, notes: 'e2e' }) + const post = await api.post(`/api/sales/retail/${id}/post`, {}) + return { id, total, paidCash, paidCard, postStatus: post.status, postBody: post.data } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Шаг 6 — приёмка №2 из табличной накладной (поставщик не указан → placeholder) +// ═══════════════════════════════════════════════════════════════════════════ +export async function step06_supply2_excel({ ctx, step, report }: StepCtx) { + const api = ctx.ownerApi! + if (!api || !ctx.storeId || !ctx.currencyId || ctx.products.length < 5) { step.status = 'skip'; return } + const picks = ctx.products.slice(0, 3) + const qty = [7, 4, 9] + // CSV-накладная «по шаблону»: Штрихкод; Наименование; Количество; Цена + const rows = ['Штрихкод;Наименование;Количество;Цена'] + picks.forEach((p, i) => rows.push(`${p.barcode};${p.name};${qty[i]};${p.cost}`)) + const csv = '' + rows.join('\r\n') + const before: Record = {} + for (const p of picks) before[p.id] = await stockOf(api, ctx.storeId, p.id) + + const up = await uploadFile(ctx.owner!.token, '/api/purchases/invoices/upload', `nakladnaya-${TS}.csv`, Buffer.from(csv, 'utf8'), 'text/csv') + const invId = (up.body?.ids ?? [])[0] + rec(step, 'api', 'Табличная накладная загружена', up.status < 400 && !!invId, '200+id', `${up.status}`) + if (!invId) { report.bug({ step: '06', severity: 'high', title: 'Загрузка табличной накладной не вернула id', detail: JSON.stringify(up.body).slice(0, 160) }); return } + + const parsed = await waitInvoiceParsed(api, invId, 60_000) + rec(step, 'api', 'Накладная разобрана (не pending/parsing)', parsed.status === 'needs_review', 'needs_review', parsed.status) + + // ручной матч по штрихкоду (на случай если авто-матч не сработал) + const lines = (parsed.lines ?? []).map((l: any) => { + if (!l.productId) { + const p = picks.find((x) => x.barcode === (l.barcode ?? '').toString() || x.barcode === (l.supplierCode ?? '').toString()) + if (p) { l.productId = p.id; l.matchedBy = 'manual' } + } + return l + }) + await api.put(`/api/purchases/invoices/${invId}/lines`, { lines, supplierName: null, invoiceNumber: `TN-${TS}`, invoiceDate: new Date().toISOString(), totalSum: null }) + + const apply = await api.post(`/api/purchases/invoices/${invId}/apply`, { supplierId: null, storeId: ctx.storeId, currencyId: ctx.currencyId, notes: null }) + const supplyId = apply.data?.supplyId + rec(step, 'api', 'Накладная перенесена в приёмку (apply)', apply.status < 400 && !!supplyId, '200+supplyId', `${apply.status}`) + if (!supplyId) { report.bug({ step: '06', severity: 'high', title: 'apply табличной накладной не создал приёмку', detail: JSON.stringify(apply.data).slice(0, 160) }); return } + + // поставщик = «Поставщик не указан» + const sdet = await api.get(`/api/purchases/supplies/${supplyId}`) + const supName: string = sdet.data?.supplierName ?? '' + ctx.placeholderId = sdet.data?.supplierId + rec(step, 'api', 'Поставщик приёмки №2 = «Поставщик не указан»', /не указан/i.test(supName), 'Поставщик не указан', supName) + + const post = await api.post(`/api/purchases/supplies/${supplyId}/post`, {}) + rec(step, 'api', 'Приёмка №2 проведена', post.status === 200 || post.status === 204, '204', `${post.status}`) + const total = round2(sum(picks.map((p, i) => qty[i] * p.cost))) + ctx.supplies.push({ id: supplyId, total: round2(Number(sdet.data?.total ?? total)), supplierId: ctx.placeholderId!, lines: picks.map((p, i) => ({ productId: p.id, quantity: qty[i], unitPrice: p.cost })), paid: 0 }) + + let ok = true + for (let i = 0; i < picks.length; i++) { + const now = await stockOf(api, ctx.storeId, picks[i].id) + const delta = round2(now - (before[picks[i].id] ?? 0)) + if (!approxEq(delta, qty[i])) ok = false + rec(step, 'api', `Остаток ${picks[i].name.slice(0, 18)} +${qty[i]}`, approxEq(delta, qty[i]), qty[i], delta) + } + if (!ok) report.bug({ step: '06', severity: 'high', title: 'Остатки после приёмки №2 не совпали', detail: `supply=${supplyId}` }) + + await capture(ctx, step, '/purchases/invoices', '06-nakladnye-spisok', 'Загруженные накладные (табличная разобрана)') + await capture(ctx, step, `/purchases/supplies/${supplyId}`, '06-priemka2-provedena', 'Приёмка №2: «Поставщик не указан», проведена', { fullPage: true }) + await maybeFailShot(ctx, step) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Шаг 7 — приёмка №3 по фото-накладной (OCR) → ручное сопоставление +// ═══════════════════════════════════════════════════════════════════════════ +export async function step07_supply3_photo({ ctx, step, report }: StepCtx) { + const api = ctx.ownerApi! + if (!api || !ctx.storeId || !ctx.currencyId || !ctx.supplierId || ctx.products.length < 5) { step.status = 'skip'; return } + const { readFileSync, existsSync } = await import('node:fs') + const { resolve } = await import('node:path') + const fixture = resolve(import.meta.dirname, '..', '..', 'fixtures', 'invoice-sample.jpg') + if (!existsSync(fixture)) { + rec(step, 'api', 'Фикстура фото-накладной есть', false, 'invoice-sample.jpg', 'нет файла') + report.gap('tests/fixtures/invoice-sample.jpg отсутствует — сгенерируй scripts/make-invoice-fixture.ts') + return + } + const bytes = readFileSync(fixture) + // OCR идёт через Claude CLI (ssh на dev-vm) — внешняя зависимость, иногда + // транзиентно падает (status=failed). Ретраим загрузку до 3 раз. + let invId: string | undefined + let parsed: { status: string; lines: any[]; supplierName?: string } = { status: 'none', lines: [] } + for (let attempt = 1; attempt <= 3; attempt++) { + const up = await uploadFile(ctx.owner!.token, '/api/purchases/invoices/upload-photos', '01.jpg', bytes, 'image/jpeg', 'files') + invId = up.body?.id + if (!invId) { await sleep(2000); continue } + parsed = await waitInvoiceParsed(api, invId, 150_000) + if (parsed.status === 'needs_review') break + // неудачная попытка: удалим загрузку и попробуем снова + await api.delete(`/api/purchases/invoices/${invId}`) + invId = undefined + if (attempt < 3) await sleep(3000) + } + rec(step, 'api', 'Фото-накладная загружена', !!invId, '200+id', invId ? 'id' : 'нет') + rec(step, 'api', 'Фото-накладная распознана (OCR, needs_review; до 3 попыток)', parsed.status === 'needs_review', 'needs_review', parsed.status) + if (!invId || parsed.status !== 'needs_review') { + report.bug({ step: '07', severity: 'high', title: 'OCR фото-накладной не дал needs_review за 3 попытки', detail: `status=${parsed.status}` }) + return + } + rec(step, 'api', 'OCR извлёк хотя бы одну строку', (parsed.lines?.length ?? 0) >= 1, '≥1', parsed.lines?.length ?? 0) + + // ручное сопоставление: каждую распознанную строку — к нашему товару по индексу + const useLines = (parsed.lines ?? []).slice(0, Math.min(3, parsed.lines.length)).map((l: any, i: number) => ({ + ...l, productId: ctx.products[i].id, matchedBy: 'manual', quantity: l.quantity > 0 ? l.quantity : 3, + })) + await api.put(`/api/purchases/invoices/${invId}/lines`, { lines: useLines, supplierName: `ТОО Поставщик ${ctx.tag}`, invoiceNumber: `PHOTO-${TS}`, invoiceDate: new Date().toISOString(), totalSum: null }) + const apply = await api.post(`/api/purchases/invoices/${invId}/apply`, { supplierId: ctx.supplierId, storeId: ctx.storeId, currencyId: ctx.currencyId, notes: null }) + const supplyId = apply.data?.supplyId + rec(step, 'api', 'Фото-накладная перенесена в приёмку', apply.status < 400 && !!supplyId, '200', `${apply.status}`) + if (supplyId) { + const post = await api.post(`/api/purchases/supplies/${supplyId}/post`, {}) + rec(step, 'api', 'Приёмка №3 проведена', post.status === 200 || post.status === 204, '204', `${post.status}`) + const sdet = await api.get(`/api/purchases/supplies/${supplyId}`) + ctx.supplies.push({ id: supplyId, total: round2(Number(sdet.data?.total ?? 0)), supplierId: ctx.supplierId, lines: useLines.map((l: any) => ({ productId: l.productId, quantity: l.quantity, unitPrice: l.price ?? 0 })), paid: 0 }) + } + // снимки удалены с диска после apply + const photo = await api.get(`/api/purchases/invoices/${invId}/photo/1`) + rec(step, 'api', 'Снимки удалены с диска после apply (photo → 404)', photo.status === 404, 404, photo.status) + if (photo.status !== 404) report.bug({ step: '07', severity: 'medium', title: 'Снимки фото-накладной не удалены после apply', detail: `GET photo/1 → ${photo.status}` }) + + await capture(ctx, step, '/purchases/invoices', '07-foto-nakladnaya-razobrana', 'Фото-накладная: распознана (OCR) и сопоставлена вручную') + if (supplyId) await capture(ctx, step, `/purchases/supplies/${supplyId}`, '07-priemka3-provedena', 'Приёмка №3 (по фото): проведена') + await captureMobile(ctx, step, '/purchases/invoices', '07-mob-foto-priemka', 'Фото-приёмка с телефона') + await maybeFailShot(ctx, step) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Шаг 8 — оплаты поставщикам +// ═══════════════════════════════════════════════════════════════════════════ +export async function step08_supplier_payments({ ctx, step, report }: StepCtx) { + const api = ctx.ownerApi! + if (!api || ctx.supplies.length < 2 || !ctx.cash1Id || !ctx.bankId) { step.status = 'skip'; return } + const s1 = ctx.supplies[0] // приёмка №1 (реальный поставщик) + const s2 = ctx.supplies[1] // приёмка №2 (placeholder) + + // №1 — частично наличными из кассы + const partial = round2(s1.total * 0.4) + const p1 = await api.post('/api/money/transactions', { + Date: new Date().toISOString(), Kind: 4 /* CashOut */, AccountId: ctx.cash1Id, + CounterpartyId: s1.supplierId, CurrencyId: ctx.currencyId, Amount: partial, + Purpose: 'Оплата приёмки №1 (частично)', Links: [{ SupplyId: s1.id, LinkedSum: partial }], + }) + let ok1 = p1.status < 400 && !!p1.data?.id + if (ok1) { const post = await api.post(`/api/money/transactions/${p1.data.id}/post`, {}); ok1 = post.status < 400; ctx.pay.push({ txId: p1.data.id, supplyIdx: 0, amount: partial, account: 'cash' }); s1.paid = partial } + rec(step, 'api', 'Оплата №1 частично наличными из кассы', ok1, '204', `${p1.status}`) + + // №2 — полностью с банковского счёта + const p2 = await api.post('/api/money/transactions', { + Date: new Date().toISOString(), Kind: 2 /* PaymentOut */, AccountId: ctx.bankId, + CounterpartyId: s2.supplierId, CurrencyId: ctx.currencyId, Amount: s2.total, + Purpose: 'Оплата приёмки №2 (полностью)', Links: [{ SupplyId: s2.id, LinkedSum: s2.total }], + }) + let ok2 = p2.status < 400 && !!p2.data?.id + if (ok2) { const post = await api.post(`/api/money/transactions/${p2.data.id}/post`, {}); ok2 = post.status < 400; ctx.pay.push({ txId: p2.data.id, supplyIdx: 1, amount: s2.total, account: 'bank' }); s2.paid = s2.total } + rec(step, 'api', 'Оплата №2 полностью с банковского счёта', ok2, '204', `${p2.status}`) + + // статусы оплаты (PayedSum) + const d1 = await api.get(`/api/purchases/supplies/${s1.id}`) + const paid1 = round2(Number(d1.data?.payedSum ?? d1.data?.paidSum ?? d1.data?.payed ?? 0)) + rec(step, 'api', 'Приёмка №1: оплачено = частичной сумме', approxEq(paid1, partial), partial, paid1) + const d2 = await api.get(`/api/purchases/supplies/${s2.id}`) + const paid2 = round2(Number(d2.data?.payedSum ?? d2.data?.paidSum ?? 0)) + rec(step, 'api', 'Приёмка №2: оплачено полностью', approxEq(paid2, s2.total), s2.total, paid2) + + // долг поставщика = начальный + Σприёмки(его) − Σоплат(его) + const mySupplies = ctx.supplies.filter((s) => s.supplierId === ctx.supplierId) + const myPaid = ctx.pay.filter((p) => ctx.supplies[p.supplyIdx].supplierId === ctx.supplierId).reduce((a, p) => a + p.amount, 0) + const expectedDebt = round2(ctx.initialDebt + sum(mySupplies.map((s) => s.total)) - myPaid) + const debts = await api.get('/api/money/reports/supplier-debts?withDebtOnly=false') + const row = ((debts.data?.rows ?? []) as { supplierId: string; debt: number }[]).find((r) => r.supplierId === ctx.supplierId) + rec(step, 'api', 'Долг реального поставщика = начальный + приёмки − оплаты', !!row && approxEq(row.debt, expectedDebt), expectedDebt, row?.debt) + if (row && !approxEq(row.debt, expectedDebt)) { + report.bug({ step: '08', severity: 'high', title: 'Долг поставщика не сходится с формулой', detail: `ожид=${expectedDebt} факт=${row.debt}` }) + } + + await capture(ctx, step, '/money/transactions', '08-platezhi', 'Платежи поставщикам: нал из кассы + с банка', { fullPage: true }) + await capture(ctx, step, '/money/supplier-debts', '08-dolg-posle-oplat', 'Долги поставщикам после оплат (= формуле)') + await maybeFailShot(ctx, step) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Шаг 9 — смена кассира: размен, продажи, oversell, возврат, внесение/выплата, закрытие +// ═══════════════════════════════════════════════════════════════════════════ +export async function step09_cashier_shift({ ctx, step, report }: StepCtx) { + const api = ctx.ownerApi! + if (!api || !ctx.point1Id || !ctx.cash1Id || !ctx.storeId) { step.status = 'skip'; return } + const openingFloat = 5000 + + // размен — открыть смену + const open = await api.post('/api/money/shifts/open', { RetailPointId: ctx.point1Id, CashAccountId: ctx.cash1Id, AcquiringAccountId: ctx.acq1Id, OpeningFloat: openingFloat }) + const shiftId = open.data?.id + rec(step, 'api', 'Смена открыта (размен 5000)', open.status < 400 && !!shiftId, '200+id', `${open.status}`) + if (!shiftId) { report.bug({ step: '09', severity: 'critical', title: 'Смена не открывается', detail: JSON.stringify(open.data).slice(0, 160) }); return } + + // товары с остатком для продаж (из приёмок) + const prods = ctx.products.slice(0, 3) + // 3 продажи: нал / карта / смешанная + const sale1 = await makeSale(api, ctx, { productId: prods[0].id, qty: 2, unitPrice: prods[0].price, method: 0, pointId: ctx.point1Id }) + rec(step, 'api', 'Продажа 1 (наличные) проведена', sale1.postStatus === 200 || sale1.postStatus === 204, '204', `${sale1.postStatus}`) + const sale2 = await makeSale(api, ctx, { productId: prods[1].id, qty: 1, unitPrice: prods[1].price, method: 1, pointId: ctx.point1Id }) + rec(step, 'api', 'Продажа 2 (карта) проведена', sale2.postStatus === 200 || sale2.postStatus === 204, '204', `${sale2.postStatus}`) + const sale3 = await makeSale(api, ctx, { productId: prods[2].id, qty: 1, unitPrice: prods[2].price, method: 99, pointId: ctx.point1Id }) + rec(step, 'api', 'Продажа 3 (смешанная) проведена', sale3.postStatus === 200 || sale3.postStatus === 204, '204', `${sale3.postStatus}`) + + // продажа больше остатка → отказ + const over = await makeSale(api, ctx, { productId: prods[0].id, qty: 999999, unitPrice: prods[0].price, method: 0, pointId: ctx.point1Id }) + rec(step, 'api', 'Продажа больше остатка → отказ (409)', over.postStatus === 409, 409, over.postStatus) + if (over.id && over.postStatus !== 409) { + report.bug({ step: '09', severity: 'high', title: 'Продажа больше остатка прошла', detail: `post=${over.postStatus}` }) + } + if (over.id) await api.delete(`/api/sales/retail/${over.id}`) + + // возврат от покупателя по чеку (частичный, наличными) + let returnCash = 0 + if (sale1.id) { + const cr = await api.post(`/api/sales/retail/${sale1.id}/create-return`, {}) + const retId = cr.data?.id ?? cr.data?.saleId + if (retId) { + // вернуть 1 шт из 2, наличными + const refund = round2(prods[0].price) + await api.put(`/api/sales/retail/${retId}`, { + date: new Date().toISOString(), storeId: ctx.storeId, retailPointId: ctx.point1Id, customerId: null, + currencyId: ctx.currencyId, payment: 0, paidCash: refund, paidCard: 0, notes: 'возврат', + isReturn: true, referenceSaleId: sale1.id, + lines: [{ productId: prods[0].id, quantity: 1, unitPrice: prods[0].price, discount: 0, vatPercent: 12 }], + }) + const rp = await api.post(`/api/sales/retail/${retId}/post`, {}) + returnCash = refund + rec(step, 'api', 'Возврат по чеку проведён (1 шт, наличные)', rp.status === 200 || rp.status === 204, '204', `${rp.status}`) + } else { + rec(step, 'api', 'Возврат по чеку создан', false, 'id', `${cr.status}`) + } + } + + // внесение и выплата + const drawerIn = 10000, drawerOut = 3000 + const di = await api.post(`/api/money/shifts/${shiftId}/drawer`, { Direction: 1, Amount: drawerIn, Reason: 'Внесение разменом' }) + const dout = await api.post(`/api/money/shifts/${shiftId}/drawer`, { Direction: 2, Amount: drawerOut, Reason: 'Выплата на хознужды' }) + rec(step, 'api', 'Внесение и выплата зафиксированы', di.status < 400 && dout.status < 400, '2×200', `${di.status}/${dout.status}`) + + // ожидаемая касса + const salesCash = round2((sale1.paidCash || 0) + (sale3.paidCash || 0)) + const salesCard = round2((sale2.paidCard || 0) + (sale3.paidCard || 0)) + const expected = round2(openingFloat + salesCash - returnCash + drawerIn - drawerOut) + const discrepancy = 500 + const actualCash = round2(expected + discrepancy) + + const close = await api.post(`/api/money/shifts/${shiftId}/close`, { ActualCash: actualCash, Notes: 'Закрытие e2e с расхождением' }) + rec(step, 'api', 'Смена закрыта', close.status < 400, '200', `${close.status}`) + rec(step, 'api', 'Закрытие: «должно быть» (expectedCash) верно', approxEq(Number(close.data?.expectedCash), expected), expected, close.data?.expectedCash) + rec(step, 'api', 'Закрытие: разница (факт − должно) = расхождению', approxEq(Number(close.data?.difference), discrepancy), discrepancy, close.data?.difference) + if (close.status < 400 && !approxEq(Number(close.data?.expectedCash), expected)) { + report.bug({ step: '09', severity: 'high', title: 'Z-отчёт: expectedCash не сходится', detail: `ожид=${expected} факт=${close.data?.expectedCash}` }) + } + + // Z-отчёт + const z = await api.get(`/api/money/shifts/${shiftId}/report`) + const zOk = z.status === 200 && approxEq(Number(z.data?.sales?.cash), salesCash) && approxEq(Number(z.data?.expectedCash), expected) + rec(step, 'api', 'Z-отчёт: нал.продажи и expectedCash сходятся', zOk, `${salesCash}/${expected}`, `${z.data?.sales?.cash}/${z.data?.expectedCash}`) + + ctx.shift = { id: shiftId, pointId: ctx.point1Id, cashId: ctx.cash1Id, openingFloat, salesCash, salesCard, returnsCash: returnCash, drawerIn, drawerOut, expected, actual: actualCash } + + // продажа без смены → отказ (смена уже закрыта) + const noShift = await makeSale(api, ctx, { productId: prods[1].id, qty: 1, unitPrice: prods[1].price, method: 0, pointId: ctx.point1Id }) + rec(step, 'api', 'Продажа без открытой смены → отказ', noShift.postStatus === 409, 409, noShift.postStatus) + if (noShift.id && noShift.postStatus !== 409) report.bug({ step: '09', severity: 'high', title: 'Продажа без смены прошла', detail: `post=${noShift.postStatus}` }) + if (noShift.id) await api.delete(`/api/sales/retail/${noShift.id}`) + + // UI: экран смен + Z-отчёт закрытой смены + чеки продаж + await capture(ctx, step, '/money/shifts', '09-smeny-spisok', 'Смены кассы: открытие/закрытие, размен') + await capture(ctx, step, `/money/shifts`, '09-smena-zakryta-z', 'Закрытая смена: должно быть / факт / разница (Z-отчёт)', { fullPage: true }) + await capture(ctx, step, '/sales/retail', '09-cheki-prodazhi', 'Чеки продаж (наличные/карта/смешанная) и возврат', { fullPage: true }) + await maybeFailShot(ctx, step) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Шаг 9а — привязка кассира к точкам; параллельные смены; неактивная точка +// ═══════════════════════════════════════════════════════════════════════════ +export async function step09a_points_binding({ ctx, step, report }: StepCtx) { + const api = ctx.ownerApi! + if (!api || !ctx.point1Id || !ctx.point2Id || !ctx.cash1Id || !ctx.cash2Id) { step.status = 'skip'; return } + + // привязка кассира к точкам: кассир закреплён за точкой 1 + if (ctx.emp.cashier) { + // переиспользуем токен, выданный в 2а (stateless JWT, валиден весь прогон) — + // чтобы не упереться в возможный lockout после неудачных попыток входа в 2а. + const cashToken = ctx.emp.cashier.token ?? (await loginCreds(ctx.emp.cashier.email, ctx.emp.cashier.password)).token + rec(step, 'api', 'Кассир авторизован для проверки привязки', !!cashToken, 'token', cashToken ? 'ok' : 'нет') + if (cashToken) { + const capi = makeClient(cashToken) + const onP2 = await capi.post('/api/money/shifts/open', { RetailPointId: ctx.point2Id, CashAccountId: ctx.cash2Id, AcquiringAccountId: ctx.acq2Id, OpeningFloat: 0 }) + rec(step, 'api', 'Кассир открывает смену на НЕзакреплённой точке 2 → отказ', onP2.status >= 400, '4xx', onP2.status) + if (onP2.status < 400) { + report.bug({ step: '09a', severity: 'high', title: 'Кассир открыл смену на непривязанной точке', detail: `status=${onP2.status}` }) + if (onP2.data?.id) await capi.post(`/api/money/shifts/${onP2.data.id}/close`, { ActualCash: 0, Notes: 'cleanup' }) + } + const onP1 = await capi.post('/api/money/shifts/open', { RetailPointId: ctx.point1Id, CashAccountId: ctx.cash1Id, AcquiringAccountId: ctx.acq1Id, OpeningFloat: 0 }) + rec(step, 'api', 'Кассир открывает смену на закреплённой точке 1 → успех', onP1.status < 400 && !!onP1.data?.id, '200+id', onP1.status) + if (onP1.data?.id) await capi.post(`/api/money/shifts/${onP1.data.id}/close`, { ActualCash: 0, Notes: 'binding-test' }) + } + } + + // две смены на ОДНОЙ точке одновременно → 409 + const o1 = await api.post('/api/money/shifts/open', { RetailPointId: ctx.point1Id, CashAccountId: ctx.cash1Id, AcquiringAccountId: ctx.acq1Id, OpeningFloat: 1000 }) + const shift1 = o1.data?.id + const o1dup = await api.post('/api/money/shifts/open', { RetailPointId: ctx.point1Id, CashAccountId: ctx.cash1Id, AcquiringAccountId: ctx.acq1Id, OpeningFloat: 1000 }) + rec(step, 'api', 'Вторая смена на той же точке → 409', o1dup.status === 409, 409, o1dup.status) + + // параллельная смена на точке 2 (своя касса) → открывается + const o2 = await api.post('/api/money/shifts/open', { RetailPointId: ctx.point2Id, CashAccountId: ctx.cash2Id, AcquiringAccountId: ctx.acq2Id, OpeningFloat: 2000 }) + const shift2 = o2.data?.id + rec(step, 'api', 'Параллельная смена на точке 2 открыта', o2.status < 400 && !!shift2, '200+id', `${o2.status}`) + + // продажа на точке 1 и точке 2 — деньги раздельны + const p = ctx.products[0] + const sp1 = await makeSale(api, ctx, { productId: p.id, qty: 1, unitPrice: p.price, method: 0, pointId: ctx.point1Id }) + const p2prod = ctx.products[1] + const sp2 = await makeSale(api, ctx, { productId: p2prod.id, qty: 1, unitPrice: p2prod.price, method: 0, pointId: ctx.point2Id }) + rec(step, 'api', 'Продажи прошли на обеих точках', (sp1.postStatus === 204 || sp1.postStatus === 200) && (sp2.postStatus === 204 || sp2.postStatus === 200), '2×204', `${sp1.postStatus}/${sp2.postStatus}`) + + // закрываем обе — expectedCash у каждой = свой размен + своя касса + if (shift1) { + const exp1 = round2(1000 + sp1.paidCash) + const c1 = await api.post(`/api/money/shifts/${shift1}/close`, { ActualCash: exp1, Notes: 't1' }) + rec(step, 'api', 'Точка 1: касса = размен + её продажа (деньги не смешаны)', approxEq(Number(c1.data?.expectedCash), exp1), exp1, c1.data?.expectedCash) + } + if (shift2) { + const exp2 = round2(2000 + sp2.paidCash) + const c2 = await api.post(`/api/money/shifts/${shift2}/close`, { ActualCash: exp2, Notes: 't2' }) + rec(step, 'api', 'Точка 2: касса = размен + её продажа (деньги не смешаны)', approxEq(Number(c2.data?.expectedCash), exp2), exp2, c2.data?.expectedCash) + } + + // продажа на НЕактивной точке → отказ + await api.put(`/api/catalog/retail-points/${ctx.point2Id}`, { name: `Касса-2изм ${ctx.tag}`, code: `P2-${TS}`, storeId: ctx.storeId, isActive: false }) + const o3 = await api.post('/api/money/shifts/open', { RetailPointId: ctx.point2Id, CashAccountId: ctx.cash2Id, AcquiringAccountId: ctx.acq2Id, OpeningFloat: 0 }) + const inactiveRefused = o3.status >= 400 + rec(step, 'api', 'Смена/продажа на неактивной точке → отказ', inactiveRefused, '4xx', o3.status) + if (!inactiveRefused) { + report.gap('Открытие смены на НЕактивной точке продаж не отклоняется сервером — продажа на деактивированной точке возможна.') + if (o3.data?.id) await api.post(`/api/money/shifts/${o3.data.id}/close`, { ActualCash: 0, Notes: 'cleanup' }) + } + await capture(ctx, step, '/money/shifts', '09a-parallelnye-smeny', 'Параллельные смены на точках 1 и 2 (деньги раздельны)', { fullPage: true }) + // вернём точку 2 активной + await api.put(`/api/catalog/retail-points/${ctx.point2Id}`, { name: `Касса-2изм ${ctx.tag}`, code: `P2-${TS}`, storeId: ctx.storeId, isActive: true }) + await maybeFailShot(ctx, step) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Шаг 10 — склад: оприходование, списание, инвентаризация CSV (часть товаров) +// ═══════════════════════════════════════════════════════════════════════════ +export async function step10_warehouse_ops({ ctx, step, report }: StepCtx) { + const api = ctx.ownerApi! + if (!api || !ctx.storeId || !ctx.currencyId || ctx.products.length < 5) { step.status = 'skip'; return } + const now = () => new Date().toISOString() + + // Bug (iOS #1): у кладовщика должны быть права на оприходование/списание/ + // инвентаризацию (веб их показывает — API должен совпадать). Проверяем, что + // НЕ 403 (создаём черновики и удаляем, не трогая остатки). + if (ctx.emp.keeper) { + const kl = await loginCreds(ctx.emp.keeper.email, ctx.emp.keeper.password) + if (kl.token) { + const kapi = makeClient(kl.token) + const kE = await kapi.post('/api/inventory/enters', { date: now(), storeId: ctx.storeId, currencyId: ctx.currencyId, notes: 'keeper-perm', lines: [{ productId: ctx.products[0].id, quantity: 1, unitCost: ctx.products[0].cost }] }) + rec(step, 'api', 'Кладовщик → оприходование доступно (не 403)', kE.status !== 403 && kE.status < 400, '<400', kE.status) + if (kE.data?.id) await kapi.delete(`/api/inventory/enters/${kE.data.id}`) + const kL = await kapi.post('/api/inventory/losses', { date: now(), storeId: ctx.storeId, currencyId: ctx.currencyId, reason: 0, notes: 'keeper-perm', lines: [{ productId: ctx.products[0].id, quantity: 1, unitCost: ctx.products[0].cost }] }) + rec(step, 'api', 'Кладовщик → списание доступно (не 403)', kL.status !== 403 && kL.status < 400, '<400', kL.status) + if (kL.data?.id) await kapi.delete(`/api/inventory/losses/${kL.data.id}`) + const kI = await kapi.get('/api/inventory/inventories?pageSize=1') + rec(step, 'api', 'Кладовщик → инвентаризация доступна (не 403)', kI.status === 200, 200, kI.status) + if (kE.status === 403 || kL.status === 403) report.bug({ step: '10', severity: 'high', title: 'Кладовщик без прав на склад (API 403, а веб показывает)', detail: `enter=${kE.status} loss=${kL.status}` }) + } + } + + // Bug (iOS #2): инвентаризация из веба/телефона создаётся с пустыми строками + // (сервер подтягивает весь склад) — проведение НЕ должно обнулять непосчитанное. + // В demo каталог огромный (авто-подтяжка всех товаров тяжела) → только режим C. + if (!IS_DEMO) { + const counted = ctx.products[2], uncounted2 = ctx.products[3] + const uBefore = await stockOf(api, ctx.storeId, uncounted2.id) + const bookC = await stockOf(api, ctx.storeId, counted.id) + const invE = await api.post('/api/inventory/inventories', { date: now(), storeId: ctx.storeId, notes: 'пустая (iOS repro)', lines: [] }) + const invEId = invE.data?.id + if (invEId) { + await api.post(`/api/inventory/inventories/${invEId}/add-line`, { productId: counted.id, actualQty: round2(bookC - 1) }) + const postE = await api.post(`/api/inventory/inventories/${invEId}/post`, {}) + rec(step, 'api', 'Пустая инвентаризация проведена (одно расхождение)', postE.status === 200 || postE.status === 204, '204', postE.status) + const uAfter = await stockOf(api, ctx.storeId, uncounted2.id) + rec(step, 'api', 'Пустая инвентаризация: непосчитанный НЕ обнулён', approxEq(uAfter, uBefore), uBefore, uAfter) + const cAfter = await stockOf(api, ctx.storeId, counted.id) + rec(step, 'api', 'Пустая инвентаризация: посчитанный применён (недостача 1)', approxEq(cAfter, round2(bookC - 1)), round2(bookC - 1), cAfter) + if (!approxEq(uAfter, uBefore)) report.bug({ step: '10', severity: 'critical', title: 'Пустая инвентаризация обнулила непосчитанные товары', detail: `было ${uBefore} стало ${uAfter}` }) + } + } + + const pEnter = ctx.products[3], pLoss = ctx.products[4] + + // оприходование излишка + const beforeEnter = await stockOf(api, ctx.storeId, pEnter.id) + const enter = await api.post('/api/inventory/enters', { date: new Date().toISOString(), storeId: ctx.storeId, currencyId: ctx.currencyId, notes: 'Излишек', lines: [{ productId: pEnter.id, quantity: 8, unitCost: pEnter.cost }] }) + const enterId = enter.data?.id + if (enterId) await api.post(`/api/inventory/enters/${enterId}/post`, {}) + const afterEnter = await stockOf(api, ctx.storeId, pEnter.id) + rec(step, 'api', 'Оприходование излишка (+8)', approxEq(afterEnter - beforeEnter, 8), 8, round2(afterEnter - beforeEnter)) + + // списание брака + const beforeLoss = await stockOf(api, ctx.storeId, pLoss.id) + const loss = await api.post('/api/inventory/losses', { date: new Date().toISOString(), storeId: ctx.storeId, currencyId: ctx.currencyId, reason: 0, notes: 'Брак', lines: [{ productId: pLoss.id, quantity: 2, unitCost: pLoss.cost }] }) + const lossId = loss.data?.id + if (lossId) await api.post(`/api/inventory/losses/${lossId}/post`, {}) + const afterLoss = await stockOf(api, ctx.storeId, pLoss.id) + rec(step, 'api', 'Списание брака (−2)', approxEq(beforeLoss - afterLoss, 2), 2, round2(beforeLoss - afterLoss)) + + // инвентаризация по CSV (только 2 товара) — у одного расхождение + const invProds = [ctx.products[0], ctx.products[1]] + const uncounted = ctx.products[2] + const uncountedBefore = await stockOf(api, ctx.storeId, uncounted.id) + const book0 = await stockOf(api, ctx.storeId, invProds[0].id) + const book1 = await stockOf(api, ctx.storeId, invProds[1].id) + const actual0 = round2(book0 - 1) // недостача 1 + const actual1 = round2(book1) // без расхождения + const csv = '' + ['Штрихкод;Количество', `${invProds[0].barcode};${actual0}`, `${invProds[1].barcode};${actual1}`].join('\r\n') + const imp = await uploadFile(ctx.owner!.token, '/api/inventory/inventories/import', `inv-${TS}.csv`, Buffer.from(csv, 'utf8'), 'text/csv', 'file') + const found = (imp.body?.found ?? []) as { id: string; qty: number }[] + rec(step, 'api', 'Импорт инвентаризации нашёл 2 товара', found.length === 2, 2, found.length) + + const inv = await api.post('/api/inventory/inventories', { + date: new Date().toISOString(), storeId: ctx.storeId, notes: 'Инвентаризация (часть)', + lines: invProds.map((p, i) => ({ productId: p.id, actualQty: i === 0 ? actual0 : actual1 })), + }) + const invId = inv.data?.id + rec(step, 'api', 'Инвентаризация (частичная) создана', inv.status < 400 && !!invId, '200+id', `${inv.status}`) + if (invId) { + const post = await api.post(`/api/inventory/inventories/${invId}/post`, {}) + rec(step, 'api', 'Инвентаризация проведена', post.status === 200 || post.status === 204, '204', `${post.status}`) + // расхождение применилось: товар 0 стал actual0 + const now0 = await stockOf(api, ctx.storeId, invProds[0].id) + rec(step, 'api', 'Расхождение применено (товар с недостачей → факт)', approxEq(now0, actual0), actual0, now0) + // непосчитанный товар НЕ обнулён + const uncountedNow = await stockOf(api, ctx.storeId, uncounted.id) + rec(step, 'api', 'Непосчитанный товар не обнулён', approxEq(uncountedNow, uncountedBefore), uncountedBefore, uncountedNow) + if (!approxEq(uncountedNow, uncountedBefore)) report.bug({ step: '10', severity: 'critical', title: 'Инвентаризация обнулила непосчитанный товар', detail: `было ${uncountedBefore} стало ${uncountedNow}` }) + } + + await capture(ctx, step, '/inventory/enters', '10-oprihodovanie', 'Оприходование излишка (+8)') + await capture(ctx, step, '/inventory/losses', '10-spisanie-braka', 'Списание брака (−2)') + await capture(ctx, step, '/inventory/inventories', '10-inventarizaciya', 'Инвентаризация (часть товаров): расхождения', { fullPage: true }) + await capture(ctx, step, '/inventory/stock', '10-ostatki-sklad', 'Остатки после складских операций', { fullPage: true }) + await captureMobile(ctx, step, '/inventory/mobile', '10-mob-inventarizaciya', 'Инвентаризация с телефона (сканер)') + await maybeFailShot(ctx, step) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Шаг 11 — отчёты +// ═══════════════════════════════════════════════════════════════════════════ +export async function step11_reports({ ctx, step, report }: StepCtx) { + const api = ctx.ownerApi! + if (!api || !ctx.storeId) { step.status = 'skip'; return } + const from = new Date(Date.now() - 36 * 3600_000).toISOString() + const to = new Date(Date.now() + 24 * 3600_000).toISOString() + ctx.reportFrom = from; ctx.reportTo = to + + // продажи за день: выручка = сумма чеков − возвраты (сам отчёт нетит возвраты) + const salesRep = await api.get(`/api/reports/sales?from=${from}&to=${to}&groupBy=period:day`) + const revenue = round2(sum(((salesRep.data ?? []) as { revenue: number }[]).map((r) => Number(r.revenue)))) + rec(step, 'api', 'Отчёт продаж за день доступен, выручка ≥ 0', salesRep.status === 200 && revenue >= 0, '≥0', revenue) + + // прибыль = выручка − себестоимость + const profitRep = await api.get(`/api/reports/profit?from=${from}&to=${to}&groupBy=period:day`) + const prows = (profitRep.data ?? []) as { revenue: number; cost: number; profit: number }[] + const profOk = profitRep.status === 200 && prows.every((r) => approxEq(Number(r.profit), round2(Number(r.revenue) - Number(r.cost)))) + rec(step, 'api', 'Прибыль = выручка − себестоимость (по каждой строке)', profOk, 'revenue−cost', profitRep.status) + + // остатки на дату + const stockRep = await api.get(`/api/reports/stock?storeId=${ctx.storeId}&includeZero=false`) + rec(step, 'api', 'Отчёт остатков на дату доступен', stockRep.status === 200 && Array.isArray(stockRep.data), 'список', `${stockRep.status}`) + + // отчёт «Деньги»: остаток каждого счёта = начальный + движения + const bal = await api.get('/api/money/reports/balances') + const accs = (bal.data?.accounts ?? []) as { accountId: string; openingBalance: number; currentBalance: number }[] + // касса: opening − оплата №1 наличными (partial); банк: opening − оплата №2 + const paidCash = ctx.pay.filter((p) => p.account === 'cash').reduce((a, p) => a + p.amount, 0) + const paidBank = ctx.pay.filter((p) => p.account === 'bank').reduce((a, p) => a + p.amount, 0) + const cashAcc = accs.find((a) => a.accountId === ctx.cash1Id) + rec(step, 'api', 'Деньги: касса = начальный − оплата наличными', !!cashAcc && approxEq(cashAcc.currentBalance, round2(ctx.cashOpening - paidCash)), round2(ctx.cashOpening - paidCash), cashAcc?.currentBalance) + const bankAcc = accs.find((a) => a.accountId === ctx.bankId) + rec(step, 'api', 'Деньги: банк = начальный − оплата с банка', !!bankAcc && approxEq(bankAcc.currentBalance, round2(ctx.bankOpening - paidBank)), round2(ctx.bankOpening - paidBank), bankAcc?.currentBalance) + + // долги поставщикам + const debts = await api.get('/api/money/reports/supplier-debts?withDebtOnly=true') + rec(step, 'api', 'Отчёт долгов поставщикам доступен', debts.status === 200, 200, debts.status) + + // по точкам и по кассирам: выручка = сумме их чеков − возвраты. + // Проверяем, что сумма по группам = общей выручке за день (нетто возвратов). + const byPoint = await api.get(`/api/reports/sales?from=${from}&to=${to}&groupBy=register`) + const pointRev = round2(sum(((byPoint.data ?? []) as { revenue: number }[]).map((r) => Number(r.revenue)))) + rec(step, 'api', 'Выручка по точкам в сумме = общей выручке дня', byPoint.status === 200 && approxEq(pointRev, revenue), revenue, pointRev) + const byCashier = await api.get(`/api/reports/sales?from=${from}&to=${to}&groupBy=cashier`) + const cashierRev = round2(sum(((byCashier.data ?? []) as { revenue: number }[]).map((r) => Number(r.revenue)))) + rec(step, 'api', 'Выручка по кассирам в сумме = общей выручке дня', byCashier.status === 200 && approxEq(cashierRev, revenue), revenue, cashierRev) + if (byPoint.status === 200 && !approxEq(pointRev, revenue)) report.bug({ step: '11', severity: 'medium', title: 'Выручка по точкам ≠ общей выручке', detail: `точки=${pointRev} всего=${revenue}` }) + + // UI: отчёты с числами (продажи, прибыль, остатки, деньги, долги) + await capture(ctx, step, '/reports/sales', '11-otchet-prodazhi', 'Отчёт: продажи за день (выручка)', { fullPage: true }) + await capture(ctx, step, '/reports/profit', '11-otchet-pribyl', 'Отчёт: прибыль (выручка − себестоимость)', { fullPage: true }) + await capture(ctx, step, '/reports/stock', '11-otchet-ostatki', 'Отчёт: остатки на дату', { fullPage: true }) + await capture(ctx, step, '/money/report', '11-otchet-dengi', 'Отчёт «Деньги»: остатки по счетам', { fullPage: true }) + await capture(ctx, step, '/money/supplier-debts', '11-otchet-dolgi', 'Отчёт: долги поставщикам') + await maybeFailShot(ctx, step) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Шаг 12 — инварианты +// ═══════════════════════════════════════════════════════════════════════════ +export async function step12_invariants({ ctx, step, report }: StepCtx) { + const api = ctx.ownerApi! + if (!api || !ctx.storeId) { step.status = 'skip'; return } + + // 1) по каждому товару: остаток ≡ Σ движений + let stockOk = true + for (const p of ctx.products) { + const stock = await stockOf(api, ctx.storeId, p.id) + const mv = await movementsSum(api, ctx.storeId, p.id) + const ok = !Number.isNaN(mv) && approxEq(stock, mv) + if (!ok) stockOk = false + rec(step, 'api', `Инвариант остаток≡Σдвижений: ${p.name.slice(0, 16)}`, ok, mv, stock) + } + if (!stockOk) report.bug({ step: '12', severity: 'critical', title: 'Остаток ≠ сумме движений склада', detail: 'см. строки шага' }) + + // 2) по каждому счёту: баланс ≡ начальный + Σ проведённых операций + const bal = await api.get('/api/money/reports/balances') + const accs = (bal.data?.accounts ?? []) as { accountId: string; name: string; openingBalance: number; currentBalance: number }[] + for (const a of accs) { + const txs = await api.get(`/api/money/transactions?accountId=${a.accountId}&status=1&pageSize=500`) + const items = (txs.data?.items ?? []) as { kind: number; amount: number }[] + const inSum = sum(items.filter((t) => [1, 3, 6].includes(t.kind)).map((t) => Number(t.amount))) + const outSum = sum(items.filter((t) => [2, 4, 5].includes(t.kind)).map((t) => Number(t.amount))) + const expected = round2(Number(a.openingBalance) + inSum - outSum) + rec(step, 'api', `Инвариант счёт≡начальный+Σопераций: ${a.name.slice(0, 16)}`, approxEq(Number(a.currentBalance), expected), expected, a.currentBalance) + } + + // 3) долг поставщика ≡ формуле (пересчёт независимо) + const mySupplies = ctx.supplies.filter((s) => s.supplierId === ctx.supplierId) + const myPaid = ctx.pay.filter((p) => ctx.supplies[p.supplyIdx].supplierId === ctx.supplierId).reduce((a, p) => a + p.amount, 0) + const expectedDebt = round2(ctx.initialDebt + sum(mySupplies.map((s) => s.total)) - myPaid) + const debts = await api.get('/api/money/reports/supplier-debts?withDebtOnly=false') + const row = ((debts.data?.rows ?? []) as { supplierId: string; debt: number }[]).find((r) => r.supplierId === ctx.supplierId) + rec(step, 'api', 'Инвариант долг≡формуле', !!row && approxEq(row.debt, expectedDebt), expectedDebt, row?.debt) + + // 4) изоляция организаций + if (!IS_DEMO) { + // demo-admin не видит наш товар; мы не видим demo-данные + try { + const demo = await demoLogin('admin') + const demoApi = makeClient(demo.token) + const ourProd = ctx.products[0].id + const seen = await demoApi.get(`/api/catalog/products/${ourProd}`) + rec(step, 'api', 'Изоляция: demo-admin НЕ видит товар e2e-орг', seen.status === 404 || seen.status === 403, '404/403', seen.status) + if (seen.status === 200) report.bug({ step: '12', severity: 'critical', title: 'Нарушение изоляции: чужая орг видит товар', detail: `demo увидел ${ourProd}` }) + } catch (e) { + rec(step, 'api', 'Изоляция: вход demo-admin', false, 'token', (e as Error).message.slice(0, 60)) + } + } else { + rec(step, 'api', 'Изоляция: режим B (демо) — проверка через temp-орг в режиме C', true, 'n/a', 'n/a') + } + + await capture(ctx, step, '/inventory/movements', '12-dvizheniya-sklada', 'Движения склада (остаток ≡ Σ движений)', { fullPage: true }) + await maybeFailShot(ctx, step) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Шаг 13 — отмена и откат +// ═══════════════════════════════════════════════════════════════════════════ +export async function step13_cancel_rollback({ ctx, step, report }: StepCtx) { + const api = ctx.ownerApi! + if (!api || ctx.supplies.length < 1) { step.status = 'skip'; return } + const s1 = ctx.supplies[0] + + // отмена проведения приёмки с уже проданным товаром → понятный отказ + const unpost = await api.post(`/api/purchases/supplies/${s1.id}/unpost`, {}) + const refused = unpost.status >= 400 + rec(step, 'api', 'Отмена приёмки с проданным товаром → отказ', refused, '4xx', unpost.status) + if (!refused) report.bug({ step: '13', severity: 'high', title: 'Отмена приёмки с проданным товаром прошла (остаток ушёл в минус?)', detail: `unpost=${unpost.status}` }) + + // отмена платежа → долг вернулся + const pay1 = ctx.pay.find((p) => p.supplyIdx === 0) + if (pay1) { + const debtsBefore = await api.get('/api/money/reports/supplier-debts?withDebtOnly=false') + const before = ((debtsBefore.data?.rows ?? []) as { supplierId: string; debt: number }[]).find((r) => r.supplierId === ctx.supplierId)?.debt ?? 0 + const un = await api.post(`/api/money/transactions/${pay1.txId}/unpost`, {}) + rec(step, 'api', 'Отмена платежа №1', un.status < 400, '204', `${un.status}`) + const debtsAfter = await api.get('/api/money/reports/supplier-debts?withDebtOnly=false') + const after = ((debtsAfter.data?.rows ?? []) as { supplierId: string; debt: number }[]).find((r) => r.supplierId === ctx.supplierId)?.debt ?? 0 + const expected = round2(before + pay1.amount) + rec(step, 'api', 'Долг вернулся на сумму платежа', approxEq(after, expected), expected, after) + if (!approxEq(after, expected)) report.bug({ step: '13', severity: 'high', title: 'После отмены платежа долг не вернулся', detail: `ожид=${expected} факт=${after}` }) + // вернём оплату, чтобы данные остались консистентными + await api.post(`/api/money/transactions/${pay1.txId}/post`, {}) + } + + await capture(ctx, step, '/money/supplier-debts', '13-otkat-dolg-vernulsya', 'Отмена платежа → долг вернулся') + await maybeFailShot(ctx, step) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Шаг 14 — финал: скриншоты + деактивация e2e-сущностей (режим B) +// ═══════════════════════════════════════════════════════════════════════════ +export async function step14_finalize({ ctx, step, report }: StepCtx) { + const api = ctx.ownerApi! + const u = await ui(ctx) + if (u) { + u.resetErrors() + await u.goto('/dashboard') + await u.collectToasts() + if (SHOTS) { + const sh = await u.shot(step.id, '14-itog-dashboard', 'Итоговый дашборд магазина после полного дня') + ;(step.screenshots ??= []).push({ rel: sh.rel, caption: 'Итоговый дашборд' }) + } + rec(step, 'ui', 'Финальный дашборд без ошибок', u.errorSummary() === '', '0 ошибок', u.errorSummary() || '0 ошибок') + // HTML-галерея всех скриншотов процессов + if (u.shots.length > 0) { + const gallery = u.writeGallery(RUN_ID, { target: IS_DEMO ? 'Демо-магазин (B)' : 'temp-орг (C)', when: new Date().toISOString() }) + report.ux(`Галерея скриншотов: ${gallery}`) + rec(step, 'ui', `Галерея index.html собрана (${u.shots.length} кадров)`, true, 'index.html', `${u.shots.length}`) + } + await u.close() + } + + // В режиме B (демо) — деактивируем e2e-сотрудников и точки (НЕ удаляем). + if (IS_DEMO && api) { + let deact = 0 + for (const key of Object.keys(ctx.emp)) { + const e = ctx.emp[key] + const roles = await api.get('/api/organization/employee-roles?pageSize=200') + const rid = ((roles.data?.items ?? []) as { id: string; name: string }[]).find((r) => r.name === e.role)?.id + const r = await api.put(`/api/organization/employees/${e.id}`, { + lastName: 'e2e', firstName: 'e2e', middleName: null, position: 'e2e', email: e.email, phone: null, + salary: null, taxNumber: null, description: null, imageUrl: null, roleId: rid, isActive: false, retailPointIds: [], + }) + if (r.status < 400) deact++ + } + for (const pid of [ctx.point1Id, ctx.point2Id]) { + if (!pid) continue + await api.put(`/api/catalog/retail-points/${pid}`, { name: `e2e (деакт.)`, code: `X-${TS}`, storeId: ctx.storeId, isActive: false }) + } + rec(step, 'api', 'Режим B: e2e-сотрудники деактивированы (не удалены)', true, `${Object.keys(ctx.emp).length}`, deact) + } else { + rec(step, 'api', 'Режим C: temp-орг чистится ночной уборкой (>3 дней)', true, 'cleanup', 'ночь') + } + report.ux(`Скриншоты ключевых экранов: tests/e2e/reports/full-chain/*.png`) +} + diff --git a/tests/e2e/scenarios/full-chain.yml b/tests/e2e/scenarios/full-chain.yml new file mode 100644 index 0000000..b18d6d6 --- /dev/null +++ b/tests/e2e/scenarios/full-chain.yml @@ -0,0 +1,45 @@ +name: full-chain +description: | + Жизнь магазина от регистрации до отчётов — сквозной прогон ВСЕХ процессов + простого режима через настоящий стенд. Каждый шаг сверяет конкретные ЧИСЛА + (ожидаемое считает сам тест) и инварианты; фиксирует 0×5xx / 0×error-тостов / + 0×console.error на UI-шагах. Режим C (temp-org через signup) по умолчанию; + режим B (FULLCHAIN_TARGET=demo) — на «Демо-магазине» на реальном каталоге. + +preconditions: + reset_db: false + smoke_login_super_admin: false + +steps: + - id: step01_register_and_login + title: "Регистрация организации → вход владельцем → простой режим (скрытые модули 403)" + - id: step02_catalog_and_supplier + title: "Справочники — группа, 5 товаров (разные ед.изм., 1 весовой) + цены + штрихкоды; поставщик" + - id: step02a_employees_and_points + title: "Сотрудники (кассир/кладовщик/админ) + 2 точки продаж; права (API 403); правка/пароль/блокировка" + - id: step03_money_accounts + title: "Деньги — касса, банковский счёт (номер), эквайринг; начальные остатки" + - id: step04_initial_supplier_debt + title: "Начальный долг поставщику через корректировку взаиморасчётов (WeOwe) → долг = сумме" + - id: step05_supply1_manual + title: "Приёмка №1 вручную (кладовщик) → провести → остатки +кол-во; себестоимость" + - id: step06_supply2_excel + title: "Приёмка №2 из табличной накладной (поставщик не указан → «Поставщик не указан») → провести" + - id: step07_supply3_photo + title: "Приёмка №3 по фото-накладной → ручное сопоставление → провести; снимки удалены с диска" + - id: step08_supplier_payments + title: "Оплата — №1 частично нал из кассы, №2 полностью с банка; статусы + долг = формуле" + - id: step09_cashier_shift + title: "Смена — размен, 3 продажи (нал/карта/смеш), oversell→отказ, возврат, внесение/выплата, закрытие с расхождением + Z; продажа без смены→отказ" + - id: step09a_points_binding + title: "Привязка кассира к точкам; параллельные смены на 2 точках не смешивают деньги; продажа на неактивной→отказ" + - id: step10_warehouse_ops + title: "Склад — оприходование излишка, списание брака, инвентаризация CSV (часть товаров): непосчитанные не обнулены" + - id: step11_reports + title: "Отчёты — продажи за день, прибыль, остатки на дату, «Деньги», долги; по точкам/кассирам" + - id: step12_invariants + title: "Инварианты — остаток≡Σдвижений; счёт≡начальный+Σопераций; долг≡формуле; изоляция орг" + - id: step13_cancel_rollback + title: "Отмена/откат — отмена приёмки с проданным товаром→отказ; отмена платежа→долг вернулся" + - id: step14_finalize + title: "Финал — скриншоты ключевых экранов, деактивация e2e-сущностей (режим B)"