Commit graph

374 commits

Author SHA1 Message Date
nns 37e9d28f69 docs(sprint1): P0-8 done
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:51:23 +05:00
nns 45326281f9 docs(deploy): .env.example + secrets.md, проброс OpenIddict env в compose (P0-8)
deploy/.env.example — все required/опц. переменные (POSTGRES_PASSWORD, REGISTRY,
*_TAG, OPENIDDICT_ISSUER/CERT_PASSWORD, FM_* бэкапа, Cors/RateLimiting/MoySklad).
docs/secrets.md — таблица переменных, где живут секреты (SMTP в БД, сертификаты в
volume), ротация, гигиена. compose: api получает OpenIddict__Issuer (за прокси
обязателен) и OpenIddict__CertPassword из .env. compose config валиден.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:51:13 +05:00
nns 5b981dd34b docs(sprint1): P0-6 done
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:49:18 +05:00
nns 7c34bb1abd feat(deploy): авто-бэкап БД+uploads — systemd timer/service + скрипт (P0-6)
food-market-backup.sh: pg_dump -Fc контейнера + tar uploads, ротация 30 дней,
атомарная запись через .tmp+mv. food-market-backup.{service,timer} — ежедневно
03:00 с догоном пропущенных. docs/backup-restore.md — установка таймера, ручной
бэкап, восстановление БД (drop+create / --clean) и uploads, проверка дампа.

Скрипт проверен против food-market-postgres: дамп PGDMP custom-format,
248 TOC, pg_restore --list читает. Установку на prod-vm не делаем — только артефакты.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:49:08 +05:00
nns 744847661d docs(sprint1): P0-1 done
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:47:12 +05:00
nns 422b7ad5ea feat(auth): prod X509-ключи OpenIddict с persistent self-signed (P0-1)
OpenIddictKeyConfigurator: dev — прежний RSA-ключ в App_Data (поведение не
менялось, шифрование access-token выключено); prod/stage — отдельные X509
сертификаты подписи и шифрования из конфига (OpenIddict:SigningCertPath /
EncryptionCertPath / CertPassword, можно env). Нет файла → генерируется
persistent self-signed (RSA 2048, 5 лет) и сохраняется в App_Data (volume),
а не dev-ephemeral — токены переживают рестарт.

Проверено: prod выдаёт 5-сегментный JWE, /api/me 200; рестарт → те же
сертификаты (fingerprint совпал), pre-restart токен валиден. dev — 3-сегментный
JWT, /api/me 200. docs/openiddict-keys.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:47:00 +05:00
nns 00964f587a docs(sprint1): P0-5 done
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:42:07 +05:00
nns 688be30226 test(e2e): roles step08 проверяет permission-enforcement + rate-limit конфигурируем
- roles.steps.ts step08: было «задокументированный gap», стало реальная
  проверка — кастомная роль без ProductsEdit → 403 на PUT товара, GET → 200.
  Сценарий roles зелёный 8/8.
- RateLimiting:* конфиг (Enabled/PerMinute/PerHour): тесты с общим loopback-IP
  поднимают/выключают лимит, чтобы повторные логины не упирались в 429.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:41:52 +05:00
nns 28010fafdb feat(authz): permission-based авторизация по флагам роли (P0-5)
PermissionAuthorizationHandler + [RequiresPermission("...")] + динамический
PermissionAuthorizationPolicyProvider (policy perm:*). Доступ определяют флаги
RolePermissions роли сотрудника (live из БД), а не зашитый список Identity-ролей.

SuperAdmin и Identity-роль Admin (= системная «Администратор» с All()) —
полный доступ шорткатом; custom-роли не маппятся на Admin, поэтому шорткат их
не задевает. Нет активного Employee/нет флага → 403 (fail-closed).

Заменены [Authorize(Roles=...)] в каталоге (Products/ProductGroups/PriceTypes/
Counterparties/Stores/RetailPoints/Units/ProductImages) и документах (Supplies/
RetailSales) на конкретные права. Currencies/Countries оставлены SuperAdmin
(глобальный справочник, не org-permission).

Проверено curl на :5091: custom-роль без ProductsEdit → PUT товара 403;
GET 200; admin/после выдачи права → 400 (не 403). Закрывает «роли — фикция» из аудита.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:37:28 +05:00
nns 2e98e384f5 docs(sprint1): P0-4 done
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:23:59 +05:00
nns 82bf53bb5c feat(api): health-пробы /health/live и /health/ready (P0-4)
/health/live — liveness без зависимостей (Predicate=false).
/health/ready — readiness: DatabaseReadyHealthCheck (CanConnect + нет
неприменённых миграций), тег ready, 503 если не готово. JSON-ответ по
каждому чеку. docker-compose api healthcheck + Dockerfile.api → /health/ready,
web ждёт api service_healthy. /health сохранён для обратной совместимости.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:23:48 +05:00
nns 6f1566c2c3 docs(sprint1): P0-3 done
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:20:12 +05:00
nns 8048c44ee4 feat(api): rate-limit /connect/token и /api/auth/signup (P0-3)
Sliding window на IP: 5/мин + 20/час (оба окна chained, оба должны
пропустить). Отдельные бакеты на эндпоинт — регистрация не съедает лимит
логинов. Глобальный лимитер с no-op для не-auth путей: двойное окно
per-endpoint policy выразить не может. 429 + JSON-телом, X-Forwarded-For
учитывается за прокси. Проверено curl'ом: 6-я попытка/мин → 429.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:20:02 +05:00
nns a15100f3bc docs(sprint1): чек-лист прогресса стабилизации
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:14:25 +05:00
nns 6098c03e1a docs(e2e): итоговый отчёт 2026-05-26 — 15 сценариев зелёные (124 шага)
Полная регрессия всех сценариев + 6 новых областей этой сессии (employees,
roles, superadmin-console, platform-smtp, auth-password, security-edge).
За день исправлено 4 бага: уволенный сотрудник логинится (P0), конкурентное
проведение приёмки ломает инвариант (critical), refresh не гасится после
ротации (high), change-owner принимал короткий reason (medium). Нереализованный
по ТЗ функционал (отчёты/склад-документы/POS/permission-authz/login-ratelimit)
зафиксирован как Logic gaps.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:05:23 +05:00
nns 888c8c28f0 test(e2e): scenario roles — CRUD ролей, защита системных/занятых
8 шагов (ТЗ 2.7.2): системные роли (ядро Администратор/Кладовщик/Кассир)
созданы и не удаляются (409); кастомная роль создаётся, права сохраняются и
редактируются; роль, занятая сотрудником → 409 на удалении; неиспользуемая
удаляется. Зафиксированы gap'ы: системных ролей 3, а не 4-6 (намеренное
упрощение Phase4b_RolesSimplify); permission-based авторизация не enforced
на эндпоинтах (после P0-5) — флаги RolePermissions справочные.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:00:59 +05:00
nns 2fd3b6d75c test(e2e): scenario security-edge — auth-гейт, traversal, SQLi, tenant, CORS
6 шагов (ТЗ 2.17): защищённые эндпоинты без токена → 401; /health и
/connect/token анонимны; path-traversal на /uploads (закодированные ../) не
отдаёт файлы ФС; SQL-инъекция в quick-search не роняет и не меняет данные;
товар чужого тенанта → 404 (не 403/200); CORS не отражает чужой Origin.
Багов в этих областях нет.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:58:54 +05:00
nns a04b4bf2dd test(e2e): scenarios platform-smtp + auth-password
platform-smtp (ТЗ 2.9, 6 шагов): причина изменения обязательна (≥10),
test-send без настроек → 400, пароль шифруется в БД (не плейнтекст) и никогда
не возвращается клиентом, сентинел __clear__ очищает пароль.

auth-password (ТЗ 2.1.3, 6 шагов): анти-энумерация (forgot всегда 200),
reset с битым токеном / коротким паролем → 400, рейт-лимит forgot (>3/час
с IP → 429).

Оба сценария зелёные, багов в этих областях нет.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:56:29 +05:00
nns 90331ff371 test(e2e): scenario superadmin-console — архив/восстановление/владелец/удаление
6 шагов (ТЗ 2.8): создание орг + аудит CreateOrg; архив с подтверждением
имени (неверное → 400); восстановление; смена владельца (без reason / reason<10
→ 400, валидно → 204 + реальная передача владения); hard-delete с
retention-гейтом (не-архив → 409, до retention → 409, retention=0 + верное
имя → 204, орг удалена, юзеры отвязаны); фильтры журнала аудита по org и
actionType (DeleteOrg переживает удаление орг — FK отсутствует).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:53:32 +05:00
nns 01568baf4f fix(superadmin): change-owner требует reason ≥ 10 символов
Смена владельца организации писала reason в журнал аудита, но проверяла лишь
его непустоту — короткие/мусорные причины («ok») проходили. PlatformSettings
для SMTP уже требует ≥10 символов; приводим change-owner к той же планке
(ТЗ 2.8: «Reason < 10 символов → 400»), чтобы журнал аудита оставался
осмысленным.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:53:32 +05:00
nns 68ce968021 test(e2e): scenario employees — CRUD, увольнение гасит логин, tenant-изоляция
10 шагов (ТЗ 2.7.1): создание без/с учёткой (temp password), email
обязателен при createAccount, дубль email, логин новым сотрудником,
увольнение гасит логин и refresh (P0-проверка), двухступенчатое удаление
(fired → soft-delete → 409), защита главного администратора/самого себя,
multi-tenant изоляция (чужой сотрудник → 404).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:47:57 +05:00
nns 5091d43f5d fix(employees): увольнение/деактивация гасит логин связанного User
Employee.Delete (увольнение и soft-delete) и Employee.Update (деактивация)
меняли только Employee.IsActive, но не трогали связанный AppUser. Логин и
refresh в AuthorizationController гейтятся на User.IsActive — поэтому
уволенный сотрудник продолжал входить и обновлять токены до 30 дней (ТЗ 0.4:
«возможность залогиниться как удалённого сотрудника» = баг, P0).

Добавлен SetLinkedUserActiveAsync: при деактивации сотрудника гасит
User.IsActive и отзывает его valid OpenIddict-токены (как при удалении орг),
при реактивации через Update — возвращает доступ. Вызывается из DELETE (оба
шага) и из Update при смене активности.

Найдено сценарием employees step07 (было: login/refresh уволенного → 200).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:47:56 +05:00
nns f2f64646b1 docs(e2e): финальный системный отчёт 2026-05-26 — все 9 сценариев зелёные
Сводный отчёт systemic-2026-05-26.md + зелёные прогоны всех сценариев
(82 шага, 0 падений). За сессию исправлено: refresh-rotation (TokenId +
zero reuse-leeway), сериализуемое проведение приёмки против lost update,
MoySklad BaseUrl в конфиг. Покрыты впервые: конкурентность приёмок,
дашбордная выручка, импорт MoySklad (идемпотентность/маппинг). Зафиксированы
gap'ы по нереализованным отчётам (профит/ABC/экспорт, ТЗ 2.12).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:30:46 +05:00
nns c7ecc39590 test(e2e): scenario moysklad-import + mock-сервер MoySklad
lib/moysklad-mock.ts — минимальный mock JSON-API remap 1.2 (organization/
counterparty/product/productfolder) с полями по MoySkladDtos. Сценарий (7
шагов): сохранение/маскирование токена, test-connection, импорт контрагентов
и товаров через фоновый job, идемпотентность повторного импорта
(overwrite=false → Skipped), обновление по ключу (overwrite=true → Updated),
и проверка маппинга в БД (BIN/тип/адрес контрагента; артикул/НДС/упаковка/
цена/штрихкод/группа/страна товара).

Требует запуск API с MoySklad__BaseUrl=http://127.0.0.1:5099/api/remap/1.2/.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:27:16 +05:00
nns e78e921dd2 chore(moysklad): базовый URL MoySklad — из конфигурации (MoySklad:BaseUrl)
MoySkladClient.BaseUrl был константой api.moysklad.ru, из-за чего импорт
нельзя было протестировать без боевого токена. Регистрация HttpClient теперь
берёт BaseAddress из MoySklad:BaseUrl (дефолт — прежний боевой URL), так что
e2e/интеграционные тесты наводят клиент на mock-сервер, не трогая прод.
MoySkladClient не меняем — он уже делает BaseAddress ??= const.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:27:16 +05:00
nns 50ae8bd18b test(e2e): scenario reports-stats — дашбордная выручка + tenant-изоляция
5 шагов: stats считает только Posted-чеки (черновик исключён), агрегаты
RevenueToday/ThisMonth/AvgTicket и непрерывная серия по дням верны, параметр
days меняет длину серии, данные строго tenant-scoped (орг A ≠ орг B).
Профит по себестоимости, ABC и экспорт (ТЗ 2.12) зафиксированы как Logic
gaps — не реализованы (нет Cost-снимка в RetailSaleLine, нет ReportsController).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:18:54 +05:00
nns ad25e12ce4 test(e2e): scenario stock-concurrency — конкурентное проведение приёмок
4 шага: стартовая приёмка, две разные приёмки одного товара одновременно,
двойное проведение одной приёмки, финальный инвариант. Главный assert —
Stock.Quantity == Σ StockMovement.Quantity под гонкой + корректность
скользящего среднего Cost.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:16:11 +05:00
nns 15f27fd16e fix(supplies): сериализуемое проведение приёмки против lost update остатков
Supply.Post шёл на дефолтной изоляции (Read Committed), а
StockService.ApplyMovementAsync делает read-modify-write по Stock.Quantity
без RowVersion. Под конкуренцией это ломало главный инвариант
Stock.Quantity == Σ StockMovement.Quantity:

- двойное проведение ОДНОЙ приёмки (оба запроса читают Status=Draft до
  коммита соседа) применяло остаток дважды — два StockMovement, но Stock
  рос лишь на одну партию (lost update);
- две разные приёмки одного товара могли потерять обновление остатка и
  посчитать скользящее среднее Cost от устаревшего currentQty.

Переводим проведение на IsolationLevel.Serializable (как RetailSale.Post)
и ловим конфликт сериализации (SQLSTATE 40001/40P01) → 409, чтобы клиент
повторил, а не получал 500. Найдено сценарием stock-concurrency
(step03: было stock=32/sum=39 → стало 32/32, statuses 204+409).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:16:11 +05:00
nns defe6860fc docs(e2e): зелёные отчёты auth/catalog/stock edge-прогонов
auth-edge 10/10, catalog-edge 12/12, stock-invariant-deep 10/10.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:03:48 +05:00
nns 9f0f071193 test(e2e): scenarios auth-edge, catalog-edge, stock-invariant-deep
- auth-edge (10 шагов): refresh rotation/redemption, подделка JWT,
  деактивированный user, архивная орг, повторный/orphan signup.
- catalog-edge (12 шагов): валидация товара, дубль артикула, удаление
  групп/единиц/системных типов цен с зависимостями, FK-guard контрагента.
- stock-invariant-deep (10 шагов): инвариант Stock == SUM(StockMovement)
  через post/unpost/repost и конкурентные продажи.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:03:47 +05:00
nns e13ac655e5 fix(catalog): FK-guard удаления контрагента + валидация полей товара
Найдено в catalog-edge:

- DELETE контрагента, на которого ссылаются supplies/retail-sales/products
  (DefaultSupplier), отдавал 500 (DbUpdateException 23503) вместо понятного
  409. Добавлен явный чек использования → Conflict со списком где занят.
- POST товара с пустым Name проходил до FK-проверки и падал неинформативно;
  теперь явный 400 с указанием поля. На ProductInput навешены
  [Required]/[MinLength]/[StringLength] на Name/Article/ImageUrl — отсекаем
  пустые и сверхдлинные значения на уровне модели.

catalog-edge: 12/12.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:03:37 +05:00
nns 32729e72a3 fix(auth): refresh-token rotation немедленно инвалидирует старый токен
Два бага в refresh-flow, из-за которых утёкший refresh-token давал доступ
после ротации (auth-edge step03):

1. AuthorizationController прокидывал в новый principal только
   AuthorizationId, но не TokenId. Handler RedeemTokenEntry читает TokenId
   из подписываемого principal, чтобы пометить погашаемый refresh как
   Redeemed — без него старый токен оставался valid.
2. Даже после починки редемпшна OpenIddict по умолчанию даёт 30-секундный
   reuse-leeway: погашенный refresh ещё принимается в этом окне. Обнуляем
   окно (SetRefreshTokenReuseLeeway(TimeSpan.Zero)) — ротация инвалидирует
   старый refresh сразу.

auth-edge: 10/10 (было 9/10).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:03:29 +05:00
nns 17a454cce5 test(e2e): scenario documents-edge — критичные edge-кейсы посту
10 шагов покрывают самую опасную зону системы (потеря денег/остатков):

1. Bootstrap: орг + admin + product + supply (10 шт по 100 KZT).
2. Supply.Post → stock=10 invariant.
3. RetailSale qty=15 (>stock 10) → POST /post → 409 «Недостаточно».
4. После заблокированного post: stock=10 + Stock == Σ StockMovement.
5. RetailSale PaidCash+PaidCard < Total → 4xx (валидация платежа).
6. PUT проведённой Supply → 409.
7. DELETE проведённой Supply → 409.
8. После Sale qty=5: unpost Supply qty=10 → 409 (stock уйдёт в минус).
9. Дубль штрихкода в одной орге → 4xx.
10. Тот же штрихкод в другой орге → 201 (per-tenant unique).

Запуск: `bash tests/e2e/run.sh documents-edge --api-only`.
Все 10 шагов зелёные после фиксов RetailSale.Post + Supply.Unpost.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 12:33:51 +05:00
nns 7a4b34bc2f fix(documents): защита денег и инварианта остатков на posting-операциях
Два P0-бага выявлены сценарием documents-edge:

BUG #11 (high): RetailSale.Post не проверял PaidCash+PaidCard ≥ Total.
Касса могла «провести» чек с фактической оплатой 0 — товар уходит
со склада, деньги не получены. Добавлена валидация: paid (округлённое
до 2 знаков) ≥ total, иначе 400 «Сумма оплаты меньше итога».
Сдача (PaidCash > Total) остаётся легальной.

BUG #12 (critical): Supply.Unpost не проверял, не уйдёт ли Stock в
минус после реверса. Сценарий: приёмка 10шт → продажа 5шт → unpost
приёмки ⇒ stock = -5. Это нарушение инварианта учёта. Добавлен guard:
агрегируем reverse-quantity по продукту, сравниваем с текущим
Stock.Quantity, при недостаче возвращаем 409 со списком конфликтных
строк.

Покрыто E2E documents-edge step05 (PaidCash<Total → 4xx) и step08
(unpost после sale → 409): обе проверки теперь зелёные.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 12:33:40 +05:00
nns 4d7d7bfe7b docs(e2e): systemic test report 2026-05-23 — оба сценария зелёные
Итоги сессии системного тестирования:
- full-cycle: 12/12 ✓
- multi-tenant-isolation: 12/12 ✓ (новый сценарий)

Найдено и исправлено 10 P0-багов: 7 в миграциях (расхождения схемы
с domain, отсутствующие [Migration] атрибуты, rudiment колонки Kind),
1 в безопасности (edit-mode override блокировался Authorize-ролями).

См. tests/e2e/reports/systemic-2026-05-23.md для полного описания
каждого бага, gap'ов и команд воспроизведения.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 12:26:40 +05:00
nns ae88a16fd2 test(e2e): scenario multi-tenant-isolation — 12 шагов проверки изоляции
Новый E2E-сценарий покрывает критичную для multi-tenant SaaS поверхность:

1. Создание двух независимых организаций (Alpha и Beta) через SuperAdmin.
2. Логин под admin'ами Alpha и Beta, проверка разных org_id в JWT.
3. Alpha seed'ит counterparty + product.
4. Beta GET по прямым ID Alpha → 404 (не 200, не 403, не 500).
5. Beta GET листинги — Alpha-записей нет.
6. Beta PUT/DELETE по ID Alpha с валидным телом → 404.
7. Beta POST product со ссылкой на supplier Alpha → 4xx.
8. Beta-admin подделывает X-Org-Override:{alphaId} → запрос
   игнорирует заголовок (только SuperAdmin может override).
9. SuperAdmin без override видит обе организации.
10. SuperAdmin + X-Org-Override без reason → read-only (PUT 403).
11. SuperAdmin + X-Org-Override + Reason ≥10 → PUT 200, audit_log растёт.
12. Stock + StockMovements Alpha не видны Beta.

Применение: `bash tests/e2e/run.sh multi-tenant-isolation --api-only`.
Использует ту же runner-инфраструктуру что и full-cycle.yml.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 12:25:05 +05:00
nns ab5c4c970d fix(security): SuperAdmin edit-mode override обходит [Authorize(Roles=Admin)]
Проблема: в режиме «открыть как…» (SuperAdmin + X-Org-Override) с reason ≥10
символов ReadonlyOverrideMiddleware пропускает PUT/POST/DELETE, но затем
контроллер падает 403 на атрибуте [Authorize(Roles="Admin,Storekeeper")] —
у SuperAdmin'а нет роли Admin тенанта. Результат: edit-mode фактически
не работает ни на одном tenant-эндпоинте.

Симптом, обнаруженный E2E:
  step11_superadmin_edit_override_with_reason: PUT → 403 «Forbidden»,
  super_admin_audit_log не растёт.

Фикс: новый SuperAdminOverrideClaimsTransformer (IClaimsTransformation).
При каждом запросе с заголовком X-Org-Override и ролью SuperAdmin
временно добавляет роли Admin/Storekeeper/Cashier в principal — только
для этого запроса. Изоляция и аудит остаются:
  - query filter всё равно скоупится через X-Org-Override (см.
    HttpContextTenantContext.TryGetHttpOverrideOrg).
  - SuperAdminEditAuditFilter пишет SuperAdminAuditLog с reason
    при успешном 2xx ответе.

Проверено E2E multi-tenant-isolation: 12/12 шагов проходят.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 12:24:52 +05:00
nns a06464baeb fix(migrations): чиним P0-блокеры разворачивания на чистой БД
Проблема: на свежей PostgreSQL `dotnet ef database update` падает на пяти
миграциях подряд + рантайм-несовместимость схемы с domain Product/Store/
Counterparty. Невозможно поднять стек ни на dev, ни на новом стейдже.

Найдено и починено:

1. Phase2c4_ReconcileStage пыталась AddColumn IsMarked, который Phase1Catalog
   (после рефакторинга) уже добавляет. Завернули в IF NOT EXISTS.

2. Phase5d_ProductVatDecimal ALTER COLUMN products.Vat падал — Vat теперь
   заменён на FK VatRateId, колонки нет. Завернули в IF EXISTS.

3. Phase5c_UnitsOfMeasureGlobal INSERT канонических ОКЕИ пропускал NOT NULL
   колонку Symbol (а также DecimalPlaces, IsBase, CreatedAt). Дополнили
   полным набором: шт/кг/л/м/уп.

4. Phase5d_DropUnitOfMeasureDescription дропала несуществующую колонку
   (Description в новой схеме отсутствует). Завернули в IF EXISTS.

5. Phase5a_EmployeeSoftDelete и Phase5b_PlatformSettings были написаны
   вручную без атрибутов [Migration] + [DbContext] — EF их игнорировал
   и пропускал применение (см. memory/feedback_ef_migrations.md).
   Добавили атрибуты + сделали идемпотентными.

6. Новая Phase5f_DropStoreKindRudiment: rudimentные колонки stores.Kind и
   counterparties.Kind (NOT NULL без default'а) роняли любой INSERT —
   ни одной организации/контрагента создать нельзя. Дропаем.

7. Новая Phase5g_ProductVatRealign: приводим products в соответствие с
   domain — дропаем FK→vat_rates + колонку VatRateId + IsAlcohol + пустую
   таблицу vat_rates; добавляем products.Vat numeric(5,2) DEFAULT 12 и
   VatEnabled bool DEFAULT true. Без этого ProductsController падает 42703
   при создании любого товара.

Все миграции идемпотентны (DO $$ ... IF EXISTS/NOT EXISTS ...) — повторное
применение на старой стейдж-БД безопасно.

Проверено: E2E full-cycle на свежей dev-БД проходит 12/12 шагов.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 12:13:19 +05:00
nns 35d70c5d80 docs: ТЗ на доработку и тестирование (полный аудит 2026-05-22)
Два документа после полного обхода кодовой базы:
- docs/TZ-доработка.md — что нужно сделать, P0/P1/P2 приоритеты,
  дорожная карта по спринтам, технический долг
- docs/TZ-тестирование.md — сценарии тестирования по модулям,
  multi-tenant изоляция, регрессионный чек-лист, стратегия
  покрытия unit/integration/E2E

Сводка готовности: ядро (auth/catalog/Supply/RetailSale/multi-tenancy)
85-95%, но критичные пробелы: ОФД, складские документы Enter/Loss/
Transfer/Inventory, Demand, отчёты, POS-приложение, observability.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 15:30:04 +05:00
nns 05c70f0368 fix(docker): обновить node:20-alpine → 22-alpine (pnpm 11 требует Node ≥22)
Some checks failed
CI / Backend (.NET 8) (push) Has been cancelled
CI / Web (React + Vite) (push) Has been cancelled
CI / POS (WPF, Windows) (push) Has been cancelled
Docker Web / Build + push Web (push) Has been cancelled
Docker Web / Deploy Web on stage (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-18 12:56:12 +05:00
nns fc2cbee3d7 fix(validation): validatePassword проверяет заглавную и цифру (соответствует хинту)
Some checks failed
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Docker Public / Build + push Public (push) Has been cancelled
Docker Public / Deploy Public on stage (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-18 12:52:59 +05:00
nns 259706e21f fix(signup): onBlur валидация через e.target.value, ре-валидация вместо сброса ошибки в onChange
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Public / Build + push Public (push) Waiting to run
Docker Public / Deploy Public on stage (push) Blocked by required conditions
- onBlur читает e.target.value напрямую из DOM (нет stale closure)
- onChange не очищает ошибку, а ре-валидирует (только если ошибка уже показана)
- Устраняет баг на мобильном: blur иногда стреляет раньше последнего onChange

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-18 12:43:53 +05:00
nns ff44afc202 feat(ux): onBlur валидация полей во всех формах
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Public / Build + push Public (push) Waiting to run
Docker Public / Deploy Public on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Ошибки полей теперь показываются сразу после потери фокуса — без
нажатия «Сохранить». При начале ввода ошибка убирается (onChange).
Submit-валидация остаётся без изменений.

Охват: SignupForm (public), LoginPage, ForgotPasswordPage,
ResetPasswordPage, EmployeesPage, CounterpartiesPage, StoresPage,
OrganizationSettingsPage, SuperAdminOrgCreatePage, PriceTypesPage,
EmployeeRolesPage, RetailPointsPage.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-17 23:38:56 +05:00
nns 42645174e0 e2e: bugs-fixed отчёт — все 12 шагов зелёные после fix HIGH+MEDIUM+2 gap'а
Прогон против commit bac527d (4 fix-коммита):
- Passed 10 → 12 (+2)
- Failed 2 → 0 (−2)
- Critical/HIGH/MEDIUM bugs 1+1 → 0+0
- Logic gaps 2 → 0

Все 4 пункта из full-pass.md закрыты.
2026-05-08 12:16:17 +05:00
nns bac527d3a8 fix(retail-sale): блок пустого Draft на UI + бэк уже отказывает
Some checks failed
CI / Backend (.NET 8) (push) Successful in 1m18s
CI / Web (React + Vite) (push) Successful in 46s
Docker API / Build + push API (push) Successful in 1m30s
Docker Web / Build + push Web (push) Successful in 44s
Docker API / Deploy API on stage (push) Successful in 19s
Docker Web / Deploy Web on stage (push) Successful in 12s
CI / POS (WPF, Windows) (push) Has been cancelled
Серверная защита от пустого RetailSale пришла вместе с FK-валидацией
(commit 5716829: «Чек должен содержать хотя бы одну позицию» → 400).
Этот коммит — UI-сторона.

В RetailSaleEditPage кнопка «Сохранить» теперь disabled когда
form.lines.length === 0, с tooltip «Добавьте хотя бы одну позицию».
Раньше пользователь мог нажать Сохранить, получить 400 «empty lines»,
не понять что делать.
2026-05-08 12:09:37 +05:00
nns 319a91ff10 feat(bootstrap): системная ProductGroup «Все товары» при создании org
Гэп из e2e-отчёта: новая орга стартует с пустым каталогом групп, и
ProductsController.Create падает с 400 «ProductGroupId required» пока
юзер вручную не заведёт группу. Это плохой UX — особенно для quick-
create товара из чека или приёмки.

Что сделано:
- ProductGroup получил поле IsSystem (default false) + миграция Phase5e.
- DevDataSeeder.SeedTenantReferencesAsync теперь создаёт идемпотентно
  системную группу «Все товары» (IsSystem=true) при bootstrap'е новой
  org. Та же логика срабатывает в SuperAdminOrganizationsController.Create
  и AuthSignupController, потому что оба зовут SeedTenantReferencesAsync.
- ProductGroupsController.Delete: системная группа защищена от удаления
  (400 «Системную группу удалить нельзя.»). Иначе продукты могли бы
  осиротеть после ON DELETE RESTRICT.
- ProductEditPage / ProductQuickCreateModal: при создании нового товара
  автоматически выбирают «Все товары» (или единственную группу), чтобы
  пользователь мог сохранить продукт без лишнего клика.
2026-05-08 12:08:28 +05:00
nns 57168299ac fix(validation): обязательные FK-Guid проверяются на 400 + DbUpdateException → 400
Было: SupplyInput.SupplierId/StoreId/CurrencyId — non-nullable Guid. Если
JSON приходит без поля или с null, оно десериализуется в Guid.Empty, и
ошибка проявляется только на SaveChanges как PostgresException 23503
(FK violation) с HTTP 500. UI получает generic 500 и не понимает какое
поле виновато.

Что изменено:
- Добавлен helper RequiredGuid.FirstMissing(...) — возвращает имя первого
  Guid.Empty поля или null.
- SuppliesController.Create/Update, RetailSalesController.Create/Update,
  ProductsController.Create/Update — теперь начинают с проверки FK-Guid'ов
  и возвращают 400 {error, field} если какое-то пусто.
- В тех же контроллерах SaveChanges обёрнут в SaveOrFkErrorAsync, который
  ловит PostgresException SqlState=23503 (foreign_key_violation), парсит
  ConstraintName и возвращает 400 вместо 500. Защита для случая когда
  Guid не пуст, но указывает на удалённую/чужую запись.

TaskUpdate: closes step08-bug «Supply без supplierId → 500».
2026-05-08 12:05:01 +05:00
nns 9eb1a6c69a fix(retail-sale): блок overselling в Post — 409 если qty>остатка
Сценарий из e2e: остаток 10, продаём 99999 → /post возвращал 204
[Posted] и стоки уходили в минус. Это критично — кассир мог провести
чек на товар, которого нет, и в БД появлялся отрицательный остаток.

Что изменено:
- В Post перед SaveChanges собираем сумму запрошенного qty по каждому
  productId (учитываем дубль одного товара в нескольких строках чека).
- Читаем stocks.Quantity для всех затронутых productId на конкретном
  StoreId одним запросом.
- Если хоть по одной строке available < requested — возвращаем 409
  с body {error, lines:[{productId, productName, qty, available}]},
  не делаем SaveChanges.
- Всё под BeginTransactionAsync(Serializable): защищает от race condition
  между двумя одновременными post'ами на один товар (без блокировки оба
  бы прочли «5», списали по 3, получили бы −1).
2026-05-08 12:01:20 +05:00
nns bf53629092 refactor(units): drop Description, hide Code from non-SuperAdmin UI
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m24s
CI / Web (React + Vite) (push) Successful in 42s
Docker API / Build + push API (push) Successful in 1m33s
Docker Web / Build + push Web (push) Successful in 37s
Docker API / Deploy API on stage (push) Successful in 18s
Docker Web / Deploy Web on stage (push) Successful in 12s
Description у пяти канонических ОКЕИ-единиц никогда не заполнялось ни UI,
ни импортом, ни сидером — выкидываем поле полностью (Domain → EF-config
→ DTO → Input → frontend types → Super-Admin форма). Migration
Phase5d_DropUnitOfMeasureDescription дропает колонку.

Code оставляем в БД (нужен для интеграций МойСклад/1С), но скрываем от
org Admin'а:
- /catalog/units-of-measure — только колонки Name + кнопка toggle, без
  Code и Description; поиск/сортировка только по Name.
- /super-admin/units-of-measure — Code продолжает показываться в таблице
  и форме редактирования.

Дропдаун единиц в ProductEditPage / ProductQuickCreateModal уже отдаёт
только {u.name} в options, проверено. На SupplyEditPage/RetailSaleEditPage
в строках документа отображается unitName, Code не показывался — без
изменений.
2026-05-08 11:02:10 +05:00
nns 37cd9aa94b test(e2e): починка контрактов supply/sale + EAN-13 + bug-hunt + full-pass отчёт
Контракты до фикса не совпадали с реальными:
- Product: unitId/groupId/retailPrice → unitOfMeasureId/productGroupId/prices[],
  плюс обязательный barcodes[] (генерим валидный EAN-13).
- Supply: counterpartyId/docDate/lines.price → supplierId/date/lines.unitPrice,
  плюс обязательный currencyId.
- RetailSale: путь /api/sales/retail-sales 404 → /api/sales/retail; payload
  обновлён под RetailSaleInput (storeId, currencyId, payment, paidCash и т.п.).

Шаги 9-12 теперь полностью проходят (не skip). Добавлены deep-bug-hunt'ы:
- Supply без supplierId / с пустым lines[]
- двойной post Supply / RetailSale → 409
- stock_movements vs Stocks.Quantity консистентность
- RetailPoint с несуществующим storeId
- продажа qty>остатка (выявил блокирующий баг — продаёт)
- discount на line, отрицательные qty/price
- stock_movements.Type = RetailSale (2)

Отчёт: tests/e2e/reports/full-cycle-2026-05-08-full-pass.md
Финальный счёт 10 ✓ / 2 ✗ / 0 ⚠ / 0 ◯ — две ✗ это РЕАЛЬНЫЕ баги:
[HIGH] step11 oversell проходит /post (нужна валидация qty≤stock)
[MEDIUM] step08 Supply без supplierId → 500 вместо 400
2026-05-08 11:01:56 +05:00