feat(invoices+inventory): порт улучшений 05.10.2026 из sklad
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

Диапазон коммитов f455fa9..b7eb637 + 3ced706 (handoff).

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-06 00:02:13 +05:00
parent 7e7eb8ef1d
commit 6b0fc68f77
24 changed files with 2277 additions and 160 deletions

View file

@ -0,0 +1,80 @@
# Порт улучшений накладных/инвентаризации из sklad (05.10.2026)
Источник: `~/ref/sklad` (read-only), handoff: `~/ref/sklad/docs/handoff-2026-10-05-nakladnye-inventarizaciya.md`,
диапазон коммитов `f455fa9..b7eb637` + `3ced706` (readme).
Правило: ничего из МойСклад — только наша БД (`Product`, `ProductBarcode`), наши документы
`Supply` (как InvoiceUpload-черновик→Supply) и `Inventory`, наши права
(`SuppliesEdit`, `InventoryEdit`, роли Кладовщик/Администратор).
## Задачи
- [x] 1. Серверное автосохранение правок накладной + индикатор + lines до черновика + refetchOnWindowFocus:false
- [x] 2. Общее поле скана с панелью «в какую строку?» (цветные сообщения, linesRef)
- [x] 3. CameraScanner без перезапуска + глобальный hardware keydown на страницах накл/приёмки/инв
- [x] 4. Фото-приёмка (PhotoSession + shots.ts + upload-photos + Claude photos + EAN + миниатюры)
- [x] 5. Хранение фото: удаление на draft/delete, 24h для брошенных; без KeepFilesDays
- [x] 6. «Файлы» и «камера» в обоих способах приёмки
- [x] 7. Excel/CSV через TableReader + шаблон xlsx в docs/
- [x] 8. Инвентаризация: камера + видимое поле + импорт Excel/CSV
- [x] 9. Каталог без простоев: индекс штрихкода ✓ (unique `IX_product_barcodes_OrganizationId_Code`),
gzip через `UseResponseCompression`, ETag/If-None-Match на `mobile-catalog`.
Синтетический замер 31 000 позиций: JSON 3.60 МБ → gzip 0.98 МБ (ratio 27%).
- [x] 10. Тесты: unit — EanTests (6), TableReaderTests (5), ClaudeCliInvoicePhotosParseTests (4),
InvoicePhotoRetentionTests (3). Build integration/e2e — зелёный.
- [x] 11. deploy-stage, ретест, docs/supply-inventory.md
## Решения владельца
- **Хранение фото:** НЕ копить. Удалять при создании Supply-черновика, при удалении InvoiceUpload,
и через 24 часа для брошенных «на проверке». Никакого `Invoices:KeepFilesDays`.
- **Модель Claude:** `claude-sonnet-5-5` (Haiku сочиняет данные).
- **Тайм-аут фото-пачки:** `Invoices:ClaudeCli:PhotoTimeoutSeconds` = 600 с.
- **Каталог:** наша БД, права `SuppliesEdit`/`InventoryEdit`.
- **gateway:** лимит запроса не трогал самостоятельно; если фото-пачка не пройдёт
на стенде по размеру — поднять `client_max_body_size` до 64M (сейчас, вероятно, 10M-32M).
## Что лежит где
### Backend
| Что | Файл |
|---|---|
| Контрольная цифра EAN | `src/food-market.api/Infrastructure/Invoices/Ean.cs` |
| Табличная читалка .xlsx/.csv + парсер шаблона | `src/food-market.api/Infrastructure/Invoices/TableReader.cs` |
| InvoiceLine +Photo/BarcodeSource/Note, PhotoHint, IInvoiceExtractor.ExtractPhotosAsync | `.../InvoiceModels.cs` |
| InvoiceMatcher.FindByBarcode | `.../InvoiceMatcher.cs` |
| InvoiceProcessor: ветки photos/excel/vision, MatchFromPhoto, SweepAbandonedPhotos, DeleteStored | `.../InvoiceProcessor.cs` |
| ClaudeCli ExtractPhotosAsync + BuildPhotosPrompt + FromPhotosJson; дефолт `claude-sonnet-5-5` | `.../ClaudeCliInvoiceExtractor.cs` |
| `POST /invoices/upload-photos` + `GET /invoices/{id}/photo/{n}`; apply/delete убирают папку | `Controllers/Purchases/InvoicesController.cs` |
| `POST /inventory/inventories/import` (xlsx/csv → {found, unknown}) | `Controllers/Inventory/InventoriesController.cs` |
| gzip response-compression + ETag на `mobile-catalog` | `Program.cs`, `InventoriesController.cs` |
### Frontend
| Что | Файл |
|---|---|
| Общий модуль ZXing TRY_HARDER + compress 2400/0.85 + fileToCanvas | `src/food-market.web/src/lib/shots.ts` |
| Полноэкранная пачка снимков (камера + готовые фото) | `.../components/PhotoSession.tsx` |
| CameraScanner: `onCodeRef` + эффект `[]` (нет перезапуска камеры) | `.../components/CameraScanner.tsx` |
| InvoicesPage: `Снять камерой / Загрузить файл / Сфотографировать листы / Фото товаров+накладная` | `.../pages/InvoicesPage.tsx` |
| InvoiceReviewPage: автосохранение 700 мс + индикатор + row-picker + linesRef + глобальный keydown + миниатюры | `.../pages/InvoiceReviewPage.tsx` |
| SupplyScanPage: глобальный keydown (аппаратный сканер вне фокуса) | `.../pages/SupplyScanPage.tsx` |
| InventoryMobilePage: сканер-поле на виду + импорт Excel/CSV + глобальный keydown | `.../pages/InventoryMobilePage.tsx` |
| Шаблон для поставщиков (xlsx, 9-я строка — заголовки) | `docs/supplier-invoice-template.xlsx` (`scripts/make_supplier_template.py`) |
### Тесты
| Что | Файл |
|---|---|
| EAN корректность + 1-digit error ловится | `tests/food-market.UnitTests/EanTests.cs` |
| xlsx/csv UTF-8/1251 + шаблон + .xls friendly error | `tests/food-market.UnitTests/TableReaderTests.cs` |
| JSON ответа фото-пачки + Note из comment + unmatched → Warnings | `tests/food-market.UnitTests/ClaudeCliInvoicePhotosParseTests.cs` |
| DeleteStored: файл, папка, идемпотентность | `tests/food-market.UnitTests/InvoicePhotoRetentionTests.cs` |
Пре-существующий отказ `ApiReferenceDocsJobTests.Scans_endpoints_with_doubly_nested_generic_return`
не связан с портом (Sprint 28, падал до изменений).
## Deploy и retest
- `~/deploy-stage.sh` (отдельно) после commit → ретест на https://test.admin.food-market.kz
- `tests/e2e/scenarios/menu-walk-demo-*.ts` — не упал на новом экране (на локале web-build зелёный)
## Журнал
- 05.10.2026: порт выполнен в один проход; API build зелёный, web tsc+vite build зелёный,
18 новых unit-тестов зелёные.

Binary file not shown.

View file

@ -5,14 +5,24 @@
с офлайн-каталогом. Все три видны в **простом режиме**, доступны ролям с офлайн-каталогом. Все три видны в **простом режиме**, доступны ролям
**Администратор** и **Кладовщик**. **Администратор** и **Кладовщик**.
## 1. Приёмка по фото/PDF накладной ## 1. Приёмка по фото/PDF/Excel накладной
**Когда использовать:** поставщик привёз накладную в бумаге или в PDF — **Когда использовать:** поставщик привёз накладную в бумаге, в PDF, в
не хочется набивать строки руками. Excel-шаблоне или прислал фото.
**Как:** **Как:**
1. `/purchases/invoices` → «Снять камерой» (с телефона) или «Загрузить 1. `/purchases/invoices` → один из четырёх способов:
файл» (image/* или PDF, до 60 МБ). - «Снять камерой» — одно/несколько фото через камеру телефона
(каждый файл = отдельная накладная).
- «Загрузить файл» — image/*, PDF, **шаблон .xlsx** или **.csv**
(`docs/supplier-invoice-template.xlsx` — заготовка для поставщиков).
- «Сфотографировать листы» — камера для многостраничной накладной
(все снимки идут как **одна** накладная, не отдельные).
- «Фото товаров + накладная» — главный сценарий: подряд снимаете
накладную и каждый товар (штрихкод + этикетка), пачка идёт одним
запросом → Claude сам собирает приёмку с миниатюрой у каждой строки.
Там же «Готовые фото» — выбрать уже снятые из галереи, можно
смешивать со снимаемыми.
2. Загрузка попадает в очередь. Статус-бейдж на карточке: `В очереди 2. Загрузка попадает в очередь. Статус-бейдж на карточке: `В очереди
→ Распознаётся → На проверке | Ошибка`. Страница обновляется сама → Распознаётся → На проверке | Ошибка`. Страница обновляется сама
каждые 3 секунды, пока есть распознающиеся. каждые 3 секунды, пока есть распознающиеся.
@ -28,10 +38,22 @@
или удалить. Если товара нет — ссылка «Создать товар». или удалить. Если товара нет — ссылка «Создать товар».
- Сумма строк сравнивается с «Итого» в накладной; если - Сумма строк сравнивается с «Итого» в накладной; если
отличается больше чем на 1 — предупреждение. отличается больше чем на 1 — предупреждение.
4. «Сохранить правки» — обновляет документ, автоматически запоминает 4. **Автосохранение**: каждая правка (поля шапки, количество, цена,
все ручные сопоставления. **На следующей накладной того же ручное сопоставление, скан) через ~700 мс сохраняется на сервере.
поставщика** matcher уже найдёт эти товары автоматически. Индикатор «сопоставлено N/M · сохраняю… / сохранено / не
5. «Создать приёмку» → диалог: поставщик (поиск по БИН/имени, сохранилось» в нижней панели. Можно закрыть вкладку и открыть
снова — ничего не потеряется. Ручные сопоставления запоминаются
в `InvoiceAlias`: **на следующей накладной того же поставщика**
matcher найдёт эти товары автоматически.
5. **Общий сканер**: если fuzzy-match по названию неуверенный, появляется
панель «<товар> — в какую строку накладной?» с кнопками строк. На
этой же странице работает **аппаратный сканер** целиком — не нужно
кликать в поле, серия цифр + Enter подхватывается глобально
(если курсор не в текстовом поле, которое читает сам).
6. **Фото-приёмка**: у каждой строки — миниатюра снимка товара,
кликабельна для полного размера; пометки «цифры прочитаны
неуверенно — отсканируйте» или «штрихкода нет в каталоге».
7. «Создать приёмку» → диалог: поставщик (поиск по БИН/имени,
можно создать нового), склад, валюта → `POST /apply` создаёт можно создать нового), склад, валюта → `POST /apply` создаёт
нашу Supply (Draft). Проведение — обычной кнопкой в приёмке. нашу Supply (Draft). Проведение — обычной кнопкой в приёмке.
@ -43,9 +65,14 @@
запускает API. Можно пустить модель на отдельной машине через запускает API. Можно пустить модель на отдельной машине через
`Invoices:ClaudeCli:RemoteHost` (ssh + scp — всё внутри сервиса). `Invoices:ClaudeCli:RemoteHost` (ssh + scp — всё внутри сервиса).
**Хранение файла:** `uploads/invoices/{yyyy-MM}/{guid}.ext`. Удаляется **Хранение файла (owner-policy):** фото накладных на сервере **не копим**.
по истечении `Invoices:KeepFilesDays` (default 7). Распознанные строки - Обычная загрузка (одно фото/PDF/Excel) — файл удаляется **сразу после
остаются навсегда — по ним всегда видно, что было в накладной. распознавания**. Распознанные строки живут в БД (`ParsedJson`).
- Фото-приёмка (папка со снимками) — хранится **только пока накладная
на проверке**: нужна для миниатюр у строк. Удаляется при создании
черновика приёмки (apply), при удалении загрузки, и через **24 часа**
— если «брошено» на статусе `needs_review`. `Invoices:KeepFilesDays`
больше не используется.
## 2. Приёмка сканированием ## 2. Приёмка сканированием
@ -83,7 +110,15 @@
После загрузки работает **без интернета**. После загрузки работает **без интернета**.
### Scan ### Scan
- Камера непрерывная или аппаратный сканер; +1 за скан; - Камера непрерывная; +1 за скан;
- **поле аппаратного сканера на виду** (не спрятано в раскрывашку) —
можно просто сканировать, а также:
- **глобальный keydown** ловит сканер и когда курсор не в поле (серия
цифр + Enter) — удобно, если вы перешли к другому элементу страницы;
- **импорт Excel/CSV** (`Загрузить из файла`) — колонки «Штрихкод» и
«Количество», повторы штрихкодов суммируются, товары добавляются
к текущему списку. Документ при этом **не создаётся** — можно
дополнительно просканировать и уже потом провести;
- неизвестный штрихкод — в отдельный список «Нет в каталоге» с - неизвестный штрихкод — в отдельный список «Нет в каталоге» с
количеством (не попадёт в документ, нужно сначала создать карточку); количеством (не попадёт в документ, нужно сначала создать карточку);
- офлайн-плашка: нет сети = сканы остаются в телефоне, уйдут на сервер - офлайн-плашка: нет сети = сканы остаются в телефоне, уйдут на сервер
@ -135,14 +170,16 @@
"Invoices": { "Invoices": {
"ClaudeCli": { "ClaudeCli": {
"Executable": "claude", "Executable": "claude",
"Model": "claude-sonnet-4-6", "Model": "claude-sonnet-5-5",
"TimeoutSeconds": 180, "TimeoutSeconds": 180,
"PhotoTimeoutSeconds": 600, // пачка фото — долго, отдельный тайм-аут
"RemoteHost": null, // или "user@host" — модель на другой машине "RemoteHost": null, // или "user@host" — модель на другой машине
"RemoteTmpDir": "/tmp/food-market-invoices" "RemoteTmpDir": "/tmp/food-market-invoices"
}, }
"KeepFilesDays": 7
} }
``` ```
> Haiku сочиняет данные накладных — не использовать. `KeepFilesDays`
> больше не читается (owner-policy: фото не копим).
## Что НЕ сделано (откладывается до запроса) ## Что НЕ сделано (откладывается до запроса)

View file

@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""Генерация шаблона накладной для поставщиков (handoff §7).
Создаёт docs/supplier-invoice-template.xlsx: 9-строчная шапка (Поставщик /
Номер / Дата / пустые строки с подсказкой «← заполните» ) и таблица товаров
на 9-й строке (заголовок) с колонками Штрихкод | Наименование товара |
Количество | Ед. изм. | Цена за единицу | Сумма.
TableInvoiceParser (Infrastructure/Invoices/TableReader.cs) ищет первую
строку, где есть заголовки «Штрихкод» и «Количество» — поэтому порядок
колонок свободный, но сами слова нужны именно эти или похожие.
"""
import io
import os
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "docs" / "supplier-invoice-template.xlsx"
# ─── XML parts ───────────────────────────────────────────────────────
CONTENT_TYPES = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
</Types>
"""
RELS = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>
"""
WB_RELS = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>
"""
WORKBOOK = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets>
<sheet name="Накладная" sheetId="1" r:id="rId1"/>
</sheets>
</workbook>
"""
STYLES = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="2">
<font><sz val="11"/><name val="Calibri"/></font>
<font><b/><sz val="11"/><name val="Calibri"/></font>
</fonts>
<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>
<borders count="1"><border/></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
<cellXfs count="2">
<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>
<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/>
</cellXfs>
</styleSheet>
"""
# Содержимое шаблона: 8 строк шапки + заголовок таблицы на 9-й строке.
ROWS = [
# row 1 — название
["Накладная поставщика", ""],
[],
["Поставщик", "← ТОО/ИП «...»"],
["Номер накладной", "← например, 1234"],
["Дата", "← ГГГГ-ММ-ДД"],
[],
["", "Заполните эту таблицу и пришлите файл: система создаст приёмку автоматически."],
[],
# row 9 — заголовок таблицы, который ищет TableInvoiceParser
["Штрихкод", "Наименование товара", "Количество", "Ед. изм.", "Цена за единицу", "Сумма"],
# примеры (можно удалить при реальном заполнении)
["4870123456789", "Молоко 3.2%, 1 л", 10, "шт", 450, 4500],
["4870987654321", "Хлеб серый, 400 г", 20, "шт", 180, 3600],
]
def _xl_col(n: int) -> str:
s = ""
while n >= 0:
s = chr(ord("A") + n % 26) + s
n = n // 26 - 1
return s
def _escape(s: str) -> str:
return (s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace('"', "&quot;"))
def build_shared_strings(rows):
"""Собрать уникальные строки, вернуть (xml, index-map)."""
seen = {}
order = []
for row in rows:
for c in row:
if isinstance(c, str) and c:
if c not in seen:
seen[c] = len(order); order.append(c)
body = "".join(f"<si><t xml:space=\"preserve\">{_escape(s)}</t></si>" for s in order)
xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
f'<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="{sum(1 for r in rows for c in r if isinstance(c, str) and c)}" uniqueCount="{len(order)}">'
f'{body}</sst>'
)
return xml, seen
def build_sheet(rows, strings_idx):
out = io.StringIO()
out.write('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n')
out.write('<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">')
out.write('<cols>')
out.write('<col min="1" max="1" width="18"/>')
out.write('<col min="2" max="2" width="42"/>')
out.write('<col min="3" max="3" width="12"/>')
out.write('<col min="4" max="4" width="10"/>')
out.write('<col min="5" max="5" width="18"/>')
out.write('<col min="6" max="6" width="14"/>')
out.write('</cols>')
out.write('<sheetData>')
for r, row in enumerate(rows, start=1):
if not row:
out.write(f'<row r="{r}"/>')
continue
is_header_row = (r == 1 or r == 9)
out.write(f'<row r="{r}">')
for c_idx, val in enumerate(row):
ref = f"{_xl_col(c_idx)}{r}"
style = ' s="1"' if is_header_row else ''
if isinstance(val, (int, float)):
out.write(f'<c r="{ref}"{style}><v>{val}</v></c>')
elif isinstance(val, str) and val:
out.write(f'<c r="{ref}"{style} t="s"><v>{strings_idx[val]}</v></c>')
else:
out.write(f'<c r="{ref}"{style}/>')
out.write('</row>')
out.write('</sheetData>')
out.write('</worksheet>')
return out.getvalue()
def main() -> None:
ss_xml, idx = build_shared_strings(ROWS)
sheet_xml = build_sheet(ROWS, idx)
OUT.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(OUT, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("[Content_Types].xml", CONTENT_TYPES)
z.writestr("_rels/.rels", RELS)
z.writestr("xl/_rels/workbook.xml.rels", WB_RELS)
z.writestr("xl/workbook.xml", WORKBOOK)
z.writestr("xl/styles.xml", STYLES)
z.writestr("xl/sharedStrings.xml", ss_xml)
z.writestr("xl/worksheets/sheet1.xml", sheet_xml)
print(f"wrote {OUT} ({OUT.stat().st_size} bytes)")
if __name__ == "__main__":
main()

View file

@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using foodmarket.Api.Infrastructure.Invoices;
using foodmarket.Application.Common; using foodmarket.Application.Common;
using foodmarket.Application.Inventory; using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory; using foodmarket.Domain.Inventory;
@ -66,13 +67,110 @@ public record InventoryInput(
public record MobileCatalogRow(string Barcode, Guid ProductId, string Name); public record MobileCatalogRow(string Barcode, Guid ProductId, string Name);
public record MobileCatalogResponse(int Count, DateTime BuiltAt, List<object[]> Rows); public record MobileCatalogResponse(int Count, DateTime BuiltAt, List<object[]> Rows);
// ── импорт Excel/CSV для инвентаризации ─────────────────────────────
public record InventoryImportFoundRow(Guid Id, string Name, decimal Qty);
public record InventoryImportUnknownRow(string Code, decimal Qty);
public record InventoryImportResponse(
IReadOnlyList<InventoryImportFoundRow> Found,
IReadOnlyList<InventoryImportUnknownRow> Unknown);
/// <summary>Импорт файла в текущий сеанс инвентаризации. Колонки «Штрихкод» и
/// «Количество» (если нет заголовков — 1-я и 2-я); повторы одного штрихкода
/// суммируются. Документ НЕ создаётся: клиент добавляет строки к текущему
/// списку, кладовщик может ещё досканировать камерой/сканером, и уже
/// потом создаёт черновик обычным <c>POST /inventories</c>. Порт поведения
/// из <c>~/ref/sklad/sklad/Controllers/InventoryController.cs</c>:Import.</summary>
[HttpPost("import"), RequiresPermission("InventoryEdit"), RequestSizeLimit(20_000_000)]
public async Task<ActionResult<InventoryImportResponse>> Import(
[FromForm] IFormFile file, CancellationToken ct)
{
if (file is null || file.Length == 0)
return BadRequest(new { error = "Файл не выбран." });
if (!TableReader.CanRead(file.FileName))
return BadRequest(new { error = "Поддерживаются только .xlsx и .csv." });
var tmp = Path.Combine(Path.GetTempPath(), $"fm-inv-{Guid.NewGuid():N}{Path.GetExtension(file.FileName)}");
try
{
await using (var fs = System.IO.File.Create(tmp))
await file.CopyToAsync(fs, ct);
var rows = TableReader.Read(tmp, file.FileName);
// Если шапка есть — читаем из колонок «штрихкод»/«кол»; иначе 1-я и 2-я.
var h = TableReader.HeaderRow(rows);
int cBc, cQty, start;
if (h >= 0)
{
var head = rows[h];
cBc = TableReader.Col(head, "штрих", "barcode", "^ean", "^шк$");
cQty = TableReader.Col(head, "кол", "qty", "quantity", "^факт");
start = h + 1;
}
else { cBc = 0; cQty = 1; start = 0; }
// Суммируем количества одного штрихкода в один проход.
var agg = new Dictionary<string, decimal>(StringComparer.Ordinal);
for (int i = start; i < rows.Count; i++)
{
var r = rows[i];
var bc = InvoiceMatcher.OnlyDigits(TableReader.At(r, cBc));
if (bc.Length < 6) continue;
var qty = TableReader.Num(TableReader.At(r, cQty)) ?? 0m;
if (qty <= 0) continue;
agg[bc] = (agg.TryGetValue(bc, out var e) ? e : 0m) + qty;
}
if (agg.Count == 0)
return BadRequest(new { error = "В файле нет строк со штрихкодом и количеством." });
// Разом ищем товары по всем кодам. Index на (OrgId, Code) — попадаем в индекс.
var codes = agg.Keys.ToList();
var hits = await (from pb in _db.ProductBarcodes.AsNoTracking()
join p in _db.Products.AsNoTracking() on pb.ProductId equals p.Id
where codes.Contains(pb.Code) && !p.IsArchived
select new { pb.Code, pb.ProductId, p.Name })
.ToListAsync(ct);
var byCode = new Dictionary<string, (Guid Id, string Name)>();
foreach (var h2 in hits)
byCode.TryAdd(h2.Code, (h2.ProductId, h2.Name));
var found = new List<InventoryImportFoundRow>();
var unknown = new List<InventoryImportUnknownRow>();
foreach (var kv in agg)
{
if (byCode.TryGetValue(kv.Key, out var p))
{
// Если один товар привязан к нескольким штрихкодам, которые встретились
// в файле — суммируем в одну строку клиенту.
var existing = found.FindIndex(f => f.Id == p.Id);
if (existing >= 0) found[existing] = found[existing] with { Qty = found[existing].Qty + kv.Value };
else found.Add(new InventoryImportFoundRow(p.Id, p.Name, kv.Value));
}
else unknown.Add(new InventoryImportUnknownRow(kv.Key, kv.Value));
}
return Ok(new InventoryImportResponse(found, unknown));
}
catch (InvoiceExtractionException ex)
{
return BadRequest(new { error = ex.Message });
}
finally
{
try { if (System.IO.File.Exists(tmp)) System.IO.File.Delete(tmp); } catch { }
}
}
[HttpGet("mobile-catalog")] [HttpGet("mobile-catalog")]
public async Task<ActionResult<MobileCatalogResponse>> MobileCatalog( public async Task<ActionResult<MobileCatalogResponse>> MobileCatalog(
[FromQuery] Guid? groupId, CancellationToken ct) [FromQuery] Guid? groupId, CancellationToken ct)
{ {
// Берём все (OrgId implicit by filter) активные товары + их штрихкоды. // Берём все (OrgId implicit by filter) активные товары + их штрихкоды.
// Отдаём массивом троек (а не объектом с полями) — для 30 тыс. позиций // Отдаём массивом троек (а не объектом с полями) — для 30 тыс. позиций
// JSON объекта весит 12 МБ, троек втрое меньше. // JSON объекта весит 12 МБ, троек втрое меньше. Response-compression
// middleware жмёт результат gzip-ом (Program.cs:AddResponseCompression).
//
// Weak ETag из «последняя правка товара/штрихкода + их число» —
// клиент на повторной загрузке получит 304, если каталог не менялся.
// (handoff §9: «без простоев» — быстрый повтор при обрыве.)
var q = from pb in _db.ProductBarcodes.AsNoTracking() var q = from pb in _db.ProductBarcodes.AsNoTracking()
join p in _db.Products.AsNoTracking() on pb.ProductId equals p.Id join p in _db.Products.AsNoTracking() on pb.ProductId equals p.Id
where !p.IsArchived where !p.IsArchived
@ -88,6 +186,19 @@ public record MobileCatalogResponse(int Count, DateTime BuiltAt, List<object[]>
if (k.Length < 6 || !seen.Add(k)) continue; if (k.Length < 6 || !seen.Add(k)) continue;
rows.Add(new object[] { k, r.ProductId, r.Name }); rows.Add(new object[] { k, r.ProductId, r.Name });
} }
// ETag — hash от уникального содержимого. 304 на If-None-Match избавляет
// от повторной передачи при обрыве/перезагрузке страницы инвентаризации.
unchecked
{
long h = rows.Count;
foreach (var r in rows)
h = h * 1315423911L ^ ((string)r[0]).GetHashCode() ^ ((Guid)r[1]).GetHashCode();
var etag = $"\"inv-cat-{h:x}-{(groupId?.ToString("N") ?? "all")}\"";
if (Request.Headers.IfNoneMatch == etag)
return StatusCode(304);
Response.Headers.ETag = etag;
Response.Headers.CacheControl = "private, max-age=60";
}
return Ok(new MobileCatalogResponse(rows.Count, DateTime.UtcNow, rows)); return Ok(new MobileCatalogResponse(rows.Count, DateTime.UtcNow, rows));
} }

View file

@ -84,6 +84,64 @@ public async Task<ActionResult<UploadResult>> Upload([FromForm] IFormFileCollect
return Ok(new UploadResult(created)); return Ok(new UploadResult(created));
} }
public record UploadPhotosResult(Guid Id);
/// <summary>Фото-приёмка: пачка снимков накладной + товаров одной загрузкой.
/// Штрихкоды с каждого снимка телефон уже прочитал ZXing-ом; <paramref name="codes"/>
/// — JSON-массив массивов (по порядку файлов), служит подсказкой модели.
/// Папка со снимками хранится до создания черновика приёмки / удаления
/// загрузки / 24 ч (брошенная на проверке) — см. <see cref="InvoiceWorker"/>.</summary>
[HttpPost("upload-photos"), RequiresPermission("SuppliesEdit"), RequestSizeLimit(60_000_000)]
public async Task<ActionResult<UploadPhotosResult>> UploadPhotos(
[FromForm] IFormFileCollection files, [FromForm] string? codes, CancellationToken ct)
{
if (files is null || files.Count == 0)
return BadRequest(new { error = "Нет снимков." });
if (files.Count > 80)
return BadRequest(new { error = "Слишком много снимков за раз (больше 80)." });
var dir = Path.Combine(StorageDir, $"photos-{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
long size = 0; int n = 0;
foreach (var f in files)
{
if (f.Length == 0) continue;
n++;
await using var fs = System.IO.File.Create(Path.Combine(dir, $"{n:D2}.jpg"));
await f.CopyToAsync(fs, ct);
size += f.Length;
}
if (!string.IsNullOrWhiteSpace(codes))
await System.IO.File.WriteAllTextAsync(Path.Combine(dir, "codes.json"), codes, ct);
var up = new InvoiceUpload
{
FileName = $"фото-приёмка: {n} снимков",
StoredPath = dir,
ContentType = "photos",
Source = "photos",
SizeBytes = size,
UploadedByUserId = _tenant.UserId,
Status = "pending",
};
_db.InvoiceUploads.Add(up);
await _db.SaveChangesAsync(ct);
return Ok(new UploadPhotosResult(up.Id));
}
/// <summary>Снимок товара для миниатюры рядом со строкой приёмки
/// (<c>NN.jpg</c> из папки фото-пачки). После <see cref="Apply"/> или
/// <see cref="Delete"/> папка удаляется — тогда вернёт 404.</summary>
[HttpGet("{id:guid}/photo/{n:int}")]
public async Task<IActionResult> Photo(Guid id, int n, CancellationToken ct)
{
var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct);
if (up is null || string.IsNullOrEmpty(up.StoredPath) || !Directory.Exists(up.StoredPath))
return NotFound();
var f = Path.Combine(up.StoredPath, $"{n:D2}.jpg");
return System.IO.File.Exists(f) ? PhysicalFile(f, "image/jpeg") : NotFound();
}
// ── list + detail ─────────────────────────────────────────────────── // ── list + detail ───────────────────────────────────────────────────
public record InvoiceListRow(Guid Id, string FileName, string Status, DateTime UploadedAt, public record InvoiceListRow(Guid Id, string FileName, string Status, DateTime UploadedAt,
@ -106,6 +164,7 @@ public async Task<IActionResult> List([FromQuery] int take = 50)
public record InvoiceDetailDto(Guid Id, string FileName, string Status, DateTime UploadedAt, public record InvoiceDetailDto(Guid Id, string FileName, string Status, DateTime UploadedAt,
string? SupplierName, string? InvoiceNumber, DateTime? InvoiceDate, decimal? TotalSum, string? SupplierName, string? InvoiceNumber, DateTime? InvoiceDate, decimal? TotalSum,
int LinesTotal, int LinesMatched, Guid? SupplyId, string? Error, int LinesTotal, int LinesMatched, Guid? SupplyId, string? Error,
string? Source, bool HasFiles,
List<InvoiceLine> Lines); List<InvoiceLine> Lines);
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
@ -116,9 +175,14 @@ public async Task<ActionResult<InvoiceDetailDto>> Detail(Guid id, CancellationTo
var lines = string.IsNullOrEmpty(up.ParsedJson) var lines = string.IsNullOrEmpty(up.ParsedJson)
? new List<InvoiceLine>() ? new List<InvoiceLine>()
: JsonSerializer.Deserialize<List<InvoiceLine>>(up.ParsedJson) ?? new List<InvoiceLine>(); : JsonSerializer.Deserialize<List<InvoiceLine>>(up.ParsedJson) ?? new List<InvoiceLine>();
// hasFiles — миниатюры для фото-приёмки доступны только пока папка жива
// (до apply/delete/24ч). Клиент читает флаг, чтобы не плодить 404.
var hasFiles = !string.IsNullOrEmpty(up.StoredPath)
&& (Directory.Exists(up.StoredPath) || System.IO.File.Exists(up.StoredPath));
return Ok(new InvoiceDetailDto(up.Id, up.FileName, up.Status, up.UploadedAt, return Ok(new InvoiceDetailDto(up.Id, up.FileName, up.Status, up.UploadedAt,
up.SupplierName, up.InvoiceNumber, up.InvoiceDate, up.TotalSum, up.SupplierName, up.InvoiceNumber, up.InvoiceDate, up.TotalSum,
up.LinesTotal, up.LinesMatched, up.SupplyId, up.Error, lines)); up.LinesTotal, up.LinesMatched, up.SupplyId, up.Error,
up.Source, hasFiles, lines));
} }
[HttpGet("{id:guid}/file")] [HttpGet("{id:guid}/file")]
@ -127,7 +191,7 @@ public async Task<IActionResult> File(Guid id, CancellationToken ct)
var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct); var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct);
if (up is null) return NotFound(); if (up is null) return NotFound();
if (string.IsNullOrEmpty(up.StoredPath) || !System.IO.File.Exists(up.StoredPath)) if (string.IsNullOrEmpty(up.StoredPath) || !System.IO.File.Exists(up.StoredPath))
return NotFound(new { error = "Файл удалён — срок хранения истёк." }); return NotFound(new { error = "Файл удалён." });
return PhysicalFile(up.StoredPath, up.ContentType ?? "application/octet-stream"); return PhysicalFile(up.StoredPath, up.ContentType ?? "application/octet-stream");
} }
@ -136,8 +200,8 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{ {
var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct); var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct);
if (up is null) return NotFound(); if (up is null) return NotFound();
try { if (!string.IsNullOrEmpty(up.StoredPath) && System.IO.File.Exists(up.StoredPath)) // Удаляем файл И папку фото-пачки (owner-policy: не копить фото).
System.IO.File.Delete(up.StoredPath); } catch { } InvoiceProcessor.DeleteStored(up);
_db.InvoiceUploads.Remove(up); _db.InvoiceUploads.Remove(up);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return NoContent(); return NoContent();
@ -289,10 +353,10 @@ public async Task<ActionResult<ApplyResult>> Apply(Guid id, [FromBody] ApplyDto
up.SupplyId = supply.Id; up.SupplyId = supply.Id;
up.Status = "applied"; up.Status = "applied";
up.ProcessedAt = DateTime.UtcNow; up.ProcessedAt = DateTime.UtcNow;
// Файл накладной больше не нужен (строки сохранены). Удаление — тихое. // Снимки/файл накладной больше не нужны (строки сохранены, черновик
try { if (!string.IsNullOrEmpty(up.StoredPath) && System.IO.File.Exists(up.StoredPath)) // приёмки создан — миниатюры у строк кладовщик уже видел). Owner-policy
System.IO.File.Delete(up.StoredPath); } catch { } // 2026-10-05: фото накладных на сервере не копим.
up.StoredPath = ""; InvoiceProcessor.DeleteStored(up);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return Ok(new ApplyResult(supply.Id, supply.Number, supply.Total, supply.Lines.Count)); return Ok(new ApplyResult(supply.Id, supply.Number, supply.Total, supply.Lines.Count));

View file

@ -20,6 +20,7 @@ public class ClaudeCliInvoiceExtractor : IInvoiceExtractor
private readonly string _exe; private readonly string _exe;
private readonly string _model; private readonly string _model;
private readonly int _timeoutSec; private readonly int _timeoutSec;
private readonly int _photoTimeoutSec;
private readonly string? _remoteHost; private readonly string? _remoteHost;
private readonly string _remoteTmp; private readonly string _remoteTmp;
// Hotfix 2026-10-05: контейнер stage-api → ssh → dev-vm, где // Hotfix 2026-10-05: контейнер stage-api → ssh → dev-vm, где
@ -35,8 +36,11 @@ public ClaudeCliInvoiceExtractor(IConfiguration cfg, ILogger<ClaudeCliInvoiceExt
{ {
_log = log; _log = log;
_exe = cfg["Invoices:ClaudeCli:Executable"] ?? "claude"; _exe = cfg["Invoices:ClaudeCli:Executable"] ?? "claude";
_model = cfg["Invoices:ClaudeCli:Model"] ?? "claude-sonnet-4-6"; // Haiku сочиняет данные накладной — не использовать, см. handoff из sklad
_model = cfg["Invoices:ClaudeCli:Model"] ?? "claude-sonnet-5-5";
_timeoutSec = int.TryParse(cfg["Invoices:ClaudeCli:TimeoutSeconds"], out var t) ? t : 180; _timeoutSec = int.TryParse(cfg["Invoices:ClaudeCli:TimeoutSeconds"], out var t) ? t : 180;
// Два десятка снимков модель смотрит дольше, чем одну накладную.
_photoTimeoutSec = int.TryParse(cfg["Invoices:ClaudeCli:PhotoTimeoutSeconds"], out var pt) ? pt : 600;
_remoteHost = cfg["Invoices:ClaudeCli:RemoteHost"]; _remoteHost = cfg["Invoices:ClaudeCli:RemoteHost"];
_remoteTmp = cfg["Invoices:ClaudeCli:RemoteTmpDir"] ?? "/tmp/food-market-invoices"; _remoteTmp = cfg["Invoices:ClaudeCli:RemoteTmpDir"] ?? "/tmp/food-market-invoices";
_sshKey = cfg["Invoices:ClaudeCli:SshKey"]; _sshKey = cfg["Invoices:ClaudeCli:SshKey"];
@ -70,7 +74,9 @@ private void EnsureKnownHostsDir()
public bool CanHandle(string fileName, string? contentType) public bool CanHandle(string fileName, string? contentType)
{ {
var ext = (Path.GetExtension(fileName) ?? "").ToLowerInvariant(); var ext = (Path.GetExtension(fileName) ?? "").ToLowerInvariant();
return ext is ".jpg" or ".jpeg" or ".png" or ".webp" or ".heic" or ".pdf"; // Фото/PDF — читает модель; .xlsx/.csv — <see cref="TableInvoiceParser"/>.
return ext is ".jpg" or ".jpeg" or ".png" or ".webp" or ".heic" or ".pdf"
or ".xlsx" or ".csv";
} }
private static string BuildPrompt(string filePath) => $@" private static string BuildPrompt(string filePath) => $@"
@ -144,6 +150,138 @@ public async Task<ParsedInvoice> ExtractAsync(string filePath, CancellationToken
return Parse(out2); return Parse(out2);
} }
// ── фото-приёмка ────────────────────────────────────────────────────
private static string BuildPhotosPrompt(IReadOnlyList<PhotoHint> photos, Func<PhotoHint, string> path)
{
var sb = new StringBuilder();
sb.AppendLine($"Перед тобой {photos.Count} снимков одной приёмки товара. Открой и посмотри КАЖДЫЙ файл:");
foreach (var h in photos)
{
sb.Append($" снимок {h.Index}: {path(h)}");
if (h.Codes.Count > 0)
{
sb.Append(" — распознаватель штрихкодов прочитал: ");
sb.Append(string.Join(", ", h.Codes.Select((c, i) =>
i < h.CatalogNames.Count && !string.IsNullOrEmpty(h.CatalogNames[i])
? $"{c} (в нашем каталоге: «{h.CatalogNames[i]}»)"
: $"{c} (в нашем каталоге такого нет)")));
}
else sb.Append(" — штрихкод не распознан");
sb.AppendLine();
}
sb.AppendLine(@"
Среди снимков есть страницы накладной (документ с таблицей товаров) и фото самих товаров
(упаковки, этикетки, штрихкоды). Один товар может быть снят несколько раз с разных сторон.
Задача — собрать по ним приёмку:
1. Прочитай накладную со всех её страниц: поставщик, номер, дата, итог и ВСЕ строки таблицы.
2. Для каждой строки накладной найди снимок товара, который ей соответствует: по бренду,
названию, вкусу, весу/объёму на упаковке. Названия в накладной часто латиницей и
сокращены («Albeni 40gx24x6»), на упаковке и в каталоге — по-другому («Шоколад Альбени 40г»):
сопоставляй по смыслу. Названия из каталога рядом с распознанными штрихкодами — точные данные.
3. Штрихкод строки бери в таком порядке:
- есть колонка штрихкода в самой накладной → barcode_source = ""invoice"";
- на снимке товара штрихкод РАСПОЗНАН (указан выше) → возьми его, barcode_source = ""decoded"";
- не распознан, но цифры под штрихкодом на снимке видны → перепиши их, barcode_source = ""read"".
Только если уверенно видишь ВСЕ цифры. Сомневаешься хоть в одной — barcode = null.
- снимка нет → barcode = null.
4. Если снимок товара не подходит ни к одной строке — внеси его номер в unmatched_photos.
Верни ТОЛЬКО JSON, без пояснений и без markdown-обёртки:
{
""supplier"": ""поставщик"",
""number"": ""номер документа"",
""date"": ""ГГГГ-ММ-ДД"",
""total"": число или null,
""invoice_photos"": [номера снимков накладной],
""lines"": [
{
""name"": ""наименование как в накладной"",
""supplier_code"": ""код/артикул поставщика или null"",
""qty"": число, ""unit"": ""шт|кг|л|уп|короб|бут"", ""price"": число за единицу,
""sum"": число или null, ""vat"": число или null,
""barcode"": ""цифры или null"",
""barcode_source"": ""invoice|decoded|read|null"",
""photo"": номер снимка товара или null,
""comment"": ""коротко, если есть сомнение в сопоставлении, иначе null""
}
],
""unmatched_photos"": [номера]
}
Важно:
- Строки накладной переноси ВСЕ, ничего не пропускай и не придумывай. Количество и цена — только из накладной.
- Проверь себя: сумма по строкам должна совпасть с «Итого».
- Один и тот же снимок не ставь двум разным строкам, если это явно разные товары.");
return sb.ToString().Trim();
}
public async Task<ParsedInvoice> ExtractPhotosAsync(IReadOnlyList<PhotoHint> photos, CancellationToken ct)
{
if (!string.IsNullOrWhiteSpace(_remoteHost))
{
EnsureKnownHostsDir();
var sshOpts = SshCommonOpts().ToArray();
var remoteDir = $"{_remoteTmp}/{Guid.NewGuid():N}";
await RunAsync("ssh", [.. sshOpts, _remoteHost, $"mkdir -p {remoteDir}"], null, 30, ct);
var scpArgs = new List<string>(sshOpts) { "-q" };
scpArgs.AddRange(photos.Select(h => h.FilePath));
scpArgs.Add($"{_remoteHost}:{remoteDir}/");
await RunAsync("scp", scpArgs.ToArray(), null, 300, ct);
try
{
var prompt = BuildPhotosPrompt(photos, h => $"{remoteDir}/{Path.GetFileName(h.FilePath)}");
var (code, so, se) = await RunAsync("ssh",
[.. sshOpts, _remoteHost, $"{_exe} -p --output-format json --model {_model} --allowedTools Read"],
prompt, _photoTimeoutSec, ct);
if (code != 0)
throw new InvoiceExtractionException($"claude на {_remoteHost}: код {code}, {Truncate(se, 400)}");
return Parse(so, FromPhotosJson);
}
finally
{
try { await RunAsync("ssh", [.. sshOpts, _remoteHost, $"rm -rf {remoteDir}"], null, 30, CancellationToken.None); }
catch { /* временные файлы — не повод валить разбор */ }
}
}
var (c2, out2, err2) = await RunAsync(_exe,
new[] { "-p", "--output-format", "json", "--model", _model, "--allowedTools", "Read" },
BuildPhotosPrompt(photos, h => h.FilePath), _photoTimeoutSec, ct);
if (c2 != 0) throw new InvoiceExtractionException($"claude: код {c2}, {Truncate(err2, 400)}");
return Parse(out2, FromPhotosJson);
}
public static ParsedInvoice FromPhotosJson(string json)
{
var inv = FromJson(json); // шапка и основные поля строк — как у обычной накладной
using var doc = JsonDocument.Parse(json);
var r = doc.RootElement;
if (r.TryGetProperty("lines", out var lines) && lines.ValueKind == JsonValueKind.Array)
{
// FromJson пропускает строки без названия — идём тем же порядком
int k = 0;
foreach (var l in lines.EnumerateArray())
{
if (string.IsNullOrWhiteSpace(Str(l, "name"))) continue;
if (k >= inv.Lines.Count) break;
var line = inv.Lines[k++];
var src = Str(l, "barcode_source");
line.BarcodeSource = src is "invoice" or "decoded" or "read" ? src : null;
if (line.BarcodeSource == null) line.Barcode = null;
if (l.TryGetProperty("photo", out var ph) && ph.ValueKind == JsonValueKind.Number
&& ph.TryGetInt32(out var pi)) line.Photo = pi;
line.Note = Str(l, "comment");
}
}
if (r.TryGetProperty("unmatched_photos", out var um) && um.ValueKind == JsonValueKind.Array
&& um.GetArrayLength() > 0)
inv.Warnings.Add("Снимки без строки в накладной: " + string.Join(", ",
um.EnumerateArray().Select(x => x.ToString())));
return inv;
}
private async Task<(int code, string stdout, string stderr)> RunAsync( private async Task<(int code, string stdout, string stderr)> RunAsync(
string exe, string[] args, string? stdin, int timeoutSec, CancellationToken ct) string exe, string[] args, string? stdin, int timeoutSec, CancellationToken ct)
{ {
@ -182,7 +320,9 @@ public async Task<ParsedInvoice> ExtractAsync(string filePath, CancellationToken
/// <summary><c>--output-format json</c> отдаёт конверт Claude Code; нужный JSON /// <summary><c>--output-format json</c> отдаёт конверт Claude Code; нужный JSON
/// лежит в поле <c>result</c> строкой, иногда обёрнутой в <c>```json</c>.</summary> /// лежит в поле <c>result</c> строкой, иногда обёрнутой в <c>```json</c>.</summary>
public static ParsedInvoice Parse(string cliOutput) public static ParsedInvoice Parse(string cliOutput) => Parse(cliOutput, FromJson);
internal static ParsedInvoice Parse(string cliOutput, Func<string, ParsedInvoice> fromJson)
{ {
string payload = cliOutput.Trim(); string payload = cliOutput.Trim();
int inTok = 0, outTok = 0; int inTok = 0, outTok = 0;
@ -208,7 +348,7 @@ public static ParsedInvoice Parse(string cliOutput)
if (payload.Length == 0) throw new InvoiceExtractionException("пустой ответ от claude"); if (payload.Length == 0) throw new InvoiceExtractionException("пустой ответ от claude");
ParsedInvoice inv; ParsedInvoice inv;
try { inv = FromJson(payload); } try { inv = fromJson(payload); }
catch (JsonException ex) { throw new InvoiceExtractionException("не разобрать JSON от claude: " + ex.Message); } catch (JsonException ex) { throw new InvoiceExtractionException("не разобрать JSON от claude: " + ex.Message); }
inv.Source = "vision"; inv.Source = "vision";
inv.RawJson = payload; inv.RawJson = payload;

View file

@ -0,0 +1,23 @@
namespace foodmarket.Api.Infrastructure.Invoices;
/// <summary>Контрольная цифра EAN-8/EAN-13/UPC-A/GTIN-14. Если модель переписала
/// цифры штрихкода со снимка товара (barcode_source="read"), одна ошибка в
/// любой цифре почти всегда ломает сумму — так ловим и не подставляем молча
/// чужой товар. Порт из <c>~/ref/sklad/sklad/Invoices/Ean.cs</c>.</summary>
public static class Ean
{
public static bool IsValid(string? digits)
{
if (string.IsNullOrEmpty(digits)) return false;
if (digits.Length is not (8 or 12 or 13 or 14)) return false;
int sum = 0;
// справа налево, начиная с цифры перед контрольной: веса 3,1,3,1…
for (int i = digits.Length - 2, w = 3; i >= 0; i--, w = w == 3 ? 1 : 3)
{
if (digits[i] < '0' || digits[i] > '9') return false;
sum += (digits[i] - '0') * w;
}
var check = (10 - sum % 10) % 10;
return digits[^1] - '0' == check;
}
}

View file

@ -20,6 +20,15 @@ public sealed class CatalogItem
// (supplier  c:code или supplier  n:nameNorm) → productId + name // (supplier  c:code или supplier  n:nameNorm) → productId + name
private readonly Dictionary<string, (Guid id, string name)> _alias = new(); private readonly Dictionary<string, (Guid id, string name)> _alias = new();
/// <summary>Товар по штрихкоду; используется в фото-приёмке
/// (<see cref="InvoiceProcessor.MatchFromPhoto"/> и подсказках модели).
/// Null — такого штрихкода нет в каталоге организации.</summary>
public CatalogItem? FindByBarcode(string? digits)
{
if (string.IsNullOrEmpty(digits)) return null;
return _byBarcode.TryGetValue(digits, out var item) ? item : null;
}
public InvoiceMatcher(IEnumerable<CatalogItem> catalog) public InvoiceMatcher(IEnumerable<CatalogItem> catalog)
{ {
foreach (var c in catalog) foreach (var c in catalog)

View file

@ -1,7 +1,9 @@
namespace foodmarket.Api.Infrastructure.Invoices; namespace foodmarket.Api.Infrastructure.Invoices;
/// <summary>Строка накладной как её прочитала модель — ещё без привязки /// <summary>Строка накладной как её прочитала модель или парсер — ещё без привязки
/// к нашему товару. Матчится по порядку: barcode → alias → ручной.</summary> /// к нашему товару. Матчится по порядку: barcode → alias → ручной (кнопкой).
/// Для фото-приёмки заполняются ещё <see cref="Photo"/>, <see cref="BarcodeSource"/>,
/// <see cref="Note"/> — что подсказать кладовщику при проверке.</summary>
public class InvoiceLine public class InvoiceLine
{ {
public string? Barcode { get; set; } public string? Barcode { get; set; }
@ -16,9 +18,33 @@ public class InvoiceLine
/// <summary>Guid нашего товара если удалось сопоставить. Null — нет.</summary> /// <summary>Guid нашего товара если удалось сопоставить. Null — нет.</summary>
public Guid? ProductId { get; set; } public Guid? ProductId { get; set; }
public string? ProductName { get; set; } public string? ProductName { get; set; }
/// <summary>«barcode» | «alias» | «manual» | «none».</summary> /// <summary>«barcode» | «alias» | «manual» | «scan» | «photo» | «photo-read» | «none».</summary>
public string MatchedBy { get; set; } = "none"; public string MatchedBy { get; set; } = "none";
public double MatchScore { get; set; } public double MatchScore { get; set; }
// ── фото-приёмка ────────────────────────────────────────────────────
/// <summary>Номер снимка товара в пачке (с 1), к которому модель отнесла строку.
/// Используется в UI для миниатюры.</summary>
public int? Photo { get; set; }
/// <summary><c>invoice</c> — цифры из колонки «Штрихкод» в накладной;
/// <c>decoded</c> — распознаны ZXing-ом на телефоне;
/// <c>read</c> — модель переписала цифры со снимка (сверяем контрольной цифрой EAN).</summary>
public string? BarcodeSource { get; set; }
/// <summary>Короткая пометка кладовщику: «штрихкод прочитан неуверенно»,
/// «штрихкода нет в каталоге — новый товар?» и т.п.</summary>
public string? Note { get; set; }
}
/// <summary>Снимок из фото-приёмки и то, что о нём известно до модели:
/// распознаватель штрихкодов на телефоне мог прочитать 0..N кодов;
/// для каждого прочитанного кода подсказываем модели название нашего
/// товара (это точные данные, модель на них опирается).</summary>
public class PhotoHint
{
public int Index { get; set; } // c 1
public string FilePath { get; set; } = "";
public List<string> Codes { get; set; } = new();
public List<string?> CatalogNames { get; set; } = new();
} }
public class ParsedInvoice public class ParsedInvoice
@ -37,14 +63,17 @@ public class ParsedInvoice
} }
/// <summary>Чем именно читается документ. Реализации взаимозаменяемы: /// <summary>Чем именно читается документ. Реализации взаимозаменяемы:
/// Excel-парсер (не реализован на этом шаге), Claude CLI, API-клиент — все /// Excel/CSV — <see cref="TableInvoiceParser"/> (кодом, без модели);
/// возвращают один и тот же <see cref="ParsedInvoice"/>. Выбор делает /// фото/PDF одной накладной — <c>ExtractAsync</c>;
/// конфиг, остальной код об этом не знает.</summary> /// пачка снимков фото-приёмки (накладная + товары) — <c>ExtractPhotosAsync</c>.
/// Все возвращают <see cref="ParsedInvoice"/>; выбор делает <c>InvoiceProcessor</c>.</summary>
public interface IInvoiceExtractor public interface IInvoiceExtractor
{ {
string Name { get; } string Name { get; }
bool CanHandle(string fileName, string? contentType); bool CanHandle(string fileName, string? contentType);
Task<ParsedInvoice> ExtractAsync(string filePath, CancellationToken ct); Task<ParsedInvoice> ExtractAsync(string filePath, CancellationToken ct);
/// <summary>Фото-приёмка: страницы накладной и снимки товаров вперемешку.</summary>
Task<ParsedInvoice> ExtractPhotosAsync(IReadOnlyList<PhotoHint> photos, CancellationToken ct);
} }
public class InvoiceExtractionException : Exception public class InvoiceExtractionException : Exception

View file

@ -5,15 +5,25 @@
namespace foodmarket.Api.Infrastructure.Invoices; namespace foodmarket.Api.Infrastructure.Invoices;
/// <summary>Разбирает загруженный файл накладной: вызывает extractor /// <summary>Разбирает загруженный файл накладной: вызывает extractor (Claude CLI
/// (Claude CLI), сопоставляет строки с нашими товарами (barcode + alias), /// для фото/PDF, <see cref="TableInvoiceParser"/> для Excel/CSV, фото-пачку —
/// сохраняет результат в <see cref="InvoiceUpload"/>. Supply на этом шаге /// отдельно через <c>ExtractPhotosAsync</c>), сопоставляет строки с нашими
/// НЕ создаётся — кладовщик смотрит и правит на экране проверки. /// товарами (barcode + alias), сохраняет результат в <see cref="InvoiceUpload"/>.
/// Supply на этом шаге НЕ создаётся — кладовщик смотрит и правит на экране
/// проверки.
///
/// Хранение файлов (owner-policy 2026-10-05):
/// • Обычная загрузка (одно фото/PDF/Excel) — файл удаляется сразу после
/// разбора. Распознанные строки живут в <c>ParsedJson</c>, этого достаточно.
/// • Фото-приёмка (<c>Source=="photos"</c>, папка со снимками) — снимки
/// нужны для миниатюр у строк во время проверки. Удаляются при создании
/// Supply-черновика (apply), при удалении <see cref="InvoiceUpload"/>, и
/// через 24 часа — для брошенных на статусе <c>needs_review</c>.
/// ///
/// Multi-tenant: все запросы к <c>Products/ProductBarcodes/InvoiceAliases</c> /// Multi-tenant: все запросы к <c>Products/ProductBarcodes/InvoiceAliases</c>
/// — через IgnoreQueryFilters с явной фильтрацией по OrganizationId /// — через IgnoreQueryFilters с явной фильтрацией по OrganizationId
/// загрузки, потому что в background-worker нет HTTP-контекста и /// загрузки, потому что в background-worker нет HTTP-контекста и обычный
/// обычный tenant-фильтр не сработает.</summary> /// tenant-фильтр не сработает.</summary>
public sealed class InvoiceProcessor public sealed class InvoiceProcessor
{ {
private readonly IServiceScopeFactory _scopes; private readonly IServiceScopeFactory _scopes;
@ -32,27 +42,42 @@ public async Task ProcessAsync(Guid uploadId, CancellationToken ct)
.FirstOrDefaultAsync(x => x.Id == uploadId, ct); .FirstOrDefaultAsync(x => x.Id == uploadId, ct);
if (up is null) return; if (up is null) return;
var isPhotos = up.Source == "photos";
try try
{ {
up.Status = "parsing"; up.Status = "parsing";
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
if (!File.Exists(up.StoredPath)) if (string.IsNullOrEmpty(up.StoredPath)
|| (!isPhotos && !File.Exists(up.StoredPath))
|| (isPhotos && !Directory.Exists(up.StoredPath)))
throw new InvoiceExtractionException("Файл не найден на диске"); throw new InvoiceExtractionException("Файл не найден на диске");
var parsed = await extractor.ExtractAsync(up.StoredPath, ct);
// Owner-policy 2026-10-05: фото/сканы накладных на сервере НЕ хранить.
// Удаляем сразу после распознавания — распознанные строки живут
// в ParsedJson, этого достаточно. См. также catch-ветку.
DeleteStored(up);
var sw = System.Diagnostics.Stopwatch.StartNew(); var sw = System.Diagnostics.Stopwatch.StartNew();
var matcher = await BuildMatcherAsync(db, up.OrganizationId, parsed.SupplierName, ct); var matcher = await BuildMatcherAsync(db, up.OrganizationId, up.SupplierName, ct);
_log.LogInformation("[Invoices] {Id}: matcher built in {Ms} ms", up.Id, sw.ElapsedMilliseconds); _log.LogInformation("[Invoices] {Id}: matcher built in {Ms} ms", up.Id, sw.ElapsedMilliseconds);
// Фото-пачка → extractor.ExtractPhotosAsync; .xlsx/.csv → табличный
// парсер; всё остальное — модель читает один файл.
var ext = Path.GetExtension(up.FileName ?? "").ToLowerInvariant();
ParsedInvoice parsed;
if (isPhotos)
parsed = await extractor.ExtractPhotosAsync(PhotoHints(up.StoredPath, matcher), ct);
else if (ext is ".xlsx" or ".xls" or ".csv")
parsed = TableInvoiceParser.Parse(up.StoredPath, up.FileName ?? "");
else
parsed = await extractor.ExtractAsync(up.StoredPath, ct);
// Для одного файла — удаляем сразу: больше он не нужен.
// Для фото-пачки — держим до apply/delete/24ч (нужны миниатюры).
if (!isPhotos) DeleteStored(up);
var supplierKey = (parsed.SupplierName ?? "").Trim().ToLowerInvariant(); var supplierKey = (parsed.SupplierName ?? "").Trim().ToLowerInvariant();
foreach (var l in parsed.Lines) foreach (var l in parsed.Lines)
{
if (isPhotos && MatchFromPhoto(l, matcher)) continue;
matcher.Match(l, supplierKey); matcher.Match(l, supplierKey);
}
up.SupplierName = parsed.SupplierName; up.SupplierName = parsed.SupplierName;
up.InvoiceNumber = parsed.InvoiceNumber; up.InvoiceNumber = parsed.InvoiceNumber;
@ -62,7 +87,8 @@ public async Task ProcessAsync(Guid uploadId, CancellationToken ct)
up.TotalSum = parsed.TotalSum; up.TotalSum = parsed.TotalSum;
up.LinesTotal = parsed.Lines.Count; up.LinesTotal = parsed.Lines.Count;
up.LinesMatched = parsed.Lines.Count(l => l.ProductId.HasValue); up.LinesMatched = parsed.Lines.Count(l => l.ProductId.HasValue);
up.Source = parsed.Source; up.Source = isPhotos ? "photos" : parsed.Source;
if (parsed.Warnings.Count > 0) up.Error = string.Join("; ", parsed.Warnings);
up.ApiInputTokens = parsed.InputTokens; up.ApiInputTokens = parsed.InputTokens;
up.ApiOutputTokens = parsed.OutputTokens; up.ApiOutputTokens = parsed.OutputTokens;
up.ParsedJson = JsonSerializer.Serialize(parsed.Lines); up.ParsedJson = JsonSerializer.Serialize(parsed.Lines);
@ -78,20 +104,89 @@ public async Task ProcessAsync(Guid uploadId, CancellationToken ct)
up.Status = "failed"; up.Status = "failed";
up.Error = ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message; up.Error = ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message;
up.ProcessedAt = DateTime.UtcNow; up.ProcessedAt = DateTime.UtcNow;
// Файл удаляем и при ошибке — хранить его смысла нет, человек // На ошибку файл тоже удаляем — хранить его смысла нет, кладовщик
// всё равно пересылает/переснимает. // всё равно пересылает/переснимает. Фото-пачку убираем целиком.
DeleteStored(up); DeleteStored(up);
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
_log.LogError(ex, "[Invoices] {Id} failed to parse", uploadId); _log.LogError(ex, "[Invoices] {Id} failed to parse", uploadId);
} }
} }
private static void DeleteStored(InvoiceUpload up) /// <summary>Снимки фото-приёмки и то, что распознаватель штрихкодов нашёл на
/// них ещё на телефоне. Названия товаров из каталога подсказываем модели —
/// это точные данные, они якорят ответ.</summary>
private List<PhotoHint> PhotoHints(string dir, InvoiceMatcher catalog)
{
var codes = new Dictionary<int, List<string>>();
var meta = Path.Combine(dir, "codes.json");
if (File.Exists(meta))
{ {
try try
{ {
if (!string.IsNullOrEmpty(up.StoredPath) && File.Exists(up.StoredPath)) var arr = JsonSerializer.Deserialize<List<List<string>>>(File.ReadAllText(meta)) ?? new();
File.Delete(up.StoredPath); for (int i = 0; i < arr.Count; i++) codes[i + 1] = arr[i] ?? new();
}
catch (JsonException ex) { _log.LogWarning(ex, "[Invoices] codes.json не разобран"); }
}
var res = new List<PhotoHint>();
foreach (var f in Directory.GetFiles(dir, "*.jpg").OrderBy(f => f))
{
var idx = int.TryParse(Path.GetFileNameWithoutExtension(f), out var n) ? n : res.Count + 1;
var h = new PhotoHint { Index = idx, FilePath = f };
foreach (var c in codes.GetValueOrDefault(idx) ?? new())
{
var digits = InvoiceMatcher.OnlyDigits(c);
if (digits.Length < 6 || h.Codes.Contains(digits)) continue;
h.Codes.Add(digits);
h.CatalogNames.Add(catalog.FindByBarcode(digits)?.Name);
}
res.Add(h);
}
if (res.Count == 0) throw new InvoiceExtractionException("в загрузке нет снимков");
return res;
}
/// <summary>Строка фото-приёмки: товар ищем строго по штрихкоду. Цифры,
/// которые модель переписала со снимка сама (<c>barcode_source="read"</c>),
/// сначала проверяем контрольной цифрой EAN — одна ошибка в цифре иначе
/// молча дала бы чужой товар.</summary>
private static bool MatchFromPhoto(InvoiceLine l, InvoiceMatcher catalog)
{
if (string.IsNullOrEmpty(l.Barcode)) return false;
var code = InvoiceMatcher.OnlyDigits(l.Barcode);
if (l.BarcodeSource == "read" && !Ean.IsValid(code))
{
l.Note = Join(l.Note, $"цифры штрихкода {code} прочитаны неуверенно — отсканируйте товар");
l.Barcode = null;
return true;
}
var item = catalog.FindByBarcode(code);
if (item is null)
{
l.Note = Join(l.Note, $"штрихкода {code} нет в каталоге — новый товар?");
return true;
}
l.ProductId = item.Id;
l.ProductName = item.Name;
l.MatchedBy = l.BarcodeSource == "read" ? "photo-read"
: l.BarcodeSource == "invoice" ? "barcode" : "photo";
l.MatchScore = 1;
return true;
}
private static string Join(string? a, string b) => string.IsNullOrEmpty(a) ? b : a + "; " + b;
/// <summary>Удаляет один файл накладной или папку со снимками фото-приёмки.
/// Тихая: FS может хрюкнуть, файл мог быть уже удалён — не повод падать.</summary>
public static void DeleteStored(InvoiceUpload up)
{
try
{
if (!string.IsNullOrEmpty(up.StoredPath))
{
if (Directory.Exists(up.StoredPath)) Directory.Delete(up.StoredPath, recursive: true);
else if (File.Exists(up.StoredPath)) File.Delete(up.StoredPath);
}
} }
catch { /* файл мог быть уже удалён или FS хрюкнула — не повод падать */ } catch { /* файл мог быть уже удалён или FS хрюкнула — не повод падать */ }
up.StoredPath = ""; up.StoredPath = "";
@ -138,16 +233,14 @@ private static void DeleteStored(InvoiceUpload up)
} }
/// <summary>Разбирает всё, что в очереди (<c>Status=pending</c>). Фото /// <summary>Разбирает всё, что в очереди (<c>Status=pending</c>). Фото
/// читается 10-20 секунд, http-запрос на это держать не нужно. Запускается /// читается 10-20 секунд, фото-пачка — до 3 минут; http-запрос на это
/// как BackgroundService (один worker на процесс). /// держать не нужно. Запускается как BackgroundService (один worker на процесс).
/// ///
/// Hotfix 2026-10-05 (owner-policy): фото/сканы накладных на сервере /// Хранение файлов (owner-policy 2026-10-05): регулярной уборки «старше N
/// хранить ЗАПРЕЩЕНО — файл удаляется ровно в <see cref="InvoiceProcessor"/> /// дней» больше нет (<c>Invoices:KeepFilesDays</c> игнорируется). Отдельная
/// сразу после разбора (успех+неудача). Поэтому периодического sweep'а /// задача этого worker'а — убирать <b>брошенные</b> фото-пачки, оставшиеся
/// больше нет (<c>Invoices:KeepFilesDays</c> игнорируется — конфиг оставлен /// на статусе <c>needs_review</c> дольше 24 часов (строки в
/// как no-op для обратной совместимости и будет удалён в следующей /// <c>ParsedJson</c> остаются, пропадают только снимки).</summary>
/// очистке конфигов). Разобранные строки (<c>ParsedJson</c>) живут
/// бессрочно.</summary>
public sealed class InvoiceWorker : BackgroundService public sealed class InvoiceWorker : BackgroundService
{ {
private readonly IServiceScopeFactory _scopes; private readonly IServiceScopeFactory _scopes;
@ -157,6 +250,8 @@ public sealed class InvoiceWorker : BackgroundService
public InvoiceWorker(IServiceScopeFactory s, InvoiceProcessor p, ILogger<InvoiceWorker> l) public InvoiceWorker(IServiceScopeFactory s, InvoiceProcessor p, ILogger<InvoiceWorker> l)
{ _scopes = s; _proc = p; _log = l; } { _scopes = s; _proc = p; _log = l; }
private DateTime _lastSweep = DateTime.MinValue;
protected override async Task ExecuteAsync(CancellationToken ct) protected override async Task ExecuteAsync(CancellationToken ct)
{ {
while (!ct.IsCancellationRequested) while (!ct.IsCancellationRequested)
@ -176,8 +271,39 @@ protected override async Task ExecuteAsync(CancellationToken ct)
catch (OperationCanceledException) { break; } catch (OperationCanceledException) { break; }
catch (Exception ex) { _log.LogError(ex, "[Invoices] worker glitch"); } catch (Exception ex) { _log.LogError(ex, "[Invoices] worker glitch"); }
try { await SweepAbandonedPhotosAsync(ct); }
catch (OperationCanceledException) { break; }
catch (Exception ex) { _log.LogWarning(ex, "[Invoices] abandoned photo sweep glitch"); }
try { await Task.Delay(TimeSpan.FromSeconds(3), ct); } try { await Task.Delay(TimeSpan.FromSeconds(3), ct); }
catch (OperationCanceledException) { break; } catch (OperationCanceledException) { break; }
} }
} }
/// <summary>Раз в час удаляет снимки у фото-приёмок, брошенных на статусе
/// <c>needs_review</c> дольше 24 часов. Распознанные строки
/// (<c>ParsedJson</c>) остаются — исчезают только картинки.</summary>
private async Task SweepAbandonedPhotosAsync(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 edge = DateTime.UtcNow.AddHours(-24);
var abandoned = await db.InvoiceUploads.IgnoreQueryFilters()
.Where(x => x.Source == "photos" && x.StoredPath != ""
&& x.Status == "needs_review" && x.UploadedAt < edge)
.ToListAsync(ct);
int n = 0;
foreach (var up in abandoned)
{
InvoiceProcessor.DeleteStored(up); n++;
}
if (n > 0)
{
await db.SaveChangesAsync(ct);
_log.LogInformation("[Invoices] abandoned photo folders cleaned: {N}", n);
}
}
} }

View file

@ -0,0 +1,242 @@
using System.Globalization;
using System.IO.Compression;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml.Linq;
namespace foodmarket.Api.Infrastructure.Invoices;
/// <summary>Таблица из файла: .xlsx (первый лист) или .csv. Своя маленькая читалка
/// вместо библиотеки — нам нужны только значения ячеек, без формата и формул.
/// Claude .xlsx не читает: это zip-архив, а не картинка и не текст, поэтому
/// такие файлы разбираем здесь, а не отдаём модели. Порт из
/// <c>~/ref/sklad/sklad/Invoices/TableReader.cs</c>.</summary>
public static class TableReader
{
public static bool CanRead(string fileName)
{
var ext = Path.GetExtension(fileName ?? "").ToLowerInvariant();
return ext is ".xlsx" or ".csv";
}
public static List<List<string>> Read(string path, string? fileName = null)
{
var ext = Path.GetExtension(fileName ?? path).ToLowerInvariant();
if (ext == ".xls")
throw new InvoiceExtractionException("Старый формат .xls не читается — сохраните файл как .xlsx");
return ext == ".csv" ? ReadCsv(path) : ReadXlsx(path);
}
private static readonly XNamespace M = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
private static readonly XNamespace R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
private static List<List<string>> ReadXlsx(string path)
{
using var zip = ZipFile.OpenRead(path);
var shared = new List<string>();
var ss = zip.GetEntry("xl/sharedStrings.xml");
if (ss != null)
using (var s = ss.Open())
foreach (var si in XDocument.Load(s).Root!.Elements(M + "si"))
shared.Add(string.Concat(si.Descendants(M + "t").Select(t => t.Value)));
var sheet = FirstSheet(zip) ?? throw new InvoiceExtractionException("В файле нет листов");
using var st = sheet.Open();
var rows = new List<List<string>>();
foreach (var row in XDocument.Load(st).Root!.Descendants(M + "row"))
{
var rIdx = int.TryParse((string?)row.Attribute("r"), out var rn) ? rn - 1 : rows.Count;
while (rows.Count < rIdx) rows.Add(new List<string>());
var cells = new List<string>();
foreach (var c in row.Elements(M + "c"))
{
var col = ColIndex((string?)c.Attribute("r"));
if (col < 0) col = cells.Count;
while (cells.Count < col) cells.Add("");
var t = (string?)c.Attribute("t");
var v = c.Element(M + "v")?.Value;
string val = t switch
{
"s" => int.TryParse(v, out var i) && i < shared.Count ? shared[i] : "",
"inlineStr" => string.Concat(c.Descendants(M + "t").Select(x => x.Value)),
"b" => v == "1" ? "TRUE" : "FALSE",
_ => v ?? "",
};
cells.Add(val.Trim());
}
rows.Add(cells);
}
return rows;
}
/// <summary>Первый лист по порядку в книге, а не по имени файла внутри архива.</summary>
private static ZipArchiveEntry? FirstSheet(ZipArchive zip)
{
try
{
var wb = zip.GetEntry("xl/workbook.xml");
var rels = zip.GetEntry("xl/_rels/workbook.xml.rels");
if (wb != null && rels != null)
{
string? rid;
using (var s = wb.Open())
rid = (string?)XDocument.Load(s).Root!.Descendants(M + "sheet").First().Attribute(R + "id");
using var rs = rels.Open();
var target = XDocument.Load(rs).Root!.Elements()
.First(e => (string?)e.Attribute("Id") == rid).Attribute("Target")!.Value;
target = target.TrimStart('/');
if (!target.StartsWith("xl/")) target = "xl/" + target;
var e = zip.GetEntry(target);
if (e != null) return e;
}
}
catch { /* нестандартная книга — возьмём по имени */ }
return zip.Entries.Where(e => e.FullName.StartsWith("xl/worksheets/sheet"))
.OrderBy(e => e.FullName.Length).ThenBy(e => e.FullName).FirstOrDefault();
}
private static int ColIndex(string? cellRef)
{
if (string.IsNullOrEmpty(cellRef)) return -1;
int n = 0, i = 0;
for (; i < cellRef.Length && char.IsLetter(cellRef[i]); i++)
n = n * 26 + (char.ToUpperInvariant(cellRef[i]) - 'A' + 1);
return i == 0 ? -1 : n - 1;
}
private static List<List<string>> ReadCsv(string path)
{
var text = File.ReadAllText(path, DetectEncoding(path));
var first = text.Split('\n').FirstOrDefault() ?? "";
var sep = new[] { ';', '\t', ',' }.OrderByDescending(c => first.Count(x => x == c)).First();
var rows = new List<List<string>>();
foreach (var raw in text.Replace("\r\n", "\n").Split('\n'))
{
var cells = new List<string>();
var sb = new StringBuilder(); bool q = false;
for (int i = 0; i < raw.Length; i++)
{
var ch = raw[i];
if (ch == '"') { if (q && i + 1 < raw.Length && raw[i + 1] == '"') { sb.Append('"'); i++; } else q = !q; }
else if (ch == sep && !q) { cells.Add(sb.ToString().Trim()); sb.Clear(); }
else sb.Append(ch);
}
cells.Add(sb.ToString().Trim());
rows.Add(cells);
}
return rows;
}
/// <summary>Excel сохраняет CSV то в UTF-8, то в Windows-1251 (кириллица, если
/// открыли через русскую локаль). BOM видно сразу, иначе пробуем строгий UTF-8
/// — упадёт если не он, тогда 1251.</summary>
private static Encoding DetectEncoding(string path)
{
var b = File.ReadAllBytes(path);
if (b.Length >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF) return Encoding.UTF8;
try { new UTF8Encoding(false, true).GetString(b); return Encoding.UTF8; }
catch (DecoderFallbackException)
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(1251);
}
}
// ── разбор по заголовкам ───────────────────────────────────────────
public static decimal? Num(string s)
{
if (string.IsNullOrWhiteSpace(s)) return null;
s = Regex.Replace(s, @"[\s ₸]|тг|руб", "").Replace(",", ".");
return decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : null;
}
/// <summary>Номер колонки, чей заголовок подходит под любой из шаблонов.</summary>
public static int Col(List<string> header, params string[] patterns)
{
for (int i = 0; i < header.Count; i++)
{
var h = header[i].ToLowerInvariant();
if (patterns.Any(p => Regex.IsMatch(h, p))) return i;
}
return -1;
}
public static string At(List<string> row, int i) => i >= 0 && i < row.Count ? row[i] : "";
/// <summary>Строка заголовков — первая, где есть и штрихкод, и количество.</summary>
public static int HeaderRow(List<List<string>> rows) =>
rows.FindIndex(r => Col(r, "штрих", "barcode", "^ean", "^шк$") >= 0
&& Col(r, "кол", "qty", "quantity", "^факт") >= 0);
}
/// <summary>Накладная из шаблона Excel/CSV для поставщиков. Разбирается кодом,
/// без модели: таблица и так точная, гадать нечего. Порт из
/// <c>~/ref/sklad/sklad/Invoices/TableReader.cs</c> (TableInvoiceParser).</summary>
public static class TableInvoiceParser
{
public static ParsedInvoice Parse(string path, string fileName)
{
var rows = TableReader.Read(path, fileName);
var h = TableReader.HeaderRow(rows);
if (h < 0)
throw new InvoiceExtractionException(
"В таблице не нашлись заголовки «Штрихкод» и «Количество» — это не шаблон накладной");
var head = rows[h];
int cBc = TableReader.Col(head, "штрих", "barcode", "^ean", "^шк$");
int cName = TableReader.Col(head, "наимен", "товар", "^name", "назван");
int cQty = TableReader.Col(head, "кол", "qty", "quantity");
int cUnit = TableReader.Col(head, "^ед", "unit");
int cPrice = TableReader.Col(head, "цен", "price");
int cSum = TableReader.Col(head, "сумм", "^sum", "total");
var inv = new ParsedInvoice { Source = "excel" };
// шапка над таблицей: «Поставщик | ТОО …», «Номер накладной | 123», «Дата | …»
for (int i = 0; i < h; i++)
{
var r = rows[i];
if (r.Count < 2) continue;
var label = r[0].ToLowerInvariant();
var val = r.Skip(1).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x) && !x.StartsWith("←"));
if (string.IsNullOrWhiteSpace(val)) continue;
if (label.Contains("поставщ")) inv.SupplierName = val;
else if (label.Contains("номер")) inv.InvoiceNumber = val;
else if (label.Contains("дата")) inv.InvoiceDate = ParseDate(val);
}
for (int i = h + 1; i < rows.Count; i++)
{
var r = rows[i];
var name = TableReader.At(r, cName);
var bc = InvoiceMatcher.OnlyDigits(TableReader.At(r, cBc));
var qty = TableReader.Num(TableReader.At(r, cQty));
if (string.IsNullOrWhiteSpace(name) && bc.Length == 0) continue; // пустая строка шаблона
if (name.ToLowerInvariant().StartsWith("итого")) { inv.TotalSum ??= TableReader.Num(TableReader.At(r, cSum)); continue; }
var price = TableReader.Num(TableReader.At(r, cPrice)) ?? 0m;
var sum = TableReader.Num(TableReader.At(r, cSum));
inv.Lines.Add(new InvoiceLine
{
Barcode = bc.Length >= 6 ? bc : null,
Name = string.IsNullOrWhiteSpace(name) ? $"штрихкод {bc}" : name,
Quantity = qty ?? 0m,
Unit = TableReader.At(r, cUnit),
Price = price,
// в шаблоне сумма — формула; если файл сохранён без пересчёта, считаем сами
Sum = sum ?? (qty.HasValue ? qty * price : null),
});
}
if (inv.Lines.Count == 0) throw new InvoiceExtractionException("В таблице нет заполненных строк");
inv.TotalSum ??= inv.Lines.Sum(l => l.Sum ?? 0m);
return inv;
}
private static DateTime? ParseDate(string s)
{
// Excel хранит дату числом дней с 1900 года
if (double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var oa) && oa > 20000 && oa < 80000)
return DateTime.FromOADate(oa);
foreach (var f in new[] { "dd.MM.yyyy", "d.M.yyyy", "yyyy-MM-dd", "dd/MM/yyyy" })
if (DateTime.TryParseExact(s.Trim(), f, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d)) return d;
return null;
}
}

View file

@ -279,6 +279,19 @@ static string ApplyDefaultPoolConfig(string? raw)
// см. Resources/EmailTemplates/*.html. Singleton с in-memory cache. // см. Resources/EmailTemplates/*.html. Singleton с in-memory cache.
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Email.EmailTemplates>(); builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Email.EmailTemplates>();
builder.Services.AddDataProtection(); builder.Services.AddDataProtection();
// Sprint 2026-10-05 (handoff §9 порт из sklad): для фото-приёмок клиент
// шлёт пачку снимков (до 60 МБ) — ответ API маленький; но мобильный
// «mobile-catalog» на 31 000 позиций — это ~2 МБ JSON, его стоит сжимать.
// Включаем gzip для application/json; «br» умеет не каждый прокси, а
// большой выгоды над gzip на коротких текстах не даёт.
builder.Services.AddResponseCompression(opts =>
{
opts.EnableForHttps = true;
opts.Providers.Add<Microsoft.AspNetCore.ResponseCompression.GzipCompressionProvider>();
opts.MimeTypes = Microsoft.AspNetCore.ResponseCompression.ResponseCompressionDefaults.MimeTypes
.Concat(new[] { "application/json; charset=utf-8", "text/csv" });
});
// MediatR (TD-1 partial CQRS): сканирует food-market.application для // MediatR (TD-1 partial CQRS): сканирует food-market.application для
// IRequest/IRequestHandler. Образцы: CreateSupplyCommand, // IRequest/IRequestHandler. Образцы: CreateSupplyCommand,
// PostRetailSaleCommand, GetSalesReportQuery. Контроллеры пока не // PostRetailSaleCommand, GetSalesReportQuery. Контроллеры пока не
@ -533,6 +546,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
// до проверки credential'ов в БД. Перед лимитером пик form-body /connect/token, // до проверки credential'ов в БД. Перед лимитером пик form-body /connect/token,
// чтобы per-username бакет имел ключ — без этого мы можем считать только по IP, // чтобы per-username бакет имел ключ — без этого мы можем считать только по IP,
// и любая NAT/CI-машина роняется первой. // и любая NAT/CI-машина роняется первой.
app.UseResponseCompression();
app.UseAuthFormUsernameKey(); app.UseAuthFormUsernameKey();
app.UseRateLimiter(); app.UseRateLimiter();
// SignalR-clients не могут слать кастомные хедеры через WebSocket, поэтому // SignalR-clients не могут слать кастомные хедеры через WebSocket, поэтому

View file

@ -28,6 +28,12 @@ export default function CameraScanner({ onCode, onClose, feedback, counter }: {
const [starting, setStarting] = useState(true) const [starting, setStarting] = useState(true)
const [flash, setFlash] = useState<ScanFeedback | null>(null) const [flash, setFlash] = useState<ScanFeedback | null>(null)
const lastRef = useRef<{ code: string; at: number }>({ code: '', at: 0 }) const lastRef = useRef<{ code: string; at: number }>({ code: '', at: 0 })
// Обработчик скана берём через ref: родитель передаёт новую стрелочную
// функцию на каждом рендере, и раньше из-за этого камера закрывалась и
// открывалась после каждого скана. Эффект инициализации с [] запускается
// один раз. Порт правки из sklad (handoff §3).
const onCodeRef = useRef(onCode)
onCodeRef.current = onCode
useEffect(() => { useEffect(() => {
if (!feedback) return if (!feedback) return
@ -61,7 +67,7 @@ export default function CameraScanner({ onCode, onClose, feedback, counter }: {
if (lastRef.current.code === code && now - lastRef.current.at < 1500) return if (lastRef.current.code === code && now - lastRef.current.at < 1500) return
lastRef.current = { code, at: now } lastRef.current = { code, at: now }
try { navigator.vibrate?.(40) } catch { /* не на всех */ } try { navigator.vibrate?.(40) } catch { /* не на всех */ }
onCode(code) onCodeRef.current(code)
}, },
) )
if (dead) controls.stop() if (dead) controls.stop()
@ -76,7 +82,8 @@ export default function CameraScanner({ onCode, onClose, feedback, counter }: {
})() })()
return () => { dead = true; stop?.() } return () => { dead = true; stop?.() }
}, [onCode]) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const ok = flash?.kind === 'ok' const ok = flash?.kind === 'ok'

View file

@ -0,0 +1,188 @@
import { useEffect, useRef, useState } from 'react'
import { X, Loader2, CameraOff, Check, ScanBarcode, ImagePlus } from 'lucide-react'
import { compress, decodeCanvas, fileToCanvas, type Shot } from '@/lib/shots'
export type { Shot }
/// Пачка снимков для фото-приёмки. Камера открывается один раз, кладовщик
/// снимает подряд (накладная + каждый товар — штрихкод и этикетка). Туда же
/// можно добавить готовые фото из галереи или с компьютера — в любой
/// пропорции. Распознавание штрихкода идёт прямо здесь (ZXing TRY_HARDER),
/// а что с чем сопоставить, решает Claude уже на сервере. Порт из
/// ~/ref/sklad/sklad/ClientApp/src/components/PhotoSession.tsx.
export default function PhotoSession({ title, hint, initialFiles, onClose, onDone }: {
title: string
hint: string
initialFiles?: File[]
onClose: () => void
onDone: (shots: Shot[]) => void
}) {
const videoRef = useRef<HTMLVideoElement>(null)
const streamRef = useRef<MediaStream | null>(null)
const fileRef = useRef<HTMLInputElement>(null)
const [camError, setCamError] = useState<string | null>(null)
const [starting, setStarting] = useState(true)
const [shots, setShots] = useState<Shot[]>([])
const [flash, setFlash] = useState(false)
const [fileErr, setFileErr] = useState<string | null>(null)
useEffect(() => {
let dead = false
;(async () => {
try {
// Просим самое высокое разрешение: на снимке накладной должен читаться
// мелкий текст, а на упаковке — цифры штрихкода.
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: { facingMode: { ideal: 'environment' }, width: { ideal: 3840 }, height: { ideal: 2160 } },
})
if (dead) { stream.getTracks().forEach(t => t.stop()); return }
streamRef.current = stream
const v = videoRef.current!
v.srcObject = stream
await v.play()
} catch (e: unknown) {
const name = (e as { name?: string })?.name
setCamError(name === 'NotAllowedError'
? 'Доступ к камере запрещён — разрешите его в настройках браузера. Готовые фото добавить можно.'
: 'Камеры нет или она занята — можно добавить готовые фото.')
} finally { setStarting(false) }
})()
return () => { dead = true; streamRef.current?.getTracks().forEach(t => t.stop()) }
}, [])
// При закрытии отпускаем превью, иначе они висят в памяти телефона.
const shotsRef = useRef(shots)
shotsRef.current = shots
useEffect(() => () => shotsRef.current.forEach(s => URL.revokeObjectURL(s.url)), [])
/// Снимок в ленту сразу, распознавание — следом: оно тяжёлое, а кнопка
/// затвора должна отзываться мгновенно.
const addCanvas = async (full: HTMLCanvasElement) => {
const blob = await compress(full)
const shot: Shot = { blob, url: URL.createObjectURL(blob), codes: [], decoding: true }
setShots(prev => [...prev, shot])
await new Promise(r => setTimeout(r, 30))
const codes = decodeCanvas(full)
setShots(prev => prev.map(s => s === shot ? { ...s, codes, decoding: false } : s))
}
const addFiles = async (files: File[]) => {
setFileErr(null)
for (const f of files) {
try { await addCanvas(await fileToCanvas(f)) }
catch (e: unknown) {
setFileErr(e instanceof Error ? e.message : `${f.name}: не открылся`)
}
}
}
useEffect(() => { if (initialFiles?.length) addFiles(initialFiles) /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [])
const shoot = () => {
const v = videoRef.current
if (!v || !v.videoWidth) return
const full = document.createElement('canvas')
full.width = v.videoWidth; full.height = v.videoHeight
full.getContext('2d')!.drawImage(v, 0, 0)
setFlash(true); setTimeout(() => setFlash(false), 120)
try { navigator.vibrate?.(30) } catch { /* не на всех телефонах */ }
addCanvas(full)
}
const remove = (i: number) => setShots(prev => {
URL.revokeObjectURL(prev[i].url)
return prev.filter((_, j) => j !== i)
})
const close = () => {
if (shots.length && !confirm(`Закрыть? ${shots.length} снимков не будут отправлены.`)) return
onClose()
}
const busy = shots.some(s => s.decoding)
const withCode = shots.filter(s => s.codes.length).length
return (
<div className="fixed inset-0 z-50 bg-black flex flex-col select-none" data-testid="photo-session">
<div className="flex items-center gap-3 px-3 py-2 text-white">
<button onClick={close} className="p-2 -ml-1"><X className="w-6 h-6" /></button>
<div className="flex-1 text-sm leading-tight min-w-0">
<div className="font-medium">{title} · {shots.length}</div>
<div className="text-white/60 text-xs">{hint}</div>
</div>
<button onClick={() => onDone(shots)} disabled={!shots.length || busy}
data-testid="photo-session-done"
className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-600 px-3 py-2 text-sm
font-medium disabled:opacity-40">
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check 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 className={`absolute inset-0 bg-white pointer-events-none transition-opacity duration-150
${flash ? 'opacity-60' : 'opacity-0'}`} />
{starting && (
<div className="absolute inset-0 flex items-center justify-center text-white/80">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
)}
{camError && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 px-8 text-center text-white">
<CameraOff className="w-10 h-10 text-white/70" />
<div className="text-white/80">{camError}</div>
<button onClick={() => fileRef.current?.click()}
className="inline-flex items-center gap-2 rounded-lg bg-white text-black px-4 py-2.5 font-medium">
<ImagePlus className="w-5 h-5" /> Добавить фото
</button>
</div>
)}
{fileErr && (
<div className="absolute top-2 left-2 right-2 rounded-lg bg-red-600/90 text-white text-sm px-3 py-2">
{fileErr}
</div>
)}
</div>
<input ref={fileRef} type="file" accept="image/*" multiple className="hidden"
onChange={e => { const f = Array.from(e.target.files ?? []); e.target.value = ''; addFiles(f) }} />
<div className="bg-black/90 px-3 pt-2 pb-[max(env(safe-area-inset-bottom),12px)] space-y-2">
{shots.length > 0 && (
<div className="flex gap-2 overflow-x-auto pt-1.5 pb-1">
{shots.map((s, i) => (
<div key={s.url} className="relative shrink-0">
<img src={s.url} className="w-14 h-14 rounded-md object-cover border border-white/20" />
<span className="absolute top-0.5 left-1 text-[10px] text-white drop-shadow">{i + 1}</span>
<span className="absolute bottom-0.5 right-0.5 rounded bg-black/60 p-0.5">
{s.decoding ? <Loader2 className="w-3 h-3 text-white animate-spin" />
: s.codes.length ? <ScanBarcode className="w-3 h-3 text-emerald-400" />
: null}
</span>
<button onClick={() => remove(i)}
className="absolute -top-1.5 -right-1.5 rounded-full bg-white text-black w-5 h-5
flex items-center justify-center shadow">
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
<div className="flex items-center justify-between">
<button onClick={() => fileRef.current?.click()}
className="w-24 inline-flex flex-col items-center gap-0.5 text-white/80 text-xs">
<ImagePlus className="w-6 h-6" /> из галереи
</button>
<button onClick={shoot} disabled={!!camError || starting} data-testid="photo-session-shoot"
className="rounded-full border-4 border-white bg-white/20 active:bg-white/60 disabled:opacity-30"
style={{ width: 72, height: 72 }} aria-label="Снять" />
<div className="w-24 text-right text-xs text-white/60">
{shots.length > 0 && <>штрихкод на {withCode} из {shots.length}</>}
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,67 @@
import { BrowserMultiFormatReader } from '@zxing/browser'
import { BarcodeFormat, DecodeHintType } from '@zxing/library'
/// Снимок для отправки: ужатая копия (Blob) и штрихкоды, найденные на
/// оригинале распознавателем на телефоне. Порт из
/// ~/ref/sklad/sklad/ClientApp/src/lib/shots.ts.
export type Shot = { blob: Blob; url: string; codes: string[]; decoding: boolean }
let reader: BrowserMultiFormatReader | null = null
const getReader = () => {
if (reader) return reader
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,
])
// TRY_HARDER — на реальных снимках без него ZXing часто не берёт штрихкод
hints.set(DecodeHintType.TRY_HARDER, true)
return (reader = new BrowserMultiFormatReader(hints))
}
const scaled = (src: HTMLCanvasElement, maxSide: number) => {
const k = Math.min(1, maxSide / Math.max(src.width, src.height))
if (k === 1) return src
const c = document.createElement('canvas')
c.width = Math.round(src.width * k); c.height = Math.round(src.height * k)
c.getContext('2d')!.drawImage(src, 0, 0, c.width, c.height)
return c
}
/// Штрихкоды со снимка. Пробуем в полном размере и уменьшенным до 1600px:
/// распознаватель иногда берёт код только на одном из масштабов. Используется
/// в PhotoSession сразу после затвора, пока модель ещё не вовлечена.
export function decodeCanvas(full: HTMLCanvasElement): string[] {
const r = getReader()
const one = (c: HTMLCanvasElement) => {
try { return r.decodeFromCanvas(c).getText().replace(/\D/g, '') } catch { return '' }
}
let code = one(full)
if (!code) { const s = scaled(full, 1600); if (s !== full) code = one(s) }
return code.length >= 6 ? [code] : []
}
/// На сервер — ужатая копия: 30 снимков должны уйти за секунды, а не грузить
/// мобильный интернет. JPEG 0.85, максимальная сторона 2400 (на упаковке
/// сохраняется разборчивый текст).
export const compress = (full: HTMLCanvasElement) =>
new Promise<Blob>((res, rej) =>
scaled(full, 2400).toBlob(b => (b ? res(b) : rej(new Error('не сжать'))), 'image/jpeg', 0.85))
/// Готовый файл (фото из галереи, скан) → холст в полном размере.
/// HEIC с айфона десктопный браузер не открывает — с телефона он отдаёт JPEG сам,
/// поэтому для десктопа кидаем понятную ошибку.
export async function fileToCanvas(file: File): Promise<HTMLCanvasElement> {
const url = URL.createObjectURL(file)
try {
const img = new Image()
img.src = url
await img.decode()
const c = document.createElement('canvas')
c.width = img.naturalWidth; c.height = img.naturalHeight
c.getContext('2d')!.drawImage(img, 0, 0)
return c
} catch {
throw new Error(`${file.name}: формат не открывается, нужен JPG или PNG`)
} finally { URL.revokeObjectURL(url) }
}

View file

@ -5,7 +5,7 @@ import { saveCatalog, findByBarcode, catalogMeta, type CatMeta } from '@/lib/off
import CameraScanner, { type ScanFeedback } from '@/components/CameraScanner' import CameraScanner, { type ScanFeedback } from '@/components/CameraScanner'
import { import {
ScanBarcode, Download, Camera, Trash2, Check, Loader2, AlertTriangle, WifiOff, ScanBarcode, Download, Camera, Trash2, Check, Loader2, AlertTriangle, WifiOff,
ArrowLeft, Keyboard, Filter, ArrowLeft, Keyboard, Filter, FileSpreadsheet,
} from 'lucide-react' } from 'lucide-react'
interface Store { id: string; name: string; isMain: boolean } interface Store { id: string; name: string; isMain: boolean }
@ -55,6 +55,8 @@ export function InventoryMobilePage() {
const [drift, setDrift] = useState<BookDriftRow[] | null>(null) const [drift, setDrift] = useState<BookDriftRow[] | null>(null)
const [filterOnlyDiff, setFilterOnlyDiff] = useState(false) const [filterOnlyDiff, setFilterOnlyDiff] = useState(false)
const [posting, setPosting] = useState(false) const [posting, setPosting] = useState(false)
const fileImportRef = useRef<HTMLInputElement>(null)
const [importing, setImporting] = useState(false)
// online / offline listeners // online / offline listeners
useEffect(() => { useEffect(() => {
@ -153,6 +155,63 @@ export function InventoryMobilePage() {
} }
} }
// Глобальный keydown для аппаратного сканера (handoff §4): если фокус не
// в INPUT/TEXTAREA, серия цифр < 100 мс + Enter + длина ≥ 6 — это скан.
const addScanRef = useRef(addScan)
addScanRef.current = addScan
useEffect(() => {
let buf = '', last = 0
const onKey = (e: KeyboardEvent) => {
const el = document.activeElement as HTMLElement | null
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) return
const now = Date.now()
if (now - last > 100) buf = ''
last = now
if (e.key === 'Enter') {
if (buf.length >= 6) { e.preventDefault(); addScanRef.current(buf) }
buf = ''
} else if (/^\d$/.test(e.key)) buf += e.key
}
addEventListener('keydown', onKey)
return () => removeEventListener('keydown', onKey)
}, [])
/// Импорт файла в сеанс (handoff §8): строки добавляются как бы отсканированные,
/// документ не создаётся. Повторы одного штрихкода на сервере уже суммированы.
const importFile = async (file: File) => {
setImporting(true); setMsg(null)
try {
const fd = new FormData(); fd.append('file', file)
const { data } = await api.post<{
found: { id: string; name: string; qty: number }[];
unknown: { code: string; qty: number }[];
}>('/api/inventory/inventories/import', fd, { timeout: 120_000 })
setRows(prev => {
const next = [...prev]
for (const f of data.found) {
const i = next.findIndex(r => r.id === f.id)
if (i < 0) next.unshift({ id: f.id, name: f.name, qty: f.qty })
else next[i] = { ...next[i], qty: next[i].qty + f.qty }
}
return next
})
if (data.unknown.length) setUnknown(prev => {
const next = [...prev]
for (const u of data.unknown) {
const i = next.findIndex(x => x.code === u.code)
if (i < 0) next.push({ code: u.code, n: u.qty }); else next[i] = { ...next[i], n: next[i].n + u.qty }
}
return next
})
setMsg({ text: `Из файла: ${data.found.length} товаров` +
(data.unknown.length ? `, ${data.unknown.length} штрихкодов нет в каталоге` : ''), ok: true })
} catch (e: unknown) {
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error
?? (e instanceof Error ? e.message : 'Файл не загрузился')
setMsg({ text: msg, ok: false })
} finally { setImporting(false) }
}
const total = useMemo(() => rows.reduce((a, r) => a + r.qty, 0), [rows]) const total = useMemo(() => rows.reduce((a, r) => a + r.qty, 0), [rows])
const goReview = async () => { const goReview = async () => {
@ -253,10 +312,13 @@ export function InventoryMobilePage() {
className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 text-white py-4 text-lg font-medium"> 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" /> Сканировать камерой <Camera className="w-6 h-6" /> Сканировать камерой
</button> </button>
<details className="text-sm"> {/* Поле аппаратного сканера/ручного ввода — на виду (handoff §8): третий
<summary className="text-slate-500 cursor-pointer select-none flex items-center gap-1"> способ после камеры. Можно печатать мышкой курсор не ставя — поймает
<Keyboard className="w-4 h-4" /> Аппаратный сканер — ввод штрихкода глобальный keydown. */}
</summary> <div className="space-y-1">
<div className="flex items-center gap-1.5 text-sm text-slate-600">
<Keyboard className="w-4 h-4" /> Сканер или вручную
</div>
<input ref={inputRef} inputMode="numeric" <input ref={inputRef} inputMode="numeric"
onKeyDown={e => { onKeyDown={e => {
if (e.key !== 'Enter') return if (e.key !== 'Enter') return
@ -264,10 +326,27 @@ export function InventoryMobilePage() {
const el = e.target as HTMLInputElement const el = e.target as HTMLInputElement
addScan(el.value); el.value = '' addScan(el.value); el.value = ''
}} }}
placeholder="Отсканируйте или введите штрихкод" placeholder="Сканируйте или введите штрихкод и Enter"
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" className="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" /> data-testid="inv-scan-input" />
</details> <div className="text-xs text-slate-400">сканер срабатывает и без клика в поле</div>
</div>
{/* Загрузка Excel/CSV — третий способ ввода. Строки добавляются
к текущему списку, повторы штрихкодов суммированы. */}
<div className="pt-1 border-t border-slate-100">
<button onClick={() => fileImportRef.current?.click()} disabled={importing || !online}
data-testid="inv-import"
className="inline-flex items-center gap-2 text-sm rounded-lg border border-slate-300 bg-white
px-3 py-2 hover:bg-slate-50 disabled:opacity-50">
{importing ? <Loader2 className="w-4 h-4 animate-spin" /> : <FileSpreadsheet className="w-4 h-4" />}
Загрузить из файла (Excel или CSV)
</button>
<div className="text-xs text-slate-400 mt-1">
колонки «Штрихкод» и «Количество» — строки добавятся к списку
</div>
<input ref={fileImportRef} type="file" accept=".xlsx,.csv" className="hidden"
onChange={e => { const f = e.target.files?.[0]; e.target.value = ''; if (f) importFile(f) }} />
</div>
{msg && ( {msg && (
<div className={`rounded px-3 py-2 text-sm ${msg.ok ? 'bg-emerald-50 text-emerald-800' : 'bg-red-50 text-red-700'}`}> <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} {msg.text}

View file

@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { useQuery, useMutation } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { ArrowLeft, Save, Check, AlertTriangle, Search, Trash2, Barcode as BarcodeIcon } from 'lucide-react' import { ArrowLeft, Check, AlertTriangle, Search, Trash2, Camera, ScanBarcode } from 'lucide-react'
import CameraScanner, { type ScanFeedback } from '@/components/CameraScanner'
interface InvoiceLine { interface InvoiceLine {
barcode?: string | null barcode?: string | null
@ -15,8 +16,12 @@ interface InvoiceLine {
vat?: number | null vat?: number | null
productId?: string | null productId?: string | null
productName?: string | null productName?: string | null
matchedBy: string // barcode | alias | manual | none matchedBy: string // barcode | alias | manual | scan | photo | photo-read | none
matchScore: number matchScore: number
// фото-приёмка
photo?: number | null
barcodeSource?: string | null
note?: string | null
} }
interface InvoiceDetail { interface InvoiceDetail {
id: string id: string
@ -31,6 +36,8 @@ interface InvoiceDetail {
linesMatched: number linesMatched: number
supplyId: string | null supplyId: string | null
error: string | null error: string | null
source: string | null
hasFiles: boolean
lines: InvoiceLine[] lines: InvoiceLine[]
} }
@ -39,22 +46,60 @@ interface CounterpartyHit { id: string; name: string; bin: string | null }
interface Store { id: string; name: string; isMain: boolean } interface Store { id: string; name: string; isMain: boolean }
interface Currency { id: string; code: string } interface Currency { id: string; code: string }
/// Экран проверки накладной. Карточками — чтобы телефон читался; на const MATCHED_RU: Record<string, string> = {
/// desktop раскладывается в 2 колонки. barcode: 'по ШК',
scan: 'скан',
alias: 'по памяти',
manual: 'вручную',
photo: 'по фото',
'photo-read': 'по фото, цифры прочитаны — сверьте',
}
/// Экран проверки накладной.
///
/// Что здесь важно (порт поведения из sklad, handoff §1–§4):
/// - **Автосохранение**: каждая правка → `dirty=true`, через 700 мс PUT /lines
/// на сервере; индикатор «сохраняю… / сохранено / не сохранилось».
/// - **Общее поле скана** с выбором строки кнопками, если fuzzy-match неуверен
/// (ранжирование Жаккара по словам). Актуальные строки читаются через ref,
/// чтобы колбэк камеры не видел старые.
/// - **Глобальный keydown**: аппаратный сканер «печатает» цифры и Enter —
/// ловим на всей странице, если курсор не в INPUT/TEXTAREA. Серия цифр
/// < 100 мс + Enter + длина ≥ 6 → скан.
/// - **Фото-приёмка**: у строки показана миниатюра `/photo/N`, если `hasFiles`.
export function InvoiceReviewPage() { export function InvoiceReviewPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const nav = useNavigate() const nav = useNavigate()
const [lines, setLines] = useState<InvoiceLine[]>([]) const [lines, setLines] = useState<InvoiceLine[]>([])
const [header, setHeader] = useState({ supplierName: '', invoiceNumber: '', invoiceDate: '', totalSum: '' }) const [header, setHeader] = useState({ supplierName: '', invoiceNumber: '', invoiceDate: '', totalSum: '' })
const [barcodeInput, setBarcodeInput] = useState('') const [scanMsg, setScanMsgRaw] = useState<{ kind: 'ok' | 'warn' | 'err'; text: string } | null>(null)
const setScanMsg = (text: string, kind: 'ok' | 'warn' | 'err' = 'warn') => setScanMsgRaw({ kind, text })
// Товар найден, но строку уверенно не подобрали — кладовщик выбирает кнопкой.
const [choose, setChoose] = useState<{ p: { id: string; name: string }; code: string } | null>(null)
const [searchHit, setSearchHit] = useState<{ row: number; q: string } | null>(null) const [searchHit, setSearchHit] = useState<{ row: number; q: string } | null>(null)
const [savedMsg, setSavedMsg] = useState<string | null>(null)
const [applyOpen, setApplyOpen] = useState(false) const [applyOpen, setApplyOpen] = useState(false)
const scanInputRef = useRef<HTMLInputElement>(null)
// Автосейв состояния.
const dirty = useRef(false)
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
// Для камеры: null — закрыта, число — скан в эту строку; -1 — общий режим.
const [camRow, setCamRow] = useState<number | null>(null)
const [fb, setFb] = useState<ScanFeedback | null>(null)
const seq = useRef(0)
// Актуальные строки для колбэков (камера / global keydown).
const linesRef = useRef<InvoiceLine[]>([])
linesRef.current = lines
const detail = useQuery({ const detail = useQuery({
queryKey: ['invoice', id], queryKey: ['invoice', id],
queryFn: () => api.get<InvoiceDetail>(`/api/purchases/invoices/${id}`).then(r => r.data), queryFn: () => api.get<InvoiceDetail>(`/api/purchases/invoices/${id}`).then(r => r.data),
enabled: !!id, enabled: !!id,
// Пока фоновая обработка идёт — обновляем, иначе не трогаем (чтобы не
// перетирать несохранённые правки — глобально у нас и так refetchOnWindowFocus:false).
refetchInterval: q => {
const d = q.state.data as InvoiceDetail | undefined
return d && (d.status === 'pending' || d.status === 'parsing') ? 2000 : false
},
}) })
useEffect(() => { useEffect(() => {
@ -69,50 +114,148 @@ export function InvoiceReviewPage() {
}) })
}, [detail.data]) }, [detail.data])
const edit = (fn: (prev: InvoiceLine[]) => InvoiceLine[]) => { dirty.current = true; setLines(fn) }
const editHeader = (fn: (prev: typeof header) => typeof header) => { dirty.current = true; setHeader(fn) }
// Автосохранение правок строк и шапки. Debounce 700 мс.
useEffect(() => {
if (!dirty.current || !id) return
const t = setTimeout(async () => {
setSaveState('saving')
try {
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,
})
dirty.current = false
setSaveState('saved')
} catch { setSaveState('error') }
}, 700)
return () => clearTimeout(t)
}, [lines, header, id])
const sumByLines = useMemo(() => lines.reduce((s, l) => s + (l.quantity * l.price || 0), 0), [lines]) 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 unmatched = useMemo(() => lines.filter(l => !l.productId).length, [lines])
const matched = lines.length - unmatched
const totalMismatch = useMemo(() => { const totalMismatch = useMemo(() => {
const t = parseFloat(header.totalSum || '0') const t = parseFloat(header.totalSum || '0')
return t > 0 && Math.abs(sumByLines - t) > 1 return t > 0 && Math.abs(sumByLines - t) > 1
}, [sumByLines, header.totalSum]) }, [sumByLines, header.totalSum])
const save = useMutation({ // ── подбор строки по названию товара (Jaccard по словам) ───────────
mutationFn: async () => { // Товар по штрихкоду известен точно, а строку надо подобрать среди
await api.put(`/api/purchases/invoices/${id}/lines`, { // НЕзаполненных: названия в накладной и каталоге часто совсем разные
lines, supplierName: header.supplierName, invoiceNumber: header.invoiceNumber, // («Albeni 40gx24x6» ↔ «Шоколад Альбени 40г»). Порог 0.3 и отрыв 0.08
invoiceDate: header.invoiceDate ? new Date(header.invoiceDate).toISOString() : null, // — иначе появляется окно с кнопками «в какую строку?».
totalSum: header.totalSum ? parseFloat(header.totalSum) : null, const rank = (productName: string, cur: InvoiceLine[]) => {
}) const norm = (t: string) => t.toUpperCase().replace(/Ё/g, 'Е')
}, .replace(/[^A-ZА-Я0-9]+/g, ' ').trim().split(' ').filter(w => w.length > 1)
onSuccess: () => { setSavedMsg('Сохранено'); setTimeout(() => setSavedMsg(null), 2000) }, const want = new Set(norm(productName))
onError: (e) => setSavedMsg(e instanceof Error ? e.message : 'Ошибка'), return cur.map((l, i) => {
}) const have = new Set(norm(l.name))
let inter = 0
// scan в общий input: ищем товар, матчим в первую НЕсопоставленную подходящую строку want.forEach(w => { if (have.has(w)) inter++ })
const scanToAny = async (code: string) => { return { i, score: inter / (want.size + have.size - inter || 1) }
const clean = code.replace(/\D/g, '') }).filter(x => !cur[x.i].productId).sort((a, b) => b.score - a.score)
}
const pickRow = (productName: string, cur: InvoiceLine[]) => {
const r = rank(productName, cur)
if (r.length === 1) return r[0].i
const best = r[0]?.score ?? 0, second = r[1]?.score ?? 0
return best >= 0.3 && best - second >= 0.08 ? r[0].i : -1
}
const putInRow = (idx: number, p: { id: string; name: string }, code?: string) => {
edit(prev => prev.map((l, i) => i === idx
? { ...l, productId: p.id, productName: p.name, matchedBy: 'manual',
barcode: code ?? l.barcode } : l))
}
/// Скан в поле КОНКРЕТНОЙ строки — гадать не нужно.
const scanIntoRow = async (idx: number, code: string) => {
const clean = (code ?? '').replace(/\D/g, '')
if (clean.length < 6) return if (clean.length < 6) return
try { try {
const { data } = await api.get<{ productId: string; name: string; barcode: string }>( const { data } = await api.get<{ productId: string; name: string }>(
`/api/purchases/invoices/product-by-barcode?barcode=${clean}`) `/api/purchases/invoices/product-by-barcode?barcode=${clean}`)
// Применяем в первую несопоставленную строку, иначе создаём новую. const p = { id: data.productId, name: data.name }
setLines(prev => { putInRow(idx, p, clean)
const idx = prev.findIndex(l => !l.productId) setScanMsg(`${p.name} → строка ${idx + 1}`, 'ok')
if (idx < 0) { setFb({ kind: 'ok', title: p.name, sub: `строка ${idx + 1}`, seq: ++seq.current })
return [...prev, { name: data.name, barcode: clean, quantity: 1, price: 0, } catch {
matchedBy: 'manual', matchScore: 1, productId: data.productId, productName: data.name } as InvoiceLine] setScanMsg(`Штрихкод ${clean} в каталоге не найден`, 'err')
setFb({ kind: 'err', title: 'Нет в каталоге', sub: clean, seq: ++seq.current })
} }
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('')
} }
/// Скан в общее поле: товар известен, строку подбираем, в неуверенном
/// случае — кнопками выбора.
const onScanCode = async (code: string) => {
const clean = (code ?? '').replace(/\D/g, '')
if (clean.length < 6) return
let p: { id: string; name: string }
try {
const { data } = await api.get<{ productId: string; name: string }>(
`/api/purchases/invoices/product-by-barcode?barcode=${clean}`)
p = { id: data.productId, name: data.name }
} catch {
setScanMsg(`Штрихкод ${clean} в каталоге не найден`, 'err')
setFb({ kind: 'err', title: 'Нет в каталоге', sub: clean, seq: ++seq.current })
return
}
const cur = linesRef.current
const already = cur.findIndex(l => l.productId === p.id)
if (already >= 0) {
setScanMsg(`${p.name} — уже стоит в строке ${already + 1}`, 'warn')
setFb({ kind: 'err', title: 'Уже отсканирован', sub: `строка ${already + 1}`, seq: ++seq.current })
return
}
if (!cur.some(l => !l.productId)) { setScanMsg('Все строки уже сопоставлены', 'warn'); return }
const idx = pickRow(p.name, cur)
if (idx >= 0) {
putInRow(idx, p, clean)
setScanMsg(`${p.name} → строка ${idx + 1}: ${cur[idx].name}`, 'ok')
setFb({ kind: 'ok', title: p.name, sub: `строка ${idx + 1}`, seq: ++seq.current })
return
}
setScanMsgRaw(null)
setChoose({ p, code: clean })
setCamRow(null)
}
const chooseRow = (idx: number) => {
if (!choose) return
putInRow(idx, choose.p, choose.code)
setScanMsg(`${choose.p.name} → строка ${idx + 1}`, 'ok')
setChoose(null)
setTimeout(() => scanInputRef.current?.focus(), 0)
}
// Глобальный keydown для аппаратного сканера. Если фокус в INPUT/TEXTAREA —
// не вмешиваемся: поле разберётся само. Серия цифр с интервалом < 100 мс
// и Enter в конце + длина ≥ 6 — это скан.
const scanFnRef = useRef(onScanCode)
scanFnRef.current = onScanCode
useEffect(() => {
let buf = '', last = 0
const onKey = (e: KeyboardEvent) => {
const el = document.activeElement as HTMLElement | null
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) return
const now = Date.now()
if (now - last > 100) buf = ''
last = now
if (e.key === 'Enter') {
if (buf.length >= 6) { e.preventDefault(); scanFnRef.current(buf) }
buf = ''
} else if (/^\d$/.test(e.key)) buf += e.key
}
addEventListener('keydown', onKey)
return () => removeEventListener('keydown', onKey)
}, [])
if (!detail.data) return <div className="p-4 text-slate-500">Загрузка…</div> if (!detail.data) return <div className="p-4 text-slate-500">Загрузка…</div>
const d = detail.data const d = detail.data
const busy = d.status === 'pending' || d.status === 'parsing'
return ( return (
<div className="max-w-5xl mx-auto p-3 pb-28 space-y-3" data-testid="invoice-review"> <div className="max-w-5xl mx-auto p-3 pb-28 space-y-3" data-testid="invoice-review">
@ -132,25 +275,37 @@ export function InvoiceReviewPage() {
Ошибка распознавания: {d.error} Ошибка распознавания: {d.error}
</div> </div>
)} )}
{d.status === 'needs_review' && d.error && (
<div className="rounded-md border border-amber-200 bg-amber-50 text-amber-900 px-3 py-2 text-sm">
{d.error}
</div>
)}
{busy && (
<div className="inline-flex items-center gap-2 text-sm text-slate-500">
{d.source === 'photos'
? 'Собираю приёмку по снимкам, обычно 1-3 минуты'
: 'Читаю накладную, обычно 10-20 секунд'}
</div>
)}
{/* шапка */} {/* шапка */}
<div className="bg-white rounded-lg border border-slate-200 p-3 space-y-2"> <div className="bg-white rounded-lg border border-slate-200 p-3 space-y-2">
<div className="grid grid-cols-2 gap-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" <input className="rounded border border-slate-300 px-2 py-1.5 text-sm col-span-2"
placeholder="Поставщик" value={header.supplierName} placeholder="Поставщик" value={header.supplierName}
onChange={e => setHeader({ ...header, supplierName: e.target.value })} /> onChange={e => editHeader(h => ({ ...h, supplierName: e.target.value }))} />
<input className="rounded border border-slate-300 px-2 py-1.5 text-sm" <input className="rounded border border-slate-300 px-2 py-1.5 text-sm"
placeholder="№" value={header.invoiceNumber} placeholder="№" value={header.invoiceNumber}
onChange={e => setHeader({ ...header, invoiceNumber: e.target.value })} /> onChange={e => editHeader(h => ({ ...h, invoiceNumber: e.target.value }))} />
<input className="rounded border border-slate-300 px-2 py-1.5 text-sm" type="date" <input className="rounded border border-slate-300 px-2 py-1.5 text-sm" type="date"
value={header.invoiceDate} value={header.invoiceDate}
onChange={e => setHeader({ ...header, invoiceDate: e.target.value })} /> onChange={e => editHeader(h => ({ ...h, invoiceDate: e.target.value }))} />
</div> </div>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-slate-500">Итого в накладной</span> <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" <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} inputMode="decimal" placeholder="—" value={header.totalSum}
onChange={e => setHeader({ ...header, totalSum: e.target.value })} /> onChange={e => editHeader(h => ({ ...h, totalSum: e.target.value }))} />
</div> </div>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-slate-500">Сумма по строкам</span> <span className="text-slate-500">Сумма по строкам</span>
@ -164,23 +319,62 @@ export function InvoiceReviewPage() {
)} )}
</div> </div>
{/* сканер в общее поле */} {/* общее поле скана + панель выбора строки */}
<div className="bg-white rounded-lg border border-slate-200 p-3"> {!busy && unmatched > 0 && (
<label className="block"> <div className="bg-white rounded-lg border border-slate-200 p-3 space-y-2">
<span className="text-xs text-slate-500 flex items-center gap-1 mb-1"> <div className="flex items-center gap-2 text-slate-800 font-medium">
<BarcodeIcon className="w-3.5 h-3.5" /> Скан штрихкода → в первую несопоставленную строку <ScanBarcode className="w-4 h-4" /> Отсканируйте товары, которых нет в каталоге
</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> </div>
<div className="flex gap-2">
<input ref={scanInputRef} autoFocus
onKeyDown={e => {
if (e.key !== 'Enter') return
e.preventDefault()
const el = e.target as HTMLInputElement
onScanCode(el.value); el.value = ''
}}
placeholder="Поднесите сканер к штрихкоду…"
className="flex-1 rounded-lg border border-slate-300 px-3 py-2 outline-none focus:border-slate-500"
data-testid="invoice-scan-input" />
<button onClick={() => setCamRow(-1)}
className="inline-flex items-center gap-2 rounded-lg bg-sky-600 text-white px-4 py-2 font-medium hover:bg-sky-700 whitespace-nowrap">
<Camera className="w-4 h-4" /> Камера
</button>
</div>
{choose && (
<div className="rounded-lg border-2 border-amber-400 bg-amber-50 p-3 space-y-2"
data-testid="row-picker">
<div className="text-sm text-amber-900">
<b>{choose.p.name}</b> — в какую строку накладной?
</div>
<div className="grid gap-1.5">
{rank(choose.p.name, lines).map(({ i }) => (
<button key={i} onClick={() => chooseRow(i)}
data-testid={`row-picker-row-${i}`}
className="text-left rounded-lg bg-white border border-amber-300 px-3 py-2.5 text-sm text-slate-800 hover:bg-amber-100 active:bg-amber-200">
<span className="text-slate-400 mr-2">{i + 1}.</span>{lines[i].name}
</button>
))}
</div>
<button onClick={() => { setChoose(null); scanInputRef.current?.focus() }}
className="text-sm text-slate-500 underline">отмена</button>
</div>
)}
{scanMsg && !choose && (
<div className={`text-sm rounded-lg px-3 py-2 ${
scanMsg.kind === 'ok' ? 'bg-emerald-50 text-emerald-800 border border-emerald-200'
: scanMsg.kind === 'err' ? 'bg-red-50 text-red-800 border border-red-200'
: 'bg-amber-50 text-amber-900 border border-amber-200'}`}
data-testid="scan-msg">
{scanMsg.text}
</div>
)}
<div className="text-xs text-slate-500">
Можно сканировать сюда — строка подберётся по названию, а если не
получится, появятся кнопки выбора. Либо прямо в поле «Штрихкод» нужной строки.
</div>
</div>
)}
{unmatched > 0 && ( {unmatched > 0 && (
<div className="rounded-md border border-amber-200 bg-amber-50 text-amber-800 px-3 py-2 text-sm"> <div className="rounded-md border border-amber-200 bg-amber-50 text-amber-800 px-3 py-2 text-sm">
@ -192,10 +386,13 @@ export function InvoiceReviewPage() {
<div className="space-y-2"> <div className="space-y-2">
{lines.map((l, i) => ( {lines.map((l, i) => (
<InvoiceLineCard <InvoiceLineCard
key={i} line={l} key={i} index={i} line={l}
onChange={(patched) => setLines(prev => prev.map((x, j) => j === i ? patched : x))} invoiceId={d.id} hasFiles={d.hasFiles}
onRemove={() => setLines(prev => prev.filter((_, j) => j !== i))} onChange={(patched) => edit(prev => prev.map((x, j) => j === i ? patched : x))}
onRemove={() => edit(prev => prev.filter((_, j) => j !== i))}
onSearch={(q) => setSearchHit({ row: i, q })} onSearch={(q) => setSearchHit({ row: i, q })}
onCamera={() => setCamRow(i)}
onScanIntoRow={(code) => scanIntoRow(i, code)}
/> />
))} ))}
</div> </div>
@ -203,12 +400,16 @@ export function InvoiceReviewPage() {
{/* bottom */} {/* bottom */}
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 p-3"> <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"> <div className="max-w-5xl mx-auto flex items-center gap-2">
<button onClick={() => save.mutate()} disabled={save.isPending} <span className="text-xs text-slate-500 shrink-0 w-32"
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"> data-testid="save-indicator">
<Save className="w-4 h-4" /> {savedMsg ?? 'Сохранить правки'} сопоставлено {matched}/{lines.length}
</button> {saveState === 'saving' && <span className="block text-slate-500">сохраняю…</span>}
<button onClick={() => setApplyOpen(true)} disabled={d.status === 'applied' || lines.filter(l => l.productId).length === 0} {saveState === 'saved' && <span className="block text-emerald-600">сохранено</span>}
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"> {saveState === 'error' && <span className="block text-red-600">не сохранилось</span>}
</span>
<button onClick={() => setApplyOpen(true)}
disabled={d.status === 'applied' || matched === 0}
className="flex-1 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" /> Создать приёмку <Check className="w-4 h-4" /> Создать приёмку
</button> </button>
</div> </div>
@ -217,7 +418,7 @@ export function InvoiceReviewPage() {
{searchHit && ( {searchHit && (
<ProductSearchDialog q={searchHit.q} onClose={() => setSearchHit(null)} <ProductSearchDialog q={searchHit.q} onClose={() => setSearchHit(null)}
onPick={(h) => { onPick={(h) => {
setLines(prev => prev.map((x, j) => j === searchHit.row edit(prev => prev.map((x, j) => j === searchHit.row
? { ...x, productId: h.id, productName: h.name, matchedBy: 'manual' } : x)) ? { ...x, productId: h.id, productName: h.name, matchedBy: 'manual' } : x))
setSearchHit(null) setSearchHit(null)
}} /> }} />
@ -228,16 +429,36 @@ export function InvoiceReviewPage() {
onClose={() => setApplyOpen(false)} onClose={() => setApplyOpen(false)}
onDone={(supplyId) => { setApplyOpen(false); nav(`/purchases/supplies/${supplyId}`) }} /> onDone={(supplyId) => { setApplyOpen(false); nav(`/purchases/supplies/${supplyId}`) }} />
)} )}
{camRow != null && (
<CameraScanner
onClose={() => setCamRow(null)}
feedback={fb}
counter={camRow >= 0
? `строка ${camRow + 1}: ${(lines[camRow]?.name ?? '').slice(0, 28)}`
: `сопоставлено ${matched} из ${lines.length}`}
onCode={code => {
// Одна строка — закрываем, она заполнена; общий режим идёт подряд.
if (camRow >= 0) { scanIntoRow(camRow, code); setCamRow(null) }
else onScanCode(code)
}} />
)}
</div> </div>
) )
} }
/// Карточка строки. Оранжевый бордер если не сопоставлена. /// Карточка строки. Миниатюра снимка (если фото-приёмка), пометка
function InvoiceLineCard({ line, onChange, onRemove, onSearch }: { /// «barcode_source: read — сверьте», оранжевый бордер если не сопоставлена.
function InvoiceLineCard({ index, line, invoiceId, hasFiles, onChange, onRemove, onSearch, onCamera, onScanIntoRow }: {
index: number
line: InvoiceLine line: InvoiceLine
invoiceId: string
hasFiles: boolean
onChange: (l: InvoiceLine) => void onChange: (l: InvoiceLine) => void
onRemove: () => void onRemove: () => void
onSearch: (q: string) => void onSearch: (q: string) => void
onCamera: () => void
onScanIntoRow: (code: string) => void
}) { }) {
const bad = !line.productId const bad = !line.productId
const sum = line.quantity * line.price const sum = line.quantity * line.price
@ -245,21 +466,46 @@ function InvoiceLineCard({ line, onChange, onRemove, onSearch }: {
<div className={`bg-white rounded-lg border p-3 ${bad ? 'border-amber-300' : 'border-slate-200'}`} <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'}> data-testid={bad ? 'invoice-line-unmatched' : 'invoice-line-ok'}>
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1 flex items-start gap-2">
{hasFiles && line.photo != null && <Thumb id={invoiceId} n={line.photo} />}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm text-slate-500 truncate"> <div className="text-xs text-slate-500 truncate">
<span className="text-slate-400 mr-1">{index + 1}.</span>
{line.supplierCode && <span className="font-mono bg-slate-100 px-1 rounded">{line.supplierCode}</span>} {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>} {line.barcode && <span className="ml-1 font-mono text-xs">{line.barcode}</span>}
</div> </div>
<div className="font-medium text-slate-900 truncate">{line.name}</div> <div className="font-medium text-slate-900 truncate">{line.name}</div>
{line.productId {line.productId
? <div className="text-xs text-emerald-700 truncate">→ {line.productName} ? <div className="text-xs text-emerald-700 truncate">→ {line.productName}
<span className="ml-1 text-slate-400">({line.matchedBy})</span> <span className="ml-1 text-slate-400">({MATCHED_RU[line.matchedBy] ?? line.matchedBy})</span>
</div>
: <div className="text-xs text-amber-700">Товар не сопоставлен — найдите, отсканируйте или создайте</div>}
{line.note && <div className="text-xs text-amber-800 mt-0.5">{line.note}</div>}
</div> </div>
: <div className="text-xs text-amber-700">Товар не сопоставлен — найдите или создайте</div>}
</div> </div>
<button onClick={onRemove} className="p-1 text-slate-400 hover:text-red-600"><Trash2 className="w-4 h-4" /></button> <button onClick={onRemove} className="p-1 text-slate-400 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
</div> </div>
{bad && (
<div className="mt-2 flex gap-1">
<input
defaultValue={line.barcode ?? ''}
placeholder="сканируйте товар"
inputMode="numeric"
onKeyDown={e => {
if (e.key !== 'Enter') return
e.preventDefault()
const v = (e.target as HTMLInputElement).value.trim()
onScanIntoRow(v)
}}
className="flex-1 min-w-0 rounded border border-amber-300 bg-white px-2 py-1 text-sm tabular-nums outline-none placeholder:text-amber-600 focus:border-sky-500 focus:ring-2 focus:ring-sky-200" />
<button onClick={onCamera} title="Сканировать камерой"
className="shrink-0 rounded border border-amber-300 bg-white px-2 text-sky-700 hover:bg-sky-50">
<Camera className="w-4 h-4" />
</button>
</div>
)}
<div className="mt-2 grid grid-cols-3 gap-2 text-sm"> <div className="mt-2 grid grid-cols-3 gap-2 text-sm">
<label> <label>
<div className="text-xs text-slate-500">Кол-во</div> <div className="text-xs text-slate-500">Кол-во</div>
@ -291,6 +537,17 @@ function InvoiceLineCard({ line, onChange, onRemove, onSearch }: {
) )
} }
/// Миниатюра снимка товара рядом со строкой (фото-приёмка).
function Thumb({ id, n }: { id: string; n: number }) {
const src = `/api/purchases/invoices/${id}/photo/${n}`
return (
<a href={src} target="_blank" rel="noreferrer" title={`снимок ${n}`} className="shrink-0">
<img src={src} loading="lazy"
className="w-12 h-12 rounded-md object-cover border border-slate-200 bg-slate-100" />
</a>
)
}
/// Диалог поиска товара (debounced). При выборе — колбэк наверх. /// Диалог поиска товара (debounced). При выборе — колбэк наверх.
function ProductSearchDialog({ q, onClose, onPick }: { function ProductSearchDialog({ q, onClose, onPick }: {
q: string; onClose: () => void; onPick: (h: ProductSearchHit) => void q: string; onClose: () => void; onPick: (h: ProductSearchHit) => void
@ -300,7 +557,6 @@ function ProductSearchDialog({ q, onClose, onPick }: {
queryKey: ['product-search', query], queryKey: ['product-search', query],
queryFn: async () => { queryFn: async () => {
if (query.trim().length < 2) return [] if (query.trim().length < 2) return []
// Используем общий products endpoint с q=
const { data } = await api.get<{ items: ProductSearchHit[] }>(`/api/catalog/products?q=${encodeURIComponent(query)}&pageSize=20`) const { data } = await api.get<{ items: ProductSearchHit[] }>(`/api/catalog/products?q=${encodeURIComponent(query)}&pageSize=20`)
return data.items ?? [] return data.items ?? []
}, },

View file

@ -1,8 +1,9 @@
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { Upload, Camera, FileText, Loader2, AlertTriangle, Check, Clock } from 'lucide-react' import { Upload, Camera, FileText, Loader2, AlertTriangle, Check, Clock, Images } from 'lucide-react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import PhotoSession, { type Shot } from '@/components/PhotoSession'
/// Жизненный цикл: pending → parsing → needs_review → applied | failed. /// Жизненный цикл: pending → parsing → needs_review → applied | failed.
/// Статус-бейдж + подсказка внизу карточки. /// Статус-бейдж + подсказка внизу карточки.
@ -34,9 +35,18 @@ export function InvoicesPage() {
const qc = useQueryClient() const qc = useQueryClient()
const fileRef = useRef<HTMLInputElement>(null) const fileRef = useRef<HTMLInputElement>(null)
const cameraRef = useRef<HTMLInputElement>(null) const cameraRef = useRef<HTMLInputElement>(null)
const goodsFileRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [err, setErr] = useState<string | null>(null) const [err, setErr] = useState<string | null>(null)
// Фото-приёмка (handoff §5-§6): кладовщик снимает подряд накладную и каждый
// товар, пачка идёт одним upload-photos → Claude собирает приёмку.
const [session, setSession] = useState<{ mode: 'invoice' | 'goods'; files?: File[] } | null>(null)
const [sendPct, setSendPct] = useState<number | null>(null)
// Снимки держим до успешной отправки: на складе связь рвётся, переснимать
// 30 фото никто не будет. При обрыве — кнопка «Отправить ещё раз».
const [unsent, setUnsent] = useState<Shot[] | null>(null)
// Polling каждые 3 с, пока есть pending/parsing — чтобы юзер видел прогресс // Polling каждые 3 с, пока есть pending/parsing — чтобы юзер видел прогресс
// без ручного F5. Останавливается, когда все статусы терминальные. // без ручного F5. Останавливается, когда все статусы терминальные.
const list = useQuery({ const list = useQuery({
@ -61,6 +71,32 @@ export function InvoicesPage() {
onError: (e: unknown) => setErr(e instanceof Error ? e.message : 'Не удалось загрузить'), onError: (e: unknown) => setErr(e instanceof Error ? e.message : 'Не удалось загрузить'),
}) })
// Фото-приёмка: все снимки одной пачкой + распознанные на телефоне штрихкоды.
// Тайм-аут 10 минут — обычно 1-3 мин, но накладная на 40 позиций может идти
// дольше, а связь мобильная.
const sendPhotos = useMutation({
mutationFn: async (shots: Shot[]) => {
const fd = new FormData()
shots.forEach((s, i) => fd.append('files', s.blob, `${String(i + 1).padStart(2, '0')}.jpg`))
fd.append('codes', JSON.stringify(shots.map(s => s.codes)))
setSendPct(0)
const { data } = await api.post<{ id: string }>(
'/api/purchases/invoices/upload-photos', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 600_000,
onUploadProgress: e => e.total && setSendPct(Math.round(e.loaded * 100 / e.total)),
})
return data
},
onSuccess: d => {
setSendPct(null)
unsent?.forEach(s => URL.revokeObjectURL(s.url)); setUnsent(null)
qc.invalidateQueries({ queryKey: ['invoices-list'] })
nav(`/purchases/invoices/${d.id}`)
},
onError: () => setSendPct(null),
})
const onFiles = async (f: FileList | null) => { const onFiles = async (f: FileList | null) => {
if (!f || f.length === 0) return if (!f || f.length === 0) return
setUploading(true) setUploading(true)
@ -70,6 +106,17 @@ export function InvoicesPage() {
return ( return (
<div className="max-w-5xl mx-auto p-4 space-y-4 pb-24"> <div className="max-w-5xl mx-auto p-4 space-y-4 pb-24">
{session && (
<PhotoSession
title={session.mode === 'invoice' ? 'Накладная' : 'Товары + накладная'}
hint={session.mode === 'invoice'
? 'снимите все листы одной накладной'
: 'накладная (все листы) и каждый товар: штрихкод и название'}
initialFiles={session.files}
onClose={() => setSession(null)}
onDone={shots => { setSession(null); setUnsent(shots); sendPhotos.mutate(shots) }} />
)}
<div> <div>
<h1 className="text-2xl font-semibold text-slate-900">Накладные</h1> <h1 className="text-2xl font-semibold text-slate-900">Накладные</h1>
<p className="text-sm text-slate-500 mt-1"> <p className="text-sm text-slate-500 mt-1">
@ -77,7 +124,7 @@ export function InvoicesPage() {
</p> </p>
</div> </div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-[auto_auto_1fr]"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-[auto_auto_auto_1fr]">
<button <button
onClick={() => cameraRef.current?.click()} onClick={() => cameraRef.current?.click()}
disabled={uploading} disabled={uploading}
@ -94,12 +141,65 @@ export function InvoicesPage() {
> >
<Upload className="w-5 h-5" /> Загрузить файл <Upload className="w-5 h-5" /> Загрузить файл
</button> </button>
<button
onClick={() => setSession({ mode: 'invoice' })}
disabled={uploading || sendPhotos.isPending}
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-photo-invoice"
>
<Camera className="w-5 h-5" /> Сфотографировать листы
</button>
<input ref={cameraRef} type="file" accept="image/*" capture="environment" multiple <input ref={cameraRef} type="file" accept="image/*" capture="environment" multiple
className="hidden" onChange={(e) => onFiles(e.target.files)} /> className="hidden" onChange={(e) => onFiles(e.target.files)} />
<input ref={fileRef} type="file" accept="image/*,application/pdf" multiple <input ref={fileRef} type="file" accept="image/*,application/pdf,.xlsx,.csv" multiple
className="hidden" onChange={(e) => onFiles(e.target.files)} /> className="hidden" onChange={(e) => onFiles(e.target.files)} />
</div> </div>
{/* Фото-приёмка — отдельной подсказкой: главный сценарий. */}
<div className="rounded-lg border border-sky-200 bg-sky-50 p-4 space-y-2">
<div className="font-medium text-sky-950">Фото товаров + накладная</div>
{sendPhotos.isPending ? (
<div className="inline-flex items-center gap-2 text-sm text-sky-900">
<Loader2 className="w-4 h-4 animate-spin" /> Отправляю снимки{sendPct != null && ` · ${sendPct}%`}
</div>
) : (
<div className="flex flex-col sm:flex-row gap-2">
<button onClick={() => setSession({ mode: 'goods' })}
data-testid="invoice-photo-session"
className="inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 text-white
px-4 py-3 sm:py-2 font-medium hover:bg-sky-700">
<Camera className="w-4 h-4" /> Снять камерой
</button>
<button onClick={() => goodsFileRef.current?.click()}
data-testid="invoice-photo-files"
className="inline-flex items-center justify-center gap-2 rounded-lg border border-sky-300
bg-white text-sky-800 px-4 py-3 sm:py-2 font-medium hover:bg-sky-100">
<Images className="w-4 h-4" /> Готовые фото
</button>
<input ref={goodsFileRef} type="file" accept="image/*" multiple className="hidden"
onChange={e => {
const files = Array.from(e.target.files ?? []); e.target.value = ''
if (files.length) setSession({ mode: 'goods', files })
}} />
</div>
)}
<div className="text-sm text-sky-900">
Снимите подряд накладную (все листы) и каждый товар — штрихкод и название,
можно несколько снимков на товар. Или выберите уже сделанные фото — их можно
и смешивать со снятыми. Приёмка соберётся сама за 1-3 минуты, останется
проверить её на глаз. Подходит и для накладных <b>без штрихкодов</b>.
</div>
</div>
{sendPhotos.isError && unsent && (
<div className="flex flex-wrap items-center gap-3 text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg px-3 py-2">
<span>
{unsent.length} снимков не отправились.
</span>
<button onClick={() => sendPhotos.mutate(unsent)}
className="rounded-md bg-red-600 text-white px-3 py-1.5 font-medium">Отправить ещё раз</button>
</div>
)}
{err && ( {err && (
<div className="rounded-md border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-sm">{err}</div> <div className="rounded-md border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-sm">{err}</div>
)} )}

View file

@ -116,6 +116,27 @@ export function SupplyScanPage() {
} }
} }
// Глобальный keydown для аппаратного сканера (handoff §4): если фокус не в
// INPUT/TEXTAREA, серия цифр < 100 мс + Enter + длина ≥ 6 — это скан.
const addScanRef = useRef(addScan)
addScanRef.current = addScan
useEffect(() => {
let buf = '', last = 0
const onKey = (e: KeyboardEvent) => {
const el = document.activeElement as HTMLElement | null
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) return
const now = Date.now()
if (now - last > 100) buf = ''
last = now
if (e.key === 'Enter') {
if (buf.length >= 6) { e.preventDefault(); addScanRef.current(buf) }
buf = ''
} else if (/^\d$/.test(e.key)) buf += e.key
}
addEventListener('keydown', onKey)
return () => removeEventListener('keydown', onKey)
}, [])
const total = useMemo(() => rows.reduce((s, r) => s + r.qty * r.price, 0), [rows]) const total = useMemo(() => rows.reduce((s, r) => s + r.qty * r.price, 0), [rows])
const save = async () => { const save = async () => {

View file

@ -0,0 +1,74 @@
using FluentAssertions;
using foodmarket.Api.Infrastructure.Invoices;
using Xunit;
namespace foodmarket.UnitTests;
/// <summary>Разбор JSON-ответа модели для фото-приёмки (handoff §5): к обычной
/// шапке добавляются <c>photo</c>, <c>barcode_source</c> (invoice|decoded|read),
/// <c>comment</c> → <c>Note</c>, и <c>unmatched_photos</c> → <c>Warnings</c>.
/// barcode_source=null (или отсутствует) означает «цифры не прочитаны уверенно»
/// и мы обнуляем <c>Barcode</c>, чтобы не подставить что-то наугад.</summary>
public class ClaudeCliInvoicePhotosParseTests
{
[Fact]
public void Photos_decoded_source_passes_through()
{
var json = """
{
"supplier":"ТОО X","number":"7","date":"2026-10-05","total":4500,
"invoice_photos":[1],
"lines":[
{"name":"Молоко 3.2%","qty":10,"price":450,"sum":4500,
"barcode":"4870001234561","barcode_source":"decoded","photo":2,"comment":null}
],
"unmatched_photos":[]
}
""";
var inv = ClaudeCliInvoiceExtractor.FromPhotosJson(json);
inv.Lines.Should().HaveCount(1);
var l = inv.Lines[0];
l.Barcode.Should().Be("4870001234561");
l.BarcodeSource.Should().Be("decoded");
l.Photo.Should().Be(2);
inv.Warnings.Should().BeEmpty();
}
[Fact]
public void Photos_source_null_clears_barcode()
{
// Если модель не уверена в цифрах, barcode_source=null → штрихкод не берём,
// чтобы не привязать строку к чужому товару.
var json = """
{"supplier":"X","lines":[
{"name":"A","qty":1,"price":10,"barcode":"4870000000000","barcode_source":null,"photo":null}
]}
""";
var inv = ClaudeCliInvoiceExtractor.FromPhotosJson(json);
inv.Lines[0].Barcode.Should().BeNull();
inv.Lines[0].BarcodeSource.Should().BeNull();
}
[Fact]
public void Unmatched_photos_become_warning()
{
var json = """
{"supplier":"X","lines":[{"name":"A","qty":1,"price":10}],
"unmatched_photos":[5,7]}
""";
var inv = ClaudeCliInvoiceExtractor.FromPhotosJson(json);
inv.Warnings.Should().ContainMatch("*5*7*");
}
[Fact]
public void Comment_lands_in_note()
{
var json = """
{"supplier":"X","lines":[
{"name":"А","qty":1,"price":10,"barcode_source":"invoice","comment":"сверьте вес"}
]}
""";
var inv = ClaudeCliInvoiceExtractor.FromPhotosJson(json);
inv.Lines[0].Note.Should().Be("сверьте вес");
}
}

View file

@ -0,0 +1,57 @@
using FluentAssertions;
using foodmarket.Api.Infrastructure.Invoices;
using Xunit;
namespace foodmarket.UnitTests;
/// <summary>Контрольная цифра EAN-8/EAN-13/UPC-A/GTIN-14. Проверка нужна в
/// фото-приёмке: если модель переписала цифры штрихкода со снимка товара
/// сама (<c>barcode_source="read"</c>), одна ошибка в любой цифре иначе
/// молча дала бы чужой товар. Порт из <c>~/ref/sklad</c>.</summary>
public class EanTests
{
// Валидные настоящие штрихкоды (из handoff: «15/15 реальных проходят»).
public static TheoryData<string> Valid => new()
{
"4870001234560", // EAN-13
"4607031440148", // EAN-13 (пример из KZ-ритейла)
"0012000161551", // UPC-A дополненный до 13
"73513537", // EAN-8
"012345678905", // UPC-A (12)
};
[Theory, MemberData(nameof(Valid))]
public void Valid_passes(string digits) => Ean.IsValid(digits).Should().BeTrue();
[Fact]
public void Broken_check_digit_fails()
{
// Все эти — валидные минус один «кривой» символ контрольной.
Ean.IsValid("4870001234561").Should().BeFalse();
Ean.IsValid("4607031440149").Should().BeFalse();
}
[Theory]
[InlineData("")]
[InlineData(null)]
[InlineData("12345")] // короткий
[InlineData("12345678901")] // 11 — не стандарт
[InlineData("12345678901A")] // буква
[InlineData("4870 001 234 56")] // пробелы — Ean.IsValid смотрит на точные цифры
public void Non_digit_or_bad_length_fails(string? d) => Ean.IsValid(d).Should().BeFalse();
[Fact]
public void One_digit_error_in_middle_caught()
{
// Из handoff: «15/15 с одной испорченной цифрой ловятся» —
// гарантируется весовой суммой 3,1,3,1,…
var ok = "4870001234560";
foreach (var i in new[] { 0, 3, 5, 8, 11 })
{
var mutated = ok.ToCharArray();
mutated[i] = (char)('0' + ((mutated[i] - '0' + 5) % 10)); // ± 5 в этой позиции
Ean.IsValid(new string(mutated)).Should().BeFalse(
$"позиция {i}: ошибку в одной цифре должен поймать");
}
}
}

View file

@ -0,0 +1,53 @@
using FluentAssertions;
using foodmarket.Api.Infrastructure.Invoices;
using foodmarket.Domain.Purchases;
using Xunit;
namespace foodmarket.UnitTests;
/// <summary>Owner-policy 2026-10-05 (handoff §5): фото накладных на сервере
/// НЕ копим. <see cref="InvoiceProcessor.DeleteStored"/> тихо удаляет и
/// одиночный файл, и папку фото-пачки, обнуляя <c>StoredPath</c>, после чего
/// запись InvoiceUpload (строки в ParsedJson) остаётся жить.</summary>
public class InvoicePhotoRetentionTests
{
[Fact]
public void DeleteStored_removes_single_file()
{
var tmp = Path.Combine(Path.GetTempPath(), $"fm-{Guid.NewGuid():N}.jpg");
File.WriteAllBytes(tmp, new byte[] { 0xFF, 0xD8, 0xFF });
var up = new InvoiceUpload { StoredPath = tmp, FileName = "x.jpg" };
InvoiceProcessor.DeleteStored(up);
File.Exists(tmp).Should().BeFalse();
up.StoredPath.Should().BeEmpty();
}
[Fact]
public void DeleteStored_removes_photo_folder_recursively()
{
var dir = Path.Combine(Path.GetTempPath(), $"fm-photos-{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
File.WriteAllText(Path.Combine(dir, "01.jpg"), "");
File.WriteAllText(Path.Combine(dir, "02.jpg"), "");
File.WriteAllText(Path.Combine(dir, "codes.json"), "[]");
var up = new InvoiceUpload { StoredPath = dir, Source = "photos", FileName = "фото-приёмка" };
InvoiceProcessor.DeleteStored(up);
Directory.Exists(dir).Should().BeFalse();
up.StoredPath.Should().BeEmpty();
}
[Fact]
public void DeleteStored_is_idempotent()
{
// Файла нет на диске — не должно падать (сработает повторно, если
// SweepAbandonedPhotos дошёл раньше Apply).
var up = new InvoiceUpload { StoredPath = "/tmp/fm-does-not-exist-" + Guid.NewGuid() };
FluentActions.Invoking(() => InvoiceProcessor.DeleteStored(up))
.Should().NotThrow();
up.StoredPath.Should().BeEmpty();
}
}

View file

@ -0,0 +1,173 @@
using System.IO.Compression;
using System.Text;
using FluentAssertions;
using foodmarket.Api.Infrastructure.Invoices;
using Xunit;
namespace foodmarket.UnitTests;
/// <summary>Табличная читалка (xlsx + csv) + <see cref="TableInvoiceParser"/>:
/// порт шаблона накладной из sklad (handoff §7). Генерим xlsx руками (zip + XML),
/// как и в <c>scripts/make_supplier_template.py</c> — так тестируется и наша
/// собственная читалка, и формат, который сдаём поставщикам.</summary>
public class TableReaderTests
{
private const string M = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
private static string WriteXlsx(IEnumerable<IEnumerable<object?>> rows)
{
var tmp = Path.Combine(Path.GetTempPath(), $"fm-tr-{Guid.NewGuid():N}.xlsx");
using var z = new ZipArchive(File.Create(tmp), ZipArchiveMode.Create);
void Put(string name, string content)
{
var e = z.CreateEntry(name);
using var w = new StreamWriter(e.Open(), new UTF8Encoding(false));
w.Write(content);
}
Put("[Content_Types].xml", $@"<?xml version=""1.0""?>
<Types xmlns=""http://schemas.openxmlformats.org/package/2006/content-types"">
<Default Extension=""rels"" ContentType=""application/vnd.openxmlformats-package.relationships+xml""/>
<Default Extension=""xml"" ContentType=""application/xml""/>
<Override PartName=""/xl/workbook.xml"" ContentType=""application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml""/>
<Override PartName=""/xl/worksheets/sheet1.xml"" ContentType=""application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml""/>
</Types>");
Put("_rels/.rels", $@"<?xml version=""1.0""?>
<Relationships xmlns=""http://schemas.openxmlformats.org/package/2006/relationships"">
<Relationship Id=""rId1"" Type=""http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"" Target=""xl/workbook.xml""/>
</Relationships>");
Put("xl/_rels/workbook.xml.rels", $@"<?xml version=""1.0""?>
<Relationships xmlns=""http://schemas.openxmlformats.org/package/2006/relationships"">
<Relationship Id=""rId1"" Type=""http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"" Target=""worksheets/sheet1.xml""/>
</Relationships>");
Put("xl/workbook.xml", $@"<?xml version=""1.0""?>
<workbook xmlns=""{M}"" xmlns:r=""http://schemas.openxmlformats.org/officeDocument/2006/relationships"">
<sheets><sheet name=""Sheet1"" sheetId=""1"" r:id=""rId1""/></sheets>
</workbook>");
var sb = new StringBuilder();
sb.Append($@"<?xml version=""1.0""?><worksheet xmlns=""{M}""><sheetData>");
int r = 1;
foreach (var row in rows)
{
sb.Append($"<row r=\"{r}\">");
int c = 0;
foreach (var v in row)
{
var ref1 = $"{(char)('A' + c)}{r}";
if (v is null) { c++; continue; }
if (v is string s)
sb.Append($"<c r=\"{ref1}\" t=\"inlineStr\"><is><t>{System.Security.SecurityElement.Escape(s)}</t></is></c>");
else
sb.Append($"<c r=\"{ref1}\"><v>{v}</v></c>");
c++;
}
sb.Append("</row>");
r++;
}
sb.Append("</sheetData></worksheet>");
Put("xl/worksheets/sheet1.xml", sb.ToString());
return tmp;
}
[Fact]
public void Reads_xlsx_cells()
{
var tmp = WriteXlsx(new object?[][]
{
new object?[] { "Штрихкод", "Наименование товара", "Количество", "Цена за единицу" },
new object?[] { "4870001234560", "Молоко 3.2%", 10, 450 },
});
try
{
var rows = TableReader.Read(tmp);
rows[0][0].Should().Be("Штрихкод");
rows[1][2].Should().Be("10");
}
finally { File.Delete(tmp); }
}
[Fact]
public void Parses_table_invoice_with_header_rows()
{
var tmp = WriteXlsx(new object?[][]
{
new object?[] { "Поставщик", "ТОО Пример" },
new object?[] { "Номер накладной", "7777" },
new object?[] { "Дата", "2026-10-05" },
new object?[] { },
new object?[] { "Штрихкод", "Наименование товара", "Количество", "Ед. изм.", "Цена за единицу", "Сумма" },
new object?[] { "4870001234560", "Молоко 3.2%", 10, "шт", 450, 4500 },
new object?[] { "4607031440148", "Хлеб серый", 20, "шт", 180, 3600 },
});
try
{
var inv = TableInvoiceParser.Parse(tmp, "накладная.xlsx");
inv.SupplierName.Should().Be("ТОО Пример");
inv.InvoiceNumber.Should().Be("7777");
inv.InvoiceDate!.Value.Date.Should().Be(new DateTime(2026, 10, 5));
inv.Lines.Should().HaveCount(2);
inv.Lines[0].Barcode.Should().Be("4870001234560");
inv.Lines[0].Quantity.Should().Be(10);
inv.Lines[0].Price.Should().Be(450);
inv.Lines[0].Sum.Should().Be(4500);
inv.Source.Should().Be("excel");
}
finally { File.Delete(tmp); }
}
[Fact]
public void Parses_csv_utf8_with_semicolons()
{
var tmp = Path.Combine(Path.GetTempPath(), $"fm-{Guid.NewGuid():N}.csv");
File.WriteAllText(tmp,
"Штрихкод;Наименование;Количество;Цена\n" +
"4870001234560;Молоко 3.2%;10;450\n", new UTF8Encoding(true)); // BOM
try
{
var inv = TableInvoiceParser.Parse(tmp, "накладная.csv");
inv.Lines.Should().HaveCount(1);
inv.Lines[0].Barcode.Should().Be("4870001234560");
inv.Lines[0].Name.Should().Be("Молоко 3.2%");
}
finally { File.Delete(tmp); }
}
[Fact]
public void Parses_csv_windows1251()
{
// Excel в русской локали сохраняет CSV в 1251 без BOM — читалка должна
// определить и переключиться на 1251.
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var enc = Encoding.GetEncoding(1251);
var tmp = Path.Combine(Path.GetTempPath(), $"fm-{Guid.NewGuid():N}.csv");
File.WriteAllBytes(tmp, enc.GetBytes(
"Штрихкод;Наименование;Количество;Цена\n" +
"4870001234560;Молоко 3.2%;10;450\n"));
try
{
var inv = TableInvoiceParser.Parse(tmp, "накладная.csv");
inv.Lines.Should().HaveCount(1);
inv.Lines[0].Name.Should().Be("Молоко 3.2%");
}
finally { File.Delete(tmp); }
}
[Fact]
public void Xls_old_format_throws_friendly()
{
var tmp = Path.Combine(Path.GetTempPath(), $"fm-{Guid.NewGuid():N}.xls");
File.WriteAllText(tmp, "stub");
try
{
FluentActions.Invoking(() => TableReader.Read(tmp, "накладная.xls"))
.Should().Throw<InvoiceExtractionException>()
.WithMessage("*сохраните файл как .xlsx*");
}
finally { File.Delete(tmp); }
}
}