food-market/docs/negative-stock-alerts-progress.md
nns 0dc8703712
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
docs(stock): прогресс+mobile-api — все пункты 1–11 закрыты (п.12 отложен владельцем)
Пасс 3: касса (индикатор+предупреждение+офлайн), Web Push (VAPID), e2e колокольчика.
Итог: unit 299/299, integration 4/4, POS 4/4, e2e-браузер 2/2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-09 04:03:01 +05:00

110 lines
13 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. [x] Каналы: **веб** — постоянный баннер + фильтр/пометка в остатках ✓; **центр уведомлений**
(колокольчик, API-backed: `/api/notifications` список/счётчик/read/read-all, живой через SignalR
`NotificationCreated`) ✓; отчёт «Отрицательные» ✓. **Web Push (VAPID)** — ключи генерятся и хранятся
в platform_settings (Phase42), `/api/web-push/{public-key,subscribe,unsubscribe}`, SW-обработчики
push/notificationclick (am-v4), кнопка «Вкл. push» в колокольчике; работает в т.ч. iOS Safari PWA ✓.
**Telegram** — рассылка владельцу org ✓. **Мобильное API** — `/api/notifications` (+count/read) +
recount ✓; `GET /api/negative-stock` + `/count` ✓. **APNs-заглушка** — `device_tokens` (Phase40) +
`IPushSender`/`StubPushSender` ✓. Прим.: фактическую доставку Web Push в браузер нельзя проверить
headless (нужен живой push-сервис) — покрыт путь генерации ключа/подписки/отправки.
6. [x] Тесты: unit **3/3** тихих часов (вся юнит-сьют **299/299**, попутно починен SQLite-харнесс +
тесты POS-остатков); integration **4/4** (минус→алерт→recount→авто-закрытие; фильтр negativeOnly;
**джоб → уведомление админу + дебаунс + read-all**; **Web Push** ключ/подписка); POS **4/4** (снимок
остатков/перезапись/NegativeCount); **e2e-браузер 2/2** (баннер+«Пересчитать»+фильтр; **колокольчик**
с ожиданием cron-джоба) ✓; full-chain 9/13 ✓. Деплой на стенд + скрины: баннер, фильтр, центр
уведомлений, настройки, Push-тумблер, **касса (индикатор + предупреждение в строке)**.
7. [x] Снята блокировка продажи в минус: розница, касса (POS-синк), опт, списание, перемещение,
возврат поставщику, отмена приёмки. Деньги (минус счёта) — не трогали (другое правило).
8. [x] Касса (Avalonia): при количестве больше остатка — предупреждение прямо в строке
«на остатке N шт — уйдёт в минус» (жёлтое, НЕ блокирует продажу); в шапке — постоянный
жёлтый индикатор «N в минусе — нужна инвентаризация» по складу кассы, по тапу — список (Flyout).
Работает **офлайн** по локальному снимку остатков: сервер уже слал остатки в sync (`StockSyncDto`),
касса теперь их хранит (таблица `stock`, перезапись каждый синк). Обновляется при старте/после
чека/после синка. Тесты PosStore 4/4, скрин кассы снят.
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 + настройки орг.
+ блок «Просроченные минусы». Пасс 3: **касса** (индикатор + предупреждение в строке + офлайн, Avalonia) +
**Web Push (VAPID)** (Phase42, SW push/notificationclick, кнопка «Вкл. push») + e2e-браузер колокольчика.
Итог: unit 299/299, integration 4/4, POS 4/4, e2e-браузер 2/2. Выложено на стенд, скрины сняты.
## Остаётся за рамками этой задачи
- **Пункт 12 (предварительная себестоимость)** — ОТЛОЖЕН владельцем (поправка 09.10), не делаем.
- **Реальная отправка APNs/FCM push на iOS** — нужен платный Apple Developer; задел (device_tokens +
IPushSender + StubPushSender) готов, подменяется реальной реализацией без правок вызова.
- Прим.: фактическую доставку Web Push в реальный браузер нельзя проверить в headless-CI (нужен живой
push-сервис) — реализация полная (web-push-csharp, RFC 8291), проверен путь ключ/подписка/отправка.
**Все пункты 1–11 выполнены (п.12 отложен владельцем) → DONE.**
## Что НЕ трогаем
Прод, 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). Скрины баннера + фильтра минусов сняты.
- 09.10 — пасс 3: **касса** (0fd04d4) — остатки в PosStore (таблица stock), предупреждение в строке +
жёлтый индикатор в шапке (офлайн), PosStore-тесты 4/4, скрин кассы. **Web Push VAPID** (560d52e) —
platform_settings-ключи (Phase42), `/api/web-push/*`, SW am-v4, кнопка «Вкл. push»; стенд: public-key
87 симв., subscribe/unsubscribe 204. **e2e колокольчика** (ff42db7) — ждёт cron-джоб, 2/2 зелёные.
Все пункты 1–11 закрыты (п.12 отложен владельцем).