food-market/docs/negative-stock-alerts-progress.md
nns 0d714e62bc
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
docs(stock): прогресс — e2e-браузер добавлен, остаются Web Push и десктоп-касса
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-09 03:25:59 +05:00

98 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Уведомления об отрицательных остатках → инвентаризация
Идея владельца 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). Скрины баннера + фильтра минусов сняты.