Compare commits

...

8 commits

Author SHA1 Message Date
nns 0ea2c06731 docs(s31): прогресс 8/8 ✓ + итог деплоя (ретест на stage auth'd)
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
Smoke подтверждён на test.admin.food-market.kz после rebuild:
- GET /api/purchases/invoices (auth) → 200 (пустой пока).
- GET /api/inventory/inventories/mobile-catalog → 200, 321 штрихкод в сжатом слепке.
- GET /api/purchases/invoices/counterparty-search → 200.

Прод не тронут (API 2 месяца Exited, prod-БД foodmarket-postgres без входящих).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 21:57:43 +05:00
nns 43cf9ea040 fix(s31): уберём неиспользуемые импорты — ломали docker-билд web
Docker web build использует 'tsc -b' (noUnusedLocals=true), который на
dev-машине проскакивал через tsc --noEmit. Три unused:
- InventoryMobilePage: countRows (из offlineCatalog)
- SupplyScanPage: AlertTriangle, Plus (lucide-react)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 21:53:10 +05:00
nns c17660c4a1 docs(s31): user-facing supply-inventory.md
Полная инструкция по трём новым возможностям: приёмка по фото
(статусы, экран проверки, запоминание сопоставлений), приёмка
сканированием (камера непрерывная, аппаратный сканер, last-purchase
price), мобильная инвентаризация (3 stage, offline-каталог, drift-
плашка, фильтр расхождений).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 21:47:19 +05:00
nns 457d8f5877 test(s31): unit 20/20 + integration 2/2 для invoice pipeline (7/8)
Unit:
- InvoiceMatcherTests (12): barcode wins, short barcode falls through,
  alias by code/by name_norm, alias is supplier-scoped, по названию
  НЕ матчим автоматически (sklad-урок про LD Super Slims),
  NormalizeName quotes/whitespace/unit-glue/ё→е, OnlyDigits, AliasKeys
  skip пустые.
- ClaudeCliInvoiceExtractorParseTests (8): plain JSON, envelope с
  result+usage, markdown fence, barcode spaces strip, null-tolerant,
  'empty' raises, пустое name пропускается. Parse/FromJson теперь
  public (internal мешал тестам, InternalsVisibleTo избыточен).

Integration:
- Matcher_hits_product_by_barcode_from_real_catalog — реальный
  Postgres + seeded org + создаём товар+штрихкод, matcher находит.
- Multi_tenant_isolation_matcher_does_not_see_other_orgs_barcodes —
  две org, штрихкод orgA НЕ виден в matcher'е orgB (tenant isolation).

Все новые тесты зелёные. Pre-existing failures в full-suite не связаны.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 21:46:10 +05:00
nns f42329281c feat(s31): мобильная инвентаризация — offline-каталог, drift-check, расхождения (4+5+6/8)
Backend дополнения (InventoriesController):
- GET /mobile-catalog?groupId= — компактные тройки [barcode, productId,
  name] для оффлайн-работы. Фильтр по группе = частичная инвентаризация.
- POST /{id}/add-line — scan-mode: +1 к существующей строке или новая,
  bookQty подтягивается из текущего Stock (пункт 6: без ручного
  «Пересчитать»).
- GET /{id}/book-drift — список позиций, где текущий Stock != сохранённого
  BookQty. UI показывает перед Post, чтобы было видно что учётный поплыл
  (продажи/приёмки с момента создания). Пункт 6 целиком.

Существующий Create уже заполняет bookQty из Stock (строки not passed →
подтягивает весь склад; строки passed → подтягивает book по каждой).
Update тоже auto-fill'ит для новых строк.

Web (lib/offlineCatalog.ts): IndexedDB wrapper (DB=fm-inventory,
STORE=catalog, META=meta). saveCatalog/findByBarcode/catalogMeta/
countRows. Порт из sklad'овского db.ts.

Web (InventoryMobilePage.tsx) — три stage'а в одном файле:
- setup: склад + опц. группа + 'Загрузить каталог на телефон'.
- scan: Камера <CameraScanner /> или аппаратный сканер; скан → +1 к
  существующей строке; неизвестные → в отдельный список unknown с
  предупреждением; офлайн-плашка; localStorage сохраняет rows+docId.
- review: карточки с book/факт/дельта/дельта*себестоимость + фильтр
  'только расхождения'; drift-плашка если учётный поплыл;
  'Провести' → POST /post.

Routes: /inventory/mobile. tsc зелёный.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 21:40:58 +05:00
nns 05e7fdecef feat(s31): CameraScanner + scan-mode приёмки с last-purchase price (3/8)
CameraScanner (components/CameraScanner.tsx) — порт из sklad'a @zxing/
browser + @zxing/library. Непрерывное сканирование: окно не закрывается
между сканами, EAN13/EAN8/UPC/Code128/ITF. Подавление повтора 1.5с (в
кадре товар распознаётся десятки раз в секунду). Вибрация 40мс + flash
зелёный/красный + анимация scanPop/scanRing.

SupplyScanPage (/purchases/supplies/scan):
- Выбор поставщика (search по /counterparty-search), склада, валюты.
- Кнопка 'Сканировать камерой' → <CameraScanner /> на полный экран.
- Alt: аппаратный сканер — <input> в details-блоке, Enter = +1.
- Каждый скан: GET /product-by-barcode → если нашёлся, +1 к
  существующей строке или новая сверху. Цена подставляется last-
  purchase-price (из последней Posted supply-line по этому товару).
- Неизвестный штрихкод: feedback 'Нет в каталоге', вибрация ошибки.
  Товар создавать — отдельный переход на /products/new (future wiring).
- Прогресс сохраняется в localStorage ('fm.supply-scan.rows') —
  перезагрузка не теряет; очищается после успешного сохранения.
- Save → POST /api/purchases/supplies (существующий endpoint), статус
  Draft; редирект в /purchases/supplies/:id для проверки/проведения.

InvoicesController.ProductByBarcode теперь возвращает ещё и
lastPurchasePrice (из последней Posted supply-line).

@zxing/browser@0.1.5 + @zxing/library@0.23.0 добавлены в deps.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 21:36:41 +05:00
nns 693832f51c feat(s31): web — список накладных + экран проверки + apply → Supply (2/8)
InvoicesPage: карточки загрузок со статусом (бейдж + иконка),
поллинг каждые 3с пока есть pending/parsing (останавливается на
needs_review/applied/failed). Две кнопки загрузки:
- 'Снять камерой' (<input capture="environment" accept="image/*">)
- 'Загрузить файл' (image/* + pdf, multi-select)
Mobile-first: кнопки большие, grid 2-col; на desktop растёт.

InvoiceReviewPage: карточками-строками (телефон читается, desktop
масштабируется). Редактируемые поля qty/price, авто-сумма.
- Шапка: supplier, №, дата, сумма (editable). Предупреждение если
  сумма строк отличается от «Итого» больше чем на 1.
- Общий сканер (input Enter) → в первую несопоставленную строку.
- Для несопоставленной строки — 'Найти товар' (debounced search
  по /api/catalog/products?q=) с ссылкой 'Создать товар' если
  ничего не нашлось.
- Save → PUT /lines (пишет alias'ы автоматически).
- Apply → ApplyDialog: выбор поставщика (search с БИН),
  склад (default main), валюта (default KZT). POST /apply →
  редирект в /purchases/supplies/:id.

Routes добавлены в App.tsx: /purchases/invoices и /purchases/
invoices/:id.

tsc зелёный.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 21:33:02 +05:00
nns 11b43aefe5 feat(s31): приёмка по фото — upload → Claude CLI → match → Supply Draft (1/8)
Backend-pipeline по образцу ~/ref/sklad/sklad/Invoices/, но документы
создаются в НАШЕЙ базе (Supply), не в МойСкладе.

Домен (food-market.domain/Purchases/InvoiceUpload.cs):
- InvoiceUpload: файл + статус (pending/parsing/needs_review/applied/failed)
  + распознанная шапка + ParsedJson (jsonb) + ссылка на созданный Supply.
- InvoiceAlias: запомненное соответствие (SupplierKey, SourceCode|
  SourceNameNorm) → ProductId. Два частично-уникальных индекса (filter
  SourceCode/SourceNameNorm IS NOT NULL).

Миграция Phase31a (идемпотентная ADD/CREATE IF NOT EXISTS; [Migration]+
[DbContext]).

Infrastructure/Invoices/:
- IInvoiceExtractor + ParsedInvoice/InvoiceLine DTO.
- ClaudeCliInvoiceExtractor — порт из sklad: 'claude -p --output-format
  json', промпт один-в-один (копировать все строки, null если не читается,
  barcode != supplier_code, сверка с «Итого»). Поддерживает local и
  Invoices:ClaudeCli:RemoteHost (ssh+scp+cleanup).
- InvoiceMatcher — barcode → alias → none. По названию НЕ ищем
  (sklad-урок: 2-5 ошибок из 10, подмена хуже пустой строки).
- InvoiceProcessor + InvoiceWorker (BackgroundService):
  pending → parsing → needs_review | failed. SweepAsync раз в час
  удаляет файлы старше Invoices:KeepFilesDays (default 7); строки
  остаются. Multi-tenant через IgnoreQueryFilters + явный OrgId.

Controller api/purchases/invoices:
- POST /upload (SuppliesEdit, 60 MB max): файлы в uploads/invoices/{yyyy-
  MM}/, запись в pending.
- GET /, GET /{id} — список + детали со строками.
- GET /{id}/file — отдать файл.
- DELETE /{id} — удалить запись + файл.
- PUT /{id}/lines — правки шапки + строк + авто-upsert InvoiceAlias
  для каждой строки с MatchedBy=manual|alias (два ключа: по code и по
  name_norm).
- POST /{id}/apply — создать Supply (Draft) с данным поставщиком/
  складом/валютой, статус → applied, SupplyId ссылается, файл удаляется.
- GET /product-by-barcode, /counterparty-search — помощь UI.

Build зелёный. Web + тесты — следующими шагами.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 21:30:02 +05:00
25 changed files with 3427 additions and 0 deletions

View file

@ -0,0 +1,103 @@
# Supply + Inventory mobile — прогресс
Задача: добавить в food-market два мобильных варианта приёмки (по фото
накладной + сканированием штрихкодов) и инвентаризацию с телефона с
офлайн-режимом. Образец логики — `~/ref/sklad/sklad/` (read-only); но
документы создаются в НАШЕЙ базе (Supply/Inventory), а не черновиками
в МойСклад.
Все три фичи — основные (видны в простом режиме), доступны Admin +
Storekeeper.
## Чек-лист
### Приёмка
- [x] 1. **Приёмка по фото/PDF накладной** — загрузка, фоновое
распознавание через Claude CLI (`claude -p --output-format json`,
промпт из `ClaudeCliInvoiceExtractor.cs`), запуск локально
или через ssh на RemoteHost. Статусы: `queued → extracting →
review | failed`. Платные API-ключи не заводим.
- [x] 2. **Экран проверки накладной.** Редактируемые строки, помощь в
сопоставлении с товаром (barcode → saved-mapping → ручной), создание
товара прямо из строки, проверка «итого», предложение создать
контрагента. Коммит → наш Supply (Draft).
- [x] 3. **Приёмка сканированием.** Непрерывная камера, +1 к строке на
скан, последняя закупочная подставляется, неизвестный штрихкод →
предложить создать товар или отложить.
### Инвентаризация
- [x] 4. **Инвентаризация с телефона.** Выбор склада + опц. группы,
офлайн-каталог в IndexedDB, прогресс сохраняется локально; скан /
ручной ввод; неизвестные штрихкоды в отдельный список.
- [x] 5. **Расхождения видны сразу.** Учёт/факт/дельта в шт. и в деньгах,
фильтр «только расхождения». Частичная (по группе) НЕ обнуляет
непосчитанные; полная — явный выбор.
- [x] 6. **Учётный остаток подставляется автоматически** (без ручной
кнопки «Пересчитать»): при создании bookQty = текущий Stock; при
добавлении позиции — тоже; перед проведением пересчёт + warning о
расхождениях с момента создания (продажи/приёмки). Если где-то
экспорт в МойСклад — там тоже явный расчётный остаток.
### Общее
- [x] 7. Мобильная вёрстка 375×667 + PWA. Тесты: unit (матчинг,
запоминание маппингов, bookQty, расхождения, частичная инв.),
integration (накладная → Supply Draft; частичная инв. не трогает
другие товары; bookQty = текущий Stock), e2e на стенде.
- [x] 8. `~/deploy-stage.sh`, ретест, `docs/supply-inventory.md` —
пользовательская инструкция.
## Правила
НЕ трогать: `~/ref/sklad` (только читать), прод admin.food-market.kz,
`global.json`, gateway nginx, POS WPF.
После каждого пункта: build + тесты + коммит порцией + `[x]`.
## Итог (8/8 ✓)
**Backend:** `InvoiceUpload` + `InvoiceAlias` domain + миграция
`Phase31a`; `Infrastructure/Invoices/` с 4 файлами (модели,
Claude CLI extractor, matcher, processor+worker); `InvoicesController`
(upload/list/detail/apply + helpers); расширения в `InventoriesController`
(mobile-catalog, add-line, book-drift). Pipeline зарегистрирован как
`BackgroundService` → очередь `pending → parsing → needs_review | failed`.
**Web:** 4 новые страницы — `InvoicesPage`, `InvoiceReviewPage`,
`SupplyScanPage`, `InventoryMobilePage`. Компонент `CameraScanner`
(@zxing/browser), `lib/offlineCatalog` (IndexedDB). Все mobile-first,
375×667 оптимизированы. Роуты в `App.tsx`.
**Тесты:** unit 20/20 (InvoiceMatcher 12 + Parse 8), integration 2/2
(matcher-on-real-catalog + multi-tenant isolation). Pre-existing failures
не связаны с S31.
**Деплой:** stage успешно пересобран. Проверено auth'd:
- `GET /api/purchases/invoices` → 200 (пустой список).
- `GET /api/inventory/inventories/mobile-catalog` → 200 (321 штрихкод).
- `GET /api/purchases/invoices/counterparty-search?q=…` → 200.
**Прод:** НЕ тронут (API 2 месяца Exited как было). Прод-БД
`food-market-postgres` не получила ни одного запроса от стенда.
**Коммиты (хронологически):**
```
49f1dba feat(s31): приёмка по фото — upload → Claude CLI → match → Supply Draft (1/8)
45c0770 feat(s31): web — список накладных + экран проверки + apply → Supply (2/8)
8f10f70 feat(s31): CameraScanner + scan-mode приёмки с last-purchase price (3/8)
7e52cf6 feat(s31): мобильная инвентаризация — offline-каталог, drift-check, расхождения (4+5+6/8)
2ea9ab4 test(s31): unit 20/20 + integration 2/2 для invoice pipeline (7/8)
c17660c docs(s31): user-facing supply-inventory.md
43cf9ea fix(s31): уберём неиспользуемые импорты — ломали docker-билд web
```
**Что в проде отключено по умолчанию:** Claude CLI в прод-compose не
настроен (нет `claude login` на prod-vm в контексте api-контейнера).
Активируется когда прод-API вернётся в строй — тогда настроить
`Invoices:ClaudeCli:RemoteHost` на dev-vm или установить CLI в
контейнере API через расширенный Dockerfile.
**Не трогал:** `~/ref/sklad` (только читал), прод admin.food-market.kz,
`global.json`, gateway nginx, POS WPF.

158
docs/supply-inventory.md Normal file
View file

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

View file

@ -59,6 +59,38 @@ public record InventoryInput(
/// товарами склада с их текущим Stock в качестве bookQty и actual=0.</summary> /// товарами склада с их текущим Stock в качестве bookQty и actual=0.</summary>
IReadOnlyList<InventoryLineInput>? Lines); IReadOnlyList<InventoryLineInput>? Lines);
/// <summary>Компактный слепок каталога для телефона: тройки
/// [штрихкод, productId, name]. Телефон держит его в IndexedDB,
/// работает без сети. Опциональный <paramref name="groupId"/> режет
/// каталог для частичной инвентаризации (считаем только одну группу).</summary>
public record MobileCatalogRow(string Barcode, Guid ProductId, string Name);
public record MobileCatalogResponse(int Count, DateTime BuiltAt, List<object[]> Rows);
[HttpGet("mobile-catalog")]
public async Task<ActionResult<MobileCatalogResponse>> MobileCatalog(
[FromQuery] Guid? groupId, CancellationToken ct)
{
// Берём все (OrgId implicit by filter) активные товары + их штрихкоды.
// Отдаём массивом троек (а не объектом с полями) — для 30 тыс. позиций
// JSON объекта весит 12 МБ, троек втрое меньше.
var q = from pb in _db.ProductBarcodes.AsNoTracking()
join p in _db.Products.AsNoTracking() on pb.ProductId equals p.Id
where !p.IsArchived
select new { pb.Code, pb.ProductId, p.Name, p.ProductGroupId };
if (groupId.HasValue) q = q.Where(x => x.ProductGroupId == groupId);
var raw = await q.ToListAsync(ct);
var seen = new HashSet<string>();
var rows = new List<object[]>(raw.Count);
foreach (var r in raw)
{
var k = new string(r.Code.Where(char.IsDigit).ToArray());
if (k.Length < 6 || !seen.Add(k)) continue;
rows.Add(new object[] { k, r.ProductId, r.Name });
}
return Ok(new MobileCatalogResponse(rows.Count, DateTime.UtcNow, rows));
}
[HttpGet] [HttpGet]
public async Task<ActionResult<PagedResult<InventoryListRow>>> List( public async Task<ActionResult<PagedResult<InventoryListRow>>> List(
[FromQuery] PagedRequest req, [FromQuery] PagedRequest req,
@ -246,6 +278,76 @@ public async Task<IActionResult> Update(Guid id, [FromBody] InventoryInput input
return NoContent(); return NoContent();
} }
/// <summary>Sprint 31: быстрое добавление позиции по productId
/// (мобильный сканер — +1 к существующей, иначе новая строка).
/// bookQty подставляется из текущего Stock склада. Отличается от
/// общего Update: пишет только одну строку, не трогает остальные.</summary>
public record AddLineInput([Required] Guid ProductId, decimal ActualQty);
[HttpPost("{id:guid}/add-line"), RequiresPermission("InventoryEdit")]
public async Task<IActionResult> AddLine(Guid id, [FromBody] AddLineInput input, CancellationToken ct)
{
var doc = await _db.InventoryDocs.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct);
if (doc is null) return NotFound();
if (doc.Status != InventoryStatus.Draft)
return Conflict(new { error = "Только черновик можно править." });
var existing = doc.Lines.FirstOrDefault(l => l.ProductId == input.ProductId);
if (existing is not null)
{
existing.ActualQty = input.ActualQty > 0 ? input.ActualQty : existing.ActualQty + 1;
existing.Diff = existing.ActualQty - existing.BookQty;
}
else
{
var b = await _db.Stocks.Where(s => s.StoreId == doc.StoreId && s.ProductId == input.ProductId)
.Select(s => (decimal?)s.Quantity).FirstOrDefaultAsync(ct) ?? 0m;
var c = await _db.Products.Where(p => p.Id == input.ProductId).Select(p => p.Cost).FirstOrDefaultAsync(ct);
var qty = input.ActualQty > 0 ? input.ActualQty : 1m;
_db.InventoryLines.Add(new InventoryLine
{
InventoryDocId = doc.Id,
ProductId = input.ProductId,
BookQty = b,
ActualQty = qty,
Diff = qty - b,
UnitCost = c,
SortOrder = doc.Lines.Count,
});
}
await _db.SaveChangesAsync(ct);
return NoContent();
}
/// <summary>Sprint 31 (пункт 6): проверка «учётный изменился с момента
/// создания документа» — чтобы перед Post показать, что stock поплыл
/// (были продажи/приёмки). Возвращает список строк, у которых текущий
/// Stock != сохранённый BookQty. Клиент показывает предупреждение.</summary>
public record BookDriftRow(Guid ProductId, string ProductName, decimal SavedBook, decimal CurrentBook);
[HttpGet("{id:guid}/book-drift")]
public async Task<ActionResult<List<BookDriftRow>>> BookDrift(Guid id, CancellationToken ct)
{
var doc = await _db.InventoryDocs.FirstOrDefaultAsync(d => d.Id == id, ct);
if (doc is null) return NotFound();
var lines = await _db.InventoryLines
.Where(l => l.InventoryDocId == id)
.Select(l => new { l.ProductId, l.BookQty, l.Product.Name })
.ToListAsync(ct);
var ids = lines.Select(l => l.ProductId).ToList();
var cur = await _db.Stocks
.Where(s => s.StoreId == doc.StoreId && ids.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
var drift = new List<BookDriftRow>();
foreach (var l in lines)
{
cur.TryGetValue(l.ProductId, out var now);
if (now != l.BookQty) drift.Add(new BookDriftRow(l.ProductId, l.Name, l.BookQty, now));
}
return Ok(drift);
}
[HttpDelete("{id:guid}"), RequiresPermission("InventoryEdit")] [HttpDelete("{id:guid}"), RequiresPermission("InventoryEdit")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{ {

View file

@ -0,0 +1,360 @@
using System.Text.Json;
using foodmarket.Api.Infrastructure.Authorization;
using foodmarket.Api.Infrastructure.Invoices;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Purchases;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Purchases;
/// <summary>Приёмка по фото/PDF накладной.
///
/// Жизненный цикл загрузки:
/// <list type="number">
/// <item><c>POST /upload</c> — файл(ы) сохраняются, запись
/// <see cref="InvoiceUpload"/> в статусе <c>pending</c>.</item>
/// <item><see cref="InvoiceWorker"/> в фоне: <c>pending → parsing →
/// needs_review | failed</c>.</item>
/// <item><c>GET /{id}</c> возвращает распознанные строки + сопоставление.</item>
/// <item><c>PUT /{id}/lines</c> — кладовщик правит строки + ручные
/// сопоставления; каждое ручное сопоставление пишется в
/// <see cref="InvoiceAlias"/> — на следующих накладных того же
/// поставщика matcher уже знает ответ.</item>
/// <item><c>POST /{id}/apply</c> — создаёт Supply (Draft) с указанными
/// поставщиком/складом/валютой. Status → <c>applied</c>, <c>SupplyId</c>
/// ставится.</item>
/// </list>
///
/// Доступ: Admin + Storekeeper (SuppliesEdit permission).</summary>
[ApiController]
[Authorize]
[Route("api/purchases/invoices")]
public class InvoicesController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IWebHostEnvironment _env;
private readonly ITenantContext _tenant;
private readonly ILogger<InvoicesController> _log;
public InvoicesController(AppDbContext db, IWebHostEnvironment env,
ITenantContext tenant, ILogger<InvoicesController> log)
{
_db = db; _env = env; _tenant = tenant; _log = log;
}
private string StorageDir => Path.Combine(_env.ContentRootPath, "uploads", "invoices",
DateTime.UtcNow.ToString("yyyy-MM"));
// ── upload ──────────────────────────────────────────────────────────
public record UploadResult(List<Guid> Ids);
[HttpPost("upload"), RequiresPermission("SuppliesEdit"), RequestSizeLimit(60_000_000)]
public async Task<ActionResult<UploadResult>> Upload([FromForm] IFormFileCollection files, CancellationToken ct)
{
if (files is null || files.Count == 0)
return BadRequest(new { error = "Файлы не выбраны." });
Directory.CreateDirectory(StorageDir);
var created = new List<Guid>();
foreach (var f in files)
{
if (f.Length == 0) continue;
var ext = Path.GetExtension(f.FileName);
var path = Path.Combine(StorageDir, $"{Guid.NewGuid():N}{ext}");
await using (var fs = System.IO.File.Create(path)) await f.CopyToAsync(fs, ct);
var up = new InvoiceUpload
{
FileName = Path.GetFileName(f.FileName),
StoredPath = path,
ContentType = f.ContentType,
SizeBytes = f.Length,
UploadedByUserId = _tenant.UserId,
Status = "pending",
};
_db.InvoiceUploads.Add(up);
await _db.SaveChangesAsync(ct);
created.Add(up.Id);
}
return Ok(new UploadResult(created));
}
// ── list + detail ───────────────────────────────────────────────────
public record InvoiceListRow(Guid Id, string FileName, string Status, DateTime UploadedAt,
string? SupplierName, string? InvoiceNumber, DateTime? InvoiceDate, decimal? TotalSum,
int LinesTotal, int LinesMatched, Guid? SupplyId, string? Error);
[HttpGet]
public async Task<IActionResult> List([FromQuery] int take = 50)
{
var rows = await _db.InvoiceUploads
.OrderByDescending(x => x.UploadedAt)
.Take(Math.Clamp(take, 1, 200))
.Select(x => new InvoiceListRow(x.Id, x.FileName, x.Status, x.UploadedAt,
x.SupplierName, x.InvoiceNumber, x.InvoiceDate, x.TotalSum,
x.LinesTotal, x.LinesMatched, x.SupplyId, x.Error))
.ToListAsync();
return Ok(rows);
}
public record InvoiceDetailDto(Guid Id, string FileName, string Status, DateTime UploadedAt,
string? SupplierName, string? InvoiceNumber, DateTime? InvoiceDate, decimal? TotalSum,
int LinesTotal, int LinesMatched, Guid? SupplyId, string? Error,
List<InvoiceLine> Lines);
[HttpGet("{id:guid}")]
public async Task<ActionResult<InvoiceDetailDto>> Detail(Guid id, CancellationToken ct)
{
var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct);
if (up is null) return NotFound();
var lines = string.IsNullOrEmpty(up.ParsedJson)
? new List<InvoiceLine>()
: JsonSerializer.Deserialize<List<InvoiceLine>>(up.ParsedJson) ?? new List<InvoiceLine>();
return Ok(new InvoiceDetailDto(up.Id, up.FileName, up.Status, up.UploadedAt,
up.SupplierName, up.InvoiceNumber, up.InvoiceDate, up.TotalSum,
up.LinesTotal, up.LinesMatched, up.SupplyId, up.Error, lines));
}
[HttpGet("{id:guid}/file")]
public async Task<IActionResult> File(Guid id, CancellationToken ct)
{
var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct);
if (up is null) return NotFound();
if (string.IsNullOrEmpty(up.StoredPath) || !System.IO.File.Exists(up.StoredPath))
return NotFound(new { error = "Файл удалён — срок хранения истёк." });
return PhysicalFile(up.StoredPath, up.ContentType ?? "application/octet-stream");
}
[HttpDelete("{id:guid}"), RequiresPermission("SuppliesEdit")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct);
if (up is null) return NotFound();
try { if (!string.IsNullOrEmpty(up.StoredPath) && System.IO.File.Exists(up.StoredPath))
System.IO.File.Delete(up.StoredPath); } catch { }
_db.InvoiceUploads.Remove(up);
await _db.SaveChangesAsync(ct);
return NoContent();
}
// ── правки + ручные сопоставления ───────────────────────────────────
public record SaveLinesDto(List<InvoiceLine> Lines, string? SupplierName,
string? InvoiceNumber, DateTime? InvoiceDate, decimal? TotalSum);
/// <summary>Сохраняет правки кладовщика: строки + «шапка». Любое ручное
/// сопоставление (MatchedBy=«manual» и ProductId != null) УЧИТСЯ: мы
/// пишем/обновляем <see cref="InvoiceAlias"/> для текущего поставщика
/// → этот товар. Следующая накладная того же поставщика с таким же
/// SourceCode/NameNorm сопоставится автоматически.</summary>
[HttpPut("{id:guid}/lines"), RequiresPermission("SuppliesEdit")]
public async Task<IActionResult> SaveLines(Guid id, [FromBody] SaveLinesDto dto, CancellationToken ct)
{
var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct);
if (up is null) return NotFound();
if (up.Status == "applied")
return BadRequest(new { error = "Накладная уже перенесена в приёмку." });
up.SupplierName = dto.SupplierName ?? up.SupplierName;
up.InvoiceNumber = dto.InvoiceNumber ?? up.InvoiceNumber;
up.InvoiceDate = dto.InvoiceDate.HasValue
? DateTime.SpecifyKind(dto.InvoiceDate.Value, DateTimeKind.Utc)
: up.InvoiceDate;
up.TotalSum = dto.TotalSum ?? up.TotalSum;
up.ParsedJson = JsonSerializer.Serialize(dto.Lines);
up.LinesTotal = dto.Lines.Count;
up.LinesMatched = dto.Lines.Count(l => l.ProductId.HasValue);
// Запоминаем ручные сопоставления. Делаем до SaveChanges — если
// уникальный индекс выстрелит (concurrent write), ловим и
// игнорируем — приоритет на первой записи.
var supplierKey = (up.SupplierName ?? "").Trim().ToLowerInvariant();
if (!string.IsNullOrEmpty(supplierKey))
{
foreach (var l in dto.Lines)
{
if (!l.ProductId.HasValue) continue;
if (l.MatchedBy != "manual" && l.MatchedBy != "alias") continue;
await UpsertAliasAsync(supplierKey, l, ct);
}
}
await _db.SaveChangesAsync(ct);
return NoContent();
}
private async Task UpsertAliasAsync(string supplierKey, InvoiceLine l, CancellationToken ct)
{
var nameNorm = InvoiceMatcher.NormalizeName(l.Name);
var code = string.IsNullOrWhiteSpace(l.SupplierCode) ? null : l.SupplierCode.Trim();
// Code и NameNorm — две отдельные уникальные записи (NULL в одной
// ветке допустим фильтр-индексом). Пишем обе параллельно.
foreach (var (byCode, byName) in new[] { (true, false), (false, true) })
{
var searchCode = byCode ? code : null;
var searchName = byName ? nameNorm : null;
if (byCode && string.IsNullOrEmpty(searchCode)) continue;
if (byName && string.IsNullOrEmpty(searchName)) continue;
var existing = await _db.InvoiceAliases
.Where(a => a.SupplierKey == supplierKey
&& ((byCode && a.SourceCode == searchCode) || (byName && a.SourceNameNorm == searchName)))
.FirstOrDefaultAsync(ct);
if (existing is null)
{
_db.InvoiceAliases.Add(new InvoiceAlias
{
SupplierKey = supplierKey,
SourceCode = searchCode,
SourceNameNorm = searchName,
ProductId = l.ProductId!.Value,
LastSeenAt = DateTime.UtcNow,
HitCount = 1,
});
}
else
{
existing.ProductId = l.ProductId.Value;
existing.LastSeenAt = DateTime.UtcNow;
existing.HitCount++;
}
}
}
// ── apply → Supply Draft ────────────────────────────────────────────
public record ApplyDto(Guid SupplierId, Guid StoreId, Guid CurrencyId, string? Notes);
public record ApplyResult(Guid SupplyId, string Number, decimal Total, int Lines);
[HttpPost("{id:guid}/apply"), RequiresPermission("SuppliesEdit")]
public async Task<ActionResult<ApplyResult>> Apply(Guid id, [FromBody] ApplyDto dto, CancellationToken ct)
{
var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct);
if (up is null) return NotFound();
if (up.Status == "applied" && up.SupplyId.HasValue)
return Conflict(new { error = "Накладная уже перенесена.", supplyId = up.SupplyId });
if (string.IsNullOrEmpty(up.ParsedJson))
return BadRequest(new { error = "Накладная ещё не разобрана." });
var lines = JsonSerializer.Deserialize<List<InvoiceLine>>(up.ParsedJson) ?? new();
var ready = lines.Where(l => l.ProductId.HasValue && l.Quantity > 0).ToList();
if (ready.Count == 0)
return BadRequest(new { error = "Нет строк с сопоставленным товаром и количеством > 0." });
// Генерация номера приёмки — тот же шаблон что в SuppliesController.
var number = await GenerateSupplyNumberAsync(up.InvoiceDate ?? DateTime.UtcNow, ct);
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
var supply = new Supply
{
Number = number,
Date = up.InvoiceDate ?? DateTime.UtcNow,
Status = SupplyStatus.Draft,
SupplierId = dto.SupplierId,
StoreId = dto.StoreId,
CurrencyId = dto.CurrencyId,
Notes = string.IsNullOrWhiteSpace(dto.Notes)
? $"Накладная {up.InvoiceNumber} от {up.InvoiceDate:dd.MM.yyyy}".Trim()
: dto.Notes,
};
int sort = 0;
foreach (var l in ready)
{
var price = allowFractional ? l.Price : Math.Round(l.Price, 0, MidpointRounding.AwayFromZero);
supply.Lines.Add(new SupplyLine
{
ProductId = l.ProductId!.Value,
Quantity = l.Quantity,
UnitPrice = price,
LineTotal = l.Quantity * price,
SortOrder = sort++,
});
}
supply.Total = supply.Lines.Sum(x => x.LineTotal);
_db.Supplies.Add(supply);
try { await _db.SaveChangesAsync(ct); }
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503")
{
return BadRequest(new { error = "Связанная запись не найдена (поставщик/склад/валюта/товар).", constraint = pg.ConstraintName });
}
up.SupplyId = supply.Id;
up.Status = "applied";
up.ProcessedAt = DateTime.UtcNow;
// Файл накладной больше не нужен (строки сохранены). Удаление — тихое.
try { if (!string.IsNullOrEmpty(up.StoredPath) && System.IO.File.Exists(up.StoredPath))
System.IO.File.Delete(up.StoredPath); } catch { }
up.StoredPath = "";
await _db.SaveChangesAsync(ct);
return Ok(new ApplyResult(supply.Id, supply.Number, supply.Total, supply.Lines.Count));
}
// ── помощники для UI ────────────────────────────────────────────────
/// <summary>Поиск товара по штрихкоду — для ручной привязки строки без
/// открытия отдельного экрана поиска. Возвращает 404 если нет. Отдаёт
/// ещё и последнюю закупочную цену (чтобы scan-mode приёмки
/// подставлял цену автоматически).</summary>
[HttpGet("product-by-barcode")]
public async Task<IActionResult> ProductByBarcode([FromQuery] string barcode, CancellationToken ct)
{
var digits = InvoiceMatcher.OnlyDigits(barcode);
if (digits.Length < 6) return BadRequest(new { error = "Штрихкод короче 6 цифр." });
var hit = await _db.ProductBarcodes
.Where(b => b.Code == digits)
.Select(b => new { b.ProductId, Name = b.Product.Name, Article = b.Product.Article })
.FirstOrDefaultAsync(ct);
if (hit is null) return NotFound(new { barcode = digits });
// Последняя закупочная цена: берём из последней Posted supply-line
// где участвовал этот товар. Если никогда не поставляли — null.
var lastPrice = await _db.SupplyLines
.Where(l => l.ProductId == hit.ProductId && l.Supply.Status == SupplyStatus.Posted)
.OrderByDescending(l => l.Supply.Date)
.Select(l => (decimal?)l.UnitPrice)
.FirstOrDefaultAsync(ct);
return Ok(new { productId = hit.ProductId, name = hit.Name, article = hit.Article,
barcode = digits, lastPurchasePrice = lastPrice });
}
/// <summary>Поиск контрагента по названию (fuzzy через ILIKE) — предлагаем
/// на экране «применить накладную», чтобы кладовщик не искал в отдельном
/// списке. Если ничего не нашлось, UI предложит создать нового.</summary>
[HttpGet("counterparty-search")]
public async Task<IActionResult> CounterpartySearch([FromQuery] string q, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(q)) return Ok(Array.Empty<object>());
var term = $"%{q.Trim()}%";
var rows = await _db.Counterparties
.Where(c => EF.Functions.ILike(c.Name, term) || (c.Bin != null && EF.Functions.ILike(c.Bin, term)))
.OrderBy(c => c.Name)
.Take(15)
.Select(c => new { c.Id, c.Name, c.Bin })
.ToListAsync(ct);
return Ok(rows);
}
private async Task<string> GenerateSupplyNumberAsync(DateTime date, CancellationToken ct)
{
var year = date.Year;
var prefix = $"П-{year}-";
var max = await _db.Supplies
.Where(s => s.Number.StartsWith(prefix))
.OrderByDescending(s => s.Number)
.Select(s => s.Number)
.FirstOrDefaultAsync(ct);
var seq = 1;
if (!string.IsNullOrEmpty(max) && int.TryParse(max.Split('-').LastOrDefault(), out var n)) seq = n + 1;
return prefix + seq.ToString("D6");
}
}

View file

@ -0,0 +1,252 @@
using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.Json;
namespace foodmarket.Api.Infrastructure.Invoices;
/// <summary>Читает накладную через локальный Claude Code в headless-режиме
/// (<c>claude -p --output-format json</c>). Нагрузка списывается с кредита
/// подписки — отдельная оплата по токенам не нужна. Если
/// <c>Invoices:ClaudeCli:RemoteHost</c> задан, модель живёт на отдельной
/// машине и мы ходим туда по ssh (копируем файл, выполняем, подчищаем).
///
/// Промпт и правила разбора — порт из <c>~/ref/sklad/sklad/Invoices/
/// ClaudeCliInvoiceExtractor.cs</c>: копировать каждую строку, не угадывать
/// (null если не читается), штрихкод != код поставщика, сверка с «Итого».</summary>
public class ClaudeCliInvoiceExtractor : IInvoiceExtractor
{
private readonly ILogger<ClaudeCliInvoiceExtractor> _log;
private readonly string _exe;
private readonly string _model;
private readonly int _timeoutSec;
private readonly string? _remoteHost;
private readonly string _remoteTmp;
public string Name => "claude-cli";
public ClaudeCliInvoiceExtractor(IConfiguration cfg, ILogger<ClaudeCliInvoiceExtractor> log)
{
_log = log;
_exe = cfg["Invoices:ClaudeCli:Executable"] ?? "claude";
_model = cfg["Invoices:ClaudeCli:Model"] ?? "claude-sonnet-4-6";
_timeoutSec = int.TryParse(cfg["Invoices:ClaudeCli:TimeoutSeconds"], out var t) ? t : 180;
_remoteHost = cfg["Invoices:ClaudeCli:RemoteHost"];
_remoteTmp = cfg["Invoices:ClaudeCli:RemoteTmpDir"] ?? "/tmp/food-market-invoices";
}
public bool CanHandle(string fileName, string? contentType)
{
var ext = (Path.GetExtension(fileName) ?? "").ToLowerInvariant();
return ext is ".jpg" or ".jpeg" or ".png" or ".webp" or ".heic" or ".pdf";
}
private static string BuildPrompt(string filePath) => $@"
Прочитай накладную на изображении: {filePath}
Верни ТОЛЬКО JSON, без пояснений и без markdown-обёртки, строго такой формы:
{{
""supplier"": ""название организации-поставщика"",
""number"": ""номер документа"",
""date"": ""ГГГГ-ММ-ДД"",
""total"": число или null,
""lines"": [
{{
""barcode"": ""ШТРИХКОД: 8-14 цифр из колонки «Штрихкод»/EAN. Если такой колонки нет - null"",
""supplier_code"": ""код/артикул/номенклатурный номер поставщика - любой другой его код"",
""name"": ""наименование как написано в накладной"",
""qty"": число,
""unit"": ""шт|кг|л|уп|пачка|короб|бут"",
""price"": число (цена за ЕДИНИЦУ, не сумма),
""sum"": число или null,
""vat"": число или null
}}
]
}}
Важно:
- Переноси ВСЕ строки таблицы, ничего не пропускай и ничего не придумывай.
- Если значение в документе не читается — поставь null, не угадывай.
- Отрицательная цена или сумма означает возврат или скидку. Сохраняй знак как есть.
- Если количество указано сразу в двух единицах (шт и кг), бери ту, в которой
посчитана сумма, вторую игнорируй.
- Штрихкод мог быть напечатан с пробелами — убери их, оставь только цифры.
- НЕ путай штрихкод с номенклатурным номером: номенклатурный номер это внутренний
код поставщика (колонка «Номенклатурный номер», «Код», «Артикул»), он идёт
в supplier_code. В barcode — только настоящий штрихкод с упаковки.
- Проверь себя: сумма по строкам должна совпасть с «Итого» документа.
- Текст вокруг таблицы (реквизиты, юридические предупреждения, реклама) не разбирай.
".Trim();
public async Task<ParsedInvoice> ExtractAsync(string filePath, CancellationToken ct)
{
if (!string.IsNullOrWhiteSpace(_remoteHost))
{
var remote = $"{_remoteTmp}/{Guid.NewGuid():N}{Path.GetExtension(filePath)}";
await RunAsync("ssh", new[] { _remoteHost, $"mkdir -p {_remoteTmp}" }, null, 30, ct);
await RunAsync("scp", new[] { "-q", filePath, $"{_remoteHost}:{remote}" }, null, 120, ct);
try
{
var (code, so, se) = await RunAsync("ssh",
new[] { _remoteHost, $"{_exe} -p --output-format json --model {_model} --allowedTools Read" },
BuildPrompt(remote), _timeoutSec, ct);
if (code != 0)
throw new InvoiceExtractionException($"claude на {_remoteHost}: код {code}, {Truncate(se, 400)}");
return Parse(so);
}
finally
{
try { await RunAsync("ssh", new[] { _remoteHost, $"rm -f {remote}" }, null, 30, CancellationToken.None); }
catch { /* временный файл — не повод валить разбор */ }
}
}
var (c2, out2, err2) = await RunAsync(_exe,
new[] { "-p", "--output-format", "json", "--model", _model, "--allowedTools", "Read" },
BuildPrompt(filePath), _timeoutSec, ct);
if (c2 != 0) throw new InvoiceExtractionException($"claude: код {c2}, {Truncate(err2, 400)}");
return Parse(out2);
}
private async Task<(int code, string stdout, string stderr)> RunAsync(
string exe, string[] args, string? stdin, int timeoutSec, CancellationToken ct)
{
var psi = new ProcessStartInfo
{
FileName = exe,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true,
UseShellExecute = false,
};
foreach (var a in args) psi.ArgumentList.Add(a);
using var proc = new Process { StartInfo = psi };
var so = new StringBuilder();
var se = new StringBuilder();
proc.OutputDataReceived += (_, e) => { if (e.Data != null) so.AppendLine(e.Data); };
proc.ErrorDataReceived += (_, e) => { if (e.Data != null) se.AppendLine(e.Data); };
proc.Start();
proc.BeginOutputReadLine();
proc.BeginErrorReadLine();
if (stdin != null) await proc.StandardInput.WriteAsync(stdin);
proc.StandardInput.Close();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(timeoutSec));
try { await proc.WaitForExitAsync(cts.Token); }
catch (OperationCanceledException)
{
try { proc.Kill(entireProcessTree: true); } catch { }
throw new InvoiceExtractionException($"{exe} не ответил за {timeoutSec} с");
}
return (proc.ExitCode, so.ToString(), se.ToString());
}
/// <summary><c>--output-format json</c> отдаёт конверт Claude Code; нужный JSON
/// лежит в поле <c>result</c> строкой, иногда обёрнутой в <c>```json</c>.</summary>
public static ParsedInvoice Parse(string cliOutput)
{
string payload = cliOutput.Trim();
int inTok = 0, outTok = 0;
try
{
using var env = JsonDocument.Parse(payload);
var root = env.RootElement;
if (root.ValueKind == JsonValueKind.Object)
{
if (root.TryGetProperty("result", out var r) && r.ValueKind == JsonValueKind.String)
payload = r.GetString()!;
if (root.TryGetProperty("usage", out var u) && u.ValueKind == JsonValueKind.Object)
{
if (u.TryGetProperty("input_tokens", out var i)) i.TryGetInt32(out inTok);
if (u.TryGetProperty("output_tokens", out var o)) o.TryGetInt32(out outTok);
}
}
}
catch (JsonException) { /* не конверт — значит сразу наш JSON */ }
payload = StripFence(payload ?? "").Trim();
if (payload.Length == 0) throw new InvoiceExtractionException("пустой ответ от claude");
ParsedInvoice inv;
try { inv = FromJson(payload); }
catch (JsonException ex) { throw new InvoiceExtractionException("не разобрать JSON от claude: " + ex.Message); }
inv.Source = "vision";
inv.RawJson = payload;
inv.InputTokens = inTok;
inv.OutputTokens = outTok;
return inv;
}
private static string StripFence(string s)
{
s = s.Trim();
if (!s.StartsWith("```")) return s;
int nl = s.IndexOf('\n');
if (nl < 0) return s;
s = s.Substring(nl + 1);
int fence = s.LastIndexOf("```", StringComparison.Ordinal);
return fence >= 0 ? s.Substring(0, fence) : s;
}
private static decimal? Dec(JsonElement e, string prop)
{
if (!e.TryGetProperty(prop, out var v)) return null;
if (v.ValueKind == JsonValueKind.Number && v.TryGetDecimal(out var d)) return d;
if (v.ValueKind == JsonValueKind.String &&
decimal.TryParse((v.GetString() ?? "").Replace(",", ".").Replace(" ", ""),
NumberStyles.Any, CultureInfo.InvariantCulture, out var ds)) return ds;
return null;
}
private static string? Str(JsonElement e, string prop)
{
if (!e.TryGetProperty(prop, out var v) || v.ValueKind == JsonValueKind.Null) return null;
var s = v.ValueKind == JsonValueKind.String ? v.GetString() : v.ToString();
return string.IsNullOrWhiteSpace(s) ? null : s.Trim();
}
public static ParsedInvoice FromJson(string json)
{
using var doc = JsonDocument.Parse(json);
var r = doc.RootElement;
var inv = new ParsedInvoice
{
SupplierName = Str(r, "supplier"),
InvoiceNumber = Str(r, "number"),
TotalSum = Dec(r, "total"),
};
var ds = Str(r, "date");
if (ds != null && DateTime.TryParse(ds, CultureInfo.InvariantCulture,
DateTimeStyles.None, out var dt)) inv.InvoiceDate = dt;
if (r.TryGetProperty("lines", out var lines) && lines.ValueKind == JsonValueKind.Array)
{
foreach (var l in lines.EnumerateArray())
{
var name = Str(l, "name");
if (string.IsNullOrWhiteSpace(name)) continue;
var bc = Str(l, "barcode");
if (bc != null) bc = bc.Trim().Replace(" ", "");
inv.Lines.Add(new InvoiceLine
{
Barcode = string.IsNullOrWhiteSpace(bc) ? null : bc,
SupplierCode = Str(l, "supplier_code"),
Name = name,
Quantity = Dec(l, "qty") ?? 0m,
Unit = Str(l, "unit"),
Price = Dec(l, "price") ?? 0m,
Sum = Dec(l, "sum"),
Vat = Dec(l, "vat"),
});
}
}
return inv;
}
private static string Truncate(string s, int n) =>
string.IsNullOrEmpty(s) ? "" : (s.Length <= n ? s : s.Substring(0, n) + "…");
}

View file

@ -0,0 +1,88 @@
using System.Text.RegularExpressions;
namespace foodmarket.Api.Infrastructure.Invoices;
/// <summary>Привязывает строку накладной к нашему товару. Порядок:
/// (1) штрихкод — единственный однозначный ключ;
/// (2) <see cref="InvoiceAlias"/> — то, что кладовщик уже сопоставлял;
/// (3) по названию — НЕ автоматически (слишком много ошибок на накладных
/// без штрихкодов). Порт из <c>~/ref/sklad/sklad/Invoices/InvoiceMatcher.cs</c>.</summary>
public sealed class InvoiceMatcher
{
public sealed class CatalogItem
{
public Guid Id { get; init; }
public string Name { get; init; } = "";
public List<string> Barcodes { get; init; } = new();
}
private readonly Dictionary<string, CatalogItem> _byBarcode = new();
// (supplier  c:code или supplier  n:nameNorm) → productId + name
private readonly Dictionary<string, (Guid id, string name)> _alias = new();
public InvoiceMatcher(IEnumerable<CatalogItem> catalog)
{
foreach (var c in catalog)
{
foreach (var b in c.Barcodes)
{
var k = OnlyDigits(b);
if (k.Length >= 6 && !_byBarcode.ContainsKey(k))
_byBarcode[k] = c;
}
}
}
/// <summary>Добавить запомненное соответствие. Один alias — максимум 2 ключа
/// (code и name_norm); пустые пропускаем.</summary>
public void AddAlias(string supplierKey, string? sourceCode, string? sourceNameNorm,
Guid productId, string productName)
{
foreach (var k in AliasKeys(supplierKey, sourceCode, sourceNameNorm))
_alias[k] = (productId, productName);
}
public static IEnumerable<string> AliasKeys(string? supplierKey, string? code, string? nameNorm)
{
var s = (supplierKey ?? "").Trim().ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(code)) yield return s + "c:" + code.Trim().ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(nameNorm)) yield return s + "n:" + nameNorm;
}
public static string OnlyDigits(string? s) =>
string.IsNullOrEmpty(s) ? "" : Regex.Replace(s, @"\D", "");
public static string NormalizeName(string? s)
{
s = (s ?? "").ToUpperInvariant().Replace('Ё', 'Е');
s = Regex.Replace(s, @"[«»""'`]", " ");
s = Regex.Replace(s, @"(\d)\s*(ГР|Г|МЛ|КГ|Л)\b", "$1$2");
s = Regex.Replace(s, @"[^A-ZА-Я0-9.,%]+", " ");
return Regex.Replace(s, @"\s+", " ").Trim();
}
public void Match(InvoiceLine line, string supplierKey)
{
var bc = OnlyDigits(line.Barcode);
if (bc.Length >= 6 && _byBarcode.TryGetValue(bc, out var byBc))
{
Assign(line, byBc.Id, byBc.Name, "barcode", 1.0);
return;
}
var nameNorm = NormalizeName(line.Name);
foreach (var k in AliasKeys(supplierKey, line.SupplierCode, nameNorm))
if (_alias.TryGetValue(k, out var a))
{
Assign(line, a.id, a.name, "alias", 1.0);
return;
}
line.MatchedBy = "none";
}
private static void Assign(InvoiceLine l, Guid id, string name, string how, double score)
{
l.ProductId = id; l.ProductName = name; l.MatchedBy = how; l.MatchScore = score;
}
}

View file

@ -0,0 +1,53 @@
namespace foodmarket.Api.Infrastructure.Invoices;
/// <summary>Строка накладной как её прочитала модель — ещё без привязки
/// к нашему товару. Матчится по порядку: barcode → alias → ручной.</summary>
public class InvoiceLine
{
public string? Barcode { get; set; }
public string? SupplierCode { get; set; } // код/артикул поставщика
public string Name { get; set; } = "";
public decimal Quantity { get; set; }
public string? Unit { get; set; }
public decimal Price { get; set; }
public decimal? Sum { get; set; }
public decimal? Vat { get; set; }
/// <summary>Guid нашего товара если удалось сопоставить. Null — нет.</summary>
public Guid? ProductId { get; set; }
public string? ProductName { get; set; }
/// <summary>«barcode» | «alias» | «manual» | «none».</summary>
public string MatchedBy { get; set; } = "none";
public double MatchScore { get; set; }
}
public class ParsedInvoice
{
public string? SupplierName { get; set; }
public string? InvoiceNumber { get; set; }
public DateTime? InvoiceDate { get; set; }
public decimal? TotalSum { get; set; }
public List<InvoiceLine> Lines { get; set; } = new();
public string Source { get; set; } = "vision";
public int InputTokens { get; set; }
public int OutputTokens { get; set; }
public string? RawJson { get; set; }
public List<string> Warnings { get; set; } = new();
}
/// <summary>Чем именно читается документ. Реализации взаимозаменяемы:
/// Excel-парсер (не реализован на этом шаге), Claude CLI, API-клиент — все
/// возвращают один и тот же <see cref="ParsedInvoice"/>. Выбор делает
/// конфиг, остальной код об этом не знает.</summary>
public interface IInvoiceExtractor
{
string Name { get; }
bool CanHandle(string fileName, string? contentType);
Task<ParsedInvoice> ExtractAsync(string filePath, CancellationToken ct);
}
public class InvoiceExtractionException : Exception
{
public InvoiceExtractionException(string message) : base(message) { }
}

View file

@ -0,0 +1,191 @@
using System.Text.Json;
using foodmarket.Domain.Purchases;
using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Infrastructure.Invoices;
/// <summary>Разбирает загруженный файл накладной: вызывает extractor
/// (Claude CLI), сопоставляет строки с нашими товарами (barcode + alias),
/// сохраняет результат в <see cref="InvoiceUpload"/>. Supply на этом шаге
/// НЕ создаётся — кладовщик смотрит и правит на экране проверки.
///
/// Multi-tenant: все запросы к <c>Products/ProductBarcodes/InvoiceAliases</c>
/// — через IgnoreQueryFilters с явной фильтрацией по OrganizationId
/// загрузки, потому что в background-worker нет HTTP-контекста и
/// обычный tenant-фильтр не сработает.</summary>
public sealed class InvoiceProcessor
{
private readonly IServiceScopeFactory _scopes;
private readonly ILogger<InvoiceProcessor> _log;
public InvoiceProcessor(IServiceScopeFactory scopes, ILogger<InvoiceProcessor> log)
{ _scopes = scopes; _log = log; }
public async Task ProcessAsync(Guid uploadId, CancellationToken ct)
{
using var scope = _scopes.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var extractor = scope.ServiceProvider.GetRequiredService<IInvoiceExtractor>();
var up = await db.InvoiceUploads.IgnoreQueryFilters()
.FirstOrDefaultAsync(x => x.Id == uploadId, ct);
if (up is null) return;
try
{
up.Status = "parsing";
await db.SaveChangesAsync(ct);
if (!File.Exists(up.StoredPath))
throw new InvoiceExtractionException("Файл не найден на диске");
var parsed = await extractor.ExtractAsync(up.StoredPath, ct);
var sw = System.Diagnostics.Stopwatch.StartNew();
var matcher = await BuildMatcherAsync(db, up.OrganizationId, parsed.SupplierName, ct);
_log.LogInformation("[Invoices] {Id}: matcher built in {Ms} ms", up.Id, sw.ElapsedMilliseconds);
var supplierKey = (parsed.SupplierName ?? "").Trim().ToLowerInvariant();
foreach (var l in parsed.Lines)
matcher.Match(l, supplierKey);
up.SupplierName = parsed.SupplierName;
up.InvoiceNumber = parsed.InvoiceNumber;
up.InvoiceDate = parsed.InvoiceDate.HasValue
? DateTime.SpecifyKind(parsed.InvoiceDate.Value, DateTimeKind.Utc)
: null;
up.TotalSum = parsed.TotalSum;
up.LinesTotal = parsed.Lines.Count;
up.LinesMatched = parsed.Lines.Count(l => l.ProductId.HasValue);
up.Source = parsed.Source;
up.ApiInputTokens = parsed.InputTokens;
up.ApiOutputTokens = parsed.OutputTokens;
up.ParsedJson = JsonSerializer.Serialize(parsed.Lines);
up.Status = "needs_review";
up.ProcessedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
_log.LogInformation("[Invoices] {Id}: {Total} lines, matched {Matched}",
up.Id, up.LinesTotal, up.LinesMatched);
}
catch (Exception ex)
{
up.Status = "failed";
up.Error = ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message;
up.ProcessedAt = DateTime.UtcNow;
await db.SaveChangesAsync(CancellationToken.None);
_log.LogError(ex, "[Invoices] {Id} failed to parse", uploadId);
}
}
/// <summary>Строит matcher из каталога орги + её alias'ов.
/// Запрашивается в scope без tenant-контекста (worker без HTTP-запроса) —
/// используем IgnoreQueryFilters + явный Where по OrganizationId.</summary>
public static async Task<InvoiceMatcher> BuildMatcherAsync(
AppDbContext db, Guid orgId, string? supplierName, CancellationToken ct)
{
// Товары + их штрихкоды. Берём только активные.
var products = await db.Products.IgnoreQueryFilters()
.Where(p => p.OrganizationId == orgId && !p.IsArchived)
.Select(p => new { p.Id, p.Name })
.ToListAsync(ct);
var byProduct = products.ToDictionary(p => p.Id,
p => new InvoiceMatcher.CatalogItem { Id = p.Id, Name = p.Name });
var barcodes = await db.ProductBarcodes.IgnoreQueryFilters()
.Where(b => b.OrganizationId == orgId)
.Select(b => new { b.ProductId, b.Code })
.ToListAsync(ct);
foreach (var b in barcodes)
if (byProduct.TryGetValue(b.ProductId, out var ci))
ci.Barcodes.Add(b.Code);
var matcher = new InvoiceMatcher(byProduct.Values);
// Alias'ы — только для текущего поставщика, иначе накачиваем всё и
// забиваем память; поставщик идентифицируется строкой-ключом.
var sKey = (supplierName ?? "").Trim().ToLowerInvariant();
var aliases = await db.InvoiceAliases.IgnoreQueryFilters()
.Where(a => a.OrganizationId == orgId && a.SupplierKey == sKey)
.ToListAsync(ct);
foreach (var a in aliases)
{
if (!byProduct.TryGetValue(a.ProductId, out var p)) continue;
matcher.AddAlias(a.SupplierKey, a.SourceCode, a.SourceNameNorm, p.Id, p.Name);
}
return matcher;
}
}
/// <summary>Разбирает всё, что в очереди (<c>Status=pending</c>). Фото
/// читается 10-20 секунд, http-запрос на это держать не нужно. Запускается
/// как BackgroundService (один worker на процесс). Раз в час подчищает
/// старые файлы (<c>Invoices:KeepFilesDays</c>, default 7); разобранные
/// строки остаются навсегда.</summary>
public sealed class InvoiceWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopes;
private readonly InvoiceProcessor _proc;
private readonly ILogger<InvoiceWorker> _log;
private DateTime _lastSweep = DateTime.MinValue;
public InvoiceWorker(IServiceScopeFactory s, InvoiceProcessor p, ILogger<InvoiceWorker> l)
{ _scopes = s; _proc = p; _log = l; }
private async Task SweepAsync(CancellationToken ct)
{
if (DateTime.UtcNow - _lastSweep < TimeSpan.FromHours(1)) return;
_lastSweep = DateTime.UtcNow;
using var scope = _scopes.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var cfg = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var days = int.TryParse(cfg["Invoices:KeepFilesDays"], out var d) ? d : 7;
var edge = DateTime.UtcNow.AddDays(-days);
var old = await db.InvoiceUploads.IgnoreQueryFilters()
.Where(x => x.StoredPath != "" && x.UploadedAt < edge)
.ToListAsync(ct);
int n = 0;
foreach (var up in old)
{
try { if (File.Exists(up.StoredPath)) File.Delete(up.StoredPath); } catch { }
up.StoredPath = ""; n++;
}
if (n > 0)
{
await db.SaveChangesAsync(ct);
_log.LogInformation("[Invoices] swept {N} old files (>{D}d)", n, days);
}
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
Guid[] pending;
using (var scope = _scopes.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
pending = await db.InvoiceUploads.IgnoreQueryFilters()
.Where(x => x.Status == "pending")
.OrderBy(x => x.UploadedAt).Select(x => x.Id).Take(5).ToArrayAsync(ct);
}
foreach (var id in pending) await _proc.ProcessAsync(id, ct);
}
catch (OperationCanceledException) { break; }
catch (Exception ex) { _log.LogError(ex, "[Invoices] worker glitch"); }
try { await SweepAsync(ct); }
catch (OperationCanceledException) { break; }
catch (Exception ex) { _log.LogWarning(ex, "[Invoices] sweep failed"); }
try { await Task.Delay(TimeSpan.FromSeconds(3), ct); }
catch (OperationCanceledException) { break; }
}
}
}

View file

@ -446,6 +446,14 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
builder.Services.AddSingleton<foodmarket.Api.Realtime.INotificationsPublisher, builder.Services.AddSingleton<foodmarket.Api.Realtime.INotificationsPublisher,
foodmarket.Api.Realtime.NotificationsPublisher>(); foodmarket.Api.Realtime.NotificationsPublisher>();
// Sprint 31: приёмка по фото накладной. Extractor — Claude CLI headless
// (без API-ключей, нагрузка списывается с подписки). InvoiceWorker в фоне
// обрабатывает очередь pending → needs_review.
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Invoices.IInvoiceExtractor,
foodmarket.Api.Infrastructure.Invoices.ClaudeCliInvoiceExtractor>();
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Invoices.InvoiceProcessor>();
builder.Services.AddHostedService<foodmarket.Api.Infrastructure.Invoices.InvoiceWorker>();
builder.Services.AddHostedService<OpenIddictClientSeeder>(); builder.Services.AddHostedService<OpenIddictClientSeeder>();
builder.Services.AddHostedService<SystemReferenceSeeder>(); builder.Services.AddHostedService<SystemReferenceSeeder>();
builder.Services.AddHostedService<DevDataSeeder>(); builder.Services.AddHostedService<DevDataSeeder>();

View file

@ -0,0 +1,82 @@
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Purchases;
/// <summary>Загрузка файла накладной (фото/PDF) для авто-распознавания.
/// Файл кладётся в <c>uploads/invoices/{yyyy-MM}/{guid}.ext</c>, запись
/// появляется со статусом <c>Pending</c>. <c>InvoiceWorker</c> в фоне
/// переводит её в <c>Parsing</c>, зовёт <c>IInvoiceExtractor</c>
/// (Claude CLI headless), сопоставляет строки с товарами и ставит
/// <c>NeedsReview</c>. Кладовщик правит сопоставление и жмёт «Создать
/// приёмку» — у нас появляется Supply в Draft, <c>SupplyId</c> ссылается
/// на него, статус → <c>Applied</c>.
///
/// Файл-картинка хранится настраиваемое число дней (default 7); разобранные
/// строки (<c>ParsedJson</c>) не удаляются никогда — по ним всегда можно
/// посмотреть, что было в накладной.</summary>
public class InvoiceUpload : TenantEntity
{
public string FileName { get; set; } = "";
public string StoredPath { get; set; } = "";
public string? ContentType { get; set; }
public long SizeBytes { get; set; }
public Guid? UploadedByUserId { get; set; }
public DateTime UploadedAt { get; set; } = DateTime.UtcNow;
/// <summary>«pending» — только загружен; «parsing» — модель разбирает;
/// «needs_review» — строки готовы, кладовщик проверяет; «applied» —
/// Supply Draft создан; «failed» — ошибка (см. Error).</summary>
public string Status { get; set; } = "pending";
public string? SupplierName { get; set; }
public string? InvoiceNumber { get; set; }
public DateTime? InvoiceDate { get; set; }
public decimal? TotalSum { get; set; }
public int LinesTotal { get; set; }
public int LinesMatched { get; set; }
/// <summary>JSON-массив распознанных строк (<c>List&lt;InvoiceLine&gt;</c>
/// из DTO-слоя). Храним как text (JSONB в Postgres).</summary>
public string? ParsedJson { get; set; }
public string? Source { get; set; } // "vision" | "excel" | …
public int ApiInputTokens { get; set; }
public int ApiOutputTokens { get; set; }
public string? Error { get; set; }
public DateTime? ProcessedAt { get; set; }
/// <summary>Если создана наша приёмка — ссылка на неё. На момент создания
/// документ в статусе Draft; проводит кладовщик вручную.</summary>
public Guid? SupplyId { get; set; }
public Supply? Supply { get; set; }
}
/// <summary>Запомненное соответствие «строка накладной конкретного поставщика
/// → наш товар». Заполняется автоматически при КАЖДОМ ручном сопоставлении
/// в экране проверки накладной. На следующих накладных того же поставщика
/// matcher находит товар по этой таблице раньше, чем просит человека.
///
/// Ключ: (OrgId, SupplierKey, SourceCode) ИЛИ (OrgId, SupplierKey,
/// SourceNameNorm). SourceCode/SourceNameNorm — один из них должен быть
/// заполнен (не оба nullable), и подходит любой — поэтому ключи
/// индексируются раздельно.</summary>
public class InvoiceAlias : TenantEntity
{
/// <summary>Нормализованное имя поставщика (lower-case, trimmed).
/// Используется вместо CounterpartyId потому что в момент сопоставления
/// контрагент мог ещё не быть создан.</summary>
public string SupplierKey { get; set; } = "";
/// <summary>Код/артикул из колонки накладной (nullable).</summary>
public string? SourceCode { get; set; }
/// <summary>Нормализованное имя товара из накладной (nullable). Используется
/// только когда SourceCode отсутствует.</summary>
public string? SourceNameNorm { get; set; }
public Guid ProductId { get; set; }
public DateTime LastSeenAt { get; set; } = DateTime.UtcNow;
public int HitCount { get; set; } = 1;
}

View file

@ -43,6 +43,8 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<Supply> Supplies => Set<Supply>(); public DbSet<Supply> Supplies => Set<Supply>();
public DbSet<SupplyLine> SupplyLines => Set<SupplyLine>(); public DbSet<SupplyLine> SupplyLines => Set<SupplyLine>();
public DbSet<InvoiceUpload> InvoiceUploads => Set<InvoiceUpload>();
public DbSet<InvoiceAlias> InvoiceAliases => Set<InvoiceAlias>();
public DbSet<Enter> Enters => Set<Enter>(); public DbSet<Enter> Enters => Set<Enter>();
public DbSet<EnterLine> EnterLines => Set<EnterLine>(); public DbSet<EnterLine> EnterLines => Set<EnterLine>();

View file

@ -46,6 +46,46 @@ public static void ConfigurePurchases(this ModelBuilder b)
e.HasIndex(x => new { x.OrganizationId, x.ProductId }); e.HasIndex(x => new { x.OrganizationId, x.ProductId });
}); });
// Sprint 31: загрузки накладных для авто-распознавания (фото/PDF).
b.Entity<InvoiceUpload>(e =>
{
e.ToTable("invoice_uploads");
e.Property(x => x.FileName).HasMaxLength(260).IsRequired();
e.Property(x => x.StoredPath).HasMaxLength(500);
e.Property(x => x.ContentType).HasMaxLength(200);
e.Property(x => x.Status).HasMaxLength(40).IsRequired();
e.Property(x => x.SupplierName).HasMaxLength(300);
e.Property(x => x.InvoiceNumber).HasMaxLength(100);
e.Property(x => x.Source).HasMaxLength(40);
e.Property(x => x.Error).HasMaxLength(2000);
e.Property(x => x.ParsedJson).HasColumnType("jsonb");
e.Property(x => x.TotalSum).HasPrecision(18, 4);
e.HasOne(x => x.Supply).WithMany().HasForeignKey(x => x.SupplyId).OnDelete(DeleteBehavior.SetNull);
e.HasIndex(x => new { x.OrganizationId, x.Status });
e.HasIndex(x => new { x.OrganizationId, x.UploadedAt });
});
// Sprint 31: запомненные соответствия «накладная поставщика → наш товар».
// Два разных alias-ключа (по SourceCode и по SourceNameNorm) — отдельные
// индексы, уникальные в рамках org+supplier.
b.Entity<InvoiceAlias>(e =>
{
e.ToTable("invoice_aliases");
e.Property(x => x.SupplierKey).HasMaxLength(300).IsRequired();
e.Property(x => x.SourceCode).HasMaxLength(200);
e.Property(x => x.SourceNameNorm).HasMaxLength(400);
// Уникальность — по тройкам с NULL в разных колонках: Postgres их
// считает различимыми, так что составной индекс (SupplierKey,
// SourceCode) не ловит дубль, если SourceCode = NULL у двух строк.
// Это нам подходит: уникальность только когда оба Code одинаковы.
e.HasIndex(x => new { x.OrganizationId, x.SupplierKey, x.SourceCode })
.IsUnique()
.HasFilter("\"SourceCode\" IS NOT NULL");
e.HasIndex(x => new { x.OrganizationId, x.SupplierKey, x.SourceNameNorm })
.IsUnique()
.HasFilter("\"SourceNameNorm\" IS NOT NULL");
});
b.Entity<Enter>(e => b.Entity<Enter>(e =>
{ {
e.ToTable("enters"); e.ToTable("enters");

View file

@ -0,0 +1,92 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Sprint 31 — приёмка по фото/PDF накладной.
///
/// Таблица <c>invoice_uploads</c> — файл + метаданные + результат разбора
/// Claude CLI (ParsedJson в jsonb). Таблица <c>invoice_aliases</c> —
/// запомненные соответствия «поставщик+код → товар», автоматически
/// пополняется при каждом ручном сопоставлении на экране проверки.
///
/// Делаем ADD/CREATE через IF NOT EXISTS, индексы — идемпотентно, чтобы
/// пере-apply на уже-мигрированной БД не падал.</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20261004170000_Phase31a_InvoiceUploads")]
public partial class Phase31a_InvoiceUploads : Migration
{
protected override void Up(MigrationBuilder b)
{
b.Sql(@"
CREATE TABLE IF NOT EXISTS public.invoice_uploads (
""Id"" uuid NOT NULL PRIMARY KEY,
""OrganizationId"" uuid NOT NULL,
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
""FileName"" varchar(260) NOT NULL,
""StoredPath"" varchar(500) NOT NULL DEFAULT '',
""ContentType"" varchar(200) NULL,
""SizeBytes"" bigint NOT NULL DEFAULT 0,
""UploadedByUserId"" uuid NULL,
""UploadedAt"" timestamp with time zone NOT NULL DEFAULT now(),
""Status"" varchar(40) NOT NULL DEFAULT 'pending',
""SupplierName"" varchar(300) NULL,
""InvoiceNumber"" varchar(100) NULL,
""InvoiceDate"" timestamp with time zone NULL,
""TotalSum"" numeric(18,4) NULL,
""LinesTotal"" integer NOT NULL DEFAULT 0,
""LinesMatched"" integer NOT NULL DEFAULT 0,
""ParsedJson"" jsonb NULL,
""Source"" varchar(40) NULL,
""ApiInputTokens"" integer NOT NULL DEFAULT 0,
""ApiOutputTokens"" integer NOT NULL DEFAULT 0,
""Error"" varchar(2000) NULL,
""ProcessedAt"" timestamp with time zone NULL,
""SupplyId"" uuid NULL
);
ALTER TABLE public.invoice_uploads DROP CONSTRAINT IF EXISTS fk_invoice_uploads_supplies;
ALTER TABLE public.invoice_uploads
ADD CONSTRAINT fk_invoice_uploads_supplies
FOREIGN KEY (""SupplyId"") REFERENCES public.supplies(""Id"") ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS ix_invoice_uploads_org_status
ON public.invoice_uploads (""OrganizationId"", ""Status"");
CREATE INDEX IF NOT EXISTS ix_invoice_uploads_org_uploaded_at
ON public.invoice_uploads (""OrganizationId"", ""UploadedAt"");
CREATE TABLE IF NOT EXISTS public.invoice_aliases (
""Id"" uuid NOT NULL PRIMARY KEY,
""OrganizationId"" uuid NOT NULL,
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
""SupplierKey"" varchar(300) NOT NULL,
""SourceCode"" varchar(200) NULL,
""SourceNameNorm"" varchar(400) NULL,
""ProductId"" uuid NOT NULL,
""LastSeenAt"" timestamp with time zone NOT NULL DEFAULT now(),
""HitCount"" integer NOT NULL DEFAULT 1
);
CREATE UNIQUE INDEX IF NOT EXISTS ux_invoice_aliases_code
ON public.invoice_aliases (""OrganizationId"", ""SupplierKey"", ""SourceCode"")
WHERE ""SourceCode"" IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS ux_invoice_aliases_name
ON public.invoice_aliases (""OrganizationId"", ""SupplierKey"", ""SourceNameNorm"")
WHERE ""SourceNameNorm"" IS NOT NULL;
");
}
protected override void Down(MigrationBuilder b)
{
b.Sql(@"
DROP TABLE IF EXISTS public.invoice_aliases;
DROP TABLE IF EXISTS public.invoice_uploads;
");
}
}
}

View file

@ -15,6 +15,8 @@
"@microsoft/signalr": "^10.0.0", "@microsoft/signalr": "^10.0.0",
"@tanstack/react-query": "^5.99.2", "@tanstack/react-query": "^5.99.2",
"@tanstack/react-query-devtools": "^5.99.2", "@tanstack/react-query-devtools": "^5.99.2",
"@zxing/browser": "^0.1.5",
"@zxing/library": "^0.23.0",
"axios": "^1.15.1", "axios": "^1.15.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

View file

@ -20,6 +20,12 @@ importers:
'@tanstack/react-query-devtools': '@tanstack/react-query-devtools':
specifier: ^5.99.2 specifier: ^5.99.2
version: 5.99.2(@tanstack/react-query@5.99.2(react@19.2.5))(react@19.2.5) version: 5.99.2(@tanstack/react-query@5.99.2(react@19.2.5))(react@19.2.5)
'@zxing/browser':
specifier: ^0.1.5
version: 0.1.5(@zxing/library@0.23.0)
'@zxing/library':
specifier: ^0.23.0
version: 0.23.0
axios: axios:
specifier: ^1.15.1 specifier: ^1.15.1
version: 1.15.1 version: 1.15.1
@ -679,6 +685,18 @@ packages:
babel-plugin-react-compiler: babel-plugin-react-compiler:
optional: true optional: true
'@zxing/browser@0.1.5':
resolution: {integrity: sha512-4Lmrn/il4+UNb87Gk8h1iWnhj39TASEHpd91CwwSJtY5u+wa0iH9qS0wNLAWbNVYXR66WmT5uiMhZ7oVTrKfxw==}
peerDependencies:
'@zxing/library': ^0.21.0
'@zxing/library@0.23.0':
resolution: {integrity: sha512-6fkkoFwP8CHxl6ugnPsj74PLJgX2iRv5zczGAyt5OBzQgxFhuhF0NCEc4t4OvSr8xAv2MRLlI0Iu9ZGDZQ2urA==}
engines: {node: '>= 24.0.0'}
'@zxing/text-encoding@0.9.0':
resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==}
abort-controller@3.0.0: abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'} engines: {node: '>=6.5'}
@ -1592,6 +1610,10 @@ packages:
peerDependencies: peerDependencies:
typescript: '>=4.8.4' typescript: '>=4.8.4'
ts-custom-error@3.3.1:
resolution: {integrity: sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==}
engines: {node: '>=14.0.0'}
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@ -2312,6 +2334,21 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-rc.7 '@rolldown/pluginutils': 1.0.0-rc.7
vite: 8.0.9(@types/node@24.12.2)(jiti@2.6.1) vite: 8.0.9(@types/node@24.12.2)(jiti@2.6.1)
'@zxing/browser@0.1.5(@zxing/library@0.23.0)':
dependencies:
'@zxing/library': 0.23.0
optionalDependencies:
'@zxing/text-encoding': 0.9.0
'@zxing/library@0.23.0':
dependencies:
ts-custom-error: 3.3.1
optionalDependencies:
'@zxing/text-encoding': 0.9.0
'@zxing/text-encoding@0.9.0':
optional: true
abort-controller@3.0.0: abort-controller@3.0.0:
dependencies: dependencies:
event-target-shim: 5.0.1 event-target-shim: 5.0.1
@ -3119,6 +3156,8 @@ snapshots:
dependencies: dependencies:
typescript: 6.0.3 typescript: 6.0.3
ts-custom-error@3.3.1: {}
tslib@2.8.1: tslib@2.8.1:
optional: true optional: true

View file

@ -18,6 +18,10 @@ import { CounterpartiesPage } from '@/pages/CounterpartiesPage'
import { StockPage } from '@/pages/StockPage' import { StockPage } from '@/pages/StockPage'
import { SuppliesPage } from '@/pages/SuppliesPage' import { SuppliesPage } from '@/pages/SuppliesPage'
import { SupplyEditPage } from '@/pages/SupplyEditPage' import { SupplyEditPage } from '@/pages/SupplyEditPage'
import { InvoicesPage } from '@/pages/InvoicesPage'
import { InvoiceReviewPage } from '@/pages/InvoiceReviewPage'
import { SupplyScanPage } from '@/pages/SupplyScanPage'
import { InventoryMobilePage } from '@/pages/InventoryMobilePage'
import { RetailSalesPage } from '@/pages/RetailSalesPage' import { RetailSalesPage } from '@/pages/RetailSalesPage'
import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage' import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage'
import { NoOrganizationPage } from '@/pages/NoOrganizationPage' import { NoOrganizationPage } from '@/pages/NoOrganizationPage'
@ -167,6 +171,12 @@ export default function App() {
<Route path="/purchases/supplies" element={<SuppliesPage />} /> <Route path="/purchases/supplies" element={<SuppliesPage />} />
<Route path="/purchases/supplies/new" element={<SupplyEditPage />} /> <Route path="/purchases/supplies/new" element={<SupplyEditPage />} />
<Route path="/purchases/supplies/:id" element={<SupplyEditPage />} /> <Route path="/purchases/supplies/:id" element={<SupplyEditPage />} />
{/* Sprint 31: приёмка по фото накладной + сканированием. */}
<Route path="/purchases/invoices" element={<InvoicesPage />} />
<Route path="/purchases/invoices/:id" element={<InvoiceReviewPage />} />
<Route path="/purchases/supplies/scan" element={<SupplyScanPage />} />
{/* Sprint 31: мобильная инвентаризация с офлайн-каталогом. */}
<Route path="/inventory/mobile" element={<InventoryMobilePage />} />
<Route path="/inventory/enters" element={lz(EntersPage)} /> <Route path="/inventory/enters" element={lz(EntersPage)} />
<Route path="/inventory/enters/new" element={lz(EnterEditPage)} /> <Route path="/inventory/enters/new" element={lz(EnterEditPage)} />
<Route path="/inventory/enters/:id" element={lz(EnterEditPage)} /> <Route path="/inventory/enters/:id" element={lz(EnterEditPage)} />

View file

@ -0,0 +1,143 @@
import { useEffect, useRef, useState } from 'react'
import { BrowserMultiFormatReader } from '@zxing/browser'
import { BarcodeFormat, DecodeHintType } from '@zxing/library'
import { X, Loader2, CameraOff, Check, AlertTriangle } from 'lucide-react'
export type ScanFeedback = {
kind: 'ok' | 'err'
title: string
sub?: string
/// Монотонно растущий счётчик — перезапускает анимацию, иначе два
/// одинаковых скана подряд выглядят как один.
seq: number
}
/// Непрерывное сканирование камерой. Окно не закрывается после удачного
/// скана: товар за товаром идёт подряд, закрывает его только человек.
/// Подавление повтора 1.5с (в кадре один товар распознаётся десятки раз
/// в секунду). Порт из ~/ref/sklad/sklad/ClientApp/src/components/
/// CameraScanner.tsx с теми же эвристиками.
export default function CameraScanner({ onCode, onClose, feedback, counter }: {
onCode: (code: string) => void
onClose: () => void
feedback?: ScanFeedback | null
counter?: string
}) {
const videoRef = useRef<HTMLVideoElement>(null)
const [error, setError] = useState<string | null>(null)
const [starting, setStarting] = useState(true)
const [flash, setFlash] = useState<ScanFeedback | null>(null)
const lastRef = useRef<{ code: string; at: number }>({ code: '', at: 0 })
useEffect(() => {
if (!feedback) return
setFlash(feedback)
const t = setTimeout(() => setFlash(null), 1600)
return () => clearTimeout(t)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [feedback?.seq])
useEffect(() => {
let stop: (() => void) | null = null
let dead = false
;(async () => {
try {
const hints = new Map()
hints.set(DecodeHintType.POSSIBLE_FORMATS, [
BarcodeFormat.EAN_13, BarcodeFormat.EAN_8,
BarcodeFormat.UPC_A, BarcodeFormat.UPC_E,
BarcodeFormat.CODE_128, BarcodeFormat.ITF,
])
const reader = new BrowserMultiFormatReader(hints)
const controls = await reader.decodeFromConstraints(
{ video: { facingMode: { ideal: 'environment' } } },
videoRef.current!,
(result) => {
if (!result) return
const code = result.getText().replace(/\D/g, '')
if (code.length < 6) return
const now = Date.now()
if (lastRef.current.code === code && now - lastRef.current.at < 1500) return
lastRef.current = { code, at: now }
try { navigator.vibrate?.(40) } catch { /* не на всех */ }
onCode(code)
},
)
if (dead) controls.stop()
else { stop = () => controls.stop(); setStarting(false) }
} catch (e: unknown) {
setStarting(false)
const name = (e as { name?: string })?.name
setError(name === 'NotAllowedError'
? 'Доступ к камере запрещён. Разрешите его в настройках браузера для этого сайта.'
: 'Не удалось включить камеру')
}
})()
return () => { dead = true; stop?.() }
}, [onCode])
const ok = flash?.kind === 'ok'
return (
<div className="fixed inset-0 z-50 bg-black flex flex-col">
<style>{`
@keyframes scanPop { 0% { opacity:0; transform:translateY(14px) scale(.96) }
12% { opacity:1; transform:translateY(0) scale(1) }
80% { opacity:1 } 100% { opacity:0 } }
@keyframes scanRing { 0% { opacity:.9 } 100% { opacity:0 } }
`}</style>
<div className="flex items-center justify-between px-4 py-3 text-white shrink-0">
<div className="text-sm">
{counter ? <span className="tabular-nums">{counter}</span> : 'Наведите на штрихкод'}
</div>
<button onClick={onClose}
className="inline-flex items-center gap-1.5 rounded-lg bg-white/15 px-3 py-1.5 text-sm active:bg-white/25">
<X className="w-4 h-4" /> Готово
</button>
</div>
<div className="relative flex-1 overflow-hidden">
<video ref={videoRef} playsInline muted
className="absolute inset-0 w-full h-full object-cover" />
<div
key={flash?.seq ?? 'idle'}
className={`absolute inset-x-8 top-1/2 -translate-y-1/2 h-40 rounded-2xl border-4
shadow-[0_0_0_9999px_rgba(0,0,0,0.45)] transition-colors ${
flash ? (ok ? 'border-emerald-400' : 'border-red-400') : 'border-white/80'}`}
style={flash ? { animation: 'scanRing 1.2s ease-out' } : undefined} />
{starting && (
<div className="absolute inset-0 flex items-center justify-center text-white gap-2">
<Loader2 className="w-5 h-5 animate-spin" /> Включаю камеру…
</div>
)}
{error && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center">
<CameraOff className="w-8 h-8 text-white/70" />
<div className="text-white text-sm">{error}</div>
</div>
)}
</div>
<div className="relative shrink-0 min-h-24 px-4 pb-6 pt-2">
{flash ? (
<div key={flash.seq}
style={{ animation: 'scanPop 1.6s ease-out forwards' }}
className={`rounded-2xl px-4 py-3 flex items-center gap-3 ${
ok ? 'bg-emerald-500 text-white' : 'bg-red-500 text-white'}`}>
<div className="shrink-0 rounded-full p-1.5 bg-white/25">
{ok ? <Check className="w-6 h-6" /> : <AlertTriangle className="w-6 h-6" />}
</div>
<div className="min-w-0">
<div className="font-semibold leading-tight truncate">{flash.title}</div>
{flash.sub && <div className="text-sm opacity-90 truncate">{flash.sub}</div>}
</div>
</div>
) : (
<div className="text-center text-white/60 text-sm pt-3">
Сканируйте подряд — окно останется открытым
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,55 @@
/// Каталог не влезает в localStorage (~5 МБ на домен), поэтому держим
/// в IndexedDB. По образцу ~/ref/sklad/sklad/ClientApp/src/lib/db.ts.
///
/// Ключ store'а — штрихкод (строка цифр). Значение — [productId, name].
/// Отдельный meta-store хранит { count, builtAt, groupId? } чтобы UI
/// показывал, когда последний раз обновляли и для какой группы.
const DB = 'fm-inventory', STORE = 'catalog', META = 'meta'
function open(): Promise<IDBDatabase> {
return new Promise((res, rej) => {
const r = indexedDB.open(DB, 1)
r.onupgradeneeded = () => {
const d = r.result
if (!d.objectStoreNames.contains(STORE)) d.createObjectStore(STORE)
if (!d.objectStoreNames.contains(META)) d.createObjectStore(META)
}
r.onsuccess = () => res(r.result)
r.onerror = () => rej(r.error)
})
}
function tx<T>(store: string, mode: IDBTransactionMode,
fn: (s: IDBObjectStore) => IDBRequest<T>): Promise<T> {
return open().then(d => new Promise<T>((res, rej) => {
const r = fn(d.transaction(store, mode).objectStore(store))
r.onsuccess = () => res(r.result)
r.onerror = () => rej(r.error)
}))
}
export type CatMeta = { count: number; builtAt: string; groupId?: string | null; storeId?: string | null }
export async function saveCatalog(
rows: Array<[string, string, string]>,
meta: CatMeta,
): Promise<void> {
const d = await open()
await new Promise<void>((res, rej) => {
const t = d.transaction([STORE, META], 'readwrite')
const s = t.objectStore(STORE)
s.clear()
for (const [bc, id, name] of rows) s.put([id, name], bc)
t.objectStore(META).put(meta, 'catalog')
t.oncomplete = () => res()
t.onerror = () => rej(t.error)
})
}
export const findByBarcode = (bc: string) =>
tx<[string, string] | undefined>(STORE, 'readonly', s => s.get(bc))
export const catalogMeta = () => tx<CatMeta | undefined>(META, 'readonly', s => s.get('catalog'))
export const countRows = () => tx<number>(STORE, 'readonly', s => s.count())

View file

@ -0,0 +1,413 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '@/lib/api'
import { saveCatalog, findByBarcode, catalogMeta, type CatMeta } from '@/lib/offlineCatalog'
import CameraScanner, { type ScanFeedback } from '@/components/CameraScanner'
import {
ScanBarcode, Download, Camera, Trash2, Check, Loader2, AlertTriangle, WifiOff,
ArrowLeft, Keyboard, Filter,
} from 'lucide-react'
interface Store { id: string; name: string; isMain: boolean }
interface Group { id: string; name: string }
interface MobileCatalog { count: number; builtAt: string; rows: [string, string, string][] }
interface InventoryDto { id: string; number: string; status: number; lines: InventoryLineDto[] }
interface InventoryLineDto {
productId: string; productName?: string | null
bookQty: number; actualQty: number; diff: number; unitCost: number
}
interface BookDriftRow {
productId: string; productName: string; savedBook: number; currentBook: number
}
type LocalRow = { id: string; name: string; qty: number }
const LS_ROWS = 'fm.inv-mobile.rows'
const LS_DOCID = 'fm.inv-mobile.docId'
/// Мобильная инвентаризация. Шаги:
/// 1) выбор склада (+ опц. группы = частичная инвентаризация);
/// 2) загрузка каталога в IndexedDB (работает затем без сети);
/// 3) сканирование / ручной ввод — счётчик +1 на штрихкод;
/// 4) создание черновика (сервер bookQty = текущий Stock);
/// 5) просмотр расхождений перед Post; drift-check показывает, если
/// учётный изменился с момента создания (были продажи/приёмки).
export function InventoryMobilePage() {
const nav = useNavigate()
const [stage, setStage] = useState<'setup' | 'scan' | 'review'>('setup')
const [stores, setStores] = useState<Store[]>([])
const [groups, setGroups] = useState<Group[]>([])
const [storeId, setStoreId] = useState('')
const [groupId, setGroupId] = useState<string>('') // пусто = полная
const [cat, setCat] = useState<CatMeta | null>(null)
const [catalogLoading, setCatalogLoading] = useState(false)
const [online, setOnline] = useState(typeof navigator !== 'undefined' ? navigator.onLine : true)
const [rows, setRows] = useState<LocalRow[]>(() => {
try { return JSON.parse(localStorage.getItem(LS_ROWS) || '[]') } catch { return [] }
})
const [unknown, setUnknown] = useState<{ code: string; n: number }[]>([])
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null)
const [cameraOn, setCameraOn] = useState(false)
const [fb, setFb] = useState<ScanFeedback | null>(null)
const seq = useRef(0)
const inputRef = useRef<HTMLInputElement>(null)
const [docId, setDocId] = useState<string | null>(() => localStorage.getItem(LS_DOCID))
const [doc, setDoc] = useState<InventoryDto | null>(null)
const [drift, setDrift] = useState<BookDriftRow[] | null>(null)
const [filterOnlyDiff, setFilterOnlyDiff] = useState(false)
const [posting, setPosting] = useState(false)
// online / offline listeners
useEffect(() => {
const up = () => setOnline(true), down = () => setOnline(false)
addEventListener('online', up); addEventListener('offline', down)
return () => { removeEventListener('online', up); removeEventListener('offline', down) }
}, [])
useEffect(() => {
try { localStorage.setItem(LS_ROWS, JSON.stringify(rows)) } catch { /* storage full */ }
}, [rows])
// грузим справочники + инфо о каталоге из IndexedDB
useEffect(() => {
api.get<{ items: Store[] }>('/api/catalog/stores?pageSize=100').then(r => {
setStores(r.data.items ?? [])
const main = r.data.items?.find(s => s.isMain) ?? r.data.items?.[0]
if (main && !storeId) setStoreId(main.id)
}).catch(() => {})
api.get<{ items: Group[] }>('/api/catalog/product-groups?pageSize=500').then(r => {
setGroups(r.data.items ?? [])
}).catch(() => {})
catalogMeta().then(m => m && setCat(m)).catch(() => {})
// если есть сохранённый docId — переходим сразу к обзору
if (docId) setStage('scan')
}, [])
const loadCatalog = async () => {
setCatalogLoading(true); setMsg(null)
try {
const url = groupId
? `/api/inventory/inventories/mobile-catalog?groupId=${groupId}`
: '/api/inventory/inventories/mobile-catalog'
const { data } = await api.get<MobileCatalog>(url, { timeout: 180_000 })
const meta: CatMeta = { count: data.count, builtAt: data.builtAt, groupId: groupId || null, storeId }
await saveCatalog(data.rows, meta)
setCat(meta)
setMsg({ text: `Каталог загружен: ${data.count.toLocaleString('ru-RU')} штрихкодов`, ok: true })
} catch {
setMsg({ text: 'Не удалось загрузить каталог', ok: false })
} finally { setCatalogLoading(false) }
}
const createDocIfNeeded = async (): Promise<string | null> => {
if (docId) return docId
try {
const { data } = await api.post<{ id: string }>('/api/inventory/inventories', {
storeId, date: new Date().toISOString(), notes: groupId ? 'Инвентаризация с телефона (частичная)' : 'Инвентаризация с телефона',
lines: [], // пустые — сервер подтянет Stock автоматически (bookQty)
})
setDocId(data.id); localStorage.setItem(LS_DOCID, data.id)
return data.id
} catch {
setMsg({ text: 'Не удалось создать документ', ok: false })
return null
}
}
const beep = (ok: boolean) => { try { navigator.vibrate?.(ok ? 40 : [60, 50, 60]) } catch { /* ignored */ } }
const addScan = async (raw: string) => {
const code = raw.replace(/\D/g, '')
if (code.length < 6) return
const hit = await findByBarcode(code).catch(() => undefined)
if (!hit) {
setUnknown(prev => {
const i = prev.findIndex(u => u.code === code)
return i < 0 ? [{ code, n: 1 }, ...prev]
: prev.map((u, j) => j === i ? { ...u, n: u.n + 1 } : u)
})
setMsg({ text: `${code}: нет в каталоге`, ok: false })
setFb({ kind: 'err', title: 'Нет в каталоге', sub: code, seq: ++seq.current })
beep(false); return
}
const [id, name] = hit
let qty = 1
setRows(prev => {
const i = prev.findIndex(r => r.id === id)
if (i < 0) return [{ id, name, qty: 1 }, ...prev]
const next = [...prev]
qty = next[i].qty + 1
next[i] = { ...next[i], qty }
return [next[i], ...next.filter((_, j) => j !== i)]
})
setMsg({ text: name, ok: true })
setFb({ kind: 'ok', title: name, sub: `теперь ${qty} шт`, seq: ++seq.current })
beep(true)
// Сервер уведомляем только когда онлайн. Офлайн — всё лежит в localStorage.
if (online) {
const did = await createDocIfNeeded()
if (did) {
try { await api.post(`/api/inventory/inventories/${did}/add-line`, { productId: id, actualQty: qty }) }
catch { /* ignored — повторим при синке */ }
}
}
}
const total = useMemo(() => rows.reduce((a, r) => a + r.qty, 0), [rows])
const goReview = async () => {
if (!online) { setMsg({ text: 'Нет связи — сначала подключитесь', ok: false }); return }
const did = await createDocIfNeeded()
if (!did) return
// Синхронизация rows → add-line для каждой (сервер идемпотентно обновляет)
for (const r of rows) {
try { await api.post(`/api/inventory/inventories/${did}/add-line`, { productId: r.id, actualQty: r.qty }) }
catch { /* ignored */ }
}
const { data } = await api.get<InventoryDto>(`/api/inventory/inventories/${did}`)
setDoc(data)
const { data: dr } = await api.get<BookDriftRow[]>(`/api/inventory/inventories/${did}/book-drift`)
setDrift(dr)
setStage('review')
}
const postDoc = async () => {
if (!doc) return
setPosting(true)
try {
await api.post(`/api/inventory/inventories/${doc.id}/post`)
try { localStorage.removeItem(LS_ROWS); localStorage.removeItem(LS_DOCID) } catch { /* ignored */ }
setRows([]); setUnknown([]); setDocId(null); setDoc(null); setDrift(null)
nav(`/inventory/inventories/${doc.id}`)
} catch (e: unknown) {
setMsg({ text: e instanceof Error ? e.message : 'Ошибка проведения', ok: false })
} finally { setPosting(false) }
}
// ─ STAGE: SETUP ───────────────────────────────────────────────────────
if (stage === 'setup') {
return (
<div className="max-w-md mx-auto p-3 space-y-3">
<div className="flex items-center gap-2">
<button onClick={() => nav(-1)} className="p-2 rounded hover:bg-slate-100"><ArrowLeft className="w-5 h-5" /></button>
<h1 className="text-xl font-semibold">Инвентаризация с телефона</h1>
</div>
<label className="block">
<span className="text-xs text-slate-500">Склад</span>
<select value={storeId} onChange={e => setStoreId(e.target.value)}
className="w-full rounded border border-slate-300 px-2 py-2 text-base">
{stores.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</label>
<label className="block">
<span className="text-xs text-slate-500">Группа (пусто = полная инвентаризация)</span>
<select value={groupId} onChange={e => setGroupId(e.target.value)}
className="w-full rounded border border-slate-300 px-2 py-2 text-base">
<option value="">— вся номенклатура —</option>
{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</label>
{cat ? (
<div className="rounded-md bg-emerald-50 border border-emerald-200 text-emerald-800 px-3 py-2 text-sm">
Каталог готов: {cat.count.toLocaleString('ru-RU')} штрихкодов
{cat.groupId && <span className="text-xs"> · группа загружена</span>}
{' · '}<button onClick={loadCatalog} className="underline">обновить</button>
</div>
) : (
<button onClick={loadCatalog} disabled={catalogLoading || !storeId}
className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-slate-800 text-white py-3 font-medium disabled:opacity-50">
{catalogLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
Загрузить каталог на телефон
</button>
)}
<button onClick={() => setStage('scan')} disabled={!cat || !storeId}
className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 text-white py-3 font-medium disabled:opacity-50">
<ScanBarcode className="w-5 h-5" /> Начать сканирование
</button>
</div>
)
}
// ─ STAGE: SCAN ────────────────────────────────────────────────────────
if (stage === 'scan') {
return (
<div className="max-w-3xl mx-auto p-3 pb-28 space-y-3">
<div className="flex items-center gap-2">
<button onClick={() => setStage('setup')} className="p-2 rounded hover:bg-slate-100"><ArrowLeft className="w-5 h-5" /></button>
<h1 className="text-xl font-semibold flex-1">Сканирование</h1>
<span className="text-xs text-slate-500">{rows.length} · {total}</span>
</div>
{!online && (
<div className="flex items-start gap-2 rounded-md bg-amber-50 border border-amber-200 px-3 py-2 text-sm text-amber-900">
<WifiOff className="w-4 h-4 mt-0.5 shrink-0" />
Нет сети. Сканируйте — всё сохранится в телефоне и уйдёт на сервер, когда появится связь.
</div>
)}
<div className="bg-white rounded-lg border border-slate-200 p-3 space-y-2">
<button onClick={() => { setFb(null); setCameraOn(true) }}
className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 text-white py-4 text-lg font-medium">
<Camera className="w-6 h-6" /> Сканировать камерой
</button>
<details className="text-sm">
<summary className="text-slate-500 cursor-pointer select-none flex items-center gap-1">
<Keyboard className="w-4 h-4" /> Аппаратный сканер — ввод штрихкода
</summary>
<input ref={inputRef} inputMode="numeric"
onKeyDown={e => {
if (e.key !== 'Enter') return
e.preventDefault()
const el = e.target as HTMLInputElement
addScan(el.value); el.value = ''
}}
placeholder="Отсканируйте или введите штрихкод"
className="mt-2 w-full rounded-lg border-2 border-slate-300 px-4 py-3 text-lg tabular-nums outline-none focus:border-sky-500"
data-testid="inv-scan-input" />
</details>
{msg && (
<div className={`rounded px-3 py-2 text-sm ${msg.ok ? 'bg-emerald-50 text-emerald-800' : 'bg-red-50 text-red-700'}`}>
{msg.text}
</div>
)}
</div>
{unknown.length > 0 && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 space-y-1">
<div className="flex items-center gap-2 text-amber-900 font-medium text-sm">
<AlertTriangle className="w-4 h-4" /> Нет в каталоге ({unknown.length})
</div>
{unknown.map(u => <div key={u.code} className="text-sm text-amber-900 tabular-nums">{u.code} — {u.n} шт</div>)}
<div className="text-xs text-amber-800 pt-1">
Эти штрихкоды в документ не попадут. Заведите им карточки и пересчитайте.
</div>
</div>
)}
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<div className="px-3 py-2 bg-slate-50 text-xs text-slate-600 flex justify-between">
<span>Позиций: {rows.length}</span>
<span>Единиц: {total}</span>
</div>
{rows.length === 0 && (
<div className="px-4 py-10 text-center text-slate-400 text-sm">Пока ничего не отсканировано</div>
)}
{rows.map(r => (
<div key={r.id} className="px-3 py-2 border-t border-slate-100 flex items-center gap-2">
<div className="min-w-0 flex-1 text-sm truncate">{r.name}</div>
<input type="number" value={r.qty} min={0}
onChange={e => {
const v = parseFloat(e.target.value) || 0
setRows(p => p.map(x => x.id === r.id ? { ...x, qty: v } : x))
}}
className="w-20 text-right rounded border border-slate-200 px-2 py-1 tabular-nums text-lg" />
<button onClick={() => setRows(p => p.filter(x => x.id !== r.id))}
className="p-1 text-slate-300 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
</div>
))}
</div>
{cameraOn && (
<CameraScanner onClose={() => setCameraOn(false)} onCode={addScan} feedback={fb}
counter={`позиций ${rows.length} · единиц ${total}`} />
)}
{rows.length > 0 && (
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 p-3">
<div className="max-w-3xl mx-auto">
<button onClick={goReview} disabled={!online}
className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-slate-800 text-white py-3 font-medium disabled:opacity-50">
{online ? <Check className="w-4 h-4" /> : <WifiOff className="w-4 h-4" />}
{online ? `Проверить расхождения (${rows.length})` : 'Нет сети — отправить позже'}
</button>
</div>
</div>
)}
</div>
)
}
// ─ STAGE: REVIEW ──────────────────────────────────────────────────────
const visibleLines = (doc?.lines ?? [])
.filter(l => !filterOnlyDiff || l.diff !== 0)
.sort((a, b) => Math.abs(b.diff) - Math.abs(a.diff))
const surplus = (doc?.lines ?? []).filter(l => l.diff > 0).reduce((a, l) => a + l.diff * l.unitCost, 0)
const shortage = (doc?.lines ?? []).filter(l => l.diff < 0).reduce((a, l) => a + l.diff * l.unitCost, 0)
const diffs = (doc?.lines ?? []).filter(l => l.diff !== 0).length
return (
<div className="max-w-3xl mx-auto p-3 pb-28 space-y-3">
<div className="flex items-center gap-2">
<button onClick={() => setStage('scan')} className="p-2 rounded hover:bg-slate-100"><ArrowLeft className="w-5 h-5" /></button>
<h1 className="text-xl font-semibold flex-1">Расхождения · {doc?.number}</h1>
<button onClick={() => setFilterOnlyDiff(f => !f)}
className={`inline-flex items-center gap-1 rounded px-2 py-1 text-xs ${filterOnlyDiff ? 'bg-sky-100 text-sky-800' : 'bg-slate-100 text-slate-600'}`}>
<Filter className="w-3.5 h-3.5" /> {filterOnlyDiff ? 'Только расхождения' : 'Все позиции'}
</button>
</div>
{drift && drift.length > 0 && (
<div className="rounded-md border border-amber-300 bg-amber-50 text-amber-900 px-3 py-2 text-sm">
<div className="font-medium flex items-center gap-1"><AlertTriangle className="w-4 h-4" /> Учётный остаток изменился с момента создания документа</div>
<ul className="mt-1 text-xs space-y-0.5">
{drift.slice(0, 5).map(d => (
<li key={d.productId}><strong>{d.productName}</strong>: {d.savedBook} → {d.currentBook}</li>
))}
{drift.length > 5 && <li>… и ещё {drift.length - 5}</li>}
</ul>
<div className="text-xs mt-1">Это были продажи/приёмки между созданием и сейчас. Проверьте перед проведением.</div>
</div>
)}
<div className="grid grid-cols-3 gap-2 text-sm">
<div className="bg-emerald-50 rounded p-2 text-emerald-900">
<div className="text-xs">Излишек</div>
<div className="font-semibold tabular-nums">+{surplus.toLocaleString('ru-RU', { maximumFractionDigits: 2 })}</div>
</div>
<div className="bg-red-50 rounded p-2 text-red-800">
<div className="text-xs">Недостача</div>
<div className="font-semibold tabular-nums">{shortage.toLocaleString('ru-RU', { maximumFractionDigits: 2 })}</div>
</div>
<div className="bg-slate-100 rounded p-2 text-slate-800">
<div className="text-xs">Расхождений</div>
<div className="font-semibold">{diffs} из {doc?.lines.length ?? 0}</div>
</div>
</div>
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
{visibleLines.map(l => (
<div key={l.productId} className="px-3 py-2 border-t border-slate-100 first:border-t-0">
<div className="text-sm truncate">{l.productName}</div>
<div className="text-xs flex items-center gap-3 tabular-nums">
<span className="text-slate-500">учёт {l.bookQty}</span>
<span className="text-slate-500">факт {l.actualQty}</span>
<span className={`font-semibold ${l.diff > 0 ? 'text-emerald-700' : l.diff < 0 ? 'text-red-700' : 'text-slate-500'}`}>
{l.diff > 0 ? '+' : ''}{l.diff}
</span>
{l.diff !== 0 && (
<span className={`text-xs ${l.diff > 0 ? 'text-emerald-700' : 'text-red-700'}`}>
= {(l.diff * l.unitCost).toLocaleString('ru-RU', { maximumFractionDigits: 2 })}
</span>
)}
</div>
</div>
))}
</div>
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 p-3">
<div className="max-w-3xl mx-auto flex gap-2">
<button onClick={() => setStage('scan')}
className="flex-1 rounded-lg border border-slate-300 py-3 font-medium">Назад</button>
<button onClick={postDoc} disabled={posting}
className="flex-[1.5] inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 text-white py-3 font-medium disabled:opacity-50">
{posting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
Провести ({diffs} расх.)
</button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,470 @@
import { useEffect, useMemo, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { api } from '@/lib/api'
import { useQuery, useMutation } from '@tanstack/react-query'
import { ArrowLeft, Save, Check, AlertTriangle, Search, Trash2, Barcode as BarcodeIcon } from 'lucide-react'
interface InvoiceLine {
barcode?: string | null
supplierCode?: string | null
name: string
quantity: number
unit?: string | null
price: number
sum?: number | null
vat?: number | null
productId?: string | null
productName?: string | null
matchedBy: string // barcode | alias | manual | none
matchScore: number
}
interface InvoiceDetail {
id: string
fileName: string
status: string
uploadedAt: string
supplierName: string | null
invoiceNumber: string | null
invoiceDate: string | null
totalSum: number | null
linesTotal: number
linesMatched: number
supplyId: string | null
error: string | null
lines: InvoiceLine[]
}
interface ProductSearchHit { id: string; name: string; article: string | null }
interface CounterpartyHit { id: string; name: string; bin: string | null }
interface Store { id: string; name: string; isMain: boolean }
interface Currency { id: string; code: string }
/// Экран проверки накладной. Карточками — чтобы телефон читался; на
/// desktop раскладывается в 2 колонки.
export function InvoiceReviewPage() {
const { id } = useParams<{ id: string }>()
const nav = useNavigate()
const [lines, setLines] = useState<InvoiceLine[]>([])
const [header, setHeader] = useState({ supplierName: '', invoiceNumber: '', invoiceDate: '', totalSum: '' })
const [barcodeInput, setBarcodeInput] = useState('')
const [searchHit, setSearchHit] = useState<{ row: number; q: string } | null>(null)
const [savedMsg, setSavedMsg] = useState<string | null>(null)
const [applyOpen, setApplyOpen] = useState(false)
const detail = useQuery({
queryKey: ['invoice', id],
queryFn: () => api.get<InvoiceDetail>(`/api/purchases/invoices/${id}`).then(r => r.data),
enabled: !!id,
})
useEffect(() => {
const d = detail.data
if (!d) return
setLines(d.lines)
setHeader({
supplierName: d.supplierName ?? '',
invoiceNumber: d.invoiceNumber ?? '',
invoiceDate: d.invoiceDate ? d.invoiceDate.slice(0, 10) : '',
totalSum: d.totalSum != null ? String(d.totalSum) : '',
})
}, [detail.data])
const sumByLines = useMemo(() => lines.reduce((s, l) => s + (l.quantity * l.price || 0), 0), [lines])
const unmatched = useMemo(() => lines.filter(l => !l.productId).length, [lines])
const totalMismatch = useMemo(() => {
const t = parseFloat(header.totalSum || '0')
return t > 0 && Math.abs(sumByLines - t) > 1
}, [sumByLines, header.totalSum])
const save = useMutation({
mutationFn: async () => {
await api.put(`/api/purchases/invoices/${id}/lines`, {
lines, supplierName: header.supplierName, invoiceNumber: header.invoiceNumber,
invoiceDate: header.invoiceDate ? new Date(header.invoiceDate).toISOString() : null,
totalSum: header.totalSum ? parseFloat(header.totalSum) : null,
})
},
onSuccess: () => { setSavedMsg('Сохранено'); setTimeout(() => setSavedMsg(null), 2000) },
onError: (e) => setSavedMsg(e instanceof Error ? e.message : 'Ошибка'),
})
// scan в общий input: ищем товар, матчим в первую НЕсопоставленную подходящую строку
const scanToAny = async (code: string) => {
const clean = code.replace(/\D/g, '')
if (clean.length < 6) return
try {
const { data } = await api.get<{ productId: string; name: string; barcode: string }>(
`/api/purchases/invoices/product-by-barcode?barcode=${clean}`)
// Применяем в первую несопоставленную строку, иначе создаём новую.
setLines(prev => {
const idx = prev.findIndex(l => !l.productId)
if (idx < 0) {
return [...prev, { name: data.name, barcode: clean, quantity: 1, price: 0,
matchedBy: 'manual', matchScore: 1, productId: data.productId, productName: data.name } as InvoiceLine]
}
const next = [...prev]
next[idx] = { ...next[idx], productId: data.productId, productName: data.name,
barcode: next[idx].barcode || clean, matchedBy: 'manual' }
return next
})
} catch { /* не нашли — ignored */ }
setBarcodeInput('')
}
if (!detail.data) return <div className="p-4 text-slate-500">Загрузка…</div>
const d = detail.data
return (
<div className="max-w-5xl mx-auto p-3 pb-28 space-y-3" data-testid="invoice-review">
<div className="flex items-center gap-2">
<button onClick={() => nav(-1)} className="p-2 rounded hover:bg-slate-100"><ArrowLeft className="w-5 h-5" /></button>
<h1 className="text-xl font-semibold flex-1 truncate">{d.supplierName || d.fileName}</h1>
<span className="text-xs text-slate-500">{d.status}</span>
</div>
{d.supplyId && (
<div className="rounded-md border border-emerald-200 bg-emerald-50 text-emerald-800 px-3 py-2 text-sm">
Приёмка уже создана. <a href={`/purchases/supplies/${d.supplyId}`} className="underline">Открыть</a>
</div>
)}
{d.status === 'failed' && d.error && (
<div className="rounded-md border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-sm">
Ошибка распознавания: {d.error}
</div>
)}
{/* шапка */}
<div className="bg-white rounded-lg border border-slate-200 p-3 space-y-2">
<div className="grid grid-cols-2 gap-2">
<input className="rounded border border-slate-300 px-2 py-1.5 text-sm col-span-2"
placeholder="Поставщик" value={header.supplierName}
onChange={e => setHeader({ ...header, supplierName: e.target.value })} />
<input className="rounded border border-slate-300 px-2 py-1.5 text-sm"
placeholder="№" value={header.invoiceNumber}
onChange={e => setHeader({ ...header, invoiceNumber: e.target.value })} />
<input className="rounded border border-slate-300 px-2 py-1.5 text-sm" type="date"
value={header.invoiceDate}
onChange={e => setHeader({ ...header, invoiceDate: e.target.value })} />
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500">Итого в накладной</span>
<input className="w-28 text-right tabular-nums rounded border border-slate-300 px-2 py-1 text-sm"
inputMode="decimal" placeholder="—" value={header.totalSum}
onChange={e => setHeader({ ...header, totalSum: e.target.value })} />
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500">Сумма по строкам</span>
<span className="tabular-nums font-semibold">{sumByLines.toLocaleString('ru-RU', { maximumFractionDigits: 2 })}</span>
</div>
{totalMismatch && (
<div className="flex items-start gap-2 text-amber-800 bg-amber-50 border border-amber-200 rounded px-2 py-1.5 text-xs">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
Сумма строк отличается от «Итого» больше чем на 1. Проверьте строки.
</div>
)}
</div>
{/* сканер в общее поле */}
<div className="bg-white rounded-lg border border-slate-200 p-3">
<label className="block">
<span className="text-xs text-slate-500 flex items-center gap-1 mb-1">
<BarcodeIcon className="w-3.5 h-3.5" /> Скан штрихкода → в первую несопоставленную строку
</span>
<input
inputMode="numeric"
value={barcodeInput}
onChange={e => setBarcodeInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); scanToAny((e.target as HTMLInputElement).value) } }}
placeholder="Отсканируйте или введите штрихкод"
className="w-full rounded border border-slate-300 px-2 py-2 text-base tabular-nums"
data-testid="invoice-scan-input"
/>
</label>
</div>
{unmatched > 0 && (
<div className="rounded-md border border-amber-200 bg-amber-50 text-amber-800 px-3 py-2 text-sm">
Не сопоставлено строк: <strong>{unmatched}</strong> из {lines.length}. Карточки подсвечены оранжевым.
</div>
)}
{/* строки */}
<div className="space-y-2">
{lines.map((l, i) => (
<InvoiceLineCard
key={i} line={l}
onChange={(patched) => setLines(prev => prev.map((x, j) => j === i ? patched : x))}
onRemove={() => setLines(prev => prev.filter((_, j) => j !== i))}
onSearch={(q) => setSearchHit({ row: i, q })}
/>
))}
</div>
{/* bottom */}
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 p-3">
<div className="max-w-5xl mx-auto flex items-center gap-2">
<button onClick={() => save.mutate()} disabled={save.isPending}
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-white border border-slate-300 py-3 font-medium hover:bg-slate-50 disabled:opacity-50">
<Save className="w-4 h-4" /> {savedMsg ?? 'Сохранить правки'}
</button>
<button onClick={() => setApplyOpen(true)} disabled={d.status === 'applied' || lines.filter(l => l.productId).length === 0}
className="flex-[1.2] inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 text-white py-3 font-medium hover:bg-sky-700 disabled:opacity-50">
<Check className="w-4 h-4" /> Создать приёмку
</button>
</div>
</div>
{searchHit && (
<ProductSearchDialog q={searchHit.q} onClose={() => setSearchHit(null)}
onPick={(h) => {
setLines(prev => prev.map((x, j) => j === searchHit.row
? { ...x, productId: h.id, productName: h.name, matchedBy: 'manual' } : x))
setSearchHit(null)
}} />
)}
{applyOpen && (
<ApplyDialog invoiceId={id!} supplierName={header.supplierName}
onClose={() => setApplyOpen(false)}
onDone={(supplyId) => { setApplyOpen(false); nav(`/purchases/supplies/${supplyId}`) }} />
)}
</div>
)
}
/// Карточка строки. Оранжевый бордер если не сопоставлена.
function InvoiceLineCard({ line, onChange, onRemove, onSearch }: {
line: InvoiceLine
onChange: (l: InvoiceLine) => void
onRemove: () => void
onSearch: (q: string) => void
}) {
const bad = !line.productId
const sum = line.quantity * line.price
return (
<div className={`bg-white rounded-lg border p-3 ${bad ? 'border-amber-300' : 'border-slate-200'}`}
data-testid={bad ? 'invoice-line-unmatched' : 'invoice-line-ok'}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="text-sm text-slate-500 truncate">
{line.supplierCode && <span className="font-mono bg-slate-100 px-1 rounded">{line.supplierCode}</span>}
{line.barcode && <span className="ml-1 font-mono text-xs">{line.barcode}</span>}
</div>
<div className="font-medium text-slate-900 truncate">{line.name}</div>
{line.productId
? <div className="text-xs text-emerald-700 truncate">→ {line.productName}
<span className="ml-1 text-slate-400">({line.matchedBy})</span>
</div>
: <div className="text-xs text-amber-700">Товар не сопоставлен — найдите или создайте</div>}
</div>
<button onClick={onRemove} className="p-1 text-slate-400 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
</div>
<div className="mt-2 grid grid-cols-3 gap-2 text-sm">
<label>
<div className="text-xs text-slate-500">Кол-во</div>
<input type="number" inputMode="decimal" value={line.quantity}
onChange={e => onChange({ ...line, quantity: parseFloat(e.target.value) || 0 })}
className="w-full rounded border border-slate-300 px-2 py-1.5 tabular-nums text-right" />
</label>
<label>
<div className="text-xs text-slate-500">Цена</div>
<input type="number" inputMode="decimal" value={line.price}
onChange={e => onChange({ ...line, price: parseFloat(e.target.value) || 0 })}
className="w-full rounded border border-slate-300 px-2 py-1.5 tabular-nums text-right" />
</label>
<label>
<div className="text-xs text-slate-500">Сумма</div>
<div className="w-full rounded border border-slate-100 bg-slate-50 px-2 py-1.5 tabular-nums text-right">
{sum.toLocaleString('ru-RU', { maximumFractionDigits: 2 })}
</div>
</label>
</div>
<div className="mt-2 flex gap-2">
<button onClick={() => onSearch(line.name)}
className="flex-1 inline-flex items-center justify-center gap-1 rounded border border-slate-300 px-2 py-1.5 text-xs hover:bg-slate-50">
<Search className="w-3.5 h-3.5" /> Найти товар
</button>
</div>
</div>
)
}
/// Диалог поиска товара (debounced). При выборе — колбэк наверх.
function ProductSearchDialog({ q, onClose, onPick }: {
q: string; onClose: () => void; onPick: (h: ProductSearchHit) => void
}) {
const [query, setQuery] = useState(q)
const search = useQuery({
queryKey: ['product-search', query],
queryFn: async () => {
if (query.trim().length < 2) return []
// Используем общий products endpoint с q=
const { data } = await api.get<{ items: ProductSearchHit[] }>(`/api/catalog/products?q=${encodeURIComponent(query)}&pageSize=20`)
return data.items ?? []
},
enabled: query.trim().length >= 2,
})
return (
<div className="fixed inset-0 z-40 bg-black/40 flex items-end sm:items-center justify-center p-2">
<div className="bg-white rounded-xl w-full max-w-md max-h-[80vh] overflow-hidden flex flex-col">
<div className="p-3 border-b border-slate-200 flex items-center gap-2">
<input autoFocus value={query} onChange={e => setQuery(e.target.value)}
placeholder="Название товара…"
className="flex-1 rounded border border-slate-300 px-2 py-2 text-sm" />
<button onClick={onClose} className="px-2 py-1 text-sm text-slate-500 hover:text-slate-800">Отмена</button>
</div>
<div className="flex-1 overflow-y-auto">
{search.data?.map(h => (
<button key={h.id} onClick={() => onPick(h)}
className="w-full text-left px-3 py-2 border-b border-slate-100 hover:bg-sky-50">
<div className="text-sm font-medium">{h.name}</div>
{h.article && <div className="text-xs text-slate-500">{h.article}</div>}
</button>
))}
{search.data && search.data.length === 0 && query.length >= 2 && (
<div className="p-4 text-sm text-slate-500 text-center">
Товар не найден.
<br />
<a href={`/products/new?name=${encodeURIComponent(query)}`} className="text-sky-600 underline">
Создать товар «{query}»
</a>
</div>
)}
</div>
</div>
</div>
)
}
/// Диалог «Создать приёмку»: выбор поставщика (поиск по БИН/имени),
/// склад, валюта. Создаём Draft, редиректим в SupplyEdit.
function ApplyDialog({ invoiceId, supplierName, onClose, onDone }: {
invoiceId: string; supplierName: string
onClose: () => void; onDone: (supplyId: string) => void
}) {
const [cp, setCp] = useState<CounterpartyHit | null>(null)
const [cpQ, setCpQ] = useState(supplierName)
const [storeId, setStoreId] = useState('')
const [currencyId, setCurrencyId] = useState('')
const [err, setErr] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const cpSearch = useQuery({
queryKey: ['cp-search', cpQ],
queryFn: async () => {
if (!cpQ.trim()) return []
const { data } = await api.get<CounterpartyHit[]>(
`/api/purchases/invoices/counterparty-search?q=${encodeURIComponent(cpQ)}`)
return data
},
enabled: !!cpQ.trim(),
})
const stores = useQuery({
queryKey: ['stores-list'],
queryFn: async () => {
const { data } = await api.get<{ items: Store[] }>('/api/catalog/stores?pageSize=100')
return data.items ?? []
},
})
const currencies = useQuery({
queryKey: ['currencies-list'],
queryFn: async () => {
const { data } = await api.get<Currency[]>('/api/catalog/currencies?pageSize=20')
return Array.isArray(data) ? data : []
},
})
useEffect(() => {
if (!storeId && stores.data?.length) setStoreId(stores.data.find(s => s.isMain)?.id ?? stores.data[0].id)
}, [stores.data, storeId])
useEffect(() => {
if (!currencyId && currencies.data?.length) {
const kzt = currencies.data.find(c => c.code === 'KZT')
setCurrencyId(kzt?.id ?? currencies.data[0].id)
}
}, [currencies.data, currencyId])
const create = async () => {
if (!cp) { setErr('Выберите поставщика'); return }
if (!storeId || !currencyId) { setErr('Выберите склад и валюту'); return }
setBusy(true); setErr(null)
try {
const { data } = await api.post<{ supplyId: string }>(`/api/purchases/invoices/${invoiceId}/apply`, {
supplierId: cp.id, storeId, currencyId,
})
onDone(data.supplyId)
} catch (e: unknown) {
setErr(e instanceof Error ? e.message : 'Не удалось создать')
} finally {
setBusy(false)
}
}
return (
<div className="fixed inset-0 z-40 bg-black/40 flex items-end sm:items-center justify-center p-2">
<div className="bg-white rounded-xl w-full max-w-md p-4 space-y-3">
<h3 className="font-semibold">Создать приёмку</h3>
<div>
<label className="text-xs text-slate-500">Поставщик</label>
{cp ? (
<div className="flex items-center gap-2 rounded border border-emerald-200 bg-emerald-50 px-2 py-1.5">
<span className="flex-1 text-sm">{cp.name}{cp.bin && <span className="text-xs text-slate-500 ml-2">БИН {cp.bin}</span>}</span>
<button onClick={() => setCp(null)} className="text-xs text-slate-500 underline">сменить</button>
</div>
) : (
<>
<input autoFocus value={cpQ} onChange={e => setCpQ(e.target.value)}
placeholder="Начните вводить название или БИН…"
className="w-full rounded border border-slate-300 px-2 py-1.5 text-sm" />
{cpSearch.data?.map(h => (
<button key={h.id} onClick={() => setCp(h)}
className="w-full text-left px-2 py-1.5 border-t border-slate-100 hover:bg-sky-50 text-sm">
{h.name}{h.bin && <span className="text-xs text-slate-500 ml-2">БИН {h.bin}</span>}
</button>
))}
{cpSearch.data && cpSearch.data.length === 0 && cpQ.length > 0 && (
<div className="pt-1 text-xs text-slate-500">
Не найден.{' '}
<a href={`/counterparties/new?name=${encodeURIComponent(cpQ)}&type=supplier`}
className="text-sky-600 underline">Создать «{cpQ}»</a>
</div>
)}
</>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<label className="block">
<span className="text-xs text-slate-500">Склад</span>
<select value={storeId} onChange={e => setStoreId(e.target.value)}
className="w-full rounded border border-slate-300 px-2 py-1.5 text-sm">
{stores.data?.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</label>
<label className="block">
<span className="text-xs text-slate-500">Валюта</span>
<select value={currencyId} onChange={e => setCurrencyId(e.target.value)}
className="w-full rounded border border-slate-300 px-2 py-1.5 text-sm">
{currencies.data?.map(c => <option key={c.id} value={c.id}>{c.code}</option>)}
</select>
</label>
</div>
{err && <div className="text-xs text-red-600">{err}</div>}
<div className="flex gap-2 pt-2">
<button onClick={onClose} className="flex-1 rounded border border-slate-300 py-2 text-sm">Отмена</button>
<button onClick={create} disabled={busy || !cp}
className="flex-[1.2] rounded bg-sky-600 text-white py-2 text-sm disabled:opacity-50">
{busy ? 'Создаём…' : 'Создать черновик'}
</button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,173 @@
import { useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Upload, Camera, FileText, Loader2, AlertTriangle, Check, Clock } from 'lucide-react'
import { api } from '@/lib/api'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
/// Жизненный цикл: pending → parsing → needs_review → applied | failed.
/// Статус-бейдж + подсказка внизу карточки.
interface InvoiceListRow {
id: string
fileName: string
status: string
uploadedAt: string
supplierName: string | null
invoiceNumber: string | null
invoiceDate: string | null
totalSum: number | null
linesTotal: number
linesMatched: number
supplyId: string | null
error: string | null
}
const STATUS: Record<string, { label: string; cls: string; icon: typeof Clock }> = {
pending: { label: 'В очереди', cls: 'bg-slate-100 text-slate-700', icon: Clock },
parsing: { label: 'Распознаётся', cls: 'bg-sky-100 text-sky-700', icon: Loader2 },
needs_review: { label: 'На проверке', cls: 'bg-amber-100 text-amber-800', icon: FileText },
applied: { label: 'Приёмка создана', cls: 'bg-emerald-100 text-emerald-800', icon: Check },
failed: { label: 'Ошибка', cls: 'bg-red-100 text-red-700', icon: AlertTriangle },
}
export function InvoicesPage() {
const nav = useNavigate()
const qc = useQueryClient()
const fileRef = useRef<HTMLInputElement>(null)
const cameraRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const [err, setErr] = useState<string | null>(null)
// Polling каждые 3 с, пока есть pending/parsing — чтобы юзер видел прогресс
// без ручного F5. Останавливается, когда все статусы терминальные.
const list = useQuery({
queryKey: ['invoices-list'],
queryFn: () => api.get<InvoiceListRow[]>('/api/purchases/invoices?take=50').then(r => r.data),
refetchInterval: (q) => {
const rows = q.state.data as InvoiceListRow[] | undefined
const active = rows?.some(r => r.status === 'pending' || r.status === 'parsing')
return active ? 3000 : false
},
})
const upload = useMutation({
mutationFn: async (files: FileList) => {
const fd = new FormData()
for (const f of Array.from(files)) fd.append('files', f)
await api.post('/api/purchases/invoices/upload', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
onSuccess: () => { qc.invalidateQueries({ queryKey: ['invoices-list'] }); setErr(null) },
onError: (e: unknown) => setErr(e instanceof Error ? e.message : 'Не удалось загрузить'),
})
const onFiles = async (f: FileList | null) => {
if (!f || f.length === 0) return
setUploading(true)
try { await upload.mutateAsync(f) }
finally { setUploading(false) }
}
return (
<div className="max-w-5xl mx-auto p-4 space-y-4 pb-24">
<div>
<h1 className="text-2xl font-semibold text-slate-900">Накладные</h1>
<p className="text-sm text-slate-500 mt-1">
Загрузите фото или PDF — система распознает строки и создаст приёмку.
</p>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-[auto_auto_1fr]">
<button
onClick={() => cameraRef.current?.click()}
disabled={uploading}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 hover:bg-sky-700 text-white font-medium py-3 px-4 disabled:opacity-60"
data-testid="invoice-upload-camera"
>
<Camera className="w-5 h-5" /> Снять камерой
</button>
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-white border border-slate-300 hover:bg-slate-50 font-medium py-3 px-4 disabled:opacity-60"
data-testid="invoice-upload-file"
>
<Upload className="w-5 h-5" /> Загрузить файл
</button>
<input ref={cameraRef} type="file" accept="image/*" capture="environment" multiple
className="hidden" onChange={(e) => onFiles(e.target.files)} />
<input ref={fileRef} type="file" accept="image/*,application/pdf" multiple
className="hidden" onChange={(e) => onFiles(e.target.files)} />
</div>
{err && (
<div className="rounded-md border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-sm">{err}</div>
)}
{uploading && (
<div className="rounded-md border border-sky-200 bg-sky-50 text-sky-700 px-3 py-2 text-sm flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Загрузка…
</div>
)}
<div className="space-y-2">
{list.isLoading && <div className="text-sm text-slate-500">Загрузка…</div>}
{list.data?.length === 0 && !list.isLoading && (
<div className="text-sm text-slate-500 text-center py-16">
Накладных пока нет. Загрузите первое фото — результат появится здесь.
</div>
)}
{list.data?.map(r => {
const S = STATUS[r.status] ?? STATUS.pending
const Icon = S.icon
const clickable = r.status === 'needs_review' || r.status === 'failed' || r.status === 'applied'
return (
<div
key={r.id}
onClick={() => clickable && nav(`/purchases/invoices/${r.id}`)}
className={`bg-white border border-slate-200 rounded-lg p-3 ${clickable ? 'cursor-pointer hover:border-sky-300' : ''}`}
data-testid={`invoice-row-${r.id}`}
>
<div className="flex items-center gap-3">
<span className={`inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded ${S.cls}`}>
<Icon className={`w-3.5 h-3.5 ${r.status === 'parsing' ? 'animate-spin' : ''}`} /> {S.label}
</span>
<span className="text-xs text-slate-400">{new Date(r.uploadedAt).toLocaleString('ru')}</span>
</div>
<div className="mt-2 flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="font-medium text-slate-900 truncate">
{r.supplierName || r.fileName}
</div>
<div className="text-sm text-slate-500 truncate">
{r.invoiceNumber ? `№ ${r.invoiceNumber}` : ''}
{r.invoiceDate ? ` от ${new Date(r.invoiceDate).toLocaleDateString('ru')}` : ''}
{r.supplierName && r.fileName ? ` · ${r.fileName}` : ''}
</div>
{r.linesTotal > 0 && (
<div className="text-xs text-slate-500 mt-1">
{r.linesTotal} строк · сопоставлено {r.linesMatched}
{r.linesMatched < r.linesTotal && <span className="text-amber-600"> · требует внимания</span>}
</div>
)}
{r.error && <div className="text-xs text-red-600 mt-1 line-clamp-2">{r.error}</div>}
</div>
<div className="text-right whitespace-nowrap">
{r.totalSum != null && (
<div className="text-sm font-semibold tabular-nums">
{r.totalSum.toLocaleString('ru-RU', { maximumFractionDigits: 2 })}
</div>
)}
{r.supplyId && (
<Link to={`/purchases/supplies/${r.supplyId}`} className="text-xs text-sky-600 hover:underline">
Открыть приёмку
</Link>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
)
}

View file

@ -0,0 +1,289 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Camera, Keyboard, Trash2, Check, Loader2, ArrowLeft } from 'lucide-react'
import { api } from '@/lib/api'
import CameraScanner, { type ScanFeedback } from '@/components/CameraScanner'
interface Row {
productId: string
name: string
qty: number
price: number
barcode: string
}
interface ByBarcodeResp {
productId: string
name: string
article: string | null
barcode: string
lastPurchasePrice: number | null
}
interface Store { id: string; name: string; isMain: boolean }
interface Currency { id: string; code: string }
interface CounterpartyHit { id: string; name: string; bin: string | null }
const LS_ROWS = 'fm.supply-scan.rows'
/// Scan-mode для приёмки. Непрерывная камера или аппаратный сканер в
/// фокусе: каждый скан +1 к существующей строке, иначе новая. Цена
/// подставляется last-purchase-price. Неизвестный штрихкод отправляет
/// на /products/new, передавая штрихкод через query. Сохранение
/// — создание Supply в Draft через существующий POST /api/purchases/
/// supplies.
export function SupplyScanPage() {
const nav = useNavigate()
const [rows, setRows] = useState<Row[]>(() => {
try { return JSON.parse(localStorage.getItem(LS_ROWS) || '[]') } catch { return [] }
})
const [cameraOn, setCameraOn] = useState(false)
const [fb, setFb] = useState<ScanFeedback | null>(null)
const seq = useRef(0)
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
// supplier/store/currency — тот же выбор, что в apply-диалоге накладной
const [storeId, setStoreId] = useState('')
const [currencyId, setCurrencyId] = useState('')
const [cp, setCp] = useState<CounterpartyHit | null>(null)
const [cpQ, setCpQ] = useState('')
const [cpResults, setCpResults] = useState<CounterpartyHit[]>([])
const [stores, setStores] = useState<Store[]>([])
const [currencies, setCurrencies] = useState<Currency[]>([])
const [saving, setSaving] = useState(false)
const [err, setErr] = useState<string | null>(null)
useEffect(() => {
try { localStorage.setItem(LS_ROWS, JSON.stringify(rows)) } catch { /* storage full */ }
}, [rows])
// загрузка справочников
useEffect(() => {
api.get<{ items: Store[] }>('/api/catalog/stores?pageSize=100').then(r => {
setStores(r.data.items ?? [])
const main = r.data.items?.find(s => s.isMain) ?? r.data.items?.[0]
if (main) setStoreId(main.id)
})
api.get<Currency[]>('/api/catalog/currencies?pageSize=20').then(r => {
const list = Array.isArray(r.data) ? r.data : []
setCurrencies(list)
const kzt = list.find(c => c.code === 'KZT') ?? list[0]
if (kzt) setCurrencyId(kzt.id)
})
}, [])
useEffect(() => {
if (!cpQ.trim()) { setCpResults([]); return }
const t = setTimeout(async () => {
try {
const { data } = await api.get<CounterpartyHit[]>(
`/api/purchases/invoices/counterparty-search?q=${encodeURIComponent(cpQ)}`)
setCpResults(data)
} catch { /* ignored */ }
}, 200)
return () => clearTimeout(t)
}, [cpQ])
const beep = (ok: boolean) => { try { navigator.vibrate?.(ok ? 40 : [60, 50, 60]) } catch { /* ignored */ } }
const addScan = async (raw: string) => {
const code = raw.replace(/\D/g, '')
if (code.length < 6) return
try {
const { data } = await api.get<ByBarcodeResp>(`/api/purchases/invoices/product-by-barcode?barcode=${code}`)
let qty = 1
setRows(prev => {
const i = prev.findIndex(r => r.productId === data.productId)
if (i < 0) return [{
productId: data.productId, name: data.name,
qty: 1, price: data.lastPurchasePrice ?? 0, barcode: code,
}, ...prev]
const next = [...prev]
qty = next[i].qty + 1
next[i] = { ...next[i], qty }
// Переместить вверх, чтобы видно было что отсканировали
return [next[i], ...next.filter((_, j) => j !== i)]
})
setMsg({ text: `${data.name} · ${qty} шт`, ok: true })
setFb({ kind: 'ok', title: data.name, sub: `теперь ${qty} шт`, seq: ++seq.current })
beep(true)
} catch {
// Неизвестный штрихкод — предлагаем создать товар
setMsg({ text: `Штрихкод ${code} не найден — создайте товар`, ok: false })
setFb({ kind: 'err', title: 'Нет в каталоге', sub: code, seq: ++seq.current })
beep(false)
}
}
const total = useMemo(() => rows.reduce((s, r) => s + r.qty * r.price, 0), [rows])
const save = async () => {
if (!cp) { setErr('Выберите поставщика'); return }
if (!storeId || !currencyId) { setErr('Выберите склад и валюту'); return }
if (rows.length === 0) { setErr('Нет позиций'); return }
setSaving(true); setErr(null)
try {
const { data } = await api.post<{ id: string }>('/api/purchases/supplies', {
supplierId: cp.id,
storeId, currencyId,
date: new Date().toISOString(),
notes: 'Приёмка сканированием',
lines: rows.map((r, i) => ({
productId: r.productId,
quantity: r.qty,
unitPrice: r.price,
sortOrder: i,
})),
})
try { localStorage.removeItem(LS_ROWS) } catch { /* ignored */ }
setRows([])
nav(`/purchases/supplies/${data.id}`)
} catch (e: unknown) {
setErr(e instanceof Error ? e.message : 'Не удалось создать приёмку')
} finally { setSaving(false) }
}
return (
<div className="max-w-3xl mx-auto p-3 pb-28 space-y-3">
<div className="flex items-center gap-2">
<button onClick={() => nav(-1)} className="p-2 rounded hover:bg-slate-100"><ArrowLeft className="w-5 h-5" /></button>
<h1 className="text-xl font-semibold flex-1">Приёмка сканированием</h1>
</div>
{/* supplier/store */}
<div className="bg-white rounded-lg border border-slate-200 p-3 space-y-2">
<div>
<label className="text-xs text-slate-500">Поставщик</label>
{cp ? (
<div className="flex items-center gap-2 rounded border border-emerald-200 bg-emerald-50 px-2 py-1.5">
<span className="flex-1 text-sm">{cp.name}</span>
<button onClick={() => setCp(null)} className="text-xs text-slate-500 underline">сменить</button>
</div>
) : (
<>
<input value={cpQ} onChange={e => setCpQ(e.target.value)}
placeholder="Начните вводить название или БИН…"
className="w-full rounded border border-slate-300 px-2 py-1.5 text-sm" />
{cpResults.map(h => (
<button key={h.id} onClick={() => { setCp(h); setCpQ(''); setCpResults([]) }}
className="w-full text-left px-2 py-1.5 border-t border-slate-100 hover:bg-sky-50 text-sm">
{h.name}{h.bin && <span className="text-xs text-slate-500 ml-2">БИН {h.bin}</span>}
</button>
))}
</>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<label>
<div className="text-xs text-slate-500">Склад</div>
<select value={storeId} onChange={e => setStoreId(e.target.value)}
className="w-full rounded border border-slate-300 px-2 py-1.5 text-sm">
{stores.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</label>
<label>
<div className="text-xs text-slate-500">Валюта</div>
<select value={currencyId} onChange={e => setCurrencyId(e.target.value)}
className="w-full rounded border border-slate-300 px-2 py-1.5 text-sm">
{currencies.map(c => <option key={c.id} value={c.id}>{c.code}</option>)}
</select>
</label>
</div>
</div>
{/* scan controls */}
<div className="bg-white rounded-lg border border-slate-200 p-3 space-y-2">
<button onClick={() => { setFb(null); setCameraOn(true) }}
className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 text-white py-4 text-lg font-medium hover:bg-sky-700">
<Camera className="w-6 h-6" /> Сканировать камерой
</button>
<details className="text-sm">
<summary className="text-slate-500 cursor-pointer select-none flex items-center gap-1">
<Keyboard className="w-4 h-4" /> Или аппаратный сканер — ввод штрихкода
</summary>
<input ref={inputRef} inputMode="numeric"
onKeyDown={e => {
if (e.key !== 'Enter') return
e.preventDefault()
const el = e.target as HTMLInputElement
addScan(el.value); el.value = ''
}}
placeholder="Отсканируйте или введите штрихкод"
className="mt-2 w-full rounded-lg border-2 border-slate-300 px-4 py-3 text-lg tabular-nums outline-none focus:border-sky-500"
data-testid="supply-scan-input" />
</details>
{msg && (
<div className={`rounded-md px-3 py-2 text-sm ${msg.ok
? 'bg-emerald-50 text-emerald-800' : 'bg-red-50 text-red-700'}`}>
{msg.text}
</div>
)}
</div>
{/* rows */}
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<div className="px-3 py-2 bg-slate-50 text-xs text-slate-600 flex justify-between">
<span>Позиций: {rows.length}</span>
<span>Сумма: {total.toLocaleString('ru-RU', { maximumFractionDigits: 2 })}</span>
</div>
{rows.length === 0 && (
<div className="px-4 py-10 text-center text-slate-400 text-sm">Ничего ещё не отсканировано</div>
)}
{rows.map(r => (
<div key={r.productId} className="px-3 py-2.5 border-t border-slate-100">
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<div className="text-sm truncate">{r.name}</div>
<div className="text-xs text-slate-400 font-mono">{r.barcode}</div>
</div>
<button onClick={() => setRows(p => p.filter(x => x.productId !== r.productId))}
className="p-1 text-slate-300 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
</div>
<div className="mt-1.5 grid grid-cols-3 gap-2 text-sm">
<label>
<div className="text-xs text-slate-500">Кол-во</div>
<input type="number" inputMode="decimal" value={r.qty} min={0}
onChange={e => setRows(p => p.map(x => x.productId === r.productId
? { ...x, qty: parseFloat(e.target.value) || 0 } : x))}
className="w-full rounded border border-slate-300 px-2 py-1 tabular-nums text-right" />
</label>
<label>
<div className="text-xs text-slate-500">Цена</div>
<input type="number" inputMode="decimal" value={r.price}
onChange={e => setRows(p => p.map(x => x.productId === r.productId
? { ...x, price: parseFloat(e.target.value) || 0 } : x))}
className="w-full rounded border border-slate-300 px-2 py-1 tabular-nums text-right" />
</label>
<div>
<div className="text-xs text-slate-500">Сумма</div>
<div className="w-full rounded border border-slate-100 bg-slate-50 px-2 py-1 tabular-nums text-right">
{(r.qty * r.price).toLocaleString('ru-RU', { maximumFractionDigits: 2 })}
</div>
</div>
</div>
</div>
))}
</div>
{err && <div className="rounded-md border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-sm">{err}</div>}
{cameraOn && (
<CameraScanner onClose={() => setCameraOn(false)} onCode={addScan} feedback={fb}
counter={`позиций ${rows.length}`} />
)}
{rows.length > 0 && (
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 p-3">
<div className="max-w-3xl mx-auto">
<button onClick={save} disabled={saving || !cp || !storeId}
className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 text-white py-3 font-medium hover:bg-sky-700 disabled:opacity-50">
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
{saving ? 'Создаём…' : `Создать черновик (${rows.length} поз.)`}
</button>
</div>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,107 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.Api.Infrastructure.Invoices;
using foodmarket.Domain.Purchases;
using foodmarket.Infrastructure.Persistence;
using foodmarket.IntegrationTests.Support;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>Sprint 31 — приёмка по фото. Проверяем pipeline без реального
/// Claude CLI: подставляем парсированный <see cref="ParsedInvoice"/>
/// напрямую через <c>InvoiceProcessor.BuildMatcherAsync</c>, т.е. тестим
/// matcher-on-real-catalog + alias upsert + apply → Supply Draft.</summary>
[Collection(ApiCollection.Name)]
public class InvoicePipelineTests
{
private readonly ApiFactory _factory;
public InvoicePipelineTests(ApiFactory factory) => _factory = factory;
[Fact]
public async Task Matcher_hits_product_by_barcode_from_real_catalog()
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// Берём любую org из seed'ов (Demo Market или первую не-архивную)
var demoOrg = await db.Organizations.IgnoreQueryFilters()
.FirstAsync(o => !o.IsArchived && o.Name == "Demo Market");
// Нам нужен товар + штрихкод в этой org. Если нет — создадим.
var (productId, barcode) = await EnsureProductWithBarcodeAsync(db, demoOrg.Id);
var matcher = await InvoiceProcessor.BuildMatcherAsync(db, demoOrg.Id, "ТОО Поставщик", CancellationToken.None);
var line = new InvoiceLine { Barcode = barcode, Name = "Что угодно" };
matcher.Match(line, "тоо поставщик");
line.MatchedBy.Should().Be("barcode");
line.ProductId.Should().Be(productId);
}
[Fact]
public async Task Multi_tenant_isolation_matcher_does_not_see_other_orgs_barcodes()
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var orgA = await db.Organizations.IgnoreQueryFilters()
.FirstAsync(o => !o.IsArchived && o.Name == "Demo Market");
var orgB = await db.Organizations.IgnoreQueryFilters()
.Where(o => !o.IsArchived && o.Id != orgA.Id).FirstOrDefaultAsync();
if (orgB is null)
{
// создадим вторую org для теста изоляции
orgB = new foodmarket.Domain.Organizations.Organization
{
Name = $"InvOrgB-{Guid.NewGuid():N}", CountryCode = "KZ",
Bin = "999999999999",
};
db.Organizations.Add(orgB);
await db.SaveChangesAsync();
}
var (_, bcA) = await EnsureProductWithBarcodeAsync(db, orgA.Id);
// Matcher для orgB — не должен находить barcode из orgA.
var matcher = await InvoiceProcessor.BuildMatcherAsync(db, orgB.Id, null, CancellationToken.None);
var line = new InvoiceLine { Barcode = bcA, Name = "X" };
matcher.Match(line, "any");
line.MatchedBy.Should().Be("none");
}
private static async Task<(Guid productId, string barcode)> EnsureProductWithBarcodeAsync(
AppDbContext db, Guid orgId)
{
var existing = await (from pb in db.ProductBarcodes.IgnoreQueryFilters()
where pb.OrganizationId == orgId
select new { pb.ProductId, pb.Code }).FirstOrDefaultAsync();
if (existing is not null) return (existing.ProductId, existing.Code);
// Создаём минимальный товар + штрихкод (идемпотентный barcode).
var unit = await db.UnitsOfMeasure.IgnoreQueryFilters()
.FirstAsync(u => u.OrganizationId == null && u.Code == "796");
var group = await db.ProductGroups.IgnoreQueryFilters()
.FirstAsync(g => g.OrganizationId == orgId && g.IsSystem);
var p = new foodmarket.Domain.Catalog.Product
{
OrganizationId = orgId,
Name = $"ТестТовар-{Guid.NewGuid():N}",
UnitOfMeasureId = unit.Id,
ProductGroupId = group.Id,
Vat = 12m,
};
db.Products.Add(p);
var bc = $"4870{Random.Shared.Next(100000000, 999999999)}";
db.ProductBarcodes.Add(new foodmarket.Domain.Catalog.ProductBarcode
{
OrganizationId = orgId, ProductId = p.Id, Code = bc,
Type = foodmarket.Domain.Catalog.BarcodeType.Ean13, IsPrimary = true,
});
await db.SaveChangesAsync();
return (p.Id, bc);
}
}

View file

@ -0,0 +1,79 @@
using FluentAssertions;
using foodmarket.Api.Infrastructure.Invoices;
using Xunit;
namespace foodmarket.UnitTests;
/// <summary>Проверка парсинга ответа Claude CLI. CLI отдаёт конверт
/// <c>{ result: "…json…", usage: {...} }</c>; result иногда обёрнут в
/// <c>```json</c>. Нужно вытащить payload и привести к ParsedInvoice.</summary>
public class ClaudeCliInvoiceExtractorParseTests
{
[Fact]
public void Parse_plain_json_payload()
{
var json = """{"supplier":"ТОО Фуд","number":"Н-1","date":"2026-10-04","total":1500.00,"lines":[{"barcode":"4870123456789","supplier_code":"ABC","name":"Молоко 3.2%","qty":10,"unit":"шт","price":150.00,"sum":1500.00,"vat":12}]}""";
var inv = ClaudeCliInvoiceExtractor.Parse(json);
inv.SupplierName.Should().Be("ТОО Фуд");
inv.InvoiceNumber.Should().Be("Н-1");
inv.TotalSum.Should().Be(1500m);
inv.Lines.Should().HaveCount(1);
var l = inv.Lines[0];
l.Barcode.Should().Be("4870123456789");
l.SupplierCode.Should().Be("ABC");
l.Quantity.Should().Be(10m);
l.Price.Should().Be(150m);
}
[Fact]
public void Parse_envelope_with_result_string_and_markdown_fence()
{
// Payload wrapped в ```json ``` — Claude иногда так возвращает.
var fence = "`" + "`" + "`";
var payloadInside = fence + "json\n{\"supplier\":\"X\",\"lines\":[{\"name\":\"А\",\"qty\":1,\"price\":10}]}\n" + fence;
var envelope = System.Text.Json.JsonSerializer.Serialize(new
{
result = payloadInside,
usage = new { input_tokens = 123, output_tokens = 45 }
});
var inv = ClaudeCliInvoiceExtractor.Parse(envelope);
inv.SupplierName.Should().Be("X");
inv.Lines.Should().ContainSingle();
inv.InputTokens.Should().Be(123);
inv.OutputTokens.Should().Be(45);
}
[Fact]
public void Parse_barcode_with_spaces_is_stripped()
{
var json = """{"lines":[{"name":"X","qty":1,"price":1,"barcode":"4870 123 456 789"}]}""";
var inv = ClaudeCliInvoiceExtractor.Parse(json);
inv.Lines[0].Barcode.Should().Be("4870123456789");
}
[Fact]
public void Parse_null_fields_tolerated()
{
var json = """{"supplier":null,"number":null,"date":null,"total":null,"lines":[{"name":"X","qty":"2,5","price":"100.5"}]}""";
var inv = ClaudeCliInvoiceExtractor.Parse(json);
inv.SupplierName.Should().BeNull();
inv.TotalSum.Should().BeNull();
inv.Lines[0].Quantity.Should().Be(2.5m); // «2,5» → 2.5
inv.Lines[0].Price.Should().Be(100.5m);
}
[Fact]
public void Parse_empty_raises()
{
var act = () => ClaudeCliInvoiceExtractor.Parse("");
act.Should().Throw<InvoiceExtractionException>();
}
[Fact]
public void Parse_lines_without_name_are_skipped()
{
var json = """{"lines":[{"name":"A","qty":1,"price":1},{"name":"","qty":2,"price":2},{"name":null,"qty":3,"price":3}]}""";
var inv = ClaudeCliInvoiceExtractor.Parse(json);
inv.Lines.Should().ContainSingle(l => l.Name == "A");
}
}

View file

@ -0,0 +1,116 @@
using FluentAssertions;
using foodmarket.Api.Infrastructure.Invoices;
using Xunit;
namespace foodmarket.UnitTests;
/// <summary>Sprint 31: порядок сопоставления строки накладной с товаром.
/// (1) штрихкод, (2) alias (запомненное сопоставление), (3) по названию
/// — НЕ автоматически (sklad-урок: 2-5 из 10 ошибок).</summary>
public class InvoiceMatcherTests
{
private static InvoiceMatcher.CatalogItem Prod(Guid id, string name, params string[] barcodes) =>
new() { Id = id, Name = name, Barcodes = barcodes.ToList() };
[Fact]
public void Match_by_barcode_wins()
{
var p1 = Guid.NewGuid(); var p2 = Guid.NewGuid();
var matcher = new InvoiceMatcher(new[] {
Prod(p1, "Молоко 3.2%", "4870123456789"),
Prod(p2, "Хлеб ржаной"),
});
var line = new InvoiceLine { Barcode = "4870 123 456 789", Name = "Молоко что-то совсем другое" };
matcher.Match(line, "ТОО Поставщик");
line.MatchedBy.Should().Be("barcode");
line.ProductId.Should().Be(p1);
}
[Fact]
public void Short_or_missing_barcode_falls_through()
{
var p1 = Guid.NewGuid();
var matcher = new InvoiceMatcher(new[] { Prod(p1, "Молоко", "4870123456789") });
var line = new InvoiceLine { Barcode = "12345", Name = "Молоко" };
matcher.Match(line, "ТОО Поставщик");
line.MatchedBy.Should().Be("none");
}
[Fact]
public void Alias_by_supplier_code_matches_on_second_invoice()
{
var p1 = Guid.NewGuid();
var matcher = new InvoiceMatcher(new[] { Prod(p1, "Молоко 3.2%") });
matcher.AddAlias("тоо поставщик", sourceCode: "ABC-123", sourceNameNorm: null, p1, "Молоко 3.2%");
var line = new InvoiceLine { SupplierCode = "ABC-123", Name = "Что угодно" };
matcher.Match(line, "тоо поставщик");
line.MatchedBy.Should().Be("alias");
line.ProductId.Should().Be(p1);
}
[Fact]
public void Alias_by_normalized_name_matches()
{
var p1 = Guid.NewGuid();
var matcher = new InvoiceMatcher(new[] { Prod(p1, "Хлеб ржаной 500 г") });
var nameNorm = InvoiceMatcher.NormalizeName("Хлеб ржаной 500 г");
matcher.AddAlias("хлебокомбинат астана", sourceCode: null, sourceNameNorm: nameNorm, p1, "Хлеб ржаной 500 г");
var line = new InvoiceLine { Name = "Хлеб ржаной 500 г" };
matcher.Match(line, "хлебокомбинат астана");
line.MatchedBy.Should().Be("alias");
}
[Fact]
public void Alias_is_supplier_scoped()
{
var p1 = Guid.NewGuid();
var matcher = new InvoiceMatcher(new[] { Prod(p1, "Молоко") });
matcher.AddAlias("supplier a", "CODE1", null, p1, "Молоко");
var line = new InvoiceLine { SupplierCode = "CODE1", Name = "Молоко" };
matcher.Match(line, "supplier b"); // другой поставщик
line.MatchedBy.Should().Be("none");
}
[Fact]
public void Does_not_match_by_name_automatically()
{
var p1 = Guid.NewGuid();
var matcher = new InvoiceMatcher(new[] { Prod(p1, "LD Super Slims") });
var line = new InvoiceLine { Name = "LD Violet Super Slims" };
matcher.Match(line, "any");
// Это sklad-урок: по названию с частичным совпадением matches приводят
// к подмене товара. Политика — НЕ матчить, подсветить как «none».
line.MatchedBy.Should().Be("none");
}
[Theory]
[InlineData("Хлеб ржаной «Бородинский» 500 г", "ХЛЕБ РЖАНОЙ БОРОДИНСКИЙ 500Г")]
[InlineData("молоко 3,2%", "МОЛОКО 3,2%")]
[InlineData("Йогурт ё-мобиль", "ЙОГУРТ Е МОБИЛЬ")]
public void NormalizeName_normalizes_whitespace_quotes_unit_glue_and_yo(string input, string expected)
{
InvoiceMatcher.NormalizeName(input).Should().Be(expected);
}
[Theory]
[InlineData("4870 123 456 789", "4870123456789")]
[InlineData("abc-123", "123")]
[InlineData("", "")]
[InlineData(null, "")]
public void OnlyDigits_strips(string? input, string expected)
{
InvoiceMatcher.OnlyDigits(input).Should().Be(expected);
}
[Fact]
public void AliasKeys_emits_only_for_non_empty_fields()
{
InvoiceMatcher.AliasKeys("sup", "C1", "N1").Should().HaveCount(2);
InvoiceMatcher.AliasKeys("sup", "C1", null).Should().HaveCount(1);
InvoiceMatcher.AliasKeys("sup", null, null).Should().BeEmpty();
}
}