Commit graph

390 commits

Author SHA1 Message Date
nns ea29ab63c7 feat(returns): возврат от покупателя (CustomerReturn) (P1-6)
Расширение RetailSale: IsReturn (bool) + ReferenceSaleId (Guid?, self-FK) +
RetailSaleLine.QtyReturned (агрегация для защиты от over-return).
Миграция Phase6e_RetailSaleReturns (IF NOT EXISTS, можно гонять на стейдже).

Контроллер api/sales/retail:
• Create принимает isReturn/referenceSaleId; для возврата с reference —
  валидация что reference существует, не сам возврат, и проведён.
• POST /{id}/create-return — создаёт Draft-возврат от проведённого чека,
  копируя строки с qty = (Quantity - QtyReturned).
• Post с IsReturn=true идёт через PostReturnAsync: проверяет лимит
  возврата по reference (Quantity - QtyReturned), создаёт StockMovement
  тип CustomerReturn с +Quantity, инкрементит QtyReturned на источнике.
• Unpost для возврата зеркально откатывает.
• Запрещён unpost исходного чека пока есть проведённые возвраты на него.

Web: кнопка «Создать возврат» на странице проведённой продажи. DTO
расширены полями isReturn/referenceSale*/qtyReturned.

Тесты: 3 интеграционных (полный цикл sale→create-return→post,
standalone-return без reference, блокировка unpost при активных возвратах).
Полный прогон: 27 зелёных интеграционных, 23 зелёных unit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:51:04 +05:00
nns 561291f226 docs(sprint2): P1-4 done
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:39:48 +05:00
nns 4285bdee91 feat(inventories): инвентаризация с CSV-импортом факта (P1-4)
Domain InventoryDoc+InventoryLine (productId, bookQty, actualQty, diff).
EF, миграция Phase6d_Inventories. Контроллер api/inventory/inventories:
Create без строк автоматически подгружает все товары склада с текущим
Stock в bookQty (actual=0); Update пишет actualQty по строкам, пересчитывая
diff. Post создаёт корректирующие движения InventoryAdjustment на diff
(положительный — приход излишка, отрицательный — списание недостачи).
Unpost атомарно откатывает; проверка «излишек уже расходован» → 409.

Web: /inventory/inventories (list с разделением излишек/недостача) +
edit с импортом CSV (productId|article;actualQty). Сайдбар «Инвентаризации».

Тесты: 3 интеграционных (create-подгрузка bookQty + apply diff;
post 400 если diff=0; tenant-изоляция).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:39:32 +05:00
nns 6254b61caa docs(sprint2): P1-3 done
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:32:47 +05:00
nns fa1e123327 feat(transfers): атомарное перемещение между складами (P1-3)
Domain Transfer+TransferLine (FromStoreId → ToStoreId, обязательны и различны).
EF, миграция Phase6c_Transfers. Контроллер api/inventory/transfers: CRUD +
Post/Unpost. Post создаёт ПАРУ движений TransferOut(-) + TransferIn(+) в
одной Serializable-транзакции; Unpost — обратная пара тоже атомарно.
Защита от ухода в минус: post (на FromStore), unpost (на ToStore — товар
мог быть уже расходован).

Web: /inventory/transfers (list+edit) с двумя селекторами складов и
визуализацией «From → To». Пункт «Перемещения» в сайдбаре. Permission
TransferEdit добавлен в RolePermissions.

Тесты: 4 интеграционных (post создаёт пару движений, unpost оставляет
ровно 4 движения и обнуляет stock-диффы; same-store → 400; short-stock
на FromStore → 409 без побочных эффектов; tenant-изоляция).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:32:32 +05:00
nns ab74d06706 docs(sprint2): P1-2 done
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:24:52 +05:00
nns 3172b0ea72 feat(losses): списание со склада с указанием причины (P1-2)
Domain Loss+LossLine + enum LossReason (Defect/Expired/Damage/Shortage/Other).
EF, миграция Phase6b_Losses. Контроллер api/inventory/losses: CRUD +
Post/Unpost. Post создаёт StockMovement тип WriteOff с -Quantity; защита
от ухода в минус (409 со списком конфликтов). Unpost возвращает товар.
Web: /inventory/losses (list+edit) с фильтром по причине и колонкой
текущего остатка в строке. Сайдбар: «Списания» (Admin/Storekeeper).

Тесты: 3 интеграционных (post→stock падает, unpost восстанавливает;
списание сверх остатка → 409; tenant-изоляция).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:24:40 +05:00
nns 683d64dc9c docs(sprint2): P1-1 done
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:18:25 +05:00
nns e392bf8ae9 feat(enters): оприходование товара без поставщика (P1-1)
Domain Enter+EnterLine (мирорит Supply, но без SupplierId и без cost rollup).
EF-конфигурация, миграция Phase6a_Enters (idempotent CREATE TABLE).
Контроллер api/inventory/enters: CRUD + Post/Unpost. Post создаёт
StockMovement тип Enter; Unpost блокируется, если остаток ушёл бы в минус.
Web: /inventory/enters (list + edit), пункт «Оприходования» в сайдбаре
Admin/Storekeeper.

Тесты: 4 интеграционных (post раздаёт stock, unpost откатывает, double
post→409, tenant-изоляция A/B, unpost блокируется при минусе после продажи).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:18:13 +05:00
nns 880be11bd8 docs(sprint2): чек-лист — 7 документов учёта (P1)
7 пунктов: Enter, Loss, Transfer, Inventory, CustomerReturn,
SupplierReturn, Hangfire dashboard.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:04:35 +05:00
nns 9ef9df3ed5 docs(sprint1): P1-21 done — все 9 пунктов выполнены, итог
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 03:16:25 +05:00
nns f2dad91e05 test(integration): Testcontainers.PostgreSql + WebApplicationFactory, 10 тестов (P1-21)
ApiFactory поднимает реальный API на одноразовом postgres:16-alpine (Ryuk off —
сеть к Docker Hub нестабильна, образ закэширован; RateLimiting off через env, т.к.
лимитер читает конфиг эагерно). Program сделан public partial для фабрики.

Сценарии (10 зелёных):
- signup-flow: signup→token→/api/me с org; дубль-signup 400; слабый пароль 400.
- tenant isolation A vs B: контрагент A не виден B (список + прямой GET 404).
- permission: кастомная роль без ProductsEdit → PUT товара 403, GET 200; админ не 403.
- supply post→unpost: остаток 0→10, Cost=70 (скользящее среднее), unpost→0; двойной post 409.
- retail overselling: продажа сверх остатка → 409; недоплата → 400.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 03:14:01 +05:00
nns e988a7dbbc docs(sprint1): P1-20 done
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 03:02:06 +05:00
nns f3d517f257 test(unit): xUnit-проект food-market.UnitTests, 23 теста (P1-20)
Чистая логика вынесена в Application для тестируемости и используется контроллерами:
- MovingAverageCost.Compute (скользящее среднее себестоимости) ← SuppliesController.Post
- RetailPaymentValidator.IsSufficient (достаточность оплаты) ← RetailSalesController.Post

Тесты:
- MovingAverageCost: первая приёмка, средневзвешенное, округление до 4 знаков, totalQty=0.
- RetailPaymentValidator: ровно/переплата/недоплата, округление до 2 знаков.
- StockService.ApplyMovement (SQLite in-memory): материализация Stock+движение,
  инкремент, отрицательное списание, throw без tenant.
- Мультитенантный query-filter AppDbContext: tenant видит своё; чужой не видит;
  SuperAdmin без override — всё; с override — только выбранную оргу.

Все 23 зелёные. EF8 SQLite поддерживает ToJson (EmployeeRole.Permissions).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 03:01:56 +05:00
nns b613adf558 docs(sprint1): P0-9 done
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:52:24 +05:00
nns 99c1f1b780 docs: чек-лист релиза (P0-9)
Пред/во время/после выкатки: предусловия (.env/issuer/сертификаты/бэкап-таймер),
тесты+миграции перед релизом, бэкап, деплой через CI, smoke после (health/ready,
логин, ключевые потоки, permission 403, rate-limit 429), откат, прод. Ссылается на
secrets.md / backup-restore.md / openiddict-keys.md / stage-setup.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:52:16 +05:00
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