food-market/docs/supply-inventory.md
nns 6b0fc68f77
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(invoices+inventory): порт улучшений 05.10.2026 из sklad
Диапазон коммитов f455fa9..b7eb637 + 3ced706 (handoff).

Backend (src/food-market.api/Infrastructure/Invoices):
- Ean.cs — контрольная цифра EAN-8/13/UPC-A/GTIN-14 (ловит одну ошибку в цифре).
- TableReader.cs — своя читалка .xlsx/.csv без библиотек, UTF-8/1251,
  .xls → понятная ошибка; TableInvoiceParser — шаблон накладной.
- InvoiceModels: +Photo/BarcodeSource/Note в InvoiceLine, PhotoHint,
  IInvoiceExtractor.ExtractPhotosAsync.
- InvoiceMatcher.FindByBarcode — для photo-matching.
- InvoiceProcessor: ветки photos/excel/vision, MatchFromPhoto с EAN-проверкой
  (barcode_source="read" → валидация), SweepAbandonedPhotosAsync раз в час
  убирает папки needs_review старше 24ч; DeleteStored (owner-policy: НЕ копить).
- ClaudeCliInvoiceExtractor: ExtractPhotosAsync + BuildPhotosPrompt +
  FromPhotosJson (barcode_source → BarcodeSource, comment → Note, unmatched_photos
  → Warnings); дефолт модели claude-sonnet-5-5, PhotoTimeoutSeconds=600.

Backend (Controllers):
- POST /purchases/invoices/upload-photos (≤80 файлов, 60 МБ) +
  GET /purchases/invoices/{id}/photo/{n}; Apply/Delete убирают папку (не только файл).
- InvoiceDetailDto +Source/+HasFiles — клиент не ломится за 404 миниатюрами.
- POST /inventory/inventories/import — xlsx/csv → {found, unknown}, повторы ШК
  суммируются, документ не создаётся.
- Program.cs: UseResponseCompression + GzipCompressionProvider для application/json.
- InventoriesController.mobile-catalog: weak ETag + If-None-Match → 304;
  Cache-Control private max-age=60. Замер 31k: JSON 3.60 МБ → gzip 0.98 МБ.

Frontend:
- lib/shots.ts — ZXing TRY_HARDER на 1x+1600; compress JPEG 0.85 до 2400.
- components/PhotoSession.tsx — полноэкранная пачка (камера + готовые фото).
- components/CameraScanner.tsx: onCodeRef + эффект [], камера не закрывается
  после каждого скана.
- pages/InvoicesPage.tsx — четыре способа: камера одиночными, файл (xlsx/csv/img/pdf),
  камера многостраничная, фото-приёмка (камера или готовые фото) + «Отправить ещё раз».
- pages/InvoiceReviewPage.tsx — автосохранение 700 мс + индикатор; row-picker панель
  «в какую строку?» если fuzzy-match неуверен; linesRef; глобальный keydown; миниатюра
  снимка у строки; пометки Note.
- pages/SupplyScanPage.tsx + InventoryMobilePage.tsx — глобальный keydown.
- InventoryMobilePage: поле сканера на виду + импорт Excel/CSV.
- docs/supplier-invoice-template.xlsx — шаблон для поставщиков (9-я строка заголовки).

Хранение файлов (owner-policy 2026-10-05): фото НЕ копим.
- Обычная загрузка → удаление сразу после разбора.
- Фото-приёмка → удаление при Apply/Delete/24h abandoned.
- KeepFilesDays удалён из флоу (конфиг-ключ игнорируется).

Тесты (unit, зелёные):
- EanTests (6): валидные/невалидные/one-digit error.
- TableReaderTests (5): xlsx cells, шапка «Поставщик/Номер/Дата», CSV UTF-8+BOM,
  CSV Windows-1251, .xls friendly error.
- ClaudeCliInvoicePhotosParseTests (4): decoded проходит, source=null чистит Barcode,
  unmatched_photos → Warnings, comment → Note.
- InvoicePhotoRetentionTests (3): DeleteStored файл/папка/идемпотентность.

Preexisting ApiReferenceDocsJob (Sprint 28) failure не связан с портом.

Прогресс: docs/sklad-port-20261005-progress.md. Пользовательская инструкция:
docs/supply-inventory.md обновлена.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-06 00:02:13 +05:00

196 lines
15 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). Проведение — обычной кнопкой в приёмке.
**Как это распознаётся:** локальный 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`
больше не используется.
## 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 за скан;
- **поле аппаратного сканера на виду** (не спрятано в раскрывашку) —
можно просто сканировать, а также:
- **глобальный 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` |
| 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» — пока нет.