# Спринт 2 — складские документы (P1) Автономная работа. После каждого пункта: `dotnet build` (SDK 8.0.126), unit + integration + (где применимо) E2E тесты этого пункта, коммит порцией, отметка `[x]` здесь, коммит прогресса. Multi-tenant: все новые сущности — `TenantEntity` с `OrganizationId` + query filter. Stock-инвариант: после каждого Post/Unpost `Stock.Quantity ≡ Σ StockMovement` для (Product, Store). ## Чек-лист 1. [x] **P1-1 Оприходование (Enter)** — Domain `Enter`+`EnterLine`, EF, миграция, контроллер CRUD + Post/Unpost (Stock + StockMovement тип `Enter`), Web `/inventory/enters`. Без поставщика (источник — начальные остатки, излишек инвентаризации). ✅ Контроллер `api/inventory/enters`; миграция `Phase6a_Enters`; пункт «Оприходования» в сайдбаре Admin/Storekeeper. Тесты: 4 интеграционных (post raise stock, unpost откатывает, double post→409, tenant-изоляция, блокировка unpost при минусе). 2. [x] **P1-2 Списание (Loss)** — Domain `Loss`+`LossLine` + enum `LossReason` (Defect/Expired/Damage/Shortage/Other). EF, миграция, контроллер, Web, `StockMovement` тип `WriteOff`. ✅ Контроллер `api/inventory/losses` (CRUD + Post/Unpost) с проверкой «не списать сверх остатка» (409). Миграция `Phase6b_Losses`. Web с фильтром по причине и колонкой stockAtStore. Тесты: 3 интеграционных (post снижает stock, over-write-off → 409, tenant-изоляция). 3. [x] **P1-3 Перемещение (Transfer)** — Domain `Transfer`+`TransferLine` (FromStoreId → ToStoreId, обязательны и различны). Атомарная транзакция: `TransferOut` из From + `TransferIn` в To. EF, миграция, контроллер + Post/Unpost, Web. Кейс: post→unpost не оставляет orphan-движений. ✅ Пара движений (Out + In) в одной Serializable-транзакции; обратная пара в Unpost. Проверка «not short» на FromStore при Post и ToStore при Unpost. Permission `TransferEdit`. Тесты: 4 интеграционных, ключевой проверяет что движений ровно 2 после Post и ровно 4 после Unpost (никаких orphan). 4. [x] **P1-4 Инвентаризация (Inventory)** — Domain `Inventory`+`InventoryLine` (productId, bookQty, actualQty, diff). EF, миграция. Контроллер: создание подгружает текущие остатки; Post создаёт `InventoryAdjustment` на diff. Web: форма со списком товаров склада, импорт CSV факта. ✅ Доменная сущность `InventoryDoc` (имя чтобы не пересекаться с системным неймспейсом). Create с пустыми lines подтягивает все товары склада; Update пишет actualQty построчно. Post создаёт `InventoryAdjustment` только по строкам с diff != 0 (400 если нет расхождений). Unpost блочит при «излишек уже расходован». Web с CSV-импортом (productId|article;qty). Тесты: 3 интеграционных. 5. [x] **P1-6 Возврат от покупателя (CustomerReturn)** — расширение `RetailSale` опцией возврата (referenceSaleId или без). Контроллер: создание возврата из проведённой продажи, `CustomerReturn` тип уже есть. Web: кнопка «Создать возврат». ✅ RetailSale.IsReturn + ReferenceSaleId; RetailSaleLine.QtyReturned (агрегация для защиты от over-return). `POST /create-return` копирует проведённый чек в Draft-возврат с qty = (Quantity - QtyReturned). Post return через `CustomerReturn`-движение с +Quantity, инкрементит QtyReturned на исходных строках. Запрещён unpost оригинала при активных возвратах. Тесты: 3 интеграционных. 6. [x] **P1-7 Возврат поставщику (SupplierReturn)** — по аналогии для Supply. Domain `SupplierReturn`+`Line` (referenceSupplyId). Контроллер. Web. ✅ Зеркалит Supply, но Post с -Quantity (тип `SupplierReturn`). Валидация что reference указывает на проведённую приёмку того же поставщика. Защита от ухода в минус. Permissions переиспользуют `SuppliesEdit/Post/Delete`. Тесты: 4 интеграционных. 7. [x] **P1-16 Hangfire dashboard + cleanup** — `Hangfire.Dashboard` с авторизацией только для SuperAdmin. Scheduled: ежедневный cleanup `StockMovement` старше 2 лет, audit-log старше 90 дней. ✅ `Hangfire.PostgreSql` storage на ConnectionStrings:Default. Сервер стартует только если `Hangfire:Enabled=true` (по умолчанию). Dashboard `/hangfire` гейтит `SuperAdminHangfireFilter`. Recurring: `prune-stock-movements` (03:30 UTC, 730 дней) и `prune-audit-log` (03:45 UTC, 90 дней) — `HousekeepingJobs` с `IgnoreQueryFilters` (межтенантно). Тесты: 1 unit + 1 интеграционный. ## Итог **Все 7 пунктов выполнены.** Спринт 2 (складские документы P1) завершён 2026-05-28. Сводка: - **P1-1 Enter** — оприходование без поставщика (`/api/inventory/enters`, миграция `Phase6a_Enters`); 4 интеграционных теста. - **P1-2 Loss** — списание с enum `LossReason` (`/api/inventory/losses`, `Phase6b_Losses`); 3 интеграционных. - **P1-3 Transfer** — атомарное перемещение пара TransferOut + TransferIn (`/api/inventory/transfers`, `Phase6c_Transfers`); 4 интеграционных, включая проверку «после Post ровно 2 движения, после Unpost ровно 4». - **P1-4 Inventory** — пересчёт с auto-load остатков (`/api/inventory/inventories`, `Phase6d_Inventories`); 3 интеграционных, импорт CSV в UI. - **P1-6 CustomerReturn** — `RetailSale.IsReturn` + `ReferenceSaleId` + `RetailSaleLine.QtyReturned`, эндпоинт `POST /create-return` (`Phase6e_RetailSaleReturns`); 3 интеграционных. - **P1-7 SupplierReturn** — зеркало Supply (`/api/purchases/supplier-returns`, `Phase6f_SupplierReturns`); 4 интеграционных, валидация совпадения поставщика при ссылке на приёмку. - **P1-16 Hangfire** — `Hangfire.PostgreSql` storage, dashboard `/hangfire` с `SuperAdminHangfireFilter`, recurring jobs `prune-stock-movements` (730 дней) и `prune-audit-log` (90 дней); 1 unit + 1 интеграционный. **Сборка:** зелёная (`dotnet build src/food-market.api`). **Тесты:** 24 unit + 32 integration = **56 зелёных**. **Web:** `pnpm build` зелёный (5 новых пар list+edit страниц + расширение RetailSale). ### Новые таблицы `enters`, `enter_lines`, `losses`, `loss_lines`, `transfers`, `transfer_lines`, `inventories`, `inventory_lines`, `supplier_returns`, `supplier_return_lines` + колонки `retail_sales.IsReturn/ReferenceSaleId`, `retail_sale_lines.QtyReturned`. ### Новые permissions `TransferEdit` добавлен в `RolePermissions` (Enter/Loss/Inventory/Supplies* — переиспользованы существующие). `All()` обновлён. ### Stock-инвариант Каждый документ при Post создаёт явные `StockMovement` через `IStockService` в Serializable-транзакции. Post→Unpost — обратные движения тем же документ-id (reversal-маркер в `DocumentType`). Проверка «не уйти в минус» на: - Enter Unpost (товар уже мог быть продан), - Loss Post (нельзя списать сверх остатка), - Transfer Post (FromStore) и Unpost (ToStore), - Inventory Unpost (излишек мог уйти), - SupplierReturn Post (нельзя вернуть сверх остатка). ## Лог - Каждый пункт: build + тесты + коммит порцией + отметка [x] + коммит прогресса. - Все правки на ветке `main` (origin Forgejo), без коммита `global.json`.