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

15 KiB
Raw Blame History

Приёмка и инвентаризация с телефона

С версии 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

Конфиг

"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» — пока нет.