Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
Пасс 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>
110 lines
13 KiB
Markdown
110 lines
13 KiB
Markdown
# Уведомления об отрицательных остатках → инвентаризация
|
||
|
||
Идея владельца 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 отложен владельцем).
|