diff --git a/docs/supply-inventory.md b/docs/supply-inventory.md new file mode 100644 index 0000000..57ea20b --- /dev/null +++ b/docs/supply-inventory.md @@ -0,0 +1,158 @@ +# Приёмка и инвентаризация с телефона + +С версии Sprint 31 в food-market три новых способа работы с товаром: +по фото накладной, сканированием штрихкодов, и мобильная инвентаризация +с офлайн-каталогом. Все три видны в **простом режиме**, доступны ролям +**Администратор** и **Кладовщик**. + +## 1. Приёмка по фото/PDF накладной + +**Когда использовать:** поставщик привёз накладную в бумаге или в PDF — +не хочется набивать строки руками. + +**Как:** +1. `/purchases/invoices` → «Снять камерой» (с телефона) или «Загрузить + файл» (image/* или PDF, до 60 МБ). +2. Загрузка попадает в очередь. Статус-бейдж на карточке: `В очереди + → Распознаётся → На проверке | Ошибка`. Страница обновляется сама + каждые 3 секунды, пока есть распознающиеся. +3. Открываем карточку «На проверке» → экран проверки: + - **Шапка** (редактируемая): поставщик, №, дата, итого. + - **Строки** карточками. Сопоставлено по штрихкоду или по памяти + прошлых накладных → зелёная плашка `→ Название товара`. Не + сопоставлено → карточка оранжевым, предупреждение сверху. + - **Общий сканер** (поле «Отсканировать…»): скан → +1 к первой + несопоставленной строке или новая строка. Удобно когда вы по + бумажке проходитесь сканером. + - **Для каждой строки** можно «Найти товар» (поиск по каталогу) + или удалить. Если товара нет — ссылка «Создать товар». + - Сумма строк сравнивается с «Итого» в накладной; если + отличается больше чем на 1 — предупреждение. +4. «Сохранить правки» — обновляет документ, автоматически запоминает + все ручные сопоставления. **На следующей накладной того же + поставщика** matcher уже найдёт эти товары автоматически. +5. «Создать приёмку» → диалог: поставщик (поиск по БИН/имени, + можно создать нового), склад, валюта → `POST /apply` создаёт + нашу Supply (Draft). Проведение — обычной кнопкой в приёмке. + +**Как это распознаётся:** локальный Claude Code в headless-режиме +(`claude -p --output-format json`, промпт сверяет «Итого», отличает +штрихкод от кода поставщика, не угадывает — пустые поля = null). +Нагрузка списывается с подписки, не с API-ключа. На сервере нужен +установленный `claude` CLI и `claude login` от того же пользователя, что +запускает API. Можно пустить модель на отдельной машине через +`Invoices:ClaudeCli:RemoteHost` (ssh + scp — всё внутри сервиса). + +**Хранение файла:** `uploads/invoices/{yyyy-MM}/{guid}.ext`. Удаляется +по истечении `Invoices:KeepFilesDays` (default 7). Распознанные строки +остаются навсегда — по ним всегда видно, что было в накладной. + +## 2. Приёмка сканированием + +**Когда:** поступил товар россыпью, по бумажке лень набивать. + +**Как:** `/purchases/supplies/scan` → поставщик + склад + валюта → +«Сканировать камерой». Камера непрерывная, окно не закрывается между +сканами. Товар за товаром: +- +1 к существующей строке, если такой уже есть; иначе — новая строка + сверху; +- цена подставляется **последняя закупочная** (из последней Posted + Supply); +- вибрация + вспышка на удачный скан; красным — если штрихкод не + в каталоге; +- аппаратный сканер тоже работает (details → поле + Enter). + +«Создать черновик» → наш Supply (Draft) в указанном складе; проверить +и провести — на обычной странице приёмки. + +**Прогресс** сохраняется в localStorage (`fm.supply-scan.rows`) — закрыли +вкладку случайно, вернулись — всё на месте. Очищается после сохранения. + +## 3. Инвентаризация с телефона + +**Когда:** регулярный пересчёт склада или проверка одной группы. + +**Как:** `/inventory/mobile` → три шага: + +### Setup +- Выбор склада. +- Опционально — **группа** (= частичная инвентаризация, непосчитанные + товары НЕ обнулятся). +- «Загрузить каталог на телефон» — компактный слепок троек + `[barcode, productId, name]` в IndexedDB. 30 тыс. штрихкодов ≈ 3 МБ. + После загрузки работает **без интернета**. + +### Scan +- Камера непрерывная или аппаратный сканер; +1 за скан; +- неизвестный штрихкод — в отдельный список «Нет в каталоге» с + количеством (не попадёт в документ, нужно сначала создать карточку); +- офлайн-плашка: нет сети = сканы остаются в телефоне, уйдут на сервер + когда сеть появится. Прогресс сохраняется в localStorage. + +### Review +- При первом переходе создаётся наш `InventoryDoc` в Draft, `bookQty` + **каждой позиции** = текущий Stock склада (без ручной «Пересчитать»). +- Отображается сводка: `Излишек +XX ₸` / `Недостача -YY ₸` / количество + расхождений. +- Фильтр «только расхождения» (по умолчанию выключен — показываем всё). +- **Drift-плашка:** если учётный остаток ПОПЛЫЛ с момента создания + документа (были продажи или приёмки), сверху показывается список + изменившихся позиций: `Молоко: 10 → 7`. Это ВИДНО до нажатия + «Провести», не молча. +- «Провести» → `POST /post` создаёт корректирующие движения + `InventoryAdjustment` по строкам с diff ≠ 0. + +## Безопасность + +- Все эндпоинты требуют permission `SuppliesEdit` или `InventoryEdit` + (Admin + Storekeeper). +- Mutli-tenant: все запросы фильтруются по `OrganizationId` текущего + пользователя. Фоновый `InvoiceWorker` работает без HTTP-контекста + и использует явный `IgnoreQueryFilters + Where OrgId` чтобы не + утечь между орг. +- Файл накладной отдаётся только владеющему org'у через `/file`. + +## Файлы + +| Что | Где | +|---|---| +| Домен | `src/food-market.domain/Purchases/InvoiceUpload.cs` | +| Миграция | `src/food-market.infrastructure/Persistence/Migrations/20261004170000_Phase31a_InvoiceUploads.cs` | +| Extractor + Matcher + Worker | `src/food-market.api/Infrastructure/Invoices/` | +| API накладных | `src/food-market.api/Controllers/Purchases/InvoicesController.cs` | +| API инвентаризации | `src/food-market.api/Controllers/Inventory/InventoriesController.cs` (mobile-catalog, add-line, book-drift) | +| Web — список накладных | `src/food-market.web/src/pages/InvoicesPage.tsx` | +| Web — проверка накладной | `src/food-market.web/src/pages/InvoiceReviewPage.tsx` | +| Web — scan приёмка | `src/food-market.web/src/pages/SupplyScanPage.tsx` | +| Web — mobile инвентаризация | `src/food-market.web/src/pages/InventoryMobilePage.tsx` | +| Камера | `src/food-market.web/src/components/CameraScanner.tsx` (@zxing/browser) | +| Offline-каталог | `src/food-market.web/src/lib/offlineCatalog.ts` (IndexedDB) | +| Тесты | `tests/food-market.UnitTests/InvoiceMatcherTests.cs`, `ClaudeCliInvoiceExtractorParseTests.cs`, `tests/food-market.IntegrationTests/InvoicePipelineTests.cs` | + +## Конфиг + +```json +"Invoices": { + "ClaudeCli": { + "Executable": "claude", + "Model": "claude-sonnet-4-6", + "TimeoutSeconds": 180, + "RemoteHost": null, // или "user@host" — модель на другой машине + "RemoteTmpDir": "/tmp/food-market-invoices" + }, + "KeepFilesDays": 7 +} +``` + +## Что НЕ сделано (откладывается до запроса) + +- Создание товара прямо в экране проверки накладной — пока уходим на + `/products/new` (future wiring в onPick из search dialog). +- PWA manifest + ServiceWorker офлайн — IndexedDB работает без них, но + установка «На главный экран» потребует manifest.json. Отдельная + задача, если понадобится. +- Автоподтверждение частичной vs полной инвентаризации при проведении + («обнулять ли непосчитанные») — сейчас сервер не обнуляет непосчитанные + в принципе (строки с diff=0 не создают движений), что совпадает с + поведением частичной. Полная «обнулить все непосчитанные» требует + явного шага в UI «добавить все остальные с actual=0» — пока нет.