Compare commits
8 commits
659a749d7f
...
6f10896d8c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f10896d8c | ||
|
|
886cb92f30 | ||
|
|
fddf552411 | ||
|
|
009a9d8a50 | ||
|
|
839fcaf9fc | ||
|
|
8785fd60d1 | ||
|
|
aedcd3f83a | ||
|
|
24646e0649 |
64
deploy/fix-internal-ean13.sql
Normal file
64
deploy/fix-internal-ean13.sql
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
-- deploy/fix-internal-ean13.sql
|
||||
-- Исправление контрольной цифры у внутренних EAN-13 (префикс «2»), которые
|
||||
-- веб-генератор выдавал с НЕверной контрольной цифрой (баг digitsChecksum:
|
||||
-- веса считались слева, а для 12-значного тела чётность переворачивается).
|
||||
-- Фикс генератора — в src/food-market.web/src/lib/barcode.ts; этот скрипт
|
||||
-- чинит УЖЕ ВЫДАННЫЕ коды, не теряя связь с товаром (та же строка product_barcodes,
|
||||
-- тот же ProductId — меняется только последняя цифра Code).
|
||||
--
|
||||
-- Идемпотентно. Пропускает коды, у которых исправленный вариант уже занят
|
||||
-- в этой организации (во избежание нарушения unique(OrganizationId,Code)) —
|
||||
-- такие выводятся отдельно для ручного разбора.
|
||||
--
|
||||
-- Запуск: ssh nns@192.168.1.190 "docker exec -i food-market-stage-postgres \
|
||||
-- psql -U food_market -d food_market" < deploy/fix-internal-ean13.sql
|
||||
|
||||
CREATE OR REPLACE FUNCTION pg_temp.ean13_check(body text) RETURNS int AS $f$
|
||||
DECLARE s int := 0; w int := 3; i int;
|
||||
BEGIN
|
||||
IF body !~ '^[0-9]{12}$' THEN RETURN -1; END IF;
|
||||
FOR i IN REVERSE 12..1 LOOP
|
||||
s := s + substr(body,i,1)::int * w;
|
||||
w := CASE WHEN w=3 THEN 1 ELSE 3 END;
|
||||
END LOOP;
|
||||
RETURN (10 - s % 10) % 10;
|
||||
END $f$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
-- Отчёт: что будет исправлено (старый → новый), с товаром и организацией.
|
||||
SELECT pb."OrganizationId", pb."ProductId", p."Name" AS product,
|
||||
pb."Code" AS old_code,
|
||||
substr(pb."Code",1,12) || pg_temp.ean13_check(substr(pb."Code",1,12))::text AS new_code
|
||||
FROM product_barcodes pb
|
||||
JOIN products p ON p."Id" = pb."ProductId"
|
||||
WHERE pb."Code" ~ '^2[0-9]{12}$'
|
||||
AND substr(pb."Code",13,1)::int <> pg_temp.ean13_check(substr(pb."Code",1,12))
|
||||
ORDER BY pb."OrganizationId", p."Name";
|
||||
|
||||
-- Конфликты: исправленный код уже занят в этой орге — НЕ трогаем.
|
||||
SELECT pb."Code" AS old_code,
|
||||
substr(pb."Code",1,12) || pg_temp.ean13_check(substr(pb."Code",1,12))::text AS new_code,
|
||||
pb."OrganizationId"
|
||||
FROM product_barcodes pb
|
||||
WHERE pb."Code" ~ '^2[0-9]{12}$'
|
||||
AND substr(pb."Code",13,1)::int <> pg_temp.ean13_check(substr(pb."Code",1,12))
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM product_barcodes x
|
||||
WHERE x."OrganizationId" = pb."OrganizationId"
|
||||
AND x."Code" = substr(pb."Code",1,12) || pg_temp.ean13_check(substr(pb."Code",1,12))::text
|
||||
AND x."Id" <> pb."Id");
|
||||
|
||||
-- Собственно исправление (без конфликтных).
|
||||
UPDATE product_barcodes pb
|
||||
SET "Code" = substr(pb."Code",1,12) || pg_temp.ean13_check(substr(pb."Code",1,12))::text
|
||||
WHERE pb."Code" ~ '^2[0-9]{12}$'
|
||||
AND substr(pb."Code",13,1)::int <> pg_temp.ean13_check(substr(pb."Code",1,12))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM product_barcodes x
|
||||
WHERE x."OrganizationId" = pb."OrganizationId"
|
||||
AND x."Code" = substr(pb."Code",1,12) || pg_temp.ean13_check(substr(pb."Code",1,12))::text
|
||||
AND x."Id" <> pb."Id");
|
||||
|
||||
-- Контроль: сколько осталось неверных (должно стать = числу конфликтных).
|
||||
SELECT count(*) AS remaining_wrong FROM product_barcodes
|
||||
WHERE "Code" ~ '^2[0-9]{12}$'
|
||||
AND substr("Code",13,1)::int <> pg_temp.ean13_check(substr("Code",1,12));
|
||||
103
deploy/stage-reset-demo.sql
Normal file
103
deploy/stage-reset-demo.sql
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
-- deploy/stage-reset-demo.sql
|
||||
-- Очистка организации «Демо-магазин» на стенде от ВСЕХ транзакционных данных,
|
||||
-- КРОМЕ каталога (товары/группы/штрихкоды/цены/ед.изм./картинки), сотрудников/
|
||||
-- пользователей/ролей и магазина/точек продаж. По поправке владельца 07.10.2026.
|
||||
--
|
||||
-- Запуск (с обязательной предварительной резервной копией!):
|
||||
-- ssh nns@192.168.1.190 "docker exec -i food-market-stage-postgres \
|
||||
-- psql -U food_market -d food_market -v demo_org=<UUID>" < deploy/stage-reset-demo.sql
|
||||
--
|
||||
-- Идемпотентно. KEEP-таблицы НЕ трогаются. Удаление — только по OrganizationId
|
||||
-- целевой орги; FK-порядок разрешается retry-loop'ом (как в stage-prune-autotest.sql).
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
-- psql не подставляет :var внутри dollar-quoted DO-блока, поэтому пробрасываем
|
||||
-- org через GUC и читаем current_setting() внутри блока.
|
||||
SELECT set_config('fm.demo_org', :'demo_org', false);
|
||||
|
||||
DO $do$
|
||||
DECLARE
|
||||
org uuid := current_setting('fm.demo_org')::uuid;
|
||||
keep text[] := ARRAY[
|
||||
-- каталог
|
||||
'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',
|
||||
-- прочий org-scoped reference, который владелец хочет сохранить
|
||||
'expense_items','user_presets'
|
||||
];
|
||||
tbl text;
|
||||
pass int;
|
||||
remaining int;
|
||||
total_deleted bigint := 0;
|
||||
n bigint;
|
||||
prod_cnt bigint;
|
||||
BEGIN
|
||||
IF org IS NULL THEN
|
||||
RAISE EXCEPTION 'demo_org не задан (psql -v demo_org=...)';
|
||||
END IF;
|
||||
PERFORM 1 FROM organizations WHERE "Id" = org;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Орг % не найдена', org;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'Очистка Демо-орг % — KEEP % таблиц', org, array_length(keep,1);
|
||||
|
||||
FOR pass IN 1..12 LOOP
|
||||
remaining := 0;
|
||||
FOR tbl IN
|
||||
SELECT c.table_name
|
||||
FROM information_schema.columns c
|
||||
JOIN information_schema.tables t
|
||||
ON t.table_schema=c.table_schema AND t.table_name=c.table_name
|
||||
WHERE c.table_schema='public'
|
||||
AND c.column_name='OrganizationId'
|
||||
AND t.table_type='BASE TABLE'
|
||||
AND c.table_name <> ALL(keep)
|
||||
LOOP
|
||||
BEGIN
|
||||
EXECUTE format('DELETE FROM public.%I WHERE "OrganizationId"=$1', tbl) USING org;
|
||||
GET DIAGNOSTICS n = ROW_COUNT;
|
||||
total_deleted := total_deleted + n;
|
||||
EXCEPTION WHEN foreign_key_violation THEN
|
||||
remaining := remaining + 1;
|
||||
END;
|
||||
END LOOP;
|
||||
EXIT WHEN remaining = 0;
|
||||
END LOOP;
|
||||
|
||||
IF remaining <> 0 THEN
|
||||
RAISE EXCEPTION 'Остались неудалённые таблицы из-за FK после 12 проходов (remaining=%)', remaining;
|
||||
END IF;
|
||||
|
||||
SELECT count(*) INTO prod_cnt FROM products WHERE "OrganizationId"=org;
|
||||
RAISE NOTICE 'Удалено строк: %, товаров осталось: %', total_deleted, prod_cnt;
|
||||
END $do$;
|
||||
|
||||
-- Контроль: документы и остатки должны быть 0, каталог/сотрудники/точки целы.
|
||||
SELECT 'products' m, count(*) v FROM products WHERE "OrganizationId"=:'demo_org'
|
||||
UNION ALL SELECT 'product_prices', count(*) FROM product_prices WHERE "OrganizationId"=:'demo_org'
|
||||
UNION ALL SELECT 'product_barcodes', count(*) FROM product_barcodes WHERE "OrganizationId"=:'demo_org'
|
||||
UNION ALL SELECT 'product_groups', count(*) FROM product_groups WHERE "OrganizationId"=:'demo_org'
|
||||
UNION ALL SELECT 'employees', count(*) FROM employees WHERE "OrganizationId"=:'demo_org'
|
||||
UNION ALL SELECT 'users', count(*) FROM users WHERE "OrganizationId"=:'demo_org'
|
||||
UNION ALL SELECT 'stores', count(*) FROM stores WHERE "OrganizationId"=:'demo_org'
|
||||
UNION ALL SELECT 'retail_points', count(*) FROM retail_points WHERE "OrganizationId"=:'demo_org'
|
||||
UNION ALL SELECT 'supplies', count(*) FROM supplies WHERE "OrganizationId"=:'demo_org'
|
||||
UNION ALL SELECT 'retail_sales', count(*) FROM retail_sales WHERE "OrganizationId"=:'demo_org'
|
||||
UNION ALL SELECT 'stocks', count(*) FROM stocks WHERE "OrganizationId"=:'demo_org'
|
||||
UNION ALL SELECT 'stock_movements', count(*) FROM stock_movements WHERE "OrganizationId"=:'demo_org'
|
||||
UNION ALL SELECT 'money_accounts', count(*) FROM money_accounts WHERE "OrganizationId"=:'demo_org'
|
||||
UNION ALL SELECT 'money_transactions', count(*) FROM money_transactions WHERE "OrganizationId"=:'demo_org'
|
||||
UNION ALL SELECT 'cash_shifts', count(*) FROM cash_shifts WHERE "OrganizationId"=:'demo_org'
|
||||
UNION ALL SELECT 'enters', count(*) FROM enters WHERE "OrganizationId"=:'demo_org'
|
||||
UNION ALL SELECT 'losses', count(*) FROM losses WHERE "OrganizationId"=:'demo_org'
|
||||
UNION ALL SELECT 'inventories', count(*) FROM inventories WHERE "OrganizationId"=:'demo_org'
|
||||
UNION ALL SELECT 'invoice_uploads', count(*) FROM invoice_uploads WHERE "OrganizationId"=:'demo_org'
|
||||
UNION ALL SELECT 'counterparties', count(*) FROM counterparties WHERE "OrganizationId"=:'demo_org'
|
||||
UNION ALL SELECT 'org_audit_log', count(*) FROM org_audit_log WHERE "OrganizationId"=:'demo_org'
|
||||
ORDER BY m;
|
||||
43
docs/ean13-fixed-20261008.txt
Normal file
43
docs/ean13-fixed-20261008.txt
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
OrganizationId | ProductId | product | old_code | new_code
|
||||
--------------------------------------+--------------------------------------+--------------------------------------+---------------+---------------
|
||||
02a0ba90-0efb-4856-8922-bc90db46032c | 989779be-8c27-4787-8140-4f94dcdb156b | Товар 1 | 2774151174816 | 2774151174818
|
||||
2030055a-1c1e-414e-ac0e-591ebfb27134 | 3edb0b3d-1992-4544-92b6-f5d4a4acd3c3 | Товар 1 | 2322682989291 | 2322682989298
|
||||
241c01d6-319e-42f7-b678-a3d257040d8d | b4d60653-355e-4f52-8b89-9528b9883a52 | Товар 1 | 2989387437553 | 2989387437556
|
||||
2b8ad716-844a-4ccc-8634-1f7054aff4f2 | cb3fec1b-94bf-46a6-9ef3-6a060279f9dd | Товар 1 | 2573011570966 | 2573011570964
|
||||
2d6f1727-649c-4517-8750-4c1055fcd1b8 | ccd8f9f8-551a-4d5a-b500-45d6fe81fec6 | Товар 1 | 2777942088706 | 2777942088707
|
||||
2df1e315-111d-4fb5-8416-d540400ee96e | 3c6c724b-38bd-4bfe-990c-d899b55ffef0 | Товар 1 | 2221311034961 | 2221311034968
|
||||
3d88118f-12ca-491b-ae8b-54889d656217 | 6f0e1fa2-5bdc-4d1e-a4b8-4ea7cd42b1eb | Товар 1 | 2319153165810 | 2319153165817
|
||||
40f09e9d-fce5-4e9b-a25c-1c9c9422b4c3 | de430ffe-b939-4478-8cdb-cf618507f6f2 | Товар 1 | 2778342543970 | 2778342543971
|
||||
43eaa2ec-cdf5-4599-9ea9-de02bd7b7f02 | 57fee67e-1907-4ea3-ab63-efc19966d93f | Товар 1 | 2641783270397 | 2641783270396
|
||||
565819c6-6821-4367-89cf-ac08fd5b8bce | 164f9409-5bb0-4f1d-ae2d-14b57d87cd5e | Товар 1 | 2104944436773 | 2104944436777
|
||||
5744895d-8f8f-4521-a171-8d01a45c9716 | 8207ad35-7321-41ba-bd83-42fe869a57f9 | Товар 1 | 2454147839196 | 2454147839197
|
||||
5ae2d7aa-1e3c-4ab9-8f37-e432c37059bd | 4644bda1-e8c1-49f1-aa4d-e587d8417822 | Товар 1 | 2773341942620 | 2773341942626
|
||||
5c739483-4cf0-4a84-abc9-e5abc2d9498c | a3de6b46-2583-42b9-a149-5c5c1cde44bb | Товар 1 | 2141394451683 | 2141394451684
|
||||
68d55389-9240-48f1-924b-5fcaef582662 | dfe0af38-8273-44e4-af2b-1863373ac7a8 | Товар 1 | 2802175321220 | 2802175321229
|
||||
6da240a9-ed19-422e-a8ff-3e88319411bc | 6005300f-a165-460c-97f5-d0bd5dd82744 | Товар 1 | 2172174489121 | 2172174489122
|
||||
7353187f-af3d-4218-b0b3-b48af77efedc | 3c7f28cb-edef-4ed8-be7f-b5b9acd54217 | Товар 1 | 2975569597001 | 2975569597002
|
||||
82f94765-c378-4eec-80e8-15e1f1216315 | e2cec8e4-9be1-4314-ad61-782dad5135a0 | Товар 1 | 2908501111892 | 2908501111899
|
||||
8404e55e-d325-4c11-9576-9c733c107afb | d38a0cde-db35-468f-8e65-3c038a980e15 | Товар 1 | 2561410176365 | 2561410176368
|
||||
88a1b10b-9006-4dbf-8010-c86833e3226e | 833451f0-eb3e-445d-9672-f501ed98d73e | Товар 1 | 2971275425507 | 2971275425509
|
||||
89262cea-977b-4097-8e78-fc34e25244de | a981266c-d936-4af6-ab29-fbce3313ec00 | Товар 1 | 2022774230099 | 2022774230092
|
||||
8bbb43a0-711f-46d9-a8bb-0f4f0d633ed5 | 681c6c8e-a808-47d7-8d50-9115d6a78395 | Товар 1 | 2166137596314 | 2166137596316
|
||||
929b00b1-cdef-4c72-a1d2-ec48a79c0e50 | 2648291a-9181-497d-b9d5-57852f2e1806 | Товар 1 | 2650860255151 | 2650860255157
|
||||
9aea0e38-cb74-4983-b6f6-b1389620cdfd | e7c5c371-a4a2-40db-9f90-6ff413d9ffa0 | Товар 3 | 2800124503582 | 2800124503580
|
||||
9b22f3cf-46cd-4388-8d39-62da0bec7377 | 2f4441dc-2a94-4a2c-ade6-5c270ad48a86 | Товар 1 | 2769454863444 | 2769454863446
|
||||
9b5593c0-7415-4347-aaf6-7908af63e075 | f4b50e3c-e63a-4c4d-9c3f-2c32a552102a | Товар 2 | 2732219120138 | 2732219120139
|
||||
a70c0d6f-b99b-41bf-b12e-56c733c55202 | 5a857b3b-57c8-4460-8ad7-9f6eaee0ce97 | Товар 1 | 2630344841029 | 2630344841021
|
||||
ab3ba17b-e1c7-4779-b4da-7a6f74fab877 | 5952aac2-0aaa-4e6b-8f73-88c3980f5a76 | Товар 1 | 2479429060400 | 2479429060403
|
||||
b72b5ccf-8b86-41d5-87a5-c69b0ead6c6d | 8e721005-feaf-432e-8ebb-34efac5a7503 | Товар 1 | 2181329206711 | 2181329206712
|
||||
b7c403fe-7fc8-48af-9490-4a9a2eafe2c2 | d93afc1e-ea27-461f-afea-16d409e88468 | Тунец натуральный 240 гр Fish master | 2000000399788 | 2000000399782
|
||||
bf4466f7-2c50-4ca9-8be7-7db2a1dc96d2 | f290ea9b-41f8-485f-8f06-024e2dd67d04 | Товар 1 | 2526982373876 | 2526982373874
|
||||
d1fe3292-a167-4c63-9944-eed29c7494a7 | ea6a9db6-581c-4b5e-a011-9d92c5d903f7 | Товар 1 | 2400138194244 | 2400138194240
|
||||
db7ff78b-1755-4388-bc73-8dde9546279c | 001f8390-fade-4e50-8942-cbe8700e8647 | Товар 1 | 2121043372681 | 2121043372683
|
||||
dcd787cd-3209-4c57-a88e-e17d8bd567da | 4d2f3b56-c73b-49e4-a6f2-1402af486e54 | Товар 1 | 2091076032522 | 2091076032529
|
||||
e1057e0f-493b-458a-ac63-2b0d3b005ce2 | 7a74794c-d672-4e63-afc8-a59e9cafb50b | Товар 1 | 2422506372305 | 2422506372302
|
||||
e166d264-2b56-4fee-8aeb-2c650f0b9e32 | ca3f24ec-ce67-4402-bda0-108205f1e6aa | Товар 1 | 2191353131442 | 2191353131447
|
||||
f158b0b8-6266-4574-bbb0-6cfe2333962b | 2aec0165-42a9-4088-a48d-0b786a367d8d | Товар 3 | 2893012813027 | 2893012813021
|
||||
f4018fd4-041a-4e72-8204-e091ecbfb9b8 | e5ace923-0c5f-4b35-bf8d-559275bd6247 | Товар 2 | 2544714677075 | 2544714677076
|
||||
f44b79c5-e617-4aa4-aeab-b4b2f7d05a7a | ab0ca323-51e9-4242-a0b5-b2833bb7782b | Товар 1 | 2965763672860 | 2965763672865
|
||||
f80269c4-5702-4d67-a56f-5f9e871094a7 | e939a60b-30bb-44d7-9fc6-f7208e318c55 | Товар 1 | 2276824292069 | 2276824292060
|
||||
fbc3505d-585e-47ac-ae91-9339907233a3 | f7ca3aab-10ec-4fb0-8130-f2b29234a225 | Товар 2 | 2021646806852 | 2021646806854
|
||||
fc966697-a6c5-4a74-b5a2-54bdac02f5e6 | 0fbacfc8-dea8-46dc-8927-3e455c1438a0 | Товар 1 | 2718687983533 | 2718687983537
|
||||
212
docs/full-chain-e2e-progress.md
Normal file
212
docs/full-chain-e2e-progress.md
Normal file
|
|
@ -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-<RUN_ID>/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-<RUN_ID>.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 «коммит только по просьбе»). Готов закоммитить по команде.
|
||||
|
|
@ -268,7 +268,13 @@ public async Task<ActionResult<InventoryDto>> Create([FromBody] InventoryInput i
|
|||
Notes = input.Notes,
|
||||
};
|
||||
|
||||
// Если строки не указаны — подтягиваем все товары с ненулевым stock на складе.
|
||||
// Если строки не указаны — подтягиваем товары склада как «ещё не
|
||||
// посчитанные»: ActualQty = BookQty, Diff = 0. Так проведение НЕ трогает
|
||||
// непосчитанные позиции (Diff=0 пропускается). Раньше здесь ставилось
|
||||
// ActualQty=0 → Diff=-stock, и проведение ОБНУЛЯЛО всё, что кладовщик не
|
||||
// пересчитал (в т.ч. всё вне выбранной группы). Кладовщик меняет ActualQty
|
||||
// только у реально пересчитанных товаров (через add-line / правку строки);
|
||||
// истинный ноль на складе вводится явным ActualQty=0.
|
||||
if (input.Lines is null || input.Lines.Count == 0)
|
||||
{
|
||||
var stocks = await (from s in _db.Stocks.AsNoTracking()
|
||||
|
|
@ -283,8 +289,8 @@ public async Task<ActionResult<InventoryDto>> Create([FromBody] InventoryInput i
|
|||
{
|
||||
ProductId = st.ProductId,
|
||||
BookQty = st.Quantity,
|
||||
ActualQty = 0,
|
||||
Diff = -st.Quantity,
|
||||
ActualQty = st.Quantity,
|
||||
Diff = 0,
|
||||
UnitCost = st.Cost,
|
||||
SortOrder = order++,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ public record DrawerOpInput(
|
|||
[Range(0.0001, 1e12)] decimal Amount,
|
||||
[Required, MaxLength(500)] string Reason);
|
||||
|
||||
[HttpGet, RequiresPermission("MoneyView")]
|
||||
[HttpGet, RequiresPermission("MoneyView,CashShiftOperate")]
|
||||
public async Task<ActionResult<PagedResult<CashShiftRow>>> List(
|
||||
[FromQuery] PagedRequest req,
|
||||
[FromQuery] CashShiftStatus? status,
|
||||
|
|
@ -115,7 +115,7 @@ public record DrawerOpInput(
|
|||
return new PagedResult<CashShiftRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}"), RequiresPermission("MoneyView")]
|
||||
[HttpGet("{id:guid}"), RequiresPermission("MoneyView,CashShiftOperate")]
|
||||
public async Task<ActionResult<object>> Get(Guid id, CancellationToken ct)
|
||||
{
|
||||
var s = await _db.CashShifts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
|
|
@ -172,7 +172,7 @@ public async Task<ActionResult<object>> Drawer(Guid id, [FromBody] DrawerOpInput
|
|||
}
|
||||
|
||||
/// <summary>Z-отчёт: все цифры смены + внесения/выплаты для печати.</summary>
|
||||
[HttpGet("{id:guid}/report"), RequiresPermission("MoneyView")]
|
||||
[HttpGet("{id:guid}/report"), RequiresPermission("MoneyView,CashShiftOperate")]
|
||||
public async Task<ActionResult<object>> Report(Guid id, CancellationToken ct)
|
||||
{
|
||||
var s = await _db.CashShifts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
|
|
|
|||
|
|
@ -283,12 +283,15 @@ public async Task<IActionResult> Update(Guid id, [FromBody] EmployeeInput input,
|
|||
if (e.IsActive != nowActive) await SetLinkedUserActiveAsync(e.UserId, nowActive, ct);
|
||||
e.IsActive = nowActive;
|
||||
|
||||
// Replace assignments wholesale
|
||||
_db.EmployeeRetailPointAssignments.RemoveRange(e.RetailPointAssignments);
|
||||
e.RetailPointAssignments.Clear();
|
||||
// Replace assignments wholesale. ExecuteDelete + DbSet.Add вместо
|
||||
// nav-collection RemoveRange+Clear+Add: иначе EF8 путается на
|
||||
// nav-collection и UPDATE employees падает с DbUpdateConcurrencyException
|
||||
// «0 rows affected» (у сотрудника с привязкой к точке правка была
|
||||
// невозможна). Тот же паттерн, что в Supplies/Enters/Losses/Demands.Update.
|
||||
await _db.EmployeeRetailPointAssignments.Where(a => a.EmployeeId == e.Id).ExecuteDeleteAsync(ct);
|
||||
foreach (var rpId in input.RetailPointIds ?? [])
|
||||
e.RetailPointAssignments.Add(new EmployeeRetailPointAssignment
|
||||
{ OrganizationId = orgId, RetailPointId = rpId });
|
||||
_db.EmployeeRetailPointAssignments.Add(new EmployeeRetailPointAssignment
|
||||
{ OrganizationId = orgId, EmployeeId = e.Id, RetailPointId = rpId });
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
|
|
|
|||
|
|
@ -128,9 +128,10 @@ private static List<ProfitRow> Group(List<FlatRow> flat, string groupBy)
|
|||
switch (groupBy)
|
||||
{
|
||||
case "period:day":
|
||||
// «По дням» — по местной дате (Asia/Almaty, UTC+5).
|
||||
grouped = flat.GroupBy(x => (
|
||||
Key: x.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||
Label: x.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)));
|
||||
Key: foodmarket.Application.Common.BusinessTime.LocalDate(x.Date).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||
Label: foodmarket.Application.Common.BusinessTime.LocalDate(x.Date).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)));
|
||||
break;
|
||||
case "period:week":
|
||||
var cal = CultureInfo.InvariantCulture.Calendar;
|
||||
|
|
|
|||
|
|
@ -193,9 +193,10 @@ private static List<SalesRow> Group(List<FlatRow> flat, string groupBy)
|
|||
switch (groupBy)
|
||||
{
|
||||
case "period:day":
|
||||
// «По дням» — по местной дате (Asia/Almaty, UTC+5), а не по UTC.
|
||||
grouped = flat.GroupBy(x => (
|
||||
Key: x.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||
Label: x.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)));
|
||||
Key: foodmarket.Application.Common.BusinessTime.LocalDate(x.Date).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||
Label: foodmarket.Application.Common.BusinessTime.LocalDate(x.Date).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)));
|
||||
break;
|
||||
case "period:week":
|
||||
var cal = CultureInfo.InvariantCulture.Calendar;
|
||||
|
|
|
|||
|
|
@ -118,14 +118,15 @@ public record SalesStatsResponse(
|
|||
[FromQuery] int days = 30,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// «Сегодня/неделя/месяц» — по местному времени (Asia/Almaty, UTC+5), а не UTC.
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var todayStart = new DateTime(nowUtc.Year, nowUtc.Month, nowUtc.Day, 0, 0, 0, DateTimeKind.Utc);
|
||||
var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var prevMonthStart = monthStart.AddMonths(-1);
|
||||
var todayStart = foodmarket.Application.Common.BusinessTime.StartOfTodayUtc();
|
||||
var monthStart = foodmarket.Application.Common.BusinessTime.StartOfMonthUtc(nowUtc);
|
||||
var prevMonthStart = foodmarket.Application.Common.BusinessTime.StartOfMonthUtc(monthStart.AddDays(-1));
|
||||
var seriesStart = todayStart.AddDays(-(days - 1));
|
||||
// Неделя: начиная с понедельника текущей UTC-недели. Без сложной локали —
|
||||
// на дашборде это «скользящие 7 дней» в первом приближении.
|
||||
var weekStart = todayStart.AddDays(-(((int)todayStart.DayOfWeek + 6) % 7));
|
||||
// Неделя: с понедельника текущей МЕСТНОЙ недели.
|
||||
var localDow = foodmarket.Application.Common.BusinessTime.NowLocal.DayOfWeek;
|
||||
var weekStart = todayStart.AddDays(-(((int)localDow + 6) % 7));
|
||||
|
||||
var posted = _db.RetailSales.AsNoTracking().Where(s => s.Status == RetailSaleStatus.Posted);
|
||||
|
||||
|
|
@ -149,15 +150,19 @@ public record SalesStatsResponse(
|
|||
.Select(g => new { Sum = g.Sum(s => s.Total) })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
var rawSeries = await posted.Where(s => s.Date >= seriesStart)
|
||||
.GroupBy(s => s.Date.Date)
|
||||
.Select(g => new { Day = g.Key, Revenue = g.Sum(s => s.Total), Tx = g.Count() })
|
||||
// Группируем «по дням» по МЕСТНОЙ дате (Almaty) — считаем в памяти, чтобы
|
||||
// не упираться в перевод AddHours(5).Date в SQL.
|
||||
var rawRows = await posted.Where(s => s.Date >= seriesStart)
|
||||
.Select(s => new { s.Date, s.Total })
|
||||
.ToListAsync(ct);
|
||||
var byDay = rawRows
|
||||
.GroupBy(s => foodmarket.Application.Common.BusinessTime.LocalDate(s.Date))
|
||||
.ToDictionary(g => g.Key, g => new { Revenue = g.Sum(x => x.Total), Tx = g.Count() });
|
||||
|
||||
// Fill missing days with zeros so the chart line is continuous.
|
||||
var byDay = rawSeries.ToDictionary(x => x.Day, x => x);
|
||||
var startLocalDate = foodmarket.Application.Common.BusinessTime.LocalDate(seriesStart);
|
||||
var series = Enumerable.Range(0, days)
|
||||
.Select(i => seriesStart.AddDays(i).Date)
|
||||
.Select(i => startLocalDate.AddDays(i))
|
||||
.Select(d => byDay.TryGetValue(d, out var v)
|
||||
? new SalesStatsBucket(d, v.Revenue, v.Tx)
|
||||
: new SalesStatsBucket(d, 0m, 0))
|
||||
|
|
|
|||
|
|
@ -57,7 +57,8 @@ public sealed class PermissionAuthorizationHandler : AuthorizationHandler<Permis
|
|||
.Select(e => e.Role)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (role is not null && HasPermission(role.Permissions, requirement.Permission))
|
||||
// ИЛИ-семантика: достаточно любого из перечисленных прав.
|
||||
if (role is not null && requirement.Permissions.Any(p => HasPermission(role.Permissions, p)))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,18 @@
|
|||
|
||||
namespace foodmarket.Api.Infrastructure.Authorization;
|
||||
|
||||
/// <summary>Требование «у текущего пользователя включён флаг права
|
||||
/// <see cref="Permission"/> в его роли (RolePermissions)». Имя права = имя
|
||||
/// boolean-свойства <c>RolePermissions</c> (например, "ProductsEdit").</summary>
|
||||
/// <summary>Требование «у текущего пользователя включён ХОТЯ БЫ ОДИН из флагов
|
||||
/// прав <see cref="Permissions"/> в его роли (RolePermissions)». Имя права = имя
|
||||
/// boolean-свойства <c>RolePermissions</c> (например, "ProductsEdit"). Несколько
|
||||
/// прав через запятую = ИЛИ-семантика (например "MoneyView,CashShiftOperate" —
|
||||
/// эндпоинт доступен и финансисту, и кассиру для своей смены).</summary>
|
||||
public sealed class PermissionRequirement : IAuthorizationRequirement
|
||||
{
|
||||
public string Permission { get; }
|
||||
public IReadOnlyList<string> Permissions { get; }
|
||||
|
||||
public PermissionRequirement(string permission) => Permission = permission;
|
||||
/// <summary>Первое право — для обратной совместимости/логов.</summary>
|
||||
public string Permission => Permissions[0];
|
||||
|
||||
public PermissionRequirement(string permission)
|
||||
=> Permissions = permission.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,10 +144,10 @@ public async Task StartAsync(CancellationToken ct)
|
|||
}
|
||||
|
||||
/// <summary>Идемпотентный back-fill модуля «Деньги» по всем организациям:
|
||||
/// системной роли «Кассир» проставляет MoneyView + CashShiftOperate (если
|
||||
/// ещё не стоят), и гарантирует 5 системных статей расходов в каждой орге.
|
||||
/// Нужно только для орг, созданных ДО Sprint 33 — у новых всё ставит
|
||||
/// <see cref="SeedEmployeeRolesAsync"/>.</summary>
|
||||
/// системной роли «Кассир» гарантирует CashShiftOperate=true (своя смена) и
|
||||
/// MoneyView=FALSE (модуль «Деньги» кассиру недоступен — требование владельца),
|
||||
/// и гарантирует 5 системных статей расходов в каждой орге. Нужно для орг,
|
||||
/// созданных ДО этого изменения (у них кассиру раньше ставился MoneyView=true).</summary>
|
||||
private static async Task EnsureMoneyDefaultsAsync(AppDbContext db, CancellationToken ct)
|
||||
{
|
||||
var cashierRoles = await db.EmployeeRoles.IgnoreQueryFilters()
|
||||
|
|
@ -156,19 +156,38 @@ private static async Task EnsureMoneyDefaultsAsync(AppDbContext db, Cancellation
|
|||
var changed = false;
|
||||
foreach (var role in cashierRoles)
|
||||
{
|
||||
if (!role.Permissions.MoneyView || !role.Permissions.CashShiftOperate)
|
||||
if (role.Permissions.MoneyView || !role.Permissions.CashShiftOperate)
|
||||
{
|
||||
// Permissions — owned-тип; мутация скалярных флагов на
|
||||
// отслеживаемой сущности ловится DetectChanges при SaveChanges.
|
||||
// Остальные права кассира (ProductsView/RetailSalesOperate/…)
|
||||
// сохраняются — мы их не трогаем.
|
||||
role.Permissions.MoneyView = true;
|
||||
role.Permissions.MoneyView = false;
|
||||
role.Permissions.CashShiftOperate = true;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) await db.SaveChangesAsync(ct);
|
||||
|
||||
// Back-fill прав кладовщика: системной роли «Кладовщик» нужны права на
|
||||
// инвентаризацию/оприходование/списание (веб их показывает, API должен
|
||||
// совпадать). Старые орги их не имели → ловили 403.
|
||||
var keeperRoles = await db.EmployeeRoles.IgnoreQueryFilters()
|
||||
.Where(r => r.IsSystem && r.Name == "Кладовщик")
|
||||
.ToListAsync(ct);
|
||||
var keeperChanged = false;
|
||||
foreach (var role in keeperRoles)
|
||||
{
|
||||
if (!role.Permissions.InventoryEdit || !role.Permissions.EnterEdit || !role.Permissions.LossEdit)
|
||||
{
|
||||
role.Permissions.InventoryEdit = true;
|
||||
role.Permissions.EnterEdit = true;
|
||||
role.Permissions.LossEdit = true;
|
||||
keeperChanged = true;
|
||||
}
|
||||
}
|
||||
if (keeperChanged) await db.SaveChangesAsync(ct);
|
||||
|
||||
var orgIds = await db.Organizations.IgnoreQueryFilters().Select(o => o.Id).ToListAsync(ct);
|
||||
foreach (var orgId in orgIds)
|
||||
await SeedExpenseItemsAsync(db, orgId, ct);
|
||||
|
|
@ -303,10 +322,11 @@ private static async Task SeedEmployeeRolesAsync(AppDbContext db, Guid orgId, Ca
|
|||
ProductsView = true,
|
||||
SuppliesView = true, SuppliesEdit = true, SuppliesPost = true,
|
||||
StocksView = true,
|
||||
// Складская работа кладовщика: инвентаризация, оприходование,
|
||||
// списание (веб уже показывает эти пункты — API должен совпадать).
|
||||
InventoryEdit = true, EnterEdit = true, LossEdit = true,
|
||||
// Кладовщику деньги не положены — только видимость оплаченности
|
||||
// приёмки через SuppliesView (payedSum в DTO). MoneyView=false
|
||||
// здесь, этим флаг «кладовщик без доступа к деньгам» (простой
|
||||
// режим, п.7 ТЗ модуля).
|
||||
// приёмки через SuppliesView (payedSum в DTO). MoneyView=false.
|
||||
},
|
||||
};
|
||||
var cashier = new EmployeeRole
|
||||
|
|
@ -325,7 +345,11 @@ private static async Task SeedEmployeeRolesAsync(AppDbContext db, Guid orgId, Ca
|
|||
// вносить/выплачивать деньги в ней. Чужие смены и движения
|
||||
// по банковским счетам ему недоступны (контроллер фильтрует).
|
||||
CashShiftOperate = true,
|
||||
MoneyView = true,
|
||||
// Кассир НЕ видит модуль «Деньги» (счета/платежи/долги/чужие
|
||||
// смены) — требование владельца «кассир не видит денег». Свою
|
||||
// смену и Z-отчёт он видит через CashShiftOperate (эндпоинты
|
||||
// shifts List/Get/Report принимают его право по ИЛИ-семантике).
|
||||
MoneyView = false,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
36
src/food-market.application/Common/BusinessTime.cs
Normal file
36
src/food-market.application/Common/BusinessTime.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
namespace foodmarket.Application.Common;
|
||||
|
||||
/// <summary>Бизнес-время организации — Казахстан, Asia/Almaty (UTC+5, без DST).
|
||||
/// «Сегодня» / «за день» на дашборде, в отчётах и сменах считаем по местному
|
||||
/// дню, а не по UTC: иначе до 05:00 по Алматы «сегодня» показывало ещё вчерашние
|
||||
/// цифры (и наоборот после 19:00 UTC данные «уезжали» в завтра).</summary>
|
||||
public static class BusinessTime
|
||||
{
|
||||
/// <summary>Смещение Казахстана относительно UTC (единое +5 с 2024 г.).</summary>
|
||||
public static readonly TimeSpan Offset = TimeSpan.FromHours(5);
|
||||
|
||||
/// <summary>Текущий момент в местном времени (Kind=Unspecified).</summary>
|
||||
public static DateTime NowLocal => DateTime.UtcNow + Offset;
|
||||
|
||||
/// <summary>UTC-инстант местной полуночи сегодня.</summary>
|
||||
public static DateTime StartOfTodayUtc() => StartOfDayUtc(DateTime.UtcNow);
|
||||
|
||||
/// <summary>UTC-инстант местной полуночи того дня, в который попадает instant.</summary>
|
||||
public static DateTime StartOfDayUtc(DateTime instantUtc)
|
||||
{
|
||||
var localMidnight = (DateTime.SpecifyKind(instantUtc, DateTimeKind.Utc) + Offset).Date;
|
||||
return DateTime.SpecifyKind(localMidnight - Offset, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
/// <summary>UTC-инстант местного начала месяца, в который попадает instant.</summary>
|
||||
public static DateTime StartOfMonthUtc(DateTime instantUtc)
|
||||
{
|
||||
var local = DateTime.SpecifyKind(instantUtc, DateTimeKind.Utc) + Offset;
|
||||
var localMonth = new DateTime(local.Year, local.Month, 1, 0, 0, 0, DateTimeKind.Unspecified);
|
||||
return DateTime.SpecifyKind(localMonth - Offset, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
/// <summary>Местная дата (без времени) для группировки «по дням» в отчётах.</summary>
|
||||
public static DateTime LocalDate(DateTime instantUtc) =>
|
||||
(DateTime.SpecifyKind(instantUtc, DateTimeKind.Utc) + Offset).Date;
|
||||
}
|
||||
|
|
@ -181,6 +181,33 @@ public async Task<MoneyServiceResult> UnpostAsync(Guid transactionId, Guid curre
|
|||
return new(MoneyServiceStatus.ShiftAlreadyOpen, null, null,
|
||||
"shift-already-open", "На этой точке уже открыта смена.");
|
||||
|
||||
// Точка продаж должна существовать и быть активной — на деактивированной
|
||||
// точке открывать смену (и, значит, продавать) нельзя.
|
||||
var rp = await _db.RetailPoints.FirstOrDefaultAsync(r => r.Id == retailPointId, ct);
|
||||
if (rp is null)
|
||||
return new(MoneyServiceStatus.ValidationFailed, null, null,
|
||||
"retail-point-not-found", "Точка продаж не найдена.");
|
||||
if (!rp.IsActive)
|
||||
return new(MoneyServiceStatus.ValidationFailed, null, null,
|
||||
"retail-point-inactive", "Точка продаж деактивирована — открыть смену нельзя.");
|
||||
|
||||
// Привязка кассира к точкам: если у сотрудника настроены привязки к точкам
|
||||
// продаж, открывать смену он может ТОЛЬКО на закреплённой точке. У кого
|
||||
// привязок нет (администратор/управляющий) — на любой.
|
||||
var emp = await _db.Employees.FirstOrDefaultAsync(e => e.UserId == cashierUserId, ct);
|
||||
if (emp is not null)
|
||||
{
|
||||
var hasAnyAssignment = await _db.EmployeeRetailPointAssignments.AnyAsync(a => a.EmployeeId == emp.Id, ct);
|
||||
if (hasAnyAssignment)
|
||||
{
|
||||
var assigned = await _db.EmployeeRetailPointAssignments
|
||||
.AnyAsync(a => a.EmployeeId == emp.Id && a.RetailPointId == retailPointId, ct);
|
||||
if (!assigned)
|
||||
return new(MoneyServiceStatus.ValidationFailed, null, null,
|
||||
"retail-point-not-assigned", "Вы не закреплены за этой точкой продаж — открыть смену нельзя.");
|
||||
}
|
||||
}
|
||||
|
||||
var cash = await _db.MoneyAccounts.FirstOrDefaultAsync(a => a.Id == cashAccountId, ct);
|
||||
if (cash is null || cash.Kind != MoneyAccountKind.Cash)
|
||||
return new(MoneyServiceStatus.ValidationFailed, null, null,
|
||||
|
|
|
|||
|
|
@ -6,12 +6,15 @@
|
|||
import { BarcodeType } from '@/lib/types'
|
||||
|
||||
function digitsChecksum(first: string, weightAtOdd: number): number {
|
||||
// Общая EAN/UPC-подобная формула: сумма с чередующимися весами, остаток от 10.
|
||||
// Нечётные позиции (с индекса 0) — weightAtOdd, чётные — 1.
|
||||
// Контрольная цифра EAN/UPC: веса идут СПРАВА НАЛЕВО (цифра перед контрольной
|
||||
// имеет вес 3, затем 1,3,1…). Нельзя привязывать вес к индексу слева: для
|
||||
// чётной длины тела (EAN-13 = 12 цифр) чётность переворачивается и check-digit
|
||||
// получается неверным. Для нечётной длины (EAN-8=7, UPC-A=11) результат тот же.
|
||||
let sum = 0
|
||||
for (let i = 0; i < first.length; i++) {
|
||||
const d = first.charCodeAt(i) - 48
|
||||
sum += i % 2 === 0 ? d * weightAtOdd : d
|
||||
let w = weightAtOdd
|
||||
for (let i = first.length - 1; i >= 0; i--) {
|
||||
sum += (first.charCodeAt(i) - 48) * w
|
||||
w = w === weightAtOdd ? 1 : weightAtOdd
|
||||
}
|
||||
return (10 - (sum % 10)) % 10
|
||||
}
|
||||
|
|
|
|||
|
|
@ -595,7 +595,9 @@ function ProductSearchDialog({ q, onClose, onPick }: {
|
|||
queryKey: ['product-search', query],
|
||||
queryFn: async () => {
|
||||
if (query.trim().length < 2) return []
|
||||
const { data } = await api.get<{ items: ProductSearchHit[] }>(`/api/catalog/products?q=${encodeURIComponent(query)}&pageSize=20`)
|
||||
// API ждёт параметр `search` (PagedRequest.Search), а не `q` — иначе
|
||||
// фильтр игнорируется и поиск товара на экране накладной не работает.
|
||||
const { data } = await api.get<{ items: ProductSearchHit[] }>(`/api/catalog/products?search=${encodeURIComponent(query)}&pageSize=20`)
|
||||
return data.items ?? []
|
||||
},
|
||||
enabled: query.trim().length >= 2,
|
||||
|
|
|
|||
|
|
@ -65,8 +65,11 @@ export function SupplyScanPage() {
|
|||
const main = r.data.items?.find(s => s.isMain) ?? r.data.items?.[0]
|
||||
if (main) setStoreId(main.id)
|
||||
})
|
||||
api.get<Currency[]>('/api/catalog/currencies?pageSize=20').then(r => {
|
||||
const list = Array.isArray(r.data) ? r.data : []
|
||||
// /api/catalog/currencies отдаёт PagedResult {items,...}, а не голый массив —
|
||||
// раньше Array.isArray(r.data) давал пустой список валют, и черновик приёмки
|
||||
// сканированием не сохранялся (нет currencyId, в т.ч. KZT по умолчанию).
|
||||
api.get<{ items: Currency[] }>('/api/catalog/currencies?pageSize=20').then(r => {
|
||||
const list = r.data?.items ?? []
|
||||
setCurrencies(list)
|
||||
const kzt = list.find(c => c.code === 'KZT') ?? list[0]
|
||||
if (kzt) setCurrencyId(kzt.id)
|
||||
|
|
|
|||
48
tests/e2e/full-chain.sh
Executable file
48
tests/e2e/full-chain.sh
Executable file
|
|
@ -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
|
||||
213
tests/e2e/lib/fc-ui.ts
Normal file
213
tests/e2e/lib/fc-ui.ts
Normal file
|
|
@ -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-<RUN_ID>/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<Ui> {
|
||||
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<Page> {
|
||||
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<Page> {
|
||||
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<Shot> {
|
||||
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<Shot> {
|
||||
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/<report>.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<string, Shot[]>()
|
||||
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) => `
|
||||
<figure>
|
||||
<a href="screenshots/${basename(s.file)}" target="_blank">
|
||||
<img src="screenshots/${basename(s.file)}" loading="lazy" alt="${esc(s.caption)}"/>
|
||||
</a>
|
||||
<figcaption>${s.mobile ? '📱 ' : '🖥 '}${esc(s.caption)}<br><small>${basename(s.file)}</small></figcaption>
|
||||
</figure>`).join('')
|
||||
return `<section><h2>${esc(stepName)}</h2><div class="grid">${cards}</div></section>`
|
||||
}).join('\n')
|
||||
const html = `<!doctype html><html lang="ru"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Full-chain e2e — скриншоты процессов (${esc(meta.target)})</title>
|
||||
<style>
|
||||
body{font:14px/1.5 system-ui,Segoe UI,Roboto,sans-serif;margin:0;background:#0f172a;color:#e2e8f0}
|
||||
header{padding:20px 24px;background:#1e293b;position:sticky;top:0;box-shadow:0 1px 0 #334155}
|
||||
h1{margin:0 0 4px;font-size:20px} .sub{color:#94a3b8;font-size:13px}
|
||||
section{padding:8px 24px 24px} h2{font-size:15px;color:#7dd3fc;border-bottom:1px solid #334155;padding-bottom:6px}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:16px}
|
||||
figure{margin:0;background:#1e293b;border:1px solid #334155;border-radius:10px;overflow:hidden}
|
||||
img{width:100%;display:block;background:#fff;cursor:zoom-in}
|
||||
figcaption{padding:8px 10px;font-size:12px;color:#cbd5e1}
|
||||
small{color:#64748b}
|
||||
</style></head><body>
|
||||
<header><h1>Жизнь магазина — скриншоты процессов</h1>
|
||||
<div class="sub">Прогон: ${esc(meta.target)} · ${esc(meta.when)} · всего кадров: ${this.shots.length}</div></header>
|
||||
${sections}
|
||||
</body></html>`
|
||||
const out = resolve(this.dir, '..', 'index.html')
|
||||
writeFileSync(out, html, 'utf8')
|
||||
return out
|
||||
}
|
||||
|
||||
/** Ссылки сайдбара для пользователя с данным токеном (эфемерный контекст). */
|
||||
async sidebarHrefs(token: string): Promise<string[]> {
|
||||
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<Shot> {
|
||||
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, '>').replace(/"/g, '"')
|
||||
}
|
||||
131
tests/e2e/lib/fc.ts
Normal file
131
tests/e2e/lib/fc.ts
Normal file
|
|
@ -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-<ts>`. Чистится ночной уборкой (>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<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' },
|
||||
})
|
||||
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-<ts>. */
|
||||
export async function signupOrg(): Promise<Session> {
|
||||
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<Session> {
|
||||
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<void>((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])
|
||||
}
|
||||
|
|
@ -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(``)
|
||||
lines.push('')
|
||||
}
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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-<RUN_ID>/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}`)
|
||||
|
|
|
|||
1319
tests/e2e/scenarios/full-chain.steps.ts
Normal file
1319
tests/e2e/scenarios/full-chain.steps.ts
Normal file
File diff suppressed because it is too large
Load diff
45
tests/e2e/scenarios/full-chain.yml
Normal file
45
tests/e2e/scenarios/full-chain.yml
Normal file
|
|
@ -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)"
|
||||
Loading…
Reference in a new issue