# Уведомления об отрицательных остатках → инвентаризация Идея владельца 08.10.2026: «когда у товара количество становится отрицательным, система и мобильное приложение должны отправлять PUSH о том, что необходимо произвести инвентаризацию по таким товарам, и сразу предоставить список таких товаров». Решение владельца 08.10: **разрешать продажу в минус нужно**, но с постоянным предупреждением везде (касса, админка, приложение) и требованием срочно провести инвентаризацию. Поправка 09.10: **пункт 12 (предварительная себестоимость) НЕ делаем** — тема отложена; себестоимость продаж в минус оставляем как сейчас, расчёт не трогаем. ## Сверка с МойСкладом (что есть у них) - **Отчёт «Остатки»** (`report/stock`) с параметром `stockMode`: значения `all`, `positiveOnly`, `negativeOnly`, `underminimum`, `nonEmpty`, `empty`. Т.е. отрицательные остатки — это **только фильтр в отчёте** (`stockMode=negativeOnly`). Отдельного «уведомления» нет. - **Вебхуки** МойСклада — на CRUD-события сущностей (создание/изменение/удаление документов и справочников). Порога «остаток ушёл в минус» среди событий **нет** — вебхук на изменение документа есть, но «стало отрицательно» как события нет. - Минус **разрешён** настройкой («разрешить отрицательные остатки»); push/требования пересчёта нет. **Вывод:** у МойСклада — пассивный отчёт-фильтр. Мы делаем **лучше**: проактивные предупреждения (событие при уходе в минус), постоянные баннеры/индикаторы в кассе/админке/приложении, push (web/telegram, APNs-заглушка), подсказка причины, и действие «Пересчитать» (авто-черновик инвентаризации по минусовым товарам), с авто-закрытием при возврате остатка ≥ 0 и эскалацией. (Источник по МойСкладу: dev.moysklad.ru/doc/api/remap/1.2 report/stock stockMode; webhooks — на сущности.) ## План / статус (легенда: [x] готово · [~] частично/заложено · [ ] отложено) 0. [x] Сверка с МойСкладом (см. выше): у них — только фильтр отчёта `stockMode=negativeOnly`, push/события нет. 1. [x] Событие при проведении документа → остаток < 0: `NegativeStockAlert` (товар, склад, остаток, документ-причина: тип/номер/автор/время), единая точка — `StockService.ApplyMovementAsync`. Остаток ≥ 0 → закрывается автоматически. Миграция Phase39, частичный уникальный индекс. 2. [x] Подсказка причины: API списка отдаёт `hint` «возможно, не проведена приёмка П-… от <поставщик>» (если по товару есть черновик приёмки). 3. [x] Без спама: фоновый джоб `NegativeStockNotificationJob` (cron */5) агрегирует новые минусы в ОДНО уведомление на склад, дебаунс по `NotifiedAt` + интервал орг. (по умолч. 15 мин), тихие часы (Asia/Almaty, настройка). Получатели — админы, владелец, кладовщики (право InventoryEdit). Тумблеры/интервал/тихие часы/эскалация — в настройках организации (GET/PUT settings + UI). 4. [x] «Пересчитать эти товары»: `POST /api/negative-stock/recount` → черновик инвентаризации по складу ровно с минусовыми товарами (BookQty = текущий отрицательный остаток), открывается из баннера, центра уведомлений и блока «Просроченные минусы». 5. [~] Каналы: **веб** — постоянный баннер + фильтр/пометка в остатках ✓; **центр уведомлений** (колокольчик, API-backed: `/api/notifications` список/счётчик/read/read-all, живой через SignalR `NotificationCreated`) ✓; отчёт «Отрицательные» ✓. **Telegram** — рассылка владельцу org ✓. **Мобильное API** — `GET /api/notifications` + `/count` + `POST …/read` + recount ✓; `GET /api/negative-stock` + `/count` ✓. **APNs-заглушка** — `device_tokens` (Phase40) + `IPushSender`/ `StubPushSender` ✓. **Отложено:** Web Push (VAPID + SW push-обработчик) — браузерный канал. 6. [~] Тесты: unit тихих часов **3/3** ✓ (вся юнит-сьют 297/297 — попутно починен SQLite-харнесс); integration **3/3** (минус→алерт→recount→авто-закрытие; фильтр negativeOnly; **джоб → уведомление админу + дебаунс + read-all**) ✓; **e2e-браузер** (`stage-ui-negative-stock.spec.ts`: баннер + «Пересчитать» → черновик + фильтр) ✓; full-chain 9/13 ✓. Скрины стенда ✓ — баннер, фильтр, центр уведомлений, настройки. **Отложено:** e2e-браузер именно на колокольчик (зависит от cron-джоба; покрыт integration-тестом + скрином). 7. [x] Снята блокировка продажи в минус: розница, касса (POS-синк), опт, списание, перемещение, возврат поставщику, отмена приёмки. Деньги (минус счёта) — не трогали (другое правило). 8. [ ] **Отложено** (десктопная Avalonia-касса): предупреждение в строке + жёлтый индикатор в шапке + офлайн по локальным остаткам. Требует предварительного задела: POS-каталог (`PosProduct`/ `ProductSyncDto`) сейчас **не синхронизирует остатки** — нет поля количества; офлайн-предупреждение невозможно без добавления остатков в sync-протокол. Онлайн-индикатор мог бы поллить `/api/negative-stock/count`. Отдельная поверхность (SDK-пин, headless-UI). 9. [x] Админка: постоянный баннер «N товаров в минусе — нужна инвентаризация» (сворачивается, крестиком не закрыть) + «Пересчитать эти товары»; в остатках — красная пометка + фильтр «Только минусы». 10. [x] Мобильное API/iOS: счётчик `/api/notifications/count` + список `/api/notifications` + `/api/ negative-stock` + recount. Баннер в самом приложении — Mac-Claude. 11. [x] Эскалация: джоб повторно уведомляет владельца/админов по минусам старше N часов (настройка, по умолч. 24; `EscalatedAt`), тип `negative-stock-overdue`; на дашборде — блок «Просроченные минусы» с «Пересчитать». Флаг `overdue` в API. 12. [ ] ОТЛОЖЕНО (поправка владельца 09.10) — предварительная себестоимость НЕ делаем. ## Что сделано (ядро + уведомления, оттестировано и выложено) Пасс 1: разрешение минуса + детекция/авто-закрытие + API (список/счётчик/recount/фильтр) + веб-баннер/ пометки + APNs-задел. Пасс 2: персистентный центр уведомлений (`notifications`, Phase41) + REST + фоновый джоб агрегации/эскалации (интервал/тихие часы/получатели по ролям) + Telegram + настройки орг. + блок «Просроченные минусы» + unit 3/3 + integration 3/3. Выложено на стенд, скрины сняты. ## Что отложено (нужен отдельный проход) - **Web Push (VAPID)** — браузерный push-канал (ключи + SW push/notificationclick + подписка). - **Avalonia-касса (п.8)** — индикатор/предупреждение; сначала нужен задел: остатки в POS-sync (у `PosProduct` нет количества). Отдельная десктоп-поверхность. - **Реальная отправка push** (APNs/FCM) — нужен платный Apple Developer; заглушка в DI готова. - e2e-браузер на сам колокольчик (зависит от cron-джоба) — покрыт integration + скрином. **DONE не ставим — остаются п.5 (Web Push VAPID) и п.8 (десктоп-касса).** ## Что НЕ трогаем Прод, shared-postgres, global.json, gateway nginx, sklad. Расчёт себестоимости (п.12 отложен). ## Журнал - 09.10 — старт: прогресс-док, сверка с МойСкладом, обследование кода. - 09.10 — ядро выложено на стенд (9ba1edb): разрешение минуса + детекция/авто-закрытие + API (список/счётчик/recount/фильтр) + веб-баннер/пометки + integration 2/2. - 09.10 — пасс 2 (29a951a, 09ee056): центр уведомлений (notifications/Phase41) + REST + джоб агрегации/эскалации + Telegram + настройки орг. + «Просроченные минусы» + unit 3/3 + integration 3/3. Стенд: `/api/notifications` 200, settings-поля применились, джоб (cron */5) создаёт агрегат «23 товара ушли в минус» админу — проверено скринами. Фикс: popover вправо. - 09.10 — задел push (3547e82): `device_tokens` (Phase40) + `IPushSender`/`StubPushSender` + `/api/me/device-tokens`, описано в `docs/mobile-api.md`. Выложено на стенд, Phase39+Phase40 применились (`/api/negative-stock/count` → 200). Скрины баннера + фильтра минусов сняты.