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
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:
parent
7e7eb8ef1d
commit
6b0fc68f77
80
docs/sklad-port-20261005-progress.md
Normal file
80
docs/sklad-port-20261005-progress.md
Normal 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-тестов зелёные.
|
||||
BIN
docs/supplier-invoice-template.xlsx
Normal file
BIN
docs/supplier-invoice-template.xlsx
Normal file
Binary file not shown.
|
|
@ -5,14 +5,24 @@
|
|||
с офлайн-каталогом. Все три видны в **простом режиме**, доступны ролям
|
||||
**Администратор** и **Кладовщик**.
|
||||
|
||||
## 1. Приёмка по фото/PDF накладной
|
||||
## 1. Приёмка по фото/PDF/Excel накладной
|
||||
|
||||
**Когда использовать:** поставщик привёз накладную в бумаге или в PDF —
|
||||
не хочется набивать строки руками.
|
||||
**Когда использовать:** поставщик привёз накладную в бумаге, в PDF, в
|
||||
Excel-шаблоне или прислал фото.
|
||||
|
||||
**Как:**
|
||||
1. `/purchases/invoices` → «Снять камерой» (с телефона) или «Загрузить
|
||||
файл» (image/* или PDF, до 60 МБ).
|
||||
1. `/purchases/invoices` → один из четырёх способов:
|
||||
- «Снять камерой» — одно/несколько фото через камеру телефона
|
||||
(каждый файл = отдельная накладная).
|
||||
- «Загрузить файл» — image/*, PDF, **шаблон .xlsx** или **.csv**
|
||||
(`docs/supplier-invoice-template.xlsx` — заготовка для поставщиков).
|
||||
- «Сфотографировать листы» — камера для многостраничной накладной
|
||||
(все снимки идут как **одна** накладная, не отдельные).
|
||||
- «Фото товаров + накладная» — главный сценарий: подряд снимаете
|
||||
накладную и каждый товар (штрихкод + этикетка), пачка идёт одним
|
||||
запросом → Claude сам собирает приёмку с миниатюрой у каждой строки.
|
||||
Там же «Готовые фото» — выбрать уже снятые из галереи, можно
|
||||
смешивать со снимаемыми.
|
||||
2. Загрузка попадает в очередь. Статус-бейдж на карточке: `В очереди
|
||||
→ Распознаётся → На проверке | Ошибка`. Страница обновляется сама
|
||||
каждые 3 секунды, пока есть распознающиеся.
|
||||
|
|
@ -28,10 +38,22 @@
|
|||
или удалить. Если товара нет — ссылка «Создать товар».
|
||||
- Сумма строк сравнивается с «Итого» в накладной; если
|
||||
отличается больше чем на 1 — предупреждение.
|
||||
4. «Сохранить правки» — обновляет документ, автоматически запоминает
|
||||
все ручные сопоставления. **На следующей накладной того же
|
||||
поставщика** matcher уже найдёт эти товары автоматически.
|
||||
5. «Создать приёмку» → диалог: поставщик (поиск по БИН/имени,
|
||||
4. **Автосохранение**: каждая правка (поля шапки, количество, цена,
|
||||
ручное сопоставление, скан) через ~700 мс сохраняется на сервере.
|
||||
Индикатор «сопоставлено N/M · сохраняю… / сохранено / не
|
||||
сохранилось» в нижней панели. Можно закрыть вкладку и открыть
|
||||
снова — ничего не потеряется. Ручные сопоставления запоминаются
|
||||
в `InvoiceAlias`: **на следующей накладной того же поставщика**
|
||||
matcher найдёт эти товары автоматически.
|
||||
5. **Общий сканер**: если fuzzy-match по названию неуверенный, появляется
|
||||
панель «<товар> — в какую строку накладной?» с кнопками строк. На
|
||||
этой же странице работает **аппаратный сканер** целиком — не нужно
|
||||
кликать в поле, серия цифр + Enter подхватывается глобально
|
||||
(если курсор не в текстовом поле, которое читает сам).
|
||||
6. **Фото-приёмка**: у каждой строки — миниатюра снимка товара,
|
||||
кликабельна для полного размера; пометки «цифры прочитаны
|
||||
неуверенно — отсканируйте» или «штрихкода нет в каталоге».
|
||||
7. «Создать приёмку» → диалог: поставщик (поиск по БИН/имени,
|
||||
можно создать нового), склад, валюта → `POST /apply` создаёт
|
||||
нашу Supply (Draft). Проведение — обычной кнопкой в приёмке.
|
||||
|
||||
|
|
@ -43,9 +65,14 @@
|
|||
запускает API. Можно пустить модель на отдельной машине через
|
||||
`Invoices:ClaudeCli:RemoteHost` (ssh + scp — всё внутри сервиса).
|
||||
|
||||
**Хранение файла:** `uploads/invoices/{yyyy-MM}/{guid}.ext`. Удаляется
|
||||
по истечении `Invoices:KeepFilesDays` (default 7). Распознанные строки
|
||||
остаются навсегда — по ним всегда видно, что было в накладной.
|
||||
**Хранение файла (owner-policy):** фото накладных на сервере **не копим**.
|
||||
- Обычная загрузка (одно фото/PDF/Excel) — файл удаляется **сразу после
|
||||
распознавания**. Распознанные строки живут в БД (`ParsedJson`).
|
||||
- Фото-приёмка (папка со снимками) — хранится **только пока накладная
|
||||
на проверке**: нужна для миниатюр у строк. Удаляется при создании
|
||||
черновика приёмки (apply), при удалении загрузки, и через **24 часа**
|
||||
— если «брошено» на статусе `needs_review`. `Invoices:KeepFilesDays`
|
||||
больше не используется.
|
||||
|
||||
## 2. Приёмка сканированием
|
||||
|
||||
|
|
@ -83,7 +110,15 @@
|
|||
После загрузки работает **без интернета**.
|
||||
|
||||
### Scan
|
||||
- Камера непрерывная или аппаратный сканер; +1 за скан;
|
||||
- Камера непрерывная; +1 за скан;
|
||||
- **поле аппаратного сканера на виду** (не спрятано в раскрывашку) —
|
||||
можно просто сканировать, а также:
|
||||
- **глобальный keydown** ловит сканер и когда курсор не в поле (серия
|
||||
цифр + Enter) — удобно, если вы перешли к другому элементу страницы;
|
||||
- **импорт Excel/CSV** (`Загрузить из файла`) — колонки «Штрихкод» и
|
||||
«Количество», повторы штрихкодов суммируются, товары добавляются
|
||||
к текущему списку. Документ при этом **не создаётся** — можно
|
||||
дополнительно просканировать и уже потом провести;
|
||||
- неизвестный штрихкод — в отдельный список «Нет в каталоге» с
|
||||
количеством (не попадёт в документ, нужно сначала создать карточку);
|
||||
- офлайн-плашка: нет сети = сканы остаются в телефоне, уйдут на сервер
|
||||
|
|
@ -135,14 +170,16 @@
|
|||
"Invoices": {
|
||||
"ClaudeCli": {
|
||||
"Executable": "claude",
|
||||
"Model": "claude-sonnet-4-6",
|
||||
"Model": "claude-sonnet-5-5",
|
||||
"TimeoutSeconds": 180,
|
||||
"PhotoTimeoutSeconds": 600, // пачка фото — долго, отдельный тайм-аут
|
||||
"RemoteHost": null, // или "user@host" — модель на другой машине
|
||||
"RemoteTmpDir": "/tmp/food-market-invoices"
|
||||
},
|
||||
"KeepFilesDays": 7
|
||||
}
|
||||
}
|
||||
```
|
||||
> Haiku сочиняет данные накладных — не использовать. `KeepFilesDays`
|
||||
> больше не читается (owner-policy: фото не копим).
|
||||
|
||||
## Что НЕ сделано (откладывается до запроса)
|
||||
|
||||
|
|
|
|||
167
scripts/make_supplier_template.py
Normal file
167
scripts/make_supplier_template.py
Normal 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("&", "&").replace("<", "<").replace(">", ">")
|
||||
.replace('"', """))
|
||||
|
||||
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()
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using foodmarket.Api.Infrastructure.Invoices;
|
||||
using foodmarket.Application.Common;
|
||||
using foodmarket.Application.Inventory;
|
||||
using foodmarket.Domain.Inventory;
|
||||
|
|
@ -66,13 +67,110 @@ public record InventoryInput(
|
|||
public record MobileCatalogRow(string Barcode, Guid ProductId, string Name);
|
||||
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")]
|
||||
public async Task<ActionResult<MobileCatalogResponse>> MobileCatalog(
|
||||
[FromQuery] Guid? groupId, CancellationToken ct)
|
||||
{
|
||||
// Берём все (OrgId implicit by filter) активные товары + их штрихкоды.
|
||||
// Отдаём массивом троек (а не объектом с полями) — для 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()
|
||||
join p in _db.Products.AsNoTracking() on pb.ProductId equals p.Id
|
||||
where !p.IsArchived
|
||||
|
|
@ -88,6 +186,19 @@ public record MobileCatalogResponse(int Count, DateTime BuiltAt, List<object[]>
|
|||
if (k.Length < 6 || !seen.Add(k)) continue;
|
||||
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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -84,6 +84,64 @@ public async Task<ActionResult<UploadResult>> Upload([FromForm] IFormFileCollect
|
|||
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 ───────────────────────────────────────────────────
|
||||
|
||||
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,
|
||||
string? SupplierName, string? InvoiceNumber, DateTime? InvoiceDate, decimal? TotalSum,
|
||||
int LinesTotal, int LinesMatched, Guid? SupplyId, string? Error,
|
||||
string? Source, bool HasFiles,
|
||||
List<InvoiceLine> Lines);
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
|
|
@ -116,9 +175,14 @@ public async Task<ActionResult<InvoiceDetailDto>> Detail(Guid id, CancellationTo
|
|||
var lines = string.IsNullOrEmpty(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,
|
||||
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")]
|
||||
|
|
@ -127,7 +191,7 @@ public async Task<IActionResult> File(Guid id, CancellationToken ct)
|
|||
var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (up is null) return NotFound();
|
||||
if (string.IsNullOrEmpty(up.StoredPath) || !System.IO.File.Exists(up.StoredPath))
|
||||
return NotFound(new { error = "Файл удалён — срок хранения истёк." });
|
||||
return NotFound(new { error = "Файл удалён." });
|
||||
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);
|
||||
if (up is null) return NotFound();
|
||||
try { if (!string.IsNullOrEmpty(up.StoredPath) && System.IO.File.Exists(up.StoredPath))
|
||||
System.IO.File.Delete(up.StoredPath); } catch { }
|
||||
// Удаляем файл И папку фото-пачки (owner-policy: не копить фото).
|
||||
InvoiceProcessor.DeleteStored(up);
|
||||
_db.InvoiceUploads.Remove(up);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return NoContent();
|
||||
|
|
@ -289,10 +353,10 @@ public async Task<ActionResult<ApplyResult>> Apply(Guid id, [FromBody] ApplyDto
|
|||
up.SupplyId = supply.Id;
|
||||
up.Status = "applied";
|
||||
up.ProcessedAt = DateTime.UtcNow;
|
||||
// Файл накладной больше не нужен (строки сохранены). Удаление — тихое.
|
||||
try { if (!string.IsNullOrEmpty(up.StoredPath) && System.IO.File.Exists(up.StoredPath))
|
||||
System.IO.File.Delete(up.StoredPath); } catch { }
|
||||
up.StoredPath = "";
|
||||
// Снимки/файл накладной больше не нужны (строки сохранены, черновик
|
||||
// приёмки создан — миниатюры у строк кладовщик уже видел). Owner-policy
|
||||
// 2026-10-05: фото накладных на сервере не копим.
|
||||
InvoiceProcessor.DeleteStored(up);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
return Ok(new ApplyResult(supply.Id, supply.Number, supply.Total, supply.Lines.Count));
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ public class ClaudeCliInvoiceExtractor : IInvoiceExtractor
|
|||
private readonly string _exe;
|
||||
private readonly string _model;
|
||||
private readonly int _timeoutSec;
|
||||
private readonly int _photoTimeoutSec;
|
||||
private readonly string? _remoteHost;
|
||||
private readonly string _remoteTmp;
|
||||
// Hotfix 2026-10-05: контейнер stage-api → ssh → dev-vm, где
|
||||
|
|
@ -35,8 +36,11 @@ public ClaudeCliInvoiceExtractor(IConfiguration cfg, ILogger<ClaudeCliInvoiceExt
|
|||
{
|
||||
_log = log;
|
||||
_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;
|
||||
// Два десятка снимков модель смотрит дольше, чем одну накладную.
|
||||
_photoTimeoutSec = int.TryParse(cfg["Invoices:ClaudeCli:PhotoTimeoutSeconds"], out var pt) ? pt : 600;
|
||||
_remoteHost = cfg["Invoices:ClaudeCli:RemoteHost"];
|
||||
_remoteTmp = cfg["Invoices:ClaudeCli:RemoteTmpDir"] ?? "/tmp/food-market-invoices";
|
||||
_sshKey = cfg["Invoices:ClaudeCli:SshKey"];
|
||||
|
|
@ -70,7 +74,9 @@ private void EnsureKnownHostsDir()
|
|||
public bool CanHandle(string fileName, string? contentType)
|
||||
{
|
||||
var ext = (Path.GetExtension(fileName) ?? "").ToLowerInvariant();
|
||||
return ext is ".jpg" or ".jpeg" or ".png" or ".webp" or ".heic" or ".pdf";
|
||||
// Фото/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) => $@"
|
||||
|
|
@ -144,6 +150,138 @@ public async Task<ParsedInvoice> ExtractAsync(string filePath, CancellationToken
|
|||
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(
|
||||
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
|
||||
/// лежит в поле <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();
|
||||
int inTok = 0, outTok = 0;
|
||||
|
|
@ -208,7 +348,7 @@ public static ParsedInvoice Parse(string cliOutput)
|
|||
if (payload.Length == 0) throw new InvoiceExtractionException("пустой ответ от claude");
|
||||
|
||||
ParsedInvoice inv;
|
||||
try { inv = FromJson(payload); }
|
||||
try { inv = fromJson(payload); }
|
||||
catch (JsonException ex) { throw new InvoiceExtractionException("не разобрать JSON от claude: " + ex.Message); }
|
||||
inv.Source = "vision";
|
||||
inv.RawJson = payload;
|
||||
|
|
|
|||
23
src/food-market.api/Infrastructure/Invoices/Ean.cs
Normal file
23
src/food-market.api/Infrastructure/Invoices/Ean.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,15 @@ public sealed class CatalogItem
|
|||
// (supplier c:code или supplier n:nameNorm) → productId + name
|
||||
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)
|
||||
{
|
||||
foreach (var c in catalog)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
namespace foodmarket.Api.Infrastructure.Invoices;
|
||||
|
||||
/// <summary>Строка накладной как её прочитала модель — ещё без привязки
|
||||
/// к нашему товару. Матчится по порядку: barcode → alias → ручной.</summary>
|
||||
/// <summary>Строка накладной как её прочитала модель или парсер — ещё без привязки
|
||||
/// к нашему товару. Матчится по порядку: barcode → alias → ручной (кнопкой).
|
||||
/// Для фото-приёмки заполняются ещё <see cref="Photo"/>, <see cref="BarcodeSource"/>,
|
||||
/// <see cref="Note"/> — что подсказать кладовщику при проверке.</summary>
|
||||
public class InvoiceLine
|
||||
{
|
||||
public string? Barcode { get; set; }
|
||||
|
|
@ -16,9 +18,33 @@ public class InvoiceLine
|
|||
/// <summary>Guid нашего товара если удалось сопоставить. Null — нет.</summary>
|
||||
public Guid? ProductId { 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 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
|
||||
|
|
@ -37,14 +63,17 @@ public class ParsedInvoice
|
|||
}
|
||||
|
||||
/// <summary>Чем именно читается документ. Реализации взаимозаменяемы:
|
||||
/// Excel-парсер (не реализован на этом шаге), Claude CLI, API-клиент — все
|
||||
/// возвращают один и тот же <see cref="ParsedInvoice"/>. Выбор делает
|
||||
/// конфиг, остальной код об этом не знает.</summary>
|
||||
/// Excel/CSV — <see cref="TableInvoiceParser"/> (кодом, без модели);
|
||||
/// фото/PDF одной накладной — <c>ExtractAsync</c>;
|
||||
/// пачка снимков фото-приёмки (накладная + товары) — <c>ExtractPhotosAsync</c>.
|
||||
/// Все возвращают <see cref="ParsedInvoice"/>; выбор делает <c>InvoiceProcessor</c>.</summary>
|
||||
public interface IInvoiceExtractor
|
||||
{
|
||||
string Name { get; }
|
||||
bool CanHandle(string fileName, string? contentType);
|
||||
Task<ParsedInvoice> ExtractAsync(string filePath, CancellationToken ct);
|
||||
/// <summary>Фото-приёмка: страницы накладной и снимки товаров вперемешку.</summary>
|
||||
Task<ParsedInvoice> ExtractPhotosAsync(IReadOnlyList<PhotoHint> photos, CancellationToken ct);
|
||||
}
|
||||
|
||||
public class InvoiceExtractionException : Exception
|
||||
|
|
|
|||
|
|
@ -5,15 +5,25 @@
|
|||
|
||||
namespace foodmarket.Api.Infrastructure.Invoices;
|
||||
|
||||
/// <summary>Разбирает загруженный файл накладной: вызывает extractor
|
||||
/// (Claude CLI), сопоставляет строки с нашими товарами (barcode + alias),
|
||||
/// сохраняет результат в <see cref="InvoiceUpload"/>. Supply на этом шаге
|
||||
/// НЕ создаётся — кладовщик смотрит и правит на экране проверки.
|
||||
/// <summary>Разбирает загруженный файл накладной: вызывает extractor (Claude CLI
|
||||
/// для фото/PDF, <see cref="TableInvoiceParser"/> для Excel/CSV, фото-пачку —
|
||||
/// отдельно через <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>
|
||||
/// — через IgnoreQueryFilters с явной фильтрацией по OrganizationId
|
||||
/// загрузки, потому что в background-worker нет HTTP-контекста и
|
||||
/// обычный tenant-фильтр не сработает.</summary>
|
||||
/// загрузки, потому что в background-worker нет HTTP-контекста и обычный
|
||||
/// tenant-фильтр не сработает.</summary>
|
||||
public sealed class InvoiceProcessor
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopes;
|
||||
|
|
@ -32,27 +42,42 @@ public async Task ProcessAsync(Guid uploadId, CancellationToken ct)
|
|||
.FirstOrDefaultAsync(x => x.Id == uploadId, ct);
|
||||
if (up is null) return;
|
||||
|
||||
var isPhotos = up.Source == "photos";
|
||||
try
|
||||
{
|
||||
up.Status = "parsing";
|
||||
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("Файл не найден на диске");
|
||||
|
||||
var parsed = await extractor.ExtractAsync(up.StoredPath, ct);
|
||||
// Owner-policy 2026-10-05: фото/сканы накладных на сервере НЕ хранить.
|
||||
// Удаляем сразу после распознавания — распознанные строки живут
|
||||
// в ParsedJson, этого достаточно. См. также catch-ветку.
|
||||
DeleteStored(up);
|
||||
|
||||
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);
|
||||
|
||||
// Фото-пачка → 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();
|
||||
foreach (var l in parsed.Lines)
|
||||
{
|
||||
if (isPhotos && MatchFromPhoto(l, matcher)) continue;
|
||||
matcher.Match(l, supplierKey);
|
||||
}
|
||||
|
||||
up.SupplierName = parsed.SupplierName;
|
||||
up.InvoiceNumber = parsed.InvoiceNumber;
|
||||
|
|
@ -62,7 +87,8 @@ public async Task ProcessAsync(Guid uploadId, CancellationToken ct)
|
|||
up.TotalSum = parsed.TotalSum;
|
||||
up.LinesTotal = parsed.Lines.Count;
|
||||
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.ApiOutputTokens = parsed.OutputTokens;
|
||||
up.ParsedJson = JsonSerializer.Serialize(parsed.Lines);
|
||||
|
|
@ -78,20 +104,89 @@ public async Task ProcessAsync(Guid uploadId, CancellationToken ct)
|
|||
up.Status = "failed";
|
||||
up.Error = ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message;
|
||||
up.ProcessedAt = DateTime.UtcNow;
|
||||
// Файл удаляем и при ошибке — хранить его смысла нет, человек
|
||||
// всё равно пересылает/переснимает.
|
||||
// На ошибку файл тоже удаляем — хранить его смысла нет, кладовщик
|
||||
// всё равно пересылает/переснимает. Фото-пачку убираем целиком.
|
||||
DeleteStored(up);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
_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
|
||||
{
|
||||
if (!string.IsNullOrEmpty(up.StoredPath) && File.Exists(up.StoredPath))
|
||||
File.Delete(up.StoredPath);
|
||||
var arr = JsonSerializer.Deserialize<List<List<string>>>(File.ReadAllText(meta)) ?? new();
|
||||
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 хрюкнула — не повод падать */ }
|
||||
up.StoredPath = "";
|
||||
|
|
@ -138,16 +233,14 @@ private static void DeleteStored(InvoiceUpload up)
|
|||
}
|
||||
|
||||
/// <summary>Разбирает всё, что в очереди (<c>Status=pending</c>). Фото
|
||||
/// читается 10-20 секунд, http-запрос на это держать не нужно. Запускается
|
||||
/// как BackgroundService (один worker на процесс).
|
||||
/// читается 10-20 секунд, фото-пачка — до 3 минут; http-запрос на это
|
||||
/// держать не нужно. Запускается как BackgroundService (один worker на процесс).
|
||||
///
|
||||
/// Hotfix 2026-10-05 (owner-policy): фото/сканы накладных на сервере
|
||||
/// хранить ЗАПРЕЩЕНО — файл удаляется ровно в <see cref="InvoiceProcessor"/>
|
||||
/// сразу после разбора (успех+неудача). Поэтому периодического sweep'а
|
||||
/// больше нет (<c>Invoices:KeepFilesDays</c> игнорируется — конфиг оставлен
|
||||
/// как no-op для обратной совместимости и будет удалён в следующей
|
||||
/// очистке конфигов). Разобранные строки (<c>ParsedJson</c>) живут
|
||||
/// бессрочно.</summary>
|
||||
/// Хранение файлов (owner-policy 2026-10-05): регулярной уборки «старше N
|
||||
/// дней» больше нет (<c>Invoices:KeepFilesDays</c> игнорируется). Отдельная
|
||||
/// задача этого worker'а — убирать <b>брошенные</b> фото-пачки, оставшиеся
|
||||
/// на статусе <c>needs_review</c> дольше 24 часов (строки в
|
||||
/// <c>ParsedJson</c> остаются, пропадают только снимки).</summary>
|
||||
public sealed class InvoiceWorker : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopes;
|
||||
|
|
@ -157,6 +250,8 @@ public sealed class InvoiceWorker : BackgroundService
|
|||
public InvoiceWorker(IServiceScopeFactory s, InvoiceProcessor p, ILogger<InvoiceWorker> l)
|
||||
{ _scopes = s; _proc = p; _log = l; }
|
||||
|
||||
private DateTime _lastSweep = DateTime.MinValue;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
|
|
@ -176,8 +271,39 @@ protected override async Task ExecuteAsync(CancellationToken ct)
|
|||
catch (OperationCanceledException) { break; }
|
||||
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); }
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
242
src/food-market.api/Infrastructure/Invoices/TableReader.cs
Normal file
242
src/food-market.api/Infrastructure/Invoices/TableReader.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -279,6 +279,19 @@ static string ApplyDefaultPoolConfig(string? raw)
|
|||
// см. Resources/EmailTemplates/*.html. Singleton с in-memory cache.
|
||||
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Email.EmailTemplates>();
|
||||
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 для
|
||||
// IRequest/IRequestHandler. Образцы: CreateSupplyCommand,
|
||||
// PostRetailSaleCommand, GetSalesReportQuery. Контроллеры пока не
|
||||
|
|
@ -533,6 +546,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
|||
// до проверки credential'ов в БД. Перед лимитером пик form-body /connect/token,
|
||||
// чтобы per-username бакет имел ключ — без этого мы можем считать только по IP,
|
||||
// и любая NAT/CI-машина роняется первой.
|
||||
app.UseResponseCompression();
|
||||
app.UseAuthFormUsernameKey();
|
||||
app.UseRateLimiter();
|
||||
// SignalR-clients не могут слать кастомные хедеры через WebSocket, поэтому
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ export default function CameraScanner({ onCode, onClose, feedback, counter }: {
|
|||
const [starting, setStarting] = useState(true)
|
||||
const [flash, setFlash] = useState<ScanFeedback | null>(null)
|
||||
const lastRef = useRef<{ code: string; at: number }>({ code: '', at: 0 })
|
||||
// Обработчик скана берём через ref: родитель передаёт новую стрелочную
|
||||
// функцию на каждом рендере, и раньше из-за этого камера закрывалась и
|
||||
// открывалась после каждого скана. Эффект инициализации с [] запускается
|
||||
// один раз. Порт правки из sklad (handoff §3).
|
||||
const onCodeRef = useRef(onCode)
|
||||
onCodeRef.current = onCode
|
||||
|
||||
useEffect(() => {
|
||||
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
|
||||
lastRef.current = { code, at: now }
|
||||
try { navigator.vibrate?.(40) } catch { /* не на всех */ }
|
||||
onCode(code)
|
||||
onCodeRef.current(code)
|
||||
},
|
||||
)
|
||||
if (dead) controls.stop()
|
||||
|
|
@ -76,7 +82,8 @@ export default function CameraScanner({ onCode, onClose, feedback, counter }: {
|
|||
})()
|
||||
|
||||
return () => { dead = true; stop?.() }
|
||||
}, [onCode])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const ok = flash?.kind === 'ok'
|
||||
|
||||
|
|
|
|||
188
src/food-market.web/src/components/PhotoSession.tsx
Normal file
188
src/food-market.web/src/components/PhotoSession.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
67
src/food-market.web/src/lib/shots.ts
Normal file
67
src/food-market.web/src/lib/shots.ts
Normal 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) }
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import { saveCatalog, findByBarcode, catalogMeta, type CatMeta } from '@/lib/off
|
|||
import CameraScanner, { type ScanFeedback } from '@/components/CameraScanner'
|
||||
import {
|
||||
ScanBarcode, Download, Camera, Trash2, Check, Loader2, AlertTriangle, WifiOff,
|
||||
ArrowLeft, Keyboard, Filter,
|
||||
ArrowLeft, Keyboard, Filter, FileSpreadsheet,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface Store { id: string; name: string; isMain: boolean }
|
||||
|
|
@ -55,6 +55,8 @@ export function InventoryMobilePage() {
|
|||
const [drift, setDrift] = useState<BookDriftRow[] | null>(null)
|
||||
const [filterOnlyDiff, setFilterOnlyDiff] = useState(false)
|
||||
const [posting, setPosting] = useState(false)
|
||||
const fileImportRef = useRef<HTMLInputElement>(null)
|
||||
const [importing, setImporting] = useState(false)
|
||||
|
||||
// online / offline listeners
|
||||
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 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">
|
||||
<Camera className="w-6 h-6" /> Сканировать камерой
|
||||
</button>
|
||||
<details className="text-sm">
|
||||
<summary className="text-slate-500 cursor-pointer select-none flex items-center gap-1">
|
||||
<Keyboard className="w-4 h-4" /> Аппаратный сканер — ввод штрихкода
|
||||
</summary>
|
||||
{/* Поле аппаратного сканера/ручного ввода — на виду (handoff §8): третий
|
||||
способ после камеры. Можно печатать мышкой курсор не ставя — поймает
|
||||
глобальный keydown. */}
|
||||
<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"
|
||||
onKeyDown={e => {
|
||||
if (e.key !== 'Enter') return
|
||||
|
|
@ -264,10 +326,27 @@ export function InventoryMobilePage() {
|
|||
const el = e.target as HTMLInputElement
|
||||
addScan(el.value); el.value = ''
|
||||
}}
|
||||
placeholder="Отсканируйте или введите штрихкод"
|
||||
className="mt-2 w-full rounded-lg border-2 border-slate-300 px-4 py-3 text-lg tabular-nums outline-none focus:border-sky-500"
|
||||
placeholder="Сканируйте или введите штрихкод и Enter"
|
||||
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" />
|
||||
</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 && (
|
||||
<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}
|
||||
|
|
|
|||
|
|
@ -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 { api } from '@/lib/api'
|
||||
import { useQuery, useMutation } from '@tanstack/react-query'
|
||||
import { ArrowLeft, Save, Check, AlertTriangle, Search, Trash2, Barcode as BarcodeIcon } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ArrowLeft, Check, AlertTriangle, Search, Trash2, Camera, ScanBarcode } from 'lucide-react'
|
||||
import CameraScanner, { type ScanFeedback } from '@/components/CameraScanner'
|
||||
|
||||
interface InvoiceLine {
|
||||
barcode?: string | null
|
||||
|
|
@ -15,8 +16,12 @@ interface InvoiceLine {
|
|||
vat?: number | null
|
||||
productId?: string | null
|
||||
productName?: string | null
|
||||
matchedBy: string // barcode | alias | manual | none
|
||||
matchedBy: string // barcode | alias | manual | scan | photo | photo-read | none
|
||||
matchScore: number
|
||||
// фото-приёмка
|
||||
photo?: number | null
|
||||
barcodeSource?: string | null
|
||||
note?: string | null
|
||||
}
|
||||
interface InvoiceDetail {
|
||||
id: string
|
||||
|
|
@ -31,6 +36,8 @@ interface InvoiceDetail {
|
|||
linesMatched: number
|
||||
supplyId: string | null
|
||||
error: string | null
|
||||
source: string | null
|
||||
hasFiles: boolean
|
||||
lines: InvoiceLine[]
|
||||
}
|
||||
|
||||
|
|
@ -39,22 +46,60 @@ interface CounterpartyHit { id: string; name: string; bin: string | null }
|
|||
interface Store { id: string; name: string; isMain: boolean }
|
||||
interface Currency { id: string; code: string }
|
||||
|
||||
/// Экран проверки накладной. Карточками — чтобы телефон читался; на
|
||||
/// desktop раскладывается в 2 колонки.
|
||||
const MATCHED_RU: Record<string, string> = {
|
||||
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() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const nav = useNavigate()
|
||||
const [lines, setLines] = useState<InvoiceLine[]>([])
|
||||
const [header, setHeader] = useState({ supplierName: '', invoiceNumber: '', invoiceDate: '', totalSum: '' })
|
||||
const [barcodeInput, setBarcodeInput] = useState('')
|
||||
const [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 [savedMsg, setSavedMsg] = useState<string | null>(null)
|
||||
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({
|
||||
queryKey: ['invoice', id],
|
||||
queryFn: () => api.get<InvoiceDetail>(`/api/purchases/invoices/${id}`).then(r => r.data),
|
||||
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(() => {
|
||||
|
|
@ -69,50 +114,148 @@ export function InvoiceReviewPage() {
|
|||
})
|
||||
}, [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 unmatched = useMemo(() => lines.filter(l => !l.productId).length, [lines])
|
||||
const matched = lines.length - unmatched
|
||||
const totalMismatch = useMemo(() => {
|
||||
const t = parseFloat(header.totalSum || '0')
|
||||
return t > 0 && Math.abs(sumByLines - t) > 1
|
||||
}, [sumByLines, header.totalSum])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
await api.put(`/api/purchases/invoices/${id}/lines`, {
|
||||
lines, supplierName: header.supplierName, invoiceNumber: header.invoiceNumber,
|
||||
invoiceDate: header.invoiceDate ? new Date(header.invoiceDate).toISOString() : null,
|
||||
totalSum: header.totalSum ? parseFloat(header.totalSum) : null,
|
||||
})
|
||||
},
|
||||
onSuccess: () => { setSavedMsg('Сохранено'); setTimeout(() => setSavedMsg(null), 2000) },
|
||||
onError: (e) => setSavedMsg(e instanceof Error ? e.message : 'Ошибка'),
|
||||
})
|
||||
|
||||
// scan в общий input: ищем товар, матчим в первую НЕсопоставленную подходящую строку
|
||||
const scanToAny = async (code: string) => {
|
||||
const clean = code.replace(/\D/g, '')
|
||||
// ── подбор строки по названию товара (Jaccard по словам) ───────────
|
||||
// Товар по штрихкоду известен точно, а строку надо подобрать среди
|
||||
// НЕзаполненных: названия в накладной и каталоге часто совсем разные
|
||||
// («Albeni 40gx24x6» ↔ «Шоколад Альбени 40г»). Порог 0.3 и отрыв 0.08
|
||||
// — иначе появляется окно с кнопками «в какую строку?».
|
||||
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)
|
||||
const want = new Set(norm(productName))
|
||||
return cur.map((l, i) => {
|
||||
const have = new Set(norm(l.name))
|
||||
let inter = 0
|
||||
want.forEach(w => { if (have.has(w)) inter++ })
|
||||
return { i, score: inter / (want.size + have.size - inter || 1) }
|
||||
}).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
|
||||
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}`)
|
||||
// Применяем в первую несопоставленную строку, иначе создаём новую.
|
||||
setLines(prev => {
|
||||
const idx = prev.findIndex(l => !l.productId)
|
||||
if (idx < 0) {
|
||||
return [...prev, { name: data.name, barcode: clean, quantity: 1, price: 0,
|
||||
matchedBy: 'manual', matchScore: 1, productId: data.productId, productName: data.name } as InvoiceLine]
|
||||
const p = { id: data.productId, name: data.name }
|
||||
putInRow(idx, p, clean)
|
||||
setScanMsg(`${p.name} → строка ${idx + 1}`, 'ok')
|
||||
setFb({ kind: 'ok', title: p.name, sub: `строка ${idx + 1}`, seq: ++seq.current })
|
||||
} catch {
|
||||
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>
|
||||
const d = detail.data
|
||||
const busy = d.status === 'pending' || d.status === 'parsing'
|
||||
|
||||
return (
|
||||
<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}
|
||||
</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="grid grid-cols-2 gap-2">
|
||||
<input className="rounded border border-slate-300 px-2 py-1.5 text-sm col-span-2"
|
||||
placeholder="Поставщик" value={header.supplierName}
|
||||
onChange={e => setHeader({ ...header, supplierName: e.target.value })} />
|
||||
onChange={e => editHeader(h => ({ ...h, supplierName: e.target.value }))} />
|
||||
<input className="rounded border border-slate-300 px-2 py-1.5 text-sm"
|
||||
placeholder="№" value={header.invoiceNumber}
|
||||
onChange={e => setHeader({ ...header, invoiceNumber: e.target.value })} />
|
||||
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"
|
||||
value={header.invoiceDate}
|
||||
onChange={e => setHeader({ ...header, invoiceDate: e.target.value })} />
|
||||
onChange={e => editHeader(h => ({ ...h, invoiceDate: e.target.value }))} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">Итого в накладной</span>
|
||||
<input className="w-28 text-right tabular-nums rounded border border-slate-300 px-2 py-1 text-sm"
|
||||
inputMode="decimal" placeholder="—" value={header.totalSum}
|
||||
onChange={e => setHeader({ ...header, totalSum: e.target.value })} />
|
||||
onChange={e => editHeader(h => ({ ...h, totalSum: e.target.value }))} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">Сумма по строкам</span>
|
||||
|
|
@ -164,23 +319,62 @@ export function InvoiceReviewPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* сканер в общее поле */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-3">
|
||||
<label className="block">
|
||||
<span className="text-xs text-slate-500 flex items-center gap-1 mb-1">
|
||||
<BarcodeIcon className="w-3.5 h-3.5" /> Скан штрихкода → в первую несопоставленную строку
|
||||
</span>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
value={barcodeInput}
|
||||
onChange={e => setBarcodeInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); scanToAny((e.target as HTMLInputElement).value) } }}
|
||||
placeholder="Отсканируйте или введите штрихкод"
|
||||
className="w-full rounded border border-slate-300 px-2 py-2 text-base tabular-nums"
|
||||
data-testid="invoice-scan-input"
|
||||
/>
|
||||
</label>
|
||||
{/* общее поле скана + панель выбора строки */}
|
||||
{!busy && unmatched > 0 && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-slate-800 font-medium">
|
||||
<ScanBarcode className="w-4 h-4" /> Отсканируйте товары, которых нет в каталоге
|
||||
</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 && (
|
||||
<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">
|
||||
{lines.map((l, i) => (
|
||||
<InvoiceLineCard
|
||||
key={i} line={l}
|
||||
onChange={(patched) => setLines(prev => prev.map((x, j) => j === i ? patched : x))}
|
||||
onRemove={() => setLines(prev => prev.filter((_, j) => j !== i))}
|
||||
key={i} index={i} line={l}
|
||||
invoiceId={d.id} hasFiles={d.hasFiles}
|
||||
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 })}
|
||||
onCamera={() => setCamRow(i)}
|
||||
onScanIntoRow={(code) => scanIntoRow(i, code)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -203,12 +400,16 @@ export function InvoiceReviewPage() {
|
|||
{/* bottom */}
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 p-3">
|
||||
<div className="max-w-5xl mx-auto flex items-center gap-2">
|
||||
<button onClick={() => save.mutate()} disabled={save.isPending}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-white border border-slate-300 py-3 font-medium hover:bg-slate-50 disabled:opacity-50">
|
||||
<Save className="w-4 h-4" /> {savedMsg ?? 'Сохранить правки'}
|
||||
</button>
|
||||
<button onClick={() => setApplyOpen(true)} disabled={d.status === 'applied' || lines.filter(l => l.productId).length === 0}
|
||||
className="flex-[1.2] inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 text-white py-3 font-medium hover:bg-sky-700 disabled:opacity-50">
|
||||
<span className="text-xs text-slate-500 shrink-0 w-32"
|
||||
data-testid="save-indicator">
|
||||
сопоставлено {matched}/{lines.length}
|
||||
{saveState === 'saving' && <span className="block text-slate-500">сохраняю…</span>}
|
||||
{saveState === 'saved' && <span className="block text-emerald-600">сохранено</span>}
|
||||
{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" /> Создать приёмку
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -217,7 +418,7 @@ export function InvoiceReviewPage() {
|
|||
{searchHit && (
|
||||
<ProductSearchDialog q={searchHit.q} onClose={() => setSearchHit(null)}
|
||||
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))
|
||||
setSearchHit(null)
|
||||
}} />
|
||||
|
|
@ -228,16 +429,36 @@ export function InvoiceReviewPage() {
|
|||
onClose={() => setApplyOpen(false)}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
/// Карточка строки. Оранжевый бордер если не сопоставлена.
|
||||
function InvoiceLineCard({ line, onChange, onRemove, onSearch }: {
|
||||
/// Карточка строки. Миниатюра снимка (если фото-приёмка), пометка
|
||||
/// «barcode_source: read — сверьте», оранжевый бордер если не сопоставлена.
|
||||
function InvoiceLineCard({ index, line, invoiceId, hasFiles, onChange, onRemove, onSearch, onCamera, onScanIntoRow }: {
|
||||
index: number
|
||||
line: InvoiceLine
|
||||
invoiceId: string
|
||||
hasFiles: boolean
|
||||
onChange: (l: InvoiceLine) => void
|
||||
onRemove: () => void
|
||||
onSearch: (q: string) => void
|
||||
onCamera: () => void
|
||||
onScanIntoRow: (code: string) => void
|
||||
}) {
|
||||
const bad = !line.productId
|
||||
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'}`}
|
||||
data-testid={bad ? 'invoice-line-unmatched' : 'invoice-line-ok'}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1 flex items-start gap-2">
|
||||
{hasFiles && line.photo != null && <Thumb id={invoiceId} n={line.photo} />}
|
||||
<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.barcode && <span className="ml-1 font-mono text-xs">{line.barcode}</span>}
|
||||
</div>
|
||||
<div className="font-medium text-slate-900 truncate">{line.name}</div>
|
||||
{line.productId
|
||||
? <div className="text-xs text-emerald-700 truncate">→ {line.productName}
|
||||
<span className="ml-1 text-slate-400">({line.matchedBy})</span>
|
||||
<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 className="text-xs text-amber-700">Товар не сопоставлен — найдите или создайте</div>}
|
||||
</div>
|
||||
<button onClick={onRemove} className="p-1 text-slate-400 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<label>
|
||||
<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). При выборе — колбэк наверх.
|
||||
function ProductSearchDialog({ q, onClose, onPick }: {
|
||||
q: string; onClose: () => void; onPick: (h: ProductSearchHit) => void
|
||||
|
|
@ -300,7 +557,6 @@ function ProductSearchDialog({ q, onClose, onPick }: {
|
|||
queryKey: ['product-search', query],
|
||||
queryFn: async () => {
|
||||
if (query.trim().length < 2) return []
|
||||
// Используем общий products endpoint с q=
|
||||
const { data } = await api.get<{ items: ProductSearchHit[] }>(`/api/catalog/products?q=${encodeURIComponent(query)}&pageSize=20`)
|
||||
return data.items ?? []
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { useRef, useState } from 'react'
|
||||
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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import PhotoSession, { type Shot } from '@/components/PhotoSession'
|
||||
|
||||
/// Жизненный цикл: pending → parsing → needs_review → applied | failed.
|
||||
/// Статус-бейдж + подсказка внизу карточки.
|
||||
|
|
@ -34,9 +35,18 @@ export function InvoicesPage() {
|
|||
const qc = useQueryClient()
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const cameraRef = useRef<HTMLInputElement>(null)
|
||||
const goodsFileRef = useRef<HTMLInputElement>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
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 — чтобы юзер видел прогресс
|
||||
// без ручного F5. Останавливается, когда все статусы терминальные.
|
||||
const list = useQuery({
|
||||
|
|
@ -61,6 +71,32 @@ export function InvoicesPage() {
|
|||
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) => {
|
||||
if (!f || f.length === 0) return
|
||||
setUploading(true)
|
||||
|
|
@ -70,6 +106,17 @@ export function InvoicesPage() {
|
|||
|
||||
return (
|
||||
<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>
|
||||
<h1 className="text-2xl font-semibold text-slate-900">Накладные</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
|
|
@ -77,7 +124,7 @@ export function InvoicesPage() {
|
|||
</p>
|
||||
</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
|
||||
onClick={() => cameraRef.current?.click()}
|
||||
disabled={uploading}
|
||||
|
|
@ -94,12 +141,65 @@ export function InvoicesPage() {
|
|||
>
|
||||
<Upload className="w-5 h-5" /> Загрузить файл
|
||||
</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
|
||||
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)} />
|
||||
</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 && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-sm">{err}</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 save = async () => {
|
||||
|
|
|
|||
|
|
@ -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("сверьте вес");
|
||||
}
|
||||
}
|
||||
57
tests/food-market.UnitTests/EanTests.cs
Normal file
57
tests/food-market.UnitTests/EanTests.cs
Normal 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}: ошибку в одной цифре должен поймать");
|
||||
}
|
||||
}
|
||||
}
|
||||
53
tests/food-market.UnitTests/InvoicePhotoRetentionTests.cs
Normal file
53
tests/food-market.UnitTests/InvoicePhotoRetentionTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
173
tests/food-market.UnitTests/TableReaderTests.cs
Normal file
173
tests/food-market.UnitTests/TableReaderTests.cs
Normal 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); }
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue