food-market/docs/supply-inventory.md
nns b4bd0e41d4
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
feat(inventory): веб-UI по модели МойСклада + доки
- InventoryEditPage: «Завершить подсчёт» вместо «Провести»; на завершённой — кнопки
  «Создать оприходование излишков» / «Создать списание недостачи» (блокируются, если нет
  разницы или документ уже создан) + блок «Связанные документы» + «Вернуть в черновик».
- InventoriesPage: статус «Завершена»; колонка «Результат» (опр./спис. № или «не применено»).
- Enter/LossEditPage: «На основании инвентаризации №…» со ссылкой.
- types.ts: InventoryStatus Draft/Completed; related в InventoryDto; результат в списке;
  inventoryDocId/Number в Enter/Loss DTO.
- docs/inventory-moysklad-reference.md (сверка), inventory-model-progress.md,
  supply-inventory.md («Как провести инвентаризацию», 5 шагов), mobile-api.md (compat post/unpost).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:34:18 +05:00

232 lines
19 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.

# Приёмка и инвентаризация с телефона
С версии Sprint 31 в food-market три новых способа работы с товаром:
по фото накладной, сканированием штрихкодов, и мобильная инвентаризация
с офлайн-каталогом. Все три видны в **простом режиме**, доступны ролям
**Администратор** и **Кладовщик**.
## 1. Приёмка по фото/PDF/Excel накладной
**Когда использовать:** поставщик привёз накладную в бумаге, в PDF, в
Excel-шаблоне или прислал фото.
**Как:**
1. `/purchases/invoices` → один из четырёх способов:
- «Снять камерой» — одно/несколько фото через камеру телефона
(каждый файл = отдельная накладная).
- «Загрузить файл» — image/*, PDF, **шаблон .xlsx** или **.csv**
(`docs/supplier-invoice-template.xlsx` — заготовка для поставщиков).
- «Сфотографировать листы» — камера для многостраничной накладной
(все снимки идут как **одна** накладная, не отдельные).
- «Фото товаров + накладная» — главный сценарий: подряд снимаете
накладную и каждый товар (штрихкод + этикетка), пачка идёт одним
запросом → Claude сам собирает приёмку с миниатюрой у каждой строки.
Там же «Готовые фото» — выбрать уже снятые из галереи, можно
смешивать со снимаемыми.
2. Загрузка попадает в очередь. Статус-бейдж на карточке: `В очереди
→ Распознаётся → На проверке | Ошибка`. Страница обновляется сама
каждые 3 секунды, пока есть распознающиеся.
3. Открываем карточку «На проверке» → экран проверки:
- **Шапка** (редактируемая): поставщик, №, дата, итого.
- **Строки** карточками. Сопоставлено по штрихкоду или по памяти
прошлых накладных → зелёная плашка `→ Название товара`. Не
сопоставлено → карточка оранжевым, предупреждение сверху.
- **Общий сканер** (поле «Отсканировать…»): скан → +1 к первой
несопоставленной строке или новая строка. Удобно когда вы по
бумажке проходитесь сканером.
- **Для каждой строки** можно «Найти товар» (поиск по каталогу)
или удалить. Если товара нет — ссылка «Создать товар».
- Сумма строк сравнивается с «Итого» в накладной; если
отличается больше чем на 1 — предупреждение.
4. **Автосохранение**: каждая правка (поля шапки, количество, цена,
ручное сопоставление, скан) через ~700 мс сохраняется на сервере.
Индикатор «сопоставлено N/M · сохраняю… / сохранено / не
сохранилось» в нижней панели. Можно закрыть вкладку и открыть
снова — ничего не потеряется. Ручные сопоставления запоминаются
в `InvoiceAlias`: **на следующей накладной того же поставщика**
matcher найдёт эти товары автоматически.
5. **Общий сканер**: если fuzzy-match по названию неуверенный, появляется
панель «<товар> — в какую строку накладной?» с кнопками строк. На
этой же странице работает **аппаратный сканер** целиком — не нужно
кликать в поле, серия цифр + Enter подхватывается глобально
(если курсор не в текстовом поле, которое читает сам).
6. **Фото-приёмка**: у каждой строки — миниатюра снимка товара,
кликабельна для полного размера; пометки «цифры прочитаны
неуверенно — отсканируйте» или «штрихкода нет в каталоге».
7. «Создать приёмку» → диалог: **поставщик — необязательный**
(поиск по уже существующим контрагентам, кнопки «создать» нет),
склад, валюта → `POST /apply` создаёт нашу Supply (Draft).
Проведение — обычной кнопкой в приёмке. Если поставщик не выбран,
приёмка идёт на служебного **«Поставщик не указан»** (один на орг,
в списке контрагентов — последним, с бейджем, удалить и
переименовать нельзя); имя из накладной показывается подсказкой
и в комментарий документа **не** пишется. Это адаптация sklad 06.10
§11: автоподбор контрагента по тексту накладной и автозаведение
карточек запрещены — на реальных накладных это плодило мусор.
8. Готовая накладная (status `applied`) открывается в **режиме
просмотра**: плашка «Черновик приёмки № … создан \<время\>» со
ссылкой на приёмку, строки и шапка readOnly, кнопки «Создать»
нет. Исправления можно сделать прямо в приёмке.
**Как это распознаётся:** локальный Claude Code в headless-режиме
(`claude -p --output-format json`, промпт сверяет «Итого», отличает
штрихкод от кода поставщика, не угадывает — пустые поля = null).
Нагрузка списывается с подписки, не с API-ключа. На сервере нужен
установленный `claude` CLI и `claude login` от того же пользователя, что
запускает API. Можно пустить модель на отдельной машине через
`Invoices:ClaudeCli:RemoteHost` (ssh + scp — всё внутри сервиса).
**Хранение файла (owner-policy):** фото накладных на сервере **не копим**.
- Обычная загрузка (одно фото/PDF/Excel) — файл удаляется **сразу после
распознавания**. Распознанные строки живут в БД (`ParsedJson`).
- Фото-приёмка (папка со снимками) — хранится **только пока накладная
на проверке**: нужна для миниатюр у строк. Удаляется при создании
черновика приёмки (apply), при удалении загрузки, и через **24 часа**
— если «брошено» на статусе `needs_review`. `Invoices:KeepFilesDays`
больше не используется.
- Детальный ответ `GET /{id}` отдаёт флаг `hasFiles` (есть ли миниатюры)
и `processedAt` (время apply), чтобы фронт мог построить плашку
«Черновик приёмки № … создан …» на applied-накладной без
дополнительного запроса.
## 2. Приёмка сканированием
**Когда:** поступил товар россыпью, по бумажке лень набивать.
**Как:** `/purchases/supplies/scan` → поставщик + склад + валюта →
«Сканировать камерой». Камера непрерывная, окно не закрывается между
сканами. Товар за товаром:
- +1 к существующей строке, если такой уже есть; иначе — новая строка
сверху;
- цена подставляется **последняя закупочная** (из последней Posted
Supply);
- вибрация + вспышка на удачный скан; красным — если штрихкод не
в каталоге;
- аппаратный сканер тоже работает (details → поле + Enter).
«Создать черновик» → наш Supply (Draft) в указанном складе; проверить
и провести — на обычной странице приёмки.
**Прогресс** сохраняется в localStorage (`fm.supply-scan.rows`) — закрыли
вкладку случайно, вернулись — всё на месте. Очищается после сохранения.
## Как провести инвентаризацию (по шагам)
По модели МойСклада **инвентаризация сама остатки НЕ меняет** — она считает, а
изменения остатков делают связанные Оприходование (излишки) и Списание (недостача).
1. **Создать** инвентаризацию (`/inventory/inventories/new`): выбрать склад → «Создать и
загрузить остатки». Подтянутся товары склада с расчётным (учётным) количеством.
2. **Посчитать**: внести фактическое количество по строкам (вручную, сканером, импортом
Excel/CSV). Видно: расчётное / фактическое / разница / сумма разницы.
3. **Завершить** («Завершить подсчёт») — строки замораживаются. **Остатки пока не меняются.**
4. **Применить результат**:
- **«Создать оприходование излишков»** — если есть `+` разница (создаётся черновик
Оприходования со строками излишков, «на основании инвентаризации №…»);
- **«Создать списание недостачи»** — если есть `−` разница.
5. **Провести** созданные Оприходование/Списание как обычные документы — **вот тогда
меняются остатки**. В карточке инвентаризации виден блок «Связанные документы».
Отмена: «Вернуть в черновик» отменит проведение и удалит связанные документы (остатки
вернутся), после чего можно снова править.
## 3. Инвентаризация с телефона
**Когда:** регулярный пересчёт склада или проверка одной группы.
**Как:** `/inventory/mobile` → три шага:
### Setup
- Выбор склада.
- Опционально — **группа** (= частичная инвентаризация, непосчитанные
товары НЕ обнулятся).
- «Загрузить каталог на телефон» — компактный слепок троек
`[barcode, productId, name]` в IndexedDB. 30 тыс. штрихкодов ≈ 3 МБ.
После загрузки работает **без интернета**.
### Scan
- Камера непрерывная; +1 за скан;
- **поле аппаратного сканера на виду** (не спрятано в раскрывашку) —
можно просто сканировать, а также:
- **глобальный keydown** ловит сканер и когда курсор не в поле (серия
цифр + Enter) — удобно, если вы перешли к другому элементу страницы;
- **импорт Excel/CSV** (`Загрузить из файла`) — колонки «Штрихкод» и
«Количество», повторы штрихкодов суммируются, товары добавляются
к текущему списку. Документ при этом **не создаётся** — можно
дополнительно просканировать и уже потом провести;
- неизвестный штрихкод — в отдельный список «Нет в каталоге» с
количеством (не попадёт в документ, нужно сначала создать карточку);
- офлайн-плашка: нет сети = сканы остаются в телефоне, уйдут на сервер
когда сеть появится. Прогресс сохраняется в 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` |
| Служебный «Поставщик не указан» (sklad 06.10 §11) | `src/food-market.infrastructure/Persistence/Migrations/20261007100000_Phase32b_CounterpartyIsPlaceholder.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-5-5",
"TimeoutSeconds": 180,
"PhotoTimeoutSeconds": 600, // пачка фото — долго, отдельный тайм-аут
"RemoteHost": null, // или "user@host" — модель на другой машине
"RemoteTmpDir": "/tmp/food-market-invoices"
}
}
```
> Haiku сочиняет данные накладных — не использовать. `KeepFilesDays`
> больше не читается (owner-policy: фото не копим).
## Что НЕ сделано (откладывается до запроса)
- Создание товара прямо в экране проверки накладной — пока уходим на
`/products/new` (future wiring в onPick из search dialog).
- PWA manifest + ServiceWorker офлайн — IndexedDB работает без них, но
установка «На главный экран» потребует manifest.json. Отдельная
задача, если понадобится.
- Автоподтверждение частичной vs полной инвентаризации при проведении
(«обнулять ли непосчитанные») — сейчас сервер не обнуляет непосчитанные
в принципе (строки с diff=0 не создают движений), что совпадает с
поведением частичной. Полная «обнулить все непосчитанные» требует
явного шага в UI «добавить все остальные с actual=0» — пока нет.