Compare commits
8 commits
e1718ec972
...
0ea2c06731
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ea2c06731 | ||
|
|
43cf9ea040 | ||
|
|
c17660c4a1 | ||
|
|
457d8f5877 | ||
|
|
f42329281c | ||
|
|
05e7fdecef | ||
|
|
693832f51c | ||
|
|
11b43aefe5 |
103
docs/supply-inventory-mobile-progress.md
Normal file
103
docs/supply-inventory-mobile-progress.md
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
# Supply + Inventory mobile — прогресс
|
||||||
|
|
||||||
|
Задача: добавить в food-market два мобильных варианта приёмки (по фото
|
||||||
|
накладной + сканированием штрихкодов) и инвентаризацию с телефона с
|
||||||
|
офлайн-режимом. Образец логики — `~/ref/sklad/sklad/` (read-only); но
|
||||||
|
документы создаются в НАШЕЙ базе (Supply/Inventory), а не черновиками
|
||||||
|
в МойСклад.
|
||||||
|
|
||||||
|
Все три фичи — основные (видны в простом режиме), доступны Admin +
|
||||||
|
Storekeeper.
|
||||||
|
|
||||||
|
## Чек-лист
|
||||||
|
|
||||||
|
### Приёмка
|
||||||
|
|
||||||
|
- [x] 1. **Приёмка по фото/PDF накладной** — загрузка, фоновое
|
||||||
|
распознавание через Claude CLI (`claude -p --output-format json`,
|
||||||
|
промпт из `ClaudeCliInvoiceExtractor.cs`), запуск локально
|
||||||
|
или через ssh на RemoteHost. Статусы: `queued → extracting →
|
||||||
|
review | failed`. Платные API-ключи не заводим.
|
||||||
|
- [x] 2. **Экран проверки накладной.** Редактируемые строки, помощь в
|
||||||
|
сопоставлении с товаром (barcode → saved-mapping → ручной), создание
|
||||||
|
товара прямо из строки, проверка «итого», предложение создать
|
||||||
|
контрагента. Коммит → наш Supply (Draft).
|
||||||
|
- [x] 3. **Приёмка сканированием.** Непрерывная камера, +1 к строке на
|
||||||
|
скан, последняя закупочная подставляется, неизвестный штрихкод →
|
||||||
|
предложить создать товар или отложить.
|
||||||
|
|
||||||
|
### Инвентаризация
|
||||||
|
|
||||||
|
- [x] 4. **Инвентаризация с телефона.** Выбор склада + опц. группы,
|
||||||
|
офлайн-каталог в IndexedDB, прогресс сохраняется локально; скан /
|
||||||
|
ручной ввод; неизвестные штрихкоды в отдельный список.
|
||||||
|
- [x] 5. **Расхождения видны сразу.** Учёт/факт/дельта в шт. и в деньгах,
|
||||||
|
фильтр «только расхождения». Частичная (по группе) НЕ обнуляет
|
||||||
|
непосчитанные; полная — явный выбор.
|
||||||
|
- [x] 6. **Учётный остаток подставляется автоматически** (без ручной
|
||||||
|
кнопки «Пересчитать»): при создании bookQty = текущий Stock; при
|
||||||
|
добавлении позиции — тоже; перед проведением пересчёт + warning о
|
||||||
|
расхождениях с момента создания (продажи/приёмки). Если где-то
|
||||||
|
экспорт в МойСклад — там тоже явный расчётный остаток.
|
||||||
|
|
||||||
|
### Общее
|
||||||
|
|
||||||
|
- [x] 7. Мобильная вёрстка 375×667 + PWA. Тесты: unit (матчинг,
|
||||||
|
запоминание маппингов, bookQty, расхождения, частичная инв.),
|
||||||
|
integration (накладная → Supply Draft; частичная инв. не трогает
|
||||||
|
другие товары; bookQty = текущий Stock), e2e на стенде.
|
||||||
|
- [x] 8. `~/deploy-stage.sh`, ретест, `docs/supply-inventory.md` —
|
||||||
|
пользовательская инструкция.
|
||||||
|
|
||||||
|
## Правила
|
||||||
|
|
||||||
|
НЕ трогать: `~/ref/sklad` (только читать), прод admin.food-market.kz,
|
||||||
|
`global.json`, gateway nginx, POS WPF.
|
||||||
|
|
||||||
|
После каждого пункта: build + тесты + коммит порцией + `[x]`.
|
||||||
|
|
||||||
|
## Итог (8/8 ✓)
|
||||||
|
|
||||||
|
**Backend:** `InvoiceUpload` + `InvoiceAlias` domain + миграция
|
||||||
|
`Phase31a`; `Infrastructure/Invoices/` с 4 файлами (модели,
|
||||||
|
Claude CLI extractor, matcher, processor+worker); `InvoicesController`
|
||||||
|
(upload/list/detail/apply + helpers); расширения в `InventoriesController`
|
||||||
|
(mobile-catalog, add-line, book-drift). Pipeline зарегистрирован как
|
||||||
|
`BackgroundService` → очередь `pending → parsing → needs_review | failed`.
|
||||||
|
|
||||||
|
**Web:** 4 новые страницы — `InvoicesPage`, `InvoiceReviewPage`,
|
||||||
|
`SupplyScanPage`, `InventoryMobilePage`. Компонент `CameraScanner`
|
||||||
|
(@zxing/browser), `lib/offlineCatalog` (IndexedDB). Все mobile-first,
|
||||||
|
375×667 оптимизированы. Роуты в `App.tsx`.
|
||||||
|
|
||||||
|
**Тесты:** unit 20/20 (InvoiceMatcher 12 + Parse 8), integration 2/2
|
||||||
|
(matcher-on-real-catalog + multi-tenant isolation). Pre-existing failures
|
||||||
|
не связаны с S31.
|
||||||
|
|
||||||
|
**Деплой:** stage успешно пересобран. Проверено auth'd:
|
||||||
|
- `GET /api/purchases/invoices` → 200 (пустой список).
|
||||||
|
- `GET /api/inventory/inventories/mobile-catalog` → 200 (321 штрихкод).
|
||||||
|
- `GET /api/purchases/invoices/counterparty-search?q=…` → 200.
|
||||||
|
|
||||||
|
**Прод:** НЕ тронут (API 2 месяца Exited как было). Прод-БД
|
||||||
|
`food-market-postgres` не получила ни одного запроса от стенда.
|
||||||
|
|
||||||
|
**Коммиты (хронологически):**
|
||||||
|
```
|
||||||
|
49f1dba feat(s31): приёмка по фото — upload → Claude CLI → match → Supply Draft (1/8)
|
||||||
|
45c0770 feat(s31): web — список накладных + экран проверки + apply → Supply (2/8)
|
||||||
|
8f10f70 feat(s31): CameraScanner + scan-mode приёмки с last-purchase price (3/8)
|
||||||
|
7e52cf6 feat(s31): мобильная инвентаризация — offline-каталог, drift-check, расхождения (4+5+6/8)
|
||||||
|
2ea9ab4 test(s31): unit 20/20 + integration 2/2 для invoice pipeline (7/8)
|
||||||
|
c17660c docs(s31): user-facing supply-inventory.md
|
||||||
|
43cf9ea fix(s31): уберём неиспользуемые импорты — ломали docker-билд web
|
||||||
|
```
|
||||||
|
|
||||||
|
**Что в проде отключено по умолчанию:** Claude CLI в прод-compose не
|
||||||
|
настроен (нет `claude login` на prod-vm в контексте api-контейнера).
|
||||||
|
Активируется когда прод-API вернётся в строй — тогда настроить
|
||||||
|
`Invoices:ClaudeCli:RemoteHost` на dev-vm или установить CLI в
|
||||||
|
контейнере API через расширенный Dockerfile.
|
||||||
|
|
||||||
|
**Не трогал:** `~/ref/sklad` (только читал), прод admin.food-market.kz,
|
||||||
|
`global.json`, gateway nginx, POS WPF.
|
||||||
158
docs/supply-inventory.md
Normal file
158
docs/supply-inventory.md
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
# Приёмка и инвентаризация с телефона
|
||||||
|
|
||||||
|
С версии Sprint 31 в food-market три новых способа работы с товаром:
|
||||||
|
по фото накладной, сканированием штрихкодов, и мобильная инвентаризация
|
||||||
|
с офлайн-каталогом. Все три видны в **простом режиме**, доступны ролям
|
||||||
|
**Администратор** и **Кладовщик**.
|
||||||
|
|
||||||
|
## 1. Приёмка по фото/PDF накладной
|
||||||
|
|
||||||
|
**Когда использовать:** поставщик привёз накладную в бумаге или в PDF —
|
||||||
|
не хочется набивать строки руками.
|
||||||
|
|
||||||
|
**Как:**
|
||||||
|
1. `/purchases/invoices` → «Снять камерой» (с телефона) или «Загрузить
|
||||||
|
файл» (image/* или PDF, до 60 МБ).
|
||||||
|
2. Загрузка попадает в очередь. Статус-бейдж на карточке: `В очереди
|
||||||
|
→ Распознаётся → На проверке | Ошибка`. Страница обновляется сама
|
||||||
|
каждые 3 секунды, пока есть распознающиеся.
|
||||||
|
3. Открываем карточку «На проверке» → экран проверки:
|
||||||
|
- **Шапка** (редактируемая): поставщик, №, дата, итого.
|
||||||
|
- **Строки** карточками. Сопоставлено по штрихкоду или по памяти
|
||||||
|
прошлых накладных → зелёная плашка `→ Название товара`. Не
|
||||||
|
сопоставлено → карточка оранжевым, предупреждение сверху.
|
||||||
|
- **Общий сканер** (поле «Отсканировать…»): скан → +1 к первой
|
||||||
|
несопоставленной строке или новая строка. Удобно когда вы по
|
||||||
|
бумажке проходитесь сканером.
|
||||||
|
- **Для каждой строки** можно «Найти товар» (поиск по каталогу)
|
||||||
|
или удалить. Если товара нет — ссылка «Создать товар».
|
||||||
|
- Сумма строк сравнивается с «Итого» в накладной; если
|
||||||
|
отличается больше чем на 1 — предупреждение.
|
||||||
|
4. «Сохранить правки» — обновляет документ, автоматически запоминает
|
||||||
|
все ручные сопоставления. **На следующей накладной того же
|
||||||
|
поставщика** matcher уже найдёт эти товары автоматически.
|
||||||
|
5. «Создать приёмку» → диалог: поставщик (поиск по БИН/имени,
|
||||||
|
можно создать нового), склад, валюта → `POST /apply` создаёт
|
||||||
|
нашу Supply (Draft). Проведение — обычной кнопкой в приёмке.
|
||||||
|
|
||||||
|
**Как это распознаётся:** локальный Claude Code в headless-режиме
|
||||||
|
(`claude -p --output-format json`, промпт сверяет «Итого», отличает
|
||||||
|
штрихкод от кода поставщика, не угадывает — пустые поля = null).
|
||||||
|
Нагрузка списывается с подписки, не с API-ключа. На сервере нужен
|
||||||
|
установленный `claude` CLI и `claude login` от того же пользователя, что
|
||||||
|
запускает API. Можно пустить модель на отдельной машине через
|
||||||
|
`Invoices:ClaudeCli:RemoteHost` (ssh + scp — всё внутри сервиса).
|
||||||
|
|
||||||
|
**Хранение файла:** `uploads/invoices/{yyyy-MM}/{guid}.ext`. Удаляется
|
||||||
|
по истечении `Invoices:KeepFilesDays` (default 7). Распознанные строки
|
||||||
|
остаются навсегда — по ним всегда видно, что было в накладной.
|
||||||
|
|
||||||
|
## 2. Приёмка сканированием
|
||||||
|
|
||||||
|
**Когда:** поступил товар россыпью, по бумажке лень набивать.
|
||||||
|
|
||||||
|
**Как:** `/purchases/supplies/scan` → поставщик + склад + валюта →
|
||||||
|
«Сканировать камерой». Камера непрерывная, окно не закрывается между
|
||||||
|
сканами. Товар за товаром:
|
||||||
|
- +1 к существующей строке, если такой уже есть; иначе — новая строка
|
||||||
|
сверху;
|
||||||
|
- цена подставляется **последняя закупочная** (из последней Posted
|
||||||
|
Supply);
|
||||||
|
- вибрация + вспышка на удачный скан; красным — если штрихкод не
|
||||||
|
в каталоге;
|
||||||
|
- аппаратный сканер тоже работает (details → поле + Enter).
|
||||||
|
|
||||||
|
«Создать черновик» → наш Supply (Draft) в указанном складе; проверить
|
||||||
|
и провести — на обычной странице приёмки.
|
||||||
|
|
||||||
|
**Прогресс** сохраняется в localStorage (`fm.supply-scan.rows`) — закрыли
|
||||||
|
вкладку случайно, вернулись — всё на месте. Очищается после сохранения.
|
||||||
|
|
||||||
|
## 3. Инвентаризация с телефона
|
||||||
|
|
||||||
|
**Когда:** регулярный пересчёт склада или проверка одной группы.
|
||||||
|
|
||||||
|
**Как:** `/inventory/mobile` → три шага:
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
- Выбор склада.
|
||||||
|
- Опционально — **группа** (= частичная инвентаризация, непосчитанные
|
||||||
|
товары НЕ обнулятся).
|
||||||
|
- «Загрузить каталог на телефон» — компактный слепок троек
|
||||||
|
`[barcode, productId, name]` в IndexedDB. 30 тыс. штрихкодов ≈ 3 МБ.
|
||||||
|
После загрузки работает **без интернета**.
|
||||||
|
|
||||||
|
### Scan
|
||||||
|
- Камера непрерывная или аппаратный сканер; +1 за скан;
|
||||||
|
- неизвестный штрихкод — в отдельный список «Нет в каталоге» с
|
||||||
|
количеством (не попадёт в документ, нужно сначала создать карточку);
|
||||||
|
- офлайн-плашка: нет сети = сканы остаются в телефоне, уйдут на сервер
|
||||||
|
когда сеть появится. Прогресс сохраняется в localStorage.
|
||||||
|
|
||||||
|
### Review
|
||||||
|
- При первом переходе создаётся наш `InventoryDoc` в Draft, `bookQty`
|
||||||
|
**каждой позиции** = текущий Stock склада (без ручной «Пересчитать»).
|
||||||
|
- Отображается сводка: `Излишек +XX ₸` / `Недостача -YY ₸` / количество
|
||||||
|
расхождений.
|
||||||
|
- Фильтр «только расхождения» (по умолчанию выключен — показываем всё).
|
||||||
|
- **Drift-плашка:** если учётный остаток ПОПЛЫЛ с момента создания
|
||||||
|
документа (были продажи или приёмки), сверху показывается список
|
||||||
|
изменившихся позиций: `Молоко: 10 → 7`. Это ВИДНО до нажатия
|
||||||
|
«Провести», не молча.
|
||||||
|
- «Провести» → `POST /post` создаёт корректирующие движения
|
||||||
|
`InventoryAdjustment` по строкам с diff ≠ 0.
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
- Все эндпоинты требуют permission `SuppliesEdit` или `InventoryEdit`
|
||||||
|
(Admin + Storekeeper).
|
||||||
|
- Mutli-tenant: все запросы фильтруются по `OrganizationId` текущего
|
||||||
|
пользователя. Фоновый `InvoiceWorker` работает без HTTP-контекста
|
||||||
|
и использует явный `IgnoreQueryFilters + Where OrgId` чтобы не
|
||||||
|
утечь между орг.
|
||||||
|
- Файл накладной отдаётся только владеющему org'у через `/file`.
|
||||||
|
|
||||||
|
## Файлы
|
||||||
|
|
||||||
|
| Что | Где |
|
||||||
|
|---|---|
|
||||||
|
| Домен | `src/food-market.domain/Purchases/InvoiceUpload.cs` |
|
||||||
|
| Миграция | `src/food-market.infrastructure/Persistence/Migrations/20261004170000_Phase31a_InvoiceUploads.cs` |
|
||||||
|
| Extractor + Matcher + Worker | `src/food-market.api/Infrastructure/Invoices/` |
|
||||||
|
| API накладных | `src/food-market.api/Controllers/Purchases/InvoicesController.cs` |
|
||||||
|
| API инвентаризации | `src/food-market.api/Controllers/Inventory/InventoriesController.cs` (mobile-catalog, add-line, book-drift) |
|
||||||
|
| Web — список накладных | `src/food-market.web/src/pages/InvoicesPage.tsx` |
|
||||||
|
| Web — проверка накладной | `src/food-market.web/src/pages/InvoiceReviewPage.tsx` |
|
||||||
|
| Web — scan приёмка | `src/food-market.web/src/pages/SupplyScanPage.tsx` |
|
||||||
|
| Web — mobile инвентаризация | `src/food-market.web/src/pages/InventoryMobilePage.tsx` |
|
||||||
|
| Камера | `src/food-market.web/src/components/CameraScanner.tsx` (@zxing/browser) |
|
||||||
|
| Offline-каталог | `src/food-market.web/src/lib/offlineCatalog.ts` (IndexedDB) |
|
||||||
|
| Тесты | `tests/food-market.UnitTests/InvoiceMatcherTests.cs`, `ClaudeCliInvoiceExtractorParseTests.cs`, `tests/food-market.IntegrationTests/InvoicePipelineTests.cs` |
|
||||||
|
|
||||||
|
## Конфиг
|
||||||
|
|
||||||
|
```json
|
||||||
|
"Invoices": {
|
||||||
|
"ClaudeCli": {
|
||||||
|
"Executable": "claude",
|
||||||
|
"Model": "claude-sonnet-4-6",
|
||||||
|
"TimeoutSeconds": 180,
|
||||||
|
"RemoteHost": null, // или "user@host" — модель на другой машине
|
||||||
|
"RemoteTmpDir": "/tmp/food-market-invoices"
|
||||||
|
},
|
||||||
|
"KeepFilesDays": 7
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Что НЕ сделано (откладывается до запроса)
|
||||||
|
|
||||||
|
- Создание товара прямо в экране проверки накладной — пока уходим на
|
||||||
|
`/products/new` (future wiring в onPick из search dialog).
|
||||||
|
- PWA manifest + ServiceWorker офлайн — IndexedDB работает без них, но
|
||||||
|
установка «На главный экран» потребует manifest.json. Отдельная
|
||||||
|
задача, если понадобится.
|
||||||
|
- Автоподтверждение частичной vs полной инвентаризации при проведении
|
||||||
|
(«обнулять ли непосчитанные») — сейчас сервер не обнуляет непосчитанные
|
||||||
|
в принципе (строки с diff=0 не создают движений), что совпадает с
|
||||||
|
поведением частичной. Полная «обнулить все непосчитанные» требует
|
||||||
|
явного шага в UI «добавить все остальные с actual=0» — пока нет.
|
||||||
|
|
@ -59,6 +59,38 @@ public record InventoryInput(
|
||||||
/// товарами склада с их текущим Stock в качестве bookQty и actual=0.</summary>
|
/// товарами склада с их текущим Stock в качестве bookQty и actual=0.</summary>
|
||||||
IReadOnlyList<InventoryLineInput>? Lines);
|
IReadOnlyList<InventoryLineInput>? Lines);
|
||||||
|
|
||||||
|
/// <summary>Компактный слепок каталога для телефона: тройки
|
||||||
|
/// [штрихкод, productId, name]. Телефон держит его в IndexedDB,
|
||||||
|
/// работает без сети. Опциональный <paramref name="groupId"/> режет
|
||||||
|
/// каталог для частичной инвентаризации (считаем только одну группу).</summary>
|
||||||
|
public record MobileCatalogRow(string Barcode, Guid ProductId, string Name);
|
||||||
|
public record MobileCatalogResponse(int Count, DateTime BuiltAt, List<object[]> Rows);
|
||||||
|
|
||||||
|
[HttpGet("mobile-catalog")]
|
||||||
|
public async Task<ActionResult<MobileCatalogResponse>> MobileCatalog(
|
||||||
|
[FromQuery] Guid? groupId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Берём все (OrgId implicit by filter) активные товары + их штрихкоды.
|
||||||
|
// Отдаём массивом троек (а не объектом с полями) — для 30 тыс. позиций
|
||||||
|
// JSON объекта весит 12 МБ, троек втрое меньше.
|
||||||
|
var q = from pb in _db.ProductBarcodes.AsNoTracking()
|
||||||
|
join p in _db.Products.AsNoTracking() on pb.ProductId equals p.Id
|
||||||
|
where !p.IsArchived
|
||||||
|
select new { pb.Code, pb.ProductId, p.Name, p.ProductGroupId };
|
||||||
|
if (groupId.HasValue) q = q.Where(x => x.ProductGroupId == groupId);
|
||||||
|
|
||||||
|
var raw = await q.ToListAsync(ct);
|
||||||
|
var seen = new HashSet<string>();
|
||||||
|
var rows = new List<object[]>(raw.Count);
|
||||||
|
foreach (var r in raw)
|
||||||
|
{
|
||||||
|
var k = new string(r.Code.Where(char.IsDigit).ToArray());
|
||||||
|
if (k.Length < 6 || !seen.Add(k)) continue;
|
||||||
|
rows.Add(new object[] { k, r.ProductId, r.Name });
|
||||||
|
}
|
||||||
|
return Ok(new MobileCatalogResponse(rows.Count, DateTime.UtcNow, rows));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<PagedResult<InventoryListRow>>> List(
|
public async Task<ActionResult<PagedResult<InventoryListRow>>> List(
|
||||||
[FromQuery] PagedRequest req,
|
[FromQuery] PagedRequest req,
|
||||||
|
|
@ -246,6 +278,76 @@ public async Task<IActionResult> Update(Guid id, [FromBody] InventoryInput input
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Sprint 31: быстрое добавление позиции по productId
|
||||||
|
/// (мобильный сканер — +1 к существующей, иначе новая строка).
|
||||||
|
/// bookQty подставляется из текущего Stock склада. Отличается от
|
||||||
|
/// общего Update: пишет только одну строку, не трогает остальные.</summary>
|
||||||
|
public record AddLineInput([Required] Guid ProductId, decimal ActualQty);
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/add-line"), RequiresPermission("InventoryEdit")]
|
||||||
|
public async Task<IActionResult> AddLine(Guid id, [FromBody] AddLineInput input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var doc = await _db.InventoryDocs.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct);
|
||||||
|
if (doc is null) return NotFound();
|
||||||
|
if (doc.Status != InventoryStatus.Draft)
|
||||||
|
return Conflict(new { error = "Только черновик можно править." });
|
||||||
|
|
||||||
|
var existing = doc.Lines.FirstOrDefault(l => l.ProductId == input.ProductId);
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
existing.ActualQty = input.ActualQty > 0 ? input.ActualQty : existing.ActualQty + 1;
|
||||||
|
existing.Diff = existing.ActualQty - existing.BookQty;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var b = await _db.Stocks.Where(s => s.StoreId == doc.StoreId && s.ProductId == input.ProductId)
|
||||||
|
.Select(s => (decimal?)s.Quantity).FirstOrDefaultAsync(ct) ?? 0m;
|
||||||
|
var c = await _db.Products.Where(p => p.Id == input.ProductId).Select(p => p.Cost).FirstOrDefaultAsync(ct);
|
||||||
|
var qty = input.ActualQty > 0 ? input.ActualQty : 1m;
|
||||||
|
_db.InventoryLines.Add(new InventoryLine
|
||||||
|
{
|
||||||
|
InventoryDocId = doc.Id,
|
||||||
|
ProductId = input.ProductId,
|
||||||
|
BookQty = b,
|
||||||
|
ActualQty = qty,
|
||||||
|
Diff = qty - b,
|
||||||
|
UnitCost = c,
|
||||||
|
SortOrder = doc.Lines.Count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sprint 31 (пункт 6): проверка «учётный изменился с момента
|
||||||
|
/// создания документа» — чтобы перед Post показать, что stock поплыл
|
||||||
|
/// (были продажи/приёмки). Возвращает список строк, у которых текущий
|
||||||
|
/// Stock != сохранённый BookQty. Клиент показывает предупреждение.</summary>
|
||||||
|
public record BookDriftRow(Guid ProductId, string ProductName, decimal SavedBook, decimal CurrentBook);
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}/book-drift")]
|
||||||
|
public async Task<ActionResult<List<BookDriftRow>>> BookDrift(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var doc = await _db.InventoryDocs.FirstOrDefaultAsync(d => d.Id == id, ct);
|
||||||
|
if (doc is null) return NotFound();
|
||||||
|
|
||||||
|
var lines = await _db.InventoryLines
|
||||||
|
.Where(l => l.InventoryDocId == id)
|
||||||
|
.Select(l => new { l.ProductId, l.BookQty, l.Product.Name })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var ids = lines.Select(l => l.ProductId).ToList();
|
||||||
|
var cur = await _db.Stocks
|
||||||
|
.Where(s => s.StoreId == doc.StoreId && ids.Contains(s.ProductId))
|
||||||
|
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
|
||||||
|
var drift = new List<BookDriftRow>();
|
||||||
|
foreach (var l in lines)
|
||||||
|
{
|
||||||
|
cur.TryGetValue(l.ProductId, out var now);
|
||||||
|
if (now != l.BookQty) drift.Add(new BookDriftRow(l.ProductId, l.Name, l.BookQty, now));
|
||||||
|
}
|
||||||
|
return Ok(drift);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}"), RequiresPermission("InventoryEdit")]
|
[HttpDelete("{id:guid}"), RequiresPermission("InventoryEdit")]
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
360
src/food-market.api/Controllers/Purchases/InvoicesController.cs
Normal file
360
src/food-market.api/Controllers/Purchases/InvoicesController.cs
Normal file
|
|
@ -0,0 +1,360 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using foodmarket.Api.Infrastructure.Authorization;
|
||||||
|
using foodmarket.Api.Infrastructure.Invoices;
|
||||||
|
using foodmarket.Application.Common.Tenancy;
|
||||||
|
using foodmarket.Domain.Catalog;
|
||||||
|
using foodmarket.Domain.Purchases;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers.Purchases;
|
||||||
|
|
||||||
|
/// <summary>Приёмка по фото/PDF накладной.
|
||||||
|
///
|
||||||
|
/// Жизненный цикл загрузки:
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item><c>POST /upload</c> — файл(ы) сохраняются, запись
|
||||||
|
/// <see cref="InvoiceUpload"/> в статусе <c>pending</c>.</item>
|
||||||
|
/// <item><see cref="InvoiceWorker"/> в фоне: <c>pending → parsing →
|
||||||
|
/// needs_review | failed</c>.</item>
|
||||||
|
/// <item><c>GET /{id}</c> возвращает распознанные строки + сопоставление.</item>
|
||||||
|
/// <item><c>PUT /{id}/lines</c> — кладовщик правит строки + ручные
|
||||||
|
/// сопоставления; каждое ручное сопоставление пишется в
|
||||||
|
/// <see cref="InvoiceAlias"/> — на следующих накладных того же
|
||||||
|
/// поставщика matcher уже знает ответ.</item>
|
||||||
|
/// <item><c>POST /{id}/apply</c> — создаёт Supply (Draft) с указанными
|
||||||
|
/// поставщиком/складом/валютой. Status → <c>applied</c>, <c>SupplyId</c>
|
||||||
|
/// ставится.</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// Доступ: Admin + Storekeeper (SuppliesEdit permission).</summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/purchases/invoices")]
|
||||||
|
public class InvoicesController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly IWebHostEnvironment _env;
|
||||||
|
private readonly ITenantContext _tenant;
|
||||||
|
private readonly ILogger<InvoicesController> _log;
|
||||||
|
|
||||||
|
public InvoicesController(AppDbContext db, IWebHostEnvironment env,
|
||||||
|
ITenantContext tenant, ILogger<InvoicesController> log)
|
||||||
|
{
|
||||||
|
_db = db; _env = env; _tenant = tenant; _log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string StorageDir => Path.Combine(_env.ContentRootPath, "uploads", "invoices",
|
||||||
|
DateTime.UtcNow.ToString("yyyy-MM"));
|
||||||
|
|
||||||
|
// ── upload ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public record UploadResult(List<Guid> Ids);
|
||||||
|
|
||||||
|
[HttpPost("upload"), RequiresPermission("SuppliesEdit"), RequestSizeLimit(60_000_000)]
|
||||||
|
public async Task<ActionResult<UploadResult>> Upload([FromForm] IFormFileCollection files, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (files is null || files.Count == 0)
|
||||||
|
return BadRequest(new { error = "Файлы не выбраны." });
|
||||||
|
|
||||||
|
Directory.CreateDirectory(StorageDir);
|
||||||
|
var created = new List<Guid>();
|
||||||
|
foreach (var f in files)
|
||||||
|
{
|
||||||
|
if (f.Length == 0) continue;
|
||||||
|
var ext = Path.GetExtension(f.FileName);
|
||||||
|
var path = Path.Combine(StorageDir, $"{Guid.NewGuid():N}{ext}");
|
||||||
|
await using (var fs = System.IO.File.Create(path)) await f.CopyToAsync(fs, ct);
|
||||||
|
|
||||||
|
var up = new InvoiceUpload
|
||||||
|
{
|
||||||
|
FileName = Path.GetFileName(f.FileName),
|
||||||
|
StoredPath = path,
|
||||||
|
ContentType = f.ContentType,
|
||||||
|
SizeBytes = f.Length,
|
||||||
|
UploadedByUserId = _tenant.UserId,
|
||||||
|
Status = "pending",
|
||||||
|
};
|
||||||
|
_db.InvoiceUploads.Add(up);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
created.Add(up.Id);
|
||||||
|
}
|
||||||
|
return Ok(new UploadResult(created));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── list + detail ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public record InvoiceListRow(Guid Id, string FileName, string Status, DateTime UploadedAt,
|
||||||
|
string? SupplierName, string? InvoiceNumber, DateTime? InvoiceDate, decimal? TotalSum,
|
||||||
|
int LinesTotal, int LinesMatched, Guid? SupplyId, string? Error);
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> List([FromQuery] int take = 50)
|
||||||
|
{
|
||||||
|
var rows = await _db.InvoiceUploads
|
||||||
|
.OrderByDescending(x => x.UploadedAt)
|
||||||
|
.Take(Math.Clamp(take, 1, 200))
|
||||||
|
.Select(x => new InvoiceListRow(x.Id, x.FileName, x.Status, x.UploadedAt,
|
||||||
|
x.SupplierName, x.InvoiceNumber, x.InvoiceDate, x.TotalSum,
|
||||||
|
x.LinesTotal, x.LinesMatched, x.SupplyId, x.Error))
|
||||||
|
.ToListAsync();
|
||||||
|
return Ok(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record InvoiceDetailDto(Guid Id, string FileName, string Status, DateTime UploadedAt,
|
||||||
|
string? SupplierName, string? InvoiceNumber, DateTime? InvoiceDate, decimal? TotalSum,
|
||||||
|
int LinesTotal, int LinesMatched, Guid? SupplyId, string? Error,
|
||||||
|
List<InvoiceLine> Lines);
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<ActionResult<InvoiceDetailDto>> Detail(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (up is null) return NotFound();
|
||||||
|
var lines = string.IsNullOrEmpty(up.ParsedJson)
|
||||||
|
? new List<InvoiceLine>()
|
||||||
|
: JsonSerializer.Deserialize<List<InvoiceLine>>(up.ParsedJson) ?? new List<InvoiceLine>();
|
||||||
|
return Ok(new InvoiceDetailDto(up.Id, up.FileName, up.Status, up.UploadedAt,
|
||||||
|
up.SupplierName, up.InvoiceNumber, up.InvoiceDate, up.TotalSum,
|
||||||
|
up.LinesTotal, up.LinesMatched, up.SupplyId, up.Error, lines));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}/file")]
|
||||||
|
public async Task<IActionResult> File(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (up is null) return NotFound();
|
||||||
|
if (string.IsNullOrEmpty(up.StoredPath) || !System.IO.File.Exists(up.StoredPath))
|
||||||
|
return NotFound(new { error = "Файл удалён — срок хранения истёк." });
|
||||||
|
return PhysicalFile(up.StoredPath, up.ContentType ?? "application/octet-stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}"), RequiresPermission("SuppliesEdit")]
|
||||||
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (up is null) return NotFound();
|
||||||
|
try { if (!string.IsNullOrEmpty(up.StoredPath) && System.IO.File.Exists(up.StoredPath))
|
||||||
|
System.IO.File.Delete(up.StoredPath); } catch { }
|
||||||
|
_db.InvoiceUploads.Remove(up);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── правки + ручные сопоставления ───────────────────────────────────
|
||||||
|
|
||||||
|
public record SaveLinesDto(List<InvoiceLine> Lines, string? SupplierName,
|
||||||
|
string? InvoiceNumber, DateTime? InvoiceDate, decimal? TotalSum);
|
||||||
|
|
||||||
|
/// <summary>Сохраняет правки кладовщика: строки + «шапка». Любое ручное
|
||||||
|
/// сопоставление (MatchedBy=«manual» и ProductId != null) УЧИТСЯ: мы
|
||||||
|
/// пишем/обновляем <see cref="InvoiceAlias"/> для текущего поставщика
|
||||||
|
/// → этот товар. Следующая накладная того же поставщика с таким же
|
||||||
|
/// SourceCode/NameNorm сопоставится автоматически.</summary>
|
||||||
|
[HttpPut("{id:guid}/lines"), RequiresPermission("SuppliesEdit")]
|
||||||
|
public async Task<IActionResult> SaveLines(Guid id, [FromBody] SaveLinesDto dto, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (up is null) return NotFound();
|
||||||
|
if (up.Status == "applied")
|
||||||
|
return BadRequest(new { error = "Накладная уже перенесена в приёмку." });
|
||||||
|
|
||||||
|
up.SupplierName = dto.SupplierName ?? up.SupplierName;
|
||||||
|
up.InvoiceNumber = dto.InvoiceNumber ?? up.InvoiceNumber;
|
||||||
|
up.InvoiceDate = dto.InvoiceDate.HasValue
|
||||||
|
? DateTime.SpecifyKind(dto.InvoiceDate.Value, DateTimeKind.Utc)
|
||||||
|
: up.InvoiceDate;
|
||||||
|
up.TotalSum = dto.TotalSum ?? up.TotalSum;
|
||||||
|
|
||||||
|
up.ParsedJson = JsonSerializer.Serialize(dto.Lines);
|
||||||
|
up.LinesTotal = dto.Lines.Count;
|
||||||
|
up.LinesMatched = dto.Lines.Count(l => l.ProductId.HasValue);
|
||||||
|
|
||||||
|
// Запоминаем ручные сопоставления. Делаем до SaveChanges — если
|
||||||
|
// уникальный индекс выстрелит (concurrent write), ловим и
|
||||||
|
// игнорируем — приоритет на первой записи.
|
||||||
|
var supplierKey = (up.SupplierName ?? "").Trim().ToLowerInvariant();
|
||||||
|
if (!string.IsNullOrEmpty(supplierKey))
|
||||||
|
{
|
||||||
|
foreach (var l in dto.Lines)
|
||||||
|
{
|
||||||
|
if (!l.ProductId.HasValue) continue;
|
||||||
|
if (l.MatchedBy != "manual" && l.MatchedBy != "alias") continue;
|
||||||
|
await UpsertAliasAsync(supplierKey, l, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpsertAliasAsync(string supplierKey, InvoiceLine l, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var nameNorm = InvoiceMatcher.NormalizeName(l.Name);
|
||||||
|
var code = string.IsNullOrWhiteSpace(l.SupplierCode) ? null : l.SupplierCode.Trim();
|
||||||
|
|
||||||
|
// Code и NameNorm — две отдельные уникальные записи (NULL в одной
|
||||||
|
// ветке допустим фильтр-индексом). Пишем обе параллельно.
|
||||||
|
foreach (var (byCode, byName) in new[] { (true, false), (false, true) })
|
||||||
|
{
|
||||||
|
var searchCode = byCode ? code : null;
|
||||||
|
var searchName = byName ? nameNorm : null;
|
||||||
|
if (byCode && string.IsNullOrEmpty(searchCode)) continue;
|
||||||
|
if (byName && string.IsNullOrEmpty(searchName)) continue;
|
||||||
|
|
||||||
|
var existing = await _db.InvoiceAliases
|
||||||
|
.Where(a => a.SupplierKey == supplierKey
|
||||||
|
&& ((byCode && a.SourceCode == searchCode) || (byName && a.SourceNameNorm == searchName)))
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
_db.InvoiceAliases.Add(new InvoiceAlias
|
||||||
|
{
|
||||||
|
SupplierKey = supplierKey,
|
||||||
|
SourceCode = searchCode,
|
||||||
|
SourceNameNorm = searchName,
|
||||||
|
ProductId = l.ProductId!.Value,
|
||||||
|
LastSeenAt = DateTime.UtcNow,
|
||||||
|
HitCount = 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existing.ProductId = l.ProductId.Value;
|
||||||
|
existing.LastSeenAt = DateTime.UtcNow;
|
||||||
|
existing.HitCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── apply → Supply Draft ────────────────────────────────────────────
|
||||||
|
|
||||||
|
public record ApplyDto(Guid SupplierId, Guid StoreId, Guid CurrencyId, string? Notes);
|
||||||
|
public record ApplyResult(Guid SupplyId, string Number, decimal Total, int Lines);
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/apply"), RequiresPermission("SuppliesEdit")]
|
||||||
|
public async Task<ActionResult<ApplyResult>> Apply(Guid id, [FromBody] ApplyDto dto, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (up is null) return NotFound();
|
||||||
|
if (up.Status == "applied" && up.SupplyId.HasValue)
|
||||||
|
return Conflict(new { error = "Накладная уже перенесена.", supplyId = up.SupplyId });
|
||||||
|
if (string.IsNullOrEmpty(up.ParsedJson))
|
||||||
|
return BadRequest(new { error = "Накладная ещё не разобрана." });
|
||||||
|
|
||||||
|
var lines = JsonSerializer.Deserialize<List<InvoiceLine>>(up.ParsedJson) ?? new();
|
||||||
|
var ready = lines.Where(l => l.ProductId.HasValue && l.Quantity > 0).ToList();
|
||||||
|
if (ready.Count == 0)
|
||||||
|
return BadRequest(new { error = "Нет строк с сопоставленным товаром и количеством > 0." });
|
||||||
|
|
||||||
|
// Генерация номера приёмки — тот же шаблон что в SuppliesController.
|
||||||
|
var number = await GenerateSupplyNumberAsync(up.InvoiceDate ?? DateTime.UtcNow, ct);
|
||||||
|
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
var supply = new Supply
|
||||||
|
{
|
||||||
|
Number = number,
|
||||||
|
Date = up.InvoiceDate ?? DateTime.UtcNow,
|
||||||
|
Status = SupplyStatus.Draft,
|
||||||
|
SupplierId = dto.SupplierId,
|
||||||
|
StoreId = dto.StoreId,
|
||||||
|
CurrencyId = dto.CurrencyId,
|
||||||
|
Notes = string.IsNullOrWhiteSpace(dto.Notes)
|
||||||
|
? $"Накладная {up.InvoiceNumber} от {up.InvoiceDate:dd.MM.yyyy}".Trim()
|
||||||
|
: dto.Notes,
|
||||||
|
};
|
||||||
|
int sort = 0;
|
||||||
|
foreach (var l in ready)
|
||||||
|
{
|
||||||
|
var price = allowFractional ? l.Price : Math.Round(l.Price, 0, MidpointRounding.AwayFromZero);
|
||||||
|
supply.Lines.Add(new SupplyLine
|
||||||
|
{
|
||||||
|
ProductId = l.ProductId!.Value,
|
||||||
|
Quantity = l.Quantity,
|
||||||
|
UnitPrice = price,
|
||||||
|
LineTotal = l.Quantity * price,
|
||||||
|
SortOrder = sort++,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
supply.Total = supply.Lines.Sum(x => x.LineTotal);
|
||||||
|
|
||||||
|
_db.Supplies.Add(supply);
|
||||||
|
try { await _db.SaveChangesAsync(ct); }
|
||||||
|
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503")
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Связанная запись не найдена (поставщик/склад/валюта/товар).", constraint = pg.ConstraintName });
|
||||||
|
}
|
||||||
|
|
||||||
|
up.SupplyId = supply.Id;
|
||||||
|
up.Status = "applied";
|
||||||
|
up.ProcessedAt = DateTime.UtcNow;
|
||||||
|
// Файл накладной больше не нужен (строки сохранены). Удаление — тихое.
|
||||||
|
try { if (!string.IsNullOrEmpty(up.StoredPath) && System.IO.File.Exists(up.StoredPath))
|
||||||
|
System.IO.File.Delete(up.StoredPath); } catch { }
|
||||||
|
up.StoredPath = "";
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Ok(new ApplyResult(supply.Id, supply.Number, supply.Total, supply.Lines.Count));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── помощники для UI ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Поиск товара по штрихкоду — для ручной привязки строки без
|
||||||
|
/// открытия отдельного экрана поиска. Возвращает 404 если нет. Отдаёт
|
||||||
|
/// ещё и последнюю закупочную цену (чтобы scan-mode приёмки
|
||||||
|
/// подставлял цену автоматически).</summary>
|
||||||
|
[HttpGet("product-by-barcode")]
|
||||||
|
public async Task<IActionResult> ProductByBarcode([FromQuery] string barcode, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var digits = InvoiceMatcher.OnlyDigits(barcode);
|
||||||
|
if (digits.Length < 6) return BadRequest(new { error = "Штрихкод короче 6 цифр." });
|
||||||
|
var hit = await _db.ProductBarcodes
|
||||||
|
.Where(b => b.Code == digits)
|
||||||
|
.Select(b => new { b.ProductId, Name = b.Product.Name, Article = b.Product.Article })
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (hit is null) return NotFound(new { barcode = digits });
|
||||||
|
|
||||||
|
// Последняя закупочная цена: берём из последней Posted supply-line
|
||||||
|
// где участвовал этот товар. Если никогда не поставляли — null.
|
||||||
|
var lastPrice = await _db.SupplyLines
|
||||||
|
.Where(l => l.ProductId == hit.ProductId && l.Supply.Status == SupplyStatus.Posted)
|
||||||
|
.OrderByDescending(l => l.Supply.Date)
|
||||||
|
.Select(l => (decimal?)l.UnitPrice)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
return Ok(new { productId = hit.ProductId, name = hit.Name, article = hit.Article,
|
||||||
|
barcode = digits, lastPurchasePrice = lastPrice });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Поиск контрагента по названию (fuzzy через ILIKE) — предлагаем
|
||||||
|
/// на экране «применить накладную», чтобы кладовщик не искал в отдельном
|
||||||
|
/// списке. Если ничего не нашлось, UI предложит создать нового.</summary>
|
||||||
|
[HttpGet("counterparty-search")]
|
||||||
|
public async Task<IActionResult> CounterpartySearch([FromQuery] string q, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(q)) return Ok(Array.Empty<object>());
|
||||||
|
var term = $"%{q.Trim()}%";
|
||||||
|
var rows = await _db.Counterparties
|
||||||
|
.Where(c => EF.Functions.ILike(c.Name, term) || (c.Bin != null && EF.Functions.ILike(c.Bin, term)))
|
||||||
|
.OrderBy(c => c.Name)
|
||||||
|
.Take(15)
|
||||||
|
.Select(c => new { c.Id, c.Name, c.Bin })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
return Ok(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> GenerateSupplyNumberAsync(DateTime date, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var year = date.Year;
|
||||||
|
var prefix = $"П-{year}-";
|
||||||
|
var max = await _db.Supplies
|
||||||
|
.Where(s => s.Number.StartsWith(prefix))
|
||||||
|
.OrderByDescending(s => s.Number)
|
||||||
|
.Select(s => s.Number)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
var seq = 1;
|
||||||
|
if (!string.IsNullOrEmpty(max) && int.TryParse(max.Split('-').LastOrDefault(), out var n)) seq = n + 1;
|
||||||
|
return prefix + seq.ToString("D6");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,252 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Infrastructure.Invoices;
|
||||||
|
|
||||||
|
/// <summary>Читает накладную через локальный Claude Code в headless-режиме
|
||||||
|
/// (<c>claude -p --output-format json</c>). Нагрузка списывается с кредита
|
||||||
|
/// подписки — отдельная оплата по токенам не нужна. Если
|
||||||
|
/// <c>Invoices:ClaudeCli:RemoteHost</c> задан, модель живёт на отдельной
|
||||||
|
/// машине и мы ходим туда по ssh (копируем файл, выполняем, подчищаем).
|
||||||
|
///
|
||||||
|
/// Промпт и правила разбора — порт из <c>~/ref/sklad/sklad/Invoices/
|
||||||
|
/// ClaudeCliInvoiceExtractor.cs</c>: копировать каждую строку, не угадывать
|
||||||
|
/// (null если не читается), штрихкод != код поставщика, сверка с «Итого».</summary>
|
||||||
|
public class ClaudeCliInvoiceExtractor : IInvoiceExtractor
|
||||||
|
{
|
||||||
|
private readonly ILogger<ClaudeCliInvoiceExtractor> _log;
|
||||||
|
private readonly string _exe;
|
||||||
|
private readonly string _model;
|
||||||
|
private readonly int _timeoutSec;
|
||||||
|
private readonly string? _remoteHost;
|
||||||
|
private readonly string _remoteTmp;
|
||||||
|
|
||||||
|
public string Name => "claude-cli";
|
||||||
|
|
||||||
|
public ClaudeCliInvoiceExtractor(IConfiguration cfg, ILogger<ClaudeCliInvoiceExtractor> log)
|
||||||
|
{
|
||||||
|
_log = log;
|
||||||
|
_exe = cfg["Invoices:ClaudeCli:Executable"] ?? "claude";
|
||||||
|
_model = cfg["Invoices:ClaudeCli:Model"] ?? "claude-sonnet-4-6";
|
||||||
|
_timeoutSec = int.TryParse(cfg["Invoices:ClaudeCli:TimeoutSeconds"], out var t) ? t : 180;
|
||||||
|
_remoteHost = cfg["Invoices:ClaudeCli:RemoteHost"];
|
||||||
|
_remoteTmp = cfg["Invoices:ClaudeCli:RemoteTmpDir"] ?? "/tmp/food-market-invoices";
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CanHandle(string fileName, string? contentType)
|
||||||
|
{
|
||||||
|
var ext = (Path.GetExtension(fileName) ?? "").ToLowerInvariant();
|
||||||
|
return ext is ".jpg" or ".jpeg" or ".png" or ".webp" or ".heic" or ".pdf";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildPrompt(string filePath) => $@"
|
||||||
|
Прочитай накладную на изображении: {filePath}
|
||||||
|
|
||||||
|
Верни ТОЛЬКО JSON, без пояснений и без markdown-обёртки, строго такой формы:
|
||||||
|
{{
|
||||||
|
""supplier"": ""название организации-поставщика"",
|
||||||
|
""number"": ""номер документа"",
|
||||||
|
""date"": ""ГГГГ-ММ-ДД"",
|
||||||
|
""total"": число или null,
|
||||||
|
""lines"": [
|
||||||
|
{{
|
||||||
|
""barcode"": ""ШТРИХКОД: 8-14 цифр из колонки «Штрихкод»/EAN. Если такой колонки нет - null"",
|
||||||
|
""supplier_code"": ""код/артикул/номенклатурный номер поставщика - любой другой его код"",
|
||||||
|
""name"": ""наименование как написано в накладной"",
|
||||||
|
""qty"": число,
|
||||||
|
""unit"": ""шт|кг|л|уп|пачка|короб|бут"",
|
||||||
|
""price"": число (цена за ЕДИНИЦУ, не сумма),
|
||||||
|
""sum"": число или null,
|
||||||
|
""vat"": число или null
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
|
||||||
|
Важно:
|
||||||
|
- Переноси ВСЕ строки таблицы, ничего не пропускай и ничего не придумывай.
|
||||||
|
- Если значение в документе не читается — поставь null, не угадывай.
|
||||||
|
- Отрицательная цена или сумма означает возврат или скидку. Сохраняй знак как есть.
|
||||||
|
- Если количество указано сразу в двух единицах (шт и кг), бери ту, в которой
|
||||||
|
посчитана сумма, вторую игнорируй.
|
||||||
|
- Штрихкод мог быть напечатан с пробелами — убери их, оставь только цифры.
|
||||||
|
- НЕ путай штрихкод с номенклатурным номером: номенклатурный номер это внутренний
|
||||||
|
код поставщика (колонка «Номенклатурный номер», «Код», «Артикул»), он идёт
|
||||||
|
в supplier_code. В barcode — только настоящий штрихкод с упаковки.
|
||||||
|
- Проверь себя: сумма по строкам должна совпасть с «Итого» документа.
|
||||||
|
- Текст вокруг таблицы (реквизиты, юридические предупреждения, реклама) не разбирай.
|
||||||
|
".Trim();
|
||||||
|
|
||||||
|
public async Task<ParsedInvoice> ExtractAsync(string filePath, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(_remoteHost))
|
||||||
|
{
|
||||||
|
var remote = $"{_remoteTmp}/{Guid.NewGuid():N}{Path.GetExtension(filePath)}";
|
||||||
|
await RunAsync("ssh", new[] { _remoteHost, $"mkdir -p {_remoteTmp}" }, null, 30, ct);
|
||||||
|
await RunAsync("scp", new[] { "-q", filePath, $"{_remoteHost}:{remote}" }, null, 120, ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (code, so, se) = await RunAsync("ssh",
|
||||||
|
new[] { _remoteHost, $"{_exe} -p --output-format json --model {_model} --allowedTools Read" },
|
||||||
|
BuildPrompt(remote), _timeoutSec, ct);
|
||||||
|
if (code != 0)
|
||||||
|
throw new InvoiceExtractionException($"claude на {_remoteHost}: код {code}, {Truncate(se, 400)}");
|
||||||
|
return Parse(so);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { await RunAsync("ssh", new[] { _remoteHost, $"rm -f {remote}" }, null, 30, CancellationToken.None); }
|
||||||
|
catch { /* временный файл — не повод валить разбор */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (c2, out2, err2) = await RunAsync(_exe,
|
||||||
|
new[] { "-p", "--output-format", "json", "--model", _model, "--allowedTools", "Read" },
|
||||||
|
BuildPrompt(filePath), _timeoutSec, ct);
|
||||||
|
if (c2 != 0) throw new InvoiceExtractionException($"claude: код {c2}, {Truncate(err2, 400)}");
|
||||||
|
return Parse(out2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(int code, string stdout, string stderr)> RunAsync(
|
||||||
|
string exe, string[] args, string? stdin, int timeoutSec, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = exe,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
RedirectStandardInput = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
};
|
||||||
|
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||||
|
|
||||||
|
using var proc = new Process { StartInfo = psi };
|
||||||
|
var so = new StringBuilder();
|
||||||
|
var se = new StringBuilder();
|
||||||
|
proc.OutputDataReceived += (_, e) => { if (e.Data != null) so.AppendLine(e.Data); };
|
||||||
|
proc.ErrorDataReceived += (_, e) => { if (e.Data != null) se.AppendLine(e.Data); };
|
||||||
|
|
||||||
|
proc.Start();
|
||||||
|
proc.BeginOutputReadLine();
|
||||||
|
proc.BeginErrorReadLine();
|
||||||
|
if (stdin != null) await proc.StandardInput.WriteAsync(stdin);
|
||||||
|
proc.StandardInput.Close();
|
||||||
|
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
cts.CancelAfter(TimeSpan.FromSeconds(timeoutSec));
|
||||||
|
try { await proc.WaitForExitAsync(cts.Token); }
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
try { proc.Kill(entireProcessTree: true); } catch { }
|
||||||
|
throw new InvoiceExtractionException($"{exe} не ответил за {timeoutSec} с");
|
||||||
|
}
|
||||||
|
return (proc.ExitCode, so.ToString(), se.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary><c>--output-format json</c> отдаёт конверт Claude Code; нужный JSON
|
||||||
|
/// лежит в поле <c>result</c> строкой, иногда обёрнутой в <c>```json</c>.</summary>
|
||||||
|
public static ParsedInvoice Parse(string cliOutput)
|
||||||
|
{
|
||||||
|
string payload = cliOutput.Trim();
|
||||||
|
int inTok = 0, outTok = 0;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var env = JsonDocument.Parse(payload);
|
||||||
|
var root = env.RootElement;
|
||||||
|
if (root.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
if (root.TryGetProperty("result", out var r) && r.ValueKind == JsonValueKind.String)
|
||||||
|
payload = r.GetString()!;
|
||||||
|
if (root.TryGetProperty("usage", out var u) && u.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
if (u.TryGetProperty("input_tokens", out var i)) i.TryGetInt32(out inTok);
|
||||||
|
if (u.TryGetProperty("output_tokens", out var o)) o.TryGetInt32(out outTok);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException) { /* не конверт — значит сразу наш JSON */ }
|
||||||
|
|
||||||
|
payload = StripFence(payload ?? "").Trim();
|
||||||
|
if (payload.Length == 0) throw new InvoiceExtractionException("пустой ответ от claude");
|
||||||
|
|
||||||
|
ParsedInvoice inv;
|
||||||
|
try { inv = FromJson(payload); }
|
||||||
|
catch (JsonException ex) { throw new InvoiceExtractionException("не разобрать JSON от claude: " + ex.Message); }
|
||||||
|
inv.Source = "vision";
|
||||||
|
inv.RawJson = payload;
|
||||||
|
inv.InputTokens = inTok;
|
||||||
|
inv.OutputTokens = outTok;
|
||||||
|
return inv;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StripFence(string s)
|
||||||
|
{
|
||||||
|
s = s.Trim();
|
||||||
|
if (!s.StartsWith("```")) return s;
|
||||||
|
int nl = s.IndexOf('\n');
|
||||||
|
if (nl < 0) return s;
|
||||||
|
s = s.Substring(nl + 1);
|
||||||
|
int fence = s.LastIndexOf("```", StringComparison.Ordinal);
|
||||||
|
return fence >= 0 ? s.Substring(0, fence) : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal? Dec(JsonElement e, string prop)
|
||||||
|
{
|
||||||
|
if (!e.TryGetProperty(prop, out var v)) return null;
|
||||||
|
if (v.ValueKind == JsonValueKind.Number && v.TryGetDecimal(out var d)) return d;
|
||||||
|
if (v.ValueKind == JsonValueKind.String &&
|
||||||
|
decimal.TryParse((v.GetString() ?? "").Replace(",", ".").Replace(" ", ""),
|
||||||
|
NumberStyles.Any, CultureInfo.InvariantCulture, out var ds)) return ds;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Str(JsonElement e, string prop)
|
||||||
|
{
|
||||||
|
if (!e.TryGetProperty(prop, out var v) || v.ValueKind == JsonValueKind.Null) return null;
|
||||||
|
var s = v.ValueKind == JsonValueKind.String ? v.GetString() : v.ToString();
|
||||||
|
return string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ParsedInvoice FromJson(string json)
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
var r = doc.RootElement;
|
||||||
|
var inv = new ParsedInvoice
|
||||||
|
{
|
||||||
|
SupplierName = Str(r, "supplier"),
|
||||||
|
InvoiceNumber = Str(r, "number"),
|
||||||
|
TotalSum = Dec(r, "total"),
|
||||||
|
};
|
||||||
|
var ds = Str(r, "date");
|
||||||
|
if (ds != null && DateTime.TryParse(ds, CultureInfo.InvariantCulture,
|
||||||
|
DateTimeStyles.None, out var dt)) inv.InvoiceDate = dt;
|
||||||
|
|
||||||
|
if (r.TryGetProperty("lines", out var lines) && lines.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var l in lines.EnumerateArray())
|
||||||
|
{
|
||||||
|
var name = Str(l, "name");
|
||||||
|
if (string.IsNullOrWhiteSpace(name)) continue;
|
||||||
|
var bc = Str(l, "barcode");
|
||||||
|
if (bc != null) bc = bc.Trim().Replace(" ", "");
|
||||||
|
inv.Lines.Add(new InvoiceLine
|
||||||
|
{
|
||||||
|
Barcode = string.IsNullOrWhiteSpace(bc) ? null : bc,
|
||||||
|
SupplierCode = Str(l, "supplier_code"),
|
||||||
|
Name = name,
|
||||||
|
Quantity = Dec(l, "qty") ?? 0m,
|
||||||
|
Unit = Str(l, "unit"),
|
||||||
|
Price = Dec(l, "price") ?? 0m,
|
||||||
|
Sum = Dec(l, "sum"),
|
||||||
|
Vat = Dec(l, "vat"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inv;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Truncate(string s, int n) =>
|
||||||
|
string.IsNullOrEmpty(s) ? "" : (s.Length <= n ? s : s.Substring(0, n) + "…");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Infrastructure.Invoices;
|
||||||
|
|
||||||
|
/// <summary>Привязывает строку накладной к нашему товару. Порядок:
|
||||||
|
/// (1) штрихкод — единственный однозначный ключ;
|
||||||
|
/// (2) <see cref="InvoiceAlias"/> — то, что кладовщик уже сопоставлял;
|
||||||
|
/// (3) по названию — НЕ автоматически (слишком много ошибок на накладных
|
||||||
|
/// без штрихкодов). Порт из <c>~/ref/sklad/sklad/Invoices/InvoiceMatcher.cs</c>.</summary>
|
||||||
|
public sealed class InvoiceMatcher
|
||||||
|
{
|
||||||
|
public sealed class CatalogItem
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
public string Name { get; init; } = "";
|
||||||
|
public List<string> Barcodes { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly Dictionary<string, CatalogItem> _byBarcode = new();
|
||||||
|
// (supplier c:code или supplier n:nameNorm) → productId + name
|
||||||
|
private readonly Dictionary<string, (Guid id, string name)> _alias = new();
|
||||||
|
|
||||||
|
public InvoiceMatcher(IEnumerable<CatalogItem> catalog)
|
||||||
|
{
|
||||||
|
foreach (var c in catalog)
|
||||||
|
{
|
||||||
|
foreach (var b in c.Barcodes)
|
||||||
|
{
|
||||||
|
var k = OnlyDigits(b);
|
||||||
|
if (k.Length >= 6 && !_byBarcode.ContainsKey(k))
|
||||||
|
_byBarcode[k] = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Добавить запомненное соответствие. Один alias — максимум 2 ключа
|
||||||
|
/// (code и name_norm); пустые пропускаем.</summary>
|
||||||
|
public void AddAlias(string supplierKey, string? sourceCode, string? sourceNameNorm,
|
||||||
|
Guid productId, string productName)
|
||||||
|
{
|
||||||
|
foreach (var k in AliasKeys(supplierKey, sourceCode, sourceNameNorm))
|
||||||
|
_alias[k] = (productId, productName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IEnumerable<string> AliasKeys(string? supplierKey, string? code, string? nameNorm)
|
||||||
|
{
|
||||||
|
var s = (supplierKey ?? "").Trim().ToLowerInvariant();
|
||||||
|
if (!string.IsNullOrWhiteSpace(code)) yield return s + "c:" + code.Trim().ToLowerInvariant();
|
||||||
|
if (!string.IsNullOrWhiteSpace(nameNorm)) yield return s + "n:" + nameNorm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string OnlyDigits(string? s) =>
|
||||||
|
string.IsNullOrEmpty(s) ? "" : Regex.Replace(s, @"\D", "");
|
||||||
|
|
||||||
|
public static string NormalizeName(string? s)
|
||||||
|
{
|
||||||
|
s = (s ?? "").ToUpperInvariant().Replace('Ё', 'Е');
|
||||||
|
s = Regex.Replace(s, @"[«»""'`]", " ");
|
||||||
|
s = Regex.Replace(s, @"(\d)\s*(ГР|Г|МЛ|КГ|Л)\b", "$1$2");
|
||||||
|
s = Regex.Replace(s, @"[^A-ZА-Я0-9.,%]+", " ");
|
||||||
|
return Regex.Replace(s, @"\s+", " ").Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Match(InvoiceLine line, string supplierKey)
|
||||||
|
{
|
||||||
|
var bc = OnlyDigits(line.Barcode);
|
||||||
|
if (bc.Length >= 6 && _byBarcode.TryGetValue(bc, out var byBc))
|
||||||
|
{
|
||||||
|
Assign(line, byBc.Id, byBc.Name, "barcode", 1.0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nameNorm = NormalizeName(line.Name);
|
||||||
|
foreach (var k in AliasKeys(supplierKey, line.SupplierCode, nameNorm))
|
||||||
|
if (_alias.TryGetValue(k, out var a))
|
||||||
|
{
|
||||||
|
Assign(line, a.id, a.name, "alias", 1.0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
line.MatchedBy = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Assign(InvoiceLine l, Guid id, string name, string how, double score)
|
||||||
|
{
|
||||||
|
l.ProductId = id; l.ProductName = name; l.MatchedBy = how; l.MatchScore = score;
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/food-market.api/Infrastructure/Invoices/InvoiceModels.cs
Normal file
53
src/food-market.api/Infrastructure/Invoices/InvoiceModels.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
namespace foodmarket.Api.Infrastructure.Invoices;
|
||||||
|
|
||||||
|
/// <summary>Строка накладной как её прочитала модель — ещё без привязки
|
||||||
|
/// к нашему товару. Матчится по порядку: barcode → alias → ручной.</summary>
|
||||||
|
public class InvoiceLine
|
||||||
|
{
|
||||||
|
public string? Barcode { get; set; }
|
||||||
|
public string? SupplierCode { get; set; } // код/артикул поставщика
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
public string? Unit { get; set; }
|
||||||
|
public decimal Price { get; set; }
|
||||||
|
public decimal? Sum { get; set; }
|
||||||
|
public decimal? Vat { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Guid нашего товара если удалось сопоставить. Null — нет.</summary>
|
||||||
|
public Guid? ProductId { get; set; }
|
||||||
|
public string? ProductName { get; set; }
|
||||||
|
/// <summary>«barcode» | «alias» | «manual» | «none».</summary>
|
||||||
|
public string MatchedBy { get; set; } = "none";
|
||||||
|
public double MatchScore { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ParsedInvoice
|
||||||
|
{
|
||||||
|
public string? SupplierName { get; set; }
|
||||||
|
public string? InvoiceNumber { get; set; }
|
||||||
|
public DateTime? InvoiceDate { get; set; }
|
||||||
|
public decimal? TotalSum { get; set; }
|
||||||
|
public List<InvoiceLine> Lines { get; set; } = new();
|
||||||
|
|
||||||
|
public string Source { get; set; } = "vision";
|
||||||
|
public int InputTokens { get; set; }
|
||||||
|
public int OutputTokens { get; set; }
|
||||||
|
public string? RawJson { get; set; }
|
||||||
|
public List<string> Warnings { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Чем именно читается документ. Реализации взаимозаменяемы:
|
||||||
|
/// Excel-парсер (не реализован на этом шаге), Claude CLI, API-клиент — все
|
||||||
|
/// возвращают один и тот же <see cref="ParsedInvoice"/>. Выбор делает
|
||||||
|
/// конфиг, остальной код об этом не знает.</summary>
|
||||||
|
public interface IInvoiceExtractor
|
||||||
|
{
|
||||||
|
string Name { get; }
|
||||||
|
bool CanHandle(string fileName, string? contentType);
|
||||||
|
Task<ParsedInvoice> ExtractAsync(string filePath, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class InvoiceExtractionException : Exception
|
||||||
|
{
|
||||||
|
public InvoiceExtractionException(string message) : base(message) { }
|
||||||
|
}
|
||||||
191
src/food-market.api/Infrastructure/Invoices/InvoiceProcessor.cs
Normal file
191
src/food-market.api/Infrastructure/Invoices/InvoiceProcessor.cs
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using foodmarket.Domain.Purchases;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Infrastructure.Invoices;
|
||||||
|
|
||||||
|
/// <summary>Разбирает загруженный файл накладной: вызывает extractor
|
||||||
|
/// (Claude CLI), сопоставляет строки с нашими товарами (barcode + alias),
|
||||||
|
/// сохраняет результат в <see cref="InvoiceUpload"/>. Supply на этом шаге
|
||||||
|
/// НЕ создаётся — кладовщик смотрит и правит на экране проверки.
|
||||||
|
///
|
||||||
|
/// Multi-tenant: все запросы к <c>Products/ProductBarcodes/InvoiceAliases</c>
|
||||||
|
/// — через IgnoreQueryFilters с явной фильтрацией по OrganizationId
|
||||||
|
/// загрузки, потому что в background-worker нет HTTP-контекста и
|
||||||
|
/// обычный tenant-фильтр не сработает.</summary>
|
||||||
|
public sealed class InvoiceProcessor
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopes;
|
||||||
|
private readonly ILogger<InvoiceProcessor> _log;
|
||||||
|
|
||||||
|
public InvoiceProcessor(IServiceScopeFactory scopes, ILogger<InvoiceProcessor> log)
|
||||||
|
{ _scopes = scopes; _log = log; }
|
||||||
|
|
||||||
|
public async Task ProcessAsync(Guid uploadId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var scope = _scopes.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var extractor = scope.ServiceProvider.GetRequiredService<IInvoiceExtractor>();
|
||||||
|
|
||||||
|
var up = await db.InvoiceUploads.IgnoreQueryFilters()
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == uploadId, ct);
|
||||||
|
if (up is null) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
up.Status = "parsing";
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
if (!File.Exists(up.StoredPath))
|
||||||
|
throw new InvoiceExtractionException("Файл не найден на диске");
|
||||||
|
|
||||||
|
var parsed = await extractor.ExtractAsync(up.StoredPath, ct);
|
||||||
|
|
||||||
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
var matcher = await BuildMatcherAsync(db, up.OrganizationId, parsed.SupplierName, ct);
|
||||||
|
_log.LogInformation("[Invoices] {Id}: matcher built in {Ms} ms", up.Id, sw.ElapsedMilliseconds);
|
||||||
|
|
||||||
|
var supplierKey = (parsed.SupplierName ?? "").Trim().ToLowerInvariant();
|
||||||
|
foreach (var l in parsed.Lines)
|
||||||
|
matcher.Match(l, supplierKey);
|
||||||
|
|
||||||
|
up.SupplierName = parsed.SupplierName;
|
||||||
|
up.InvoiceNumber = parsed.InvoiceNumber;
|
||||||
|
up.InvoiceDate = parsed.InvoiceDate.HasValue
|
||||||
|
? DateTime.SpecifyKind(parsed.InvoiceDate.Value, DateTimeKind.Utc)
|
||||||
|
: null;
|
||||||
|
up.TotalSum = parsed.TotalSum;
|
||||||
|
up.LinesTotal = parsed.Lines.Count;
|
||||||
|
up.LinesMatched = parsed.Lines.Count(l => l.ProductId.HasValue);
|
||||||
|
up.Source = parsed.Source;
|
||||||
|
up.ApiInputTokens = parsed.InputTokens;
|
||||||
|
up.ApiOutputTokens = parsed.OutputTokens;
|
||||||
|
up.ParsedJson = JsonSerializer.Serialize(parsed.Lines);
|
||||||
|
up.Status = "needs_review";
|
||||||
|
up.ProcessedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
_log.LogInformation("[Invoices] {Id}: {Total} lines, matched {Matched}",
|
||||||
|
up.Id, up.LinesTotal, up.LinesMatched);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
up.Status = "failed";
|
||||||
|
up.Error = ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message;
|
||||||
|
up.ProcessedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
_log.LogError(ex, "[Invoices] {Id} failed to parse", uploadId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Строит matcher из каталога орги + её alias'ов.
|
||||||
|
/// Запрашивается в scope без tenant-контекста (worker без HTTP-запроса) —
|
||||||
|
/// используем IgnoreQueryFilters + явный Where по OrganizationId.</summary>
|
||||||
|
public static async Task<InvoiceMatcher> BuildMatcherAsync(
|
||||||
|
AppDbContext db, Guid orgId, string? supplierName, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Товары + их штрихкоды. Берём только активные.
|
||||||
|
var products = await db.Products.IgnoreQueryFilters()
|
||||||
|
.Where(p => p.OrganizationId == orgId && !p.IsArchived)
|
||||||
|
.Select(p => new { p.Id, p.Name })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var byProduct = products.ToDictionary(p => p.Id,
|
||||||
|
p => new InvoiceMatcher.CatalogItem { Id = p.Id, Name = p.Name });
|
||||||
|
|
||||||
|
var barcodes = await db.ProductBarcodes.IgnoreQueryFilters()
|
||||||
|
.Where(b => b.OrganizationId == orgId)
|
||||||
|
.Select(b => new { b.ProductId, b.Code })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
foreach (var b in barcodes)
|
||||||
|
if (byProduct.TryGetValue(b.ProductId, out var ci))
|
||||||
|
ci.Barcodes.Add(b.Code);
|
||||||
|
|
||||||
|
var matcher = new InvoiceMatcher(byProduct.Values);
|
||||||
|
|
||||||
|
// Alias'ы — только для текущего поставщика, иначе накачиваем всё и
|
||||||
|
// забиваем память; поставщик идентифицируется строкой-ключом.
|
||||||
|
var sKey = (supplierName ?? "").Trim().ToLowerInvariant();
|
||||||
|
var aliases = await db.InvoiceAliases.IgnoreQueryFilters()
|
||||||
|
.Where(a => a.OrganizationId == orgId && a.SupplierKey == sKey)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
foreach (var a in aliases)
|
||||||
|
{
|
||||||
|
if (!byProduct.TryGetValue(a.ProductId, out var p)) continue;
|
||||||
|
matcher.AddAlias(a.SupplierKey, a.SourceCode, a.SourceNameNorm, p.Id, p.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return matcher;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Разбирает всё, что в очереди (<c>Status=pending</c>). Фото
|
||||||
|
/// читается 10-20 секунд, http-запрос на это держать не нужно. Запускается
|
||||||
|
/// как BackgroundService (один worker на процесс). Раз в час подчищает
|
||||||
|
/// старые файлы (<c>Invoices:KeepFilesDays</c>, default 7); разобранные
|
||||||
|
/// строки остаются навсегда.</summary>
|
||||||
|
public sealed class InvoiceWorker : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopes;
|
||||||
|
private readonly InvoiceProcessor _proc;
|
||||||
|
private readonly ILogger<InvoiceWorker> _log;
|
||||||
|
private DateTime _lastSweep = DateTime.MinValue;
|
||||||
|
|
||||||
|
public InvoiceWorker(IServiceScopeFactory s, InvoiceProcessor p, ILogger<InvoiceWorker> l)
|
||||||
|
{ _scopes = s; _proc = p; _log = l; }
|
||||||
|
|
||||||
|
private async Task SweepAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (DateTime.UtcNow - _lastSweep < TimeSpan.FromHours(1)) return;
|
||||||
|
_lastSweep = DateTime.UtcNow;
|
||||||
|
|
||||||
|
using var scope = _scopes.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var cfg = scope.ServiceProvider.GetRequiredService<IConfiguration>();
|
||||||
|
var days = int.TryParse(cfg["Invoices:KeepFilesDays"], out var d) ? d : 7;
|
||||||
|
var edge = DateTime.UtcNow.AddDays(-days);
|
||||||
|
|
||||||
|
var old = await db.InvoiceUploads.IgnoreQueryFilters()
|
||||||
|
.Where(x => x.StoredPath != "" && x.UploadedAt < edge)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
int n = 0;
|
||||||
|
foreach (var up in old)
|
||||||
|
{
|
||||||
|
try { if (File.Exists(up.StoredPath)) File.Delete(up.StoredPath); } catch { }
|
||||||
|
up.StoredPath = ""; n++;
|
||||||
|
}
|
||||||
|
if (n > 0)
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
_log.LogInformation("[Invoices] swept {N} old files (>{D}d)", n, days);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Guid[] pending;
|
||||||
|
using (var scope = _scopes.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
pending = await db.InvoiceUploads.IgnoreQueryFilters()
|
||||||
|
.Where(x => x.Status == "pending")
|
||||||
|
.OrderBy(x => x.UploadedAt).Select(x => x.Id).Take(5).ToArrayAsync(ct);
|
||||||
|
}
|
||||||
|
foreach (var id in pending) await _proc.ProcessAsync(id, ct);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
catch (Exception ex) { _log.LogError(ex, "[Invoices] worker glitch"); }
|
||||||
|
|
||||||
|
try { await SweepAsync(ct); }
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
catch (Exception ex) { _log.LogWarning(ex, "[Invoices] sweep failed"); }
|
||||||
|
|
||||||
|
try { await Task.Delay(TimeSpan.FromSeconds(3), ct); }
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -446,6 +446,14 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||||
builder.Services.AddSingleton<foodmarket.Api.Realtime.INotificationsPublisher,
|
builder.Services.AddSingleton<foodmarket.Api.Realtime.INotificationsPublisher,
|
||||||
foodmarket.Api.Realtime.NotificationsPublisher>();
|
foodmarket.Api.Realtime.NotificationsPublisher>();
|
||||||
|
|
||||||
|
// Sprint 31: приёмка по фото накладной. Extractor — Claude CLI headless
|
||||||
|
// (без API-ключей, нагрузка списывается с подписки). InvoiceWorker в фоне
|
||||||
|
// обрабатывает очередь pending → needs_review.
|
||||||
|
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Invoices.IInvoiceExtractor,
|
||||||
|
foodmarket.Api.Infrastructure.Invoices.ClaudeCliInvoiceExtractor>();
|
||||||
|
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Invoices.InvoiceProcessor>();
|
||||||
|
builder.Services.AddHostedService<foodmarket.Api.Infrastructure.Invoices.InvoiceWorker>();
|
||||||
|
|
||||||
builder.Services.AddHostedService<OpenIddictClientSeeder>();
|
builder.Services.AddHostedService<OpenIddictClientSeeder>();
|
||||||
builder.Services.AddHostedService<SystemReferenceSeeder>();
|
builder.Services.AddHostedService<SystemReferenceSeeder>();
|
||||||
builder.Services.AddHostedService<DevDataSeeder>();
|
builder.Services.AddHostedService<DevDataSeeder>();
|
||||||
|
|
|
||||||
82
src/food-market.domain/Purchases/InvoiceUpload.cs
Normal file
82
src/food-market.domain/Purchases/InvoiceUpload.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
using foodmarket.Domain.Common;
|
||||||
|
|
||||||
|
namespace foodmarket.Domain.Purchases;
|
||||||
|
|
||||||
|
/// <summary>Загрузка файла накладной (фото/PDF) для авто-распознавания.
|
||||||
|
/// Файл кладётся в <c>uploads/invoices/{yyyy-MM}/{guid}.ext</c>, запись
|
||||||
|
/// появляется со статусом <c>Pending</c>. <c>InvoiceWorker</c> в фоне
|
||||||
|
/// переводит её в <c>Parsing</c>, зовёт <c>IInvoiceExtractor</c>
|
||||||
|
/// (Claude CLI headless), сопоставляет строки с товарами и ставит
|
||||||
|
/// <c>NeedsReview</c>. Кладовщик правит сопоставление и жмёт «Создать
|
||||||
|
/// приёмку» — у нас появляется Supply в Draft, <c>SupplyId</c> ссылается
|
||||||
|
/// на него, статус → <c>Applied</c>.
|
||||||
|
///
|
||||||
|
/// Файл-картинка хранится настраиваемое число дней (default 7); разобранные
|
||||||
|
/// строки (<c>ParsedJson</c>) не удаляются никогда — по ним всегда можно
|
||||||
|
/// посмотреть, что было в накладной.</summary>
|
||||||
|
public class InvoiceUpload : TenantEntity
|
||||||
|
{
|
||||||
|
public string FileName { get; set; } = "";
|
||||||
|
public string StoredPath { get; set; } = "";
|
||||||
|
public string? ContentType { get; set; }
|
||||||
|
public long SizeBytes { get; set; }
|
||||||
|
|
||||||
|
public Guid? UploadedByUserId { get; set; }
|
||||||
|
public DateTime UploadedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>«pending» — только загружен; «parsing» — модель разбирает;
|
||||||
|
/// «needs_review» — строки готовы, кладовщик проверяет; «applied» —
|
||||||
|
/// Supply Draft создан; «failed» — ошибка (см. Error).</summary>
|
||||||
|
public string Status { get; set; } = "pending";
|
||||||
|
|
||||||
|
public string? SupplierName { get; set; }
|
||||||
|
public string? InvoiceNumber { get; set; }
|
||||||
|
public DateTime? InvoiceDate { get; set; }
|
||||||
|
public decimal? TotalSum { get; set; }
|
||||||
|
public int LinesTotal { get; set; }
|
||||||
|
public int LinesMatched { get; set; }
|
||||||
|
|
||||||
|
/// <summary>JSON-массив распознанных строк (<c>List<InvoiceLine></c>
|
||||||
|
/// из DTO-слоя). Храним как text (JSONB в Postgres).</summary>
|
||||||
|
public string? ParsedJson { get; set; }
|
||||||
|
|
||||||
|
public string? Source { get; set; } // "vision" | "excel" | …
|
||||||
|
public int ApiInputTokens { get; set; }
|
||||||
|
public int ApiOutputTokens { get; set; }
|
||||||
|
public string? Error { get; set; }
|
||||||
|
public DateTime? ProcessedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Если создана наша приёмка — ссылка на неё. На момент создания
|
||||||
|
/// документ в статусе Draft; проводит кладовщик вручную.</summary>
|
||||||
|
public Guid? SupplyId { get; set; }
|
||||||
|
public Supply? Supply { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Запомненное соответствие «строка накладной конкретного поставщика
|
||||||
|
/// → наш товар». Заполняется автоматически при КАЖДОМ ручном сопоставлении
|
||||||
|
/// в экране проверки накладной. На следующих накладных того же поставщика
|
||||||
|
/// matcher находит товар по этой таблице раньше, чем просит человека.
|
||||||
|
///
|
||||||
|
/// Ключ: (OrgId, SupplierKey, SourceCode) ИЛИ (OrgId, SupplierKey,
|
||||||
|
/// SourceNameNorm). SourceCode/SourceNameNorm — один из них должен быть
|
||||||
|
/// заполнен (не оба nullable), и подходит любой — поэтому ключи
|
||||||
|
/// индексируются раздельно.</summary>
|
||||||
|
public class InvoiceAlias : TenantEntity
|
||||||
|
{
|
||||||
|
/// <summary>Нормализованное имя поставщика (lower-case, trimmed).
|
||||||
|
/// Используется вместо CounterpartyId потому что в момент сопоставления
|
||||||
|
/// контрагент мог ещё не быть создан.</summary>
|
||||||
|
public string SupplierKey { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>Код/артикул из колонки накладной (nullable).</summary>
|
||||||
|
public string? SourceCode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Нормализованное имя товара из накладной (nullable). Используется
|
||||||
|
/// только когда SourceCode отсутствует.</summary>
|
||||||
|
public string? SourceNameNorm { get; set; }
|
||||||
|
|
||||||
|
public Guid ProductId { get; set; }
|
||||||
|
|
||||||
|
public DateTime LastSeenAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public int HitCount { get; set; } = 1;
|
||||||
|
}
|
||||||
|
|
@ -43,6 +43,8 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
|
||||||
|
|
||||||
public DbSet<Supply> Supplies => Set<Supply>();
|
public DbSet<Supply> Supplies => Set<Supply>();
|
||||||
public DbSet<SupplyLine> SupplyLines => Set<SupplyLine>();
|
public DbSet<SupplyLine> SupplyLines => Set<SupplyLine>();
|
||||||
|
public DbSet<InvoiceUpload> InvoiceUploads => Set<InvoiceUpload>();
|
||||||
|
public DbSet<InvoiceAlias> InvoiceAliases => Set<InvoiceAlias>();
|
||||||
|
|
||||||
public DbSet<Enter> Enters => Set<Enter>();
|
public DbSet<Enter> Enters => Set<Enter>();
|
||||||
public DbSet<EnterLine> EnterLines => Set<EnterLine>();
|
public DbSet<EnterLine> EnterLines => Set<EnterLine>();
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,46 @@ public static void ConfigurePurchases(this ModelBuilder b)
|
||||||
e.HasIndex(x => new { x.OrganizationId, x.ProductId });
|
e.HasIndex(x => new { x.OrganizationId, x.ProductId });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sprint 31: загрузки накладных для авто-распознавания (фото/PDF).
|
||||||
|
b.Entity<InvoiceUpload>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("invoice_uploads");
|
||||||
|
e.Property(x => x.FileName).HasMaxLength(260).IsRequired();
|
||||||
|
e.Property(x => x.StoredPath).HasMaxLength(500);
|
||||||
|
e.Property(x => x.ContentType).HasMaxLength(200);
|
||||||
|
e.Property(x => x.Status).HasMaxLength(40).IsRequired();
|
||||||
|
e.Property(x => x.SupplierName).HasMaxLength(300);
|
||||||
|
e.Property(x => x.InvoiceNumber).HasMaxLength(100);
|
||||||
|
e.Property(x => x.Source).HasMaxLength(40);
|
||||||
|
e.Property(x => x.Error).HasMaxLength(2000);
|
||||||
|
e.Property(x => x.ParsedJson).HasColumnType("jsonb");
|
||||||
|
e.Property(x => x.TotalSum).HasPrecision(18, 4);
|
||||||
|
e.HasOne(x => x.Supply).WithMany().HasForeignKey(x => x.SupplyId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
e.HasIndex(x => new { x.OrganizationId, x.Status });
|
||||||
|
e.HasIndex(x => new { x.OrganizationId, x.UploadedAt });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sprint 31: запомненные соответствия «накладная поставщика → наш товар».
|
||||||
|
// Два разных alias-ключа (по SourceCode и по SourceNameNorm) — отдельные
|
||||||
|
// индексы, уникальные в рамках org+supplier.
|
||||||
|
b.Entity<InvoiceAlias>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("invoice_aliases");
|
||||||
|
e.Property(x => x.SupplierKey).HasMaxLength(300).IsRequired();
|
||||||
|
e.Property(x => x.SourceCode).HasMaxLength(200);
|
||||||
|
e.Property(x => x.SourceNameNorm).HasMaxLength(400);
|
||||||
|
// Уникальность — по тройкам с NULL в разных колонках: Postgres их
|
||||||
|
// считает различимыми, так что составной индекс (SupplierKey,
|
||||||
|
// SourceCode) не ловит дубль, если SourceCode = NULL у двух строк.
|
||||||
|
// Это нам подходит: уникальность только когда оба Code одинаковы.
|
||||||
|
e.HasIndex(x => new { x.OrganizationId, x.SupplierKey, x.SourceCode })
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"SourceCode\" IS NOT NULL");
|
||||||
|
e.HasIndex(x => new { x.OrganizationId, x.SupplierKey, x.SourceNameNorm })
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"SourceNameNorm\" IS NOT NULL");
|
||||||
|
});
|
||||||
|
|
||||||
b.Entity<Enter>(e =>
|
b.Entity<Enter>(e =>
|
||||||
{
|
{
|
||||||
e.ToTable("enters");
|
e.ToTable("enters");
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>Sprint 31 — приёмка по фото/PDF накладной.
|
||||||
|
///
|
||||||
|
/// Таблица <c>invoice_uploads</c> — файл + метаданные + результат разбора
|
||||||
|
/// Claude CLI (ParsedJson в jsonb). Таблица <c>invoice_aliases</c> —
|
||||||
|
/// запомненные соответствия «поставщик+код → товар», автоматически
|
||||||
|
/// пополняется при каждом ручном сопоставлении на экране проверки.
|
||||||
|
///
|
||||||
|
/// Делаем ADD/CREATE через IF NOT EXISTS, индексы — идемпотентно, чтобы
|
||||||
|
/// пере-apply на уже-мигрированной БД не падал.</summary>
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20261004170000_Phase31a_InvoiceUploads")]
|
||||||
|
public partial class Phase31a_InvoiceUploads : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder b)
|
||||||
|
{
|
||||||
|
b.Sql(@"
|
||||||
|
CREATE TABLE IF NOT EXISTS public.invoice_uploads (
|
||||||
|
""Id"" uuid NOT NULL PRIMARY KEY,
|
||||||
|
""OrganizationId"" uuid NOT NULL,
|
||||||
|
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
""FileName"" varchar(260) NOT NULL,
|
||||||
|
""StoredPath"" varchar(500) NOT NULL DEFAULT '',
|
||||||
|
""ContentType"" varchar(200) NULL,
|
||||||
|
""SizeBytes"" bigint NOT NULL DEFAULT 0,
|
||||||
|
""UploadedByUserId"" uuid NULL,
|
||||||
|
""UploadedAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
""Status"" varchar(40) NOT NULL DEFAULT 'pending',
|
||||||
|
""SupplierName"" varchar(300) NULL,
|
||||||
|
""InvoiceNumber"" varchar(100) NULL,
|
||||||
|
""InvoiceDate"" timestamp with time zone NULL,
|
||||||
|
""TotalSum"" numeric(18,4) NULL,
|
||||||
|
""LinesTotal"" integer NOT NULL DEFAULT 0,
|
||||||
|
""LinesMatched"" integer NOT NULL DEFAULT 0,
|
||||||
|
""ParsedJson"" jsonb NULL,
|
||||||
|
""Source"" varchar(40) NULL,
|
||||||
|
""ApiInputTokens"" integer NOT NULL DEFAULT 0,
|
||||||
|
""ApiOutputTokens"" integer NOT NULL DEFAULT 0,
|
||||||
|
""Error"" varchar(2000) NULL,
|
||||||
|
""ProcessedAt"" timestamp with time zone NULL,
|
||||||
|
""SupplyId"" uuid NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.invoice_uploads DROP CONSTRAINT IF EXISTS fk_invoice_uploads_supplies;
|
||||||
|
ALTER TABLE public.invoice_uploads
|
||||||
|
ADD CONSTRAINT fk_invoice_uploads_supplies
|
||||||
|
FOREIGN KEY (""SupplyId"") REFERENCES public.supplies(""Id"") ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_invoice_uploads_org_status
|
||||||
|
ON public.invoice_uploads (""OrganizationId"", ""Status"");
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_invoice_uploads_org_uploaded_at
|
||||||
|
ON public.invoice_uploads (""OrganizationId"", ""UploadedAt"");
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.invoice_aliases (
|
||||||
|
""Id"" uuid NOT NULL PRIMARY KEY,
|
||||||
|
""OrganizationId"" uuid NOT NULL,
|
||||||
|
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
""SupplierKey"" varchar(300) NOT NULL,
|
||||||
|
""SourceCode"" varchar(200) NULL,
|
||||||
|
""SourceNameNorm"" varchar(400) NULL,
|
||||||
|
""ProductId"" uuid NOT NULL,
|
||||||
|
""LastSeenAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
""HitCount"" integer NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_invoice_aliases_code
|
||||||
|
ON public.invoice_aliases (""OrganizationId"", ""SupplierKey"", ""SourceCode"")
|
||||||
|
WHERE ""SourceCode"" IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_invoice_aliases_name
|
||||||
|
ON public.invoice_aliases (""OrganizationId"", ""SupplierKey"", ""SourceNameNorm"")
|
||||||
|
WHERE ""SourceNameNorm"" IS NOT NULL;
|
||||||
|
");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder b)
|
||||||
|
{
|
||||||
|
b.Sql(@"
|
||||||
|
DROP TABLE IF EXISTS public.invoice_aliases;
|
||||||
|
DROP TABLE IF EXISTS public.invoice_uploads;
|
||||||
|
");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,8 @@
|
||||||
"@microsoft/signalr": "^10.0.0",
|
"@microsoft/signalr": "^10.0.0",
|
||||||
"@tanstack/react-query": "^5.99.2",
|
"@tanstack/react-query": "^5.99.2",
|
||||||
"@tanstack/react-query-devtools": "^5.99.2",
|
"@tanstack/react-query-devtools": "^5.99.2",
|
||||||
|
"@zxing/browser": "^0.1.5",
|
||||||
|
"@zxing/library": "^0.23.0",
|
||||||
"axios": "^1.15.1",
|
"axios": "^1.15.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,12 @@ importers:
|
||||||
'@tanstack/react-query-devtools':
|
'@tanstack/react-query-devtools':
|
||||||
specifier: ^5.99.2
|
specifier: ^5.99.2
|
||||||
version: 5.99.2(@tanstack/react-query@5.99.2(react@19.2.5))(react@19.2.5)
|
version: 5.99.2(@tanstack/react-query@5.99.2(react@19.2.5))(react@19.2.5)
|
||||||
|
'@zxing/browser':
|
||||||
|
specifier: ^0.1.5
|
||||||
|
version: 0.1.5(@zxing/library@0.23.0)
|
||||||
|
'@zxing/library':
|
||||||
|
specifier: ^0.23.0
|
||||||
|
version: 0.23.0
|
||||||
axios:
|
axios:
|
||||||
specifier: ^1.15.1
|
specifier: ^1.15.1
|
||||||
version: 1.15.1
|
version: 1.15.1
|
||||||
|
|
@ -679,6 +685,18 @@ packages:
|
||||||
babel-plugin-react-compiler:
|
babel-plugin-react-compiler:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@zxing/browser@0.1.5':
|
||||||
|
resolution: {integrity: sha512-4Lmrn/il4+UNb87Gk8h1iWnhj39TASEHpd91CwwSJtY5u+wa0iH9qS0wNLAWbNVYXR66WmT5uiMhZ7oVTrKfxw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@zxing/library': ^0.21.0
|
||||||
|
|
||||||
|
'@zxing/library@0.23.0':
|
||||||
|
resolution: {integrity: sha512-6fkkoFwP8CHxl6ugnPsj74PLJgX2iRv5zczGAyt5OBzQgxFhuhF0NCEc4t4OvSr8xAv2MRLlI0Iu9ZGDZQ2urA==}
|
||||||
|
engines: {node: '>= 24.0.0'}
|
||||||
|
|
||||||
|
'@zxing/text-encoding@0.9.0':
|
||||||
|
resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==}
|
||||||
|
|
||||||
abort-controller@3.0.0:
|
abort-controller@3.0.0:
|
||||||
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||||
engines: {node: '>=6.5'}
|
engines: {node: '>=6.5'}
|
||||||
|
|
@ -1592,6 +1610,10 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: '>=4.8.4'
|
typescript: '>=4.8.4'
|
||||||
|
|
||||||
|
ts-custom-error@3.3.1:
|
||||||
|
resolution: {integrity: sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
|
|
@ -2312,6 +2334,21 @@ snapshots:
|
||||||
'@rolldown/pluginutils': 1.0.0-rc.7
|
'@rolldown/pluginutils': 1.0.0-rc.7
|
||||||
vite: 8.0.9(@types/node@24.12.2)(jiti@2.6.1)
|
vite: 8.0.9(@types/node@24.12.2)(jiti@2.6.1)
|
||||||
|
|
||||||
|
'@zxing/browser@0.1.5(@zxing/library@0.23.0)':
|
||||||
|
dependencies:
|
||||||
|
'@zxing/library': 0.23.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@zxing/text-encoding': 0.9.0
|
||||||
|
|
||||||
|
'@zxing/library@0.23.0':
|
||||||
|
dependencies:
|
||||||
|
ts-custom-error: 3.3.1
|
||||||
|
optionalDependencies:
|
||||||
|
'@zxing/text-encoding': 0.9.0
|
||||||
|
|
||||||
|
'@zxing/text-encoding@0.9.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
abort-controller@3.0.0:
|
abort-controller@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
event-target-shim: 5.0.1
|
event-target-shim: 5.0.1
|
||||||
|
|
@ -3119,6 +3156,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
typescript: 6.0.3
|
typescript: 6.0.3
|
||||||
|
|
||||||
|
ts-custom-error@3.3.1: {}
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ import { CounterpartiesPage } from '@/pages/CounterpartiesPage'
|
||||||
import { StockPage } from '@/pages/StockPage'
|
import { StockPage } from '@/pages/StockPage'
|
||||||
import { SuppliesPage } from '@/pages/SuppliesPage'
|
import { SuppliesPage } from '@/pages/SuppliesPage'
|
||||||
import { SupplyEditPage } from '@/pages/SupplyEditPage'
|
import { SupplyEditPage } from '@/pages/SupplyEditPage'
|
||||||
|
import { InvoicesPage } from '@/pages/InvoicesPage'
|
||||||
|
import { InvoiceReviewPage } from '@/pages/InvoiceReviewPage'
|
||||||
|
import { SupplyScanPage } from '@/pages/SupplyScanPage'
|
||||||
|
import { InventoryMobilePage } from '@/pages/InventoryMobilePage'
|
||||||
import { RetailSalesPage } from '@/pages/RetailSalesPage'
|
import { RetailSalesPage } from '@/pages/RetailSalesPage'
|
||||||
import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage'
|
import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage'
|
||||||
import { NoOrganizationPage } from '@/pages/NoOrganizationPage'
|
import { NoOrganizationPage } from '@/pages/NoOrganizationPage'
|
||||||
|
|
@ -167,6 +171,12 @@ export default function App() {
|
||||||
<Route path="/purchases/supplies" element={<SuppliesPage />} />
|
<Route path="/purchases/supplies" element={<SuppliesPage />} />
|
||||||
<Route path="/purchases/supplies/new" element={<SupplyEditPage />} />
|
<Route path="/purchases/supplies/new" element={<SupplyEditPage />} />
|
||||||
<Route path="/purchases/supplies/:id" element={<SupplyEditPage />} />
|
<Route path="/purchases/supplies/:id" element={<SupplyEditPage />} />
|
||||||
|
{/* Sprint 31: приёмка по фото накладной + сканированием. */}
|
||||||
|
<Route path="/purchases/invoices" element={<InvoicesPage />} />
|
||||||
|
<Route path="/purchases/invoices/:id" element={<InvoiceReviewPage />} />
|
||||||
|
<Route path="/purchases/supplies/scan" element={<SupplyScanPage />} />
|
||||||
|
{/* Sprint 31: мобильная инвентаризация с офлайн-каталогом. */}
|
||||||
|
<Route path="/inventory/mobile" element={<InventoryMobilePage />} />
|
||||||
<Route path="/inventory/enters" element={lz(EntersPage)} />
|
<Route path="/inventory/enters" element={lz(EntersPage)} />
|
||||||
<Route path="/inventory/enters/new" element={lz(EnterEditPage)} />
|
<Route path="/inventory/enters/new" element={lz(EnterEditPage)} />
|
||||||
<Route path="/inventory/enters/:id" element={lz(EnterEditPage)} />
|
<Route path="/inventory/enters/:id" element={lz(EnterEditPage)} />
|
||||||
|
|
|
||||||
143
src/food-market.web/src/components/CameraScanner.tsx
Normal file
143
src/food-market.web/src/components/CameraScanner.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { BrowserMultiFormatReader } from '@zxing/browser'
|
||||||
|
import { BarcodeFormat, DecodeHintType } from '@zxing/library'
|
||||||
|
import { X, Loader2, CameraOff, Check, AlertTriangle } from 'lucide-react'
|
||||||
|
|
||||||
|
export type ScanFeedback = {
|
||||||
|
kind: 'ok' | 'err'
|
||||||
|
title: string
|
||||||
|
sub?: string
|
||||||
|
/// Монотонно растущий счётчик — перезапускает анимацию, иначе два
|
||||||
|
/// одинаковых скана подряд выглядят как один.
|
||||||
|
seq: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Непрерывное сканирование камерой. Окно не закрывается после удачного
|
||||||
|
/// скана: товар за товаром идёт подряд, закрывает его только человек.
|
||||||
|
/// Подавление повтора 1.5с (в кадре один товар распознаётся десятки раз
|
||||||
|
/// в секунду). Порт из ~/ref/sklad/sklad/ClientApp/src/components/
|
||||||
|
/// CameraScanner.tsx с теми же эвристиками.
|
||||||
|
export default function CameraScanner({ onCode, onClose, feedback, counter }: {
|
||||||
|
onCode: (code: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
feedback?: ScanFeedback | null
|
||||||
|
counter?: string
|
||||||
|
}) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [starting, setStarting] = useState(true)
|
||||||
|
const [flash, setFlash] = useState<ScanFeedback | null>(null)
|
||||||
|
const lastRef = useRef<{ code: string; at: number }>({ code: '', at: 0 })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!feedback) return
|
||||||
|
setFlash(feedback)
|
||||||
|
const t = setTimeout(() => setFlash(null), 1600)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [feedback?.seq])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let stop: (() => void) | null = null
|
||||||
|
let dead = false
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const hints = new Map()
|
||||||
|
hints.set(DecodeHintType.POSSIBLE_FORMATS, [
|
||||||
|
BarcodeFormat.EAN_13, BarcodeFormat.EAN_8,
|
||||||
|
BarcodeFormat.UPC_A, BarcodeFormat.UPC_E,
|
||||||
|
BarcodeFormat.CODE_128, BarcodeFormat.ITF,
|
||||||
|
])
|
||||||
|
const reader = new BrowserMultiFormatReader(hints)
|
||||||
|
const controls = await reader.decodeFromConstraints(
|
||||||
|
{ video: { facingMode: { ideal: 'environment' } } },
|
||||||
|
videoRef.current!,
|
||||||
|
(result) => {
|
||||||
|
if (!result) return
|
||||||
|
const code = result.getText().replace(/\D/g, '')
|
||||||
|
if (code.length < 6) return
|
||||||
|
const now = Date.now()
|
||||||
|
if (lastRef.current.code === code && now - lastRef.current.at < 1500) return
|
||||||
|
lastRef.current = { code, at: now }
|
||||||
|
try { navigator.vibrate?.(40) } catch { /* не на всех */ }
|
||||||
|
onCode(code)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (dead) controls.stop()
|
||||||
|
else { stop = () => controls.stop(); setStarting(false) }
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setStarting(false)
|
||||||
|
const name = (e as { name?: string })?.name
|
||||||
|
setError(name === 'NotAllowedError'
|
||||||
|
? 'Доступ к камере запрещён. Разрешите его в настройках браузера для этого сайта.'
|
||||||
|
: 'Не удалось включить камеру')
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => { dead = true; stop?.() }
|
||||||
|
}, [onCode])
|
||||||
|
|
||||||
|
const ok = flash?.kind === 'ok'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black flex flex-col">
|
||||||
|
<style>{`
|
||||||
|
@keyframes scanPop { 0% { opacity:0; transform:translateY(14px) scale(.96) }
|
||||||
|
12% { opacity:1; transform:translateY(0) scale(1) }
|
||||||
|
80% { opacity:1 } 100% { opacity:0 } }
|
||||||
|
@keyframes scanRing { 0% { opacity:.9 } 100% { opacity:0 } }
|
||||||
|
`}</style>
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 text-white shrink-0">
|
||||||
|
<div className="text-sm">
|
||||||
|
{counter ? <span className="tabular-nums">{counter}</span> : 'Наведите на штрихкод'}
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg bg-white/15 px-3 py-1.5 text-sm active:bg-white/25">
|
||||||
|
<X className="w-4 h-4" /> Готово
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex-1 overflow-hidden">
|
||||||
|
<video ref={videoRef} playsInline muted
|
||||||
|
className="absolute inset-0 w-full h-full object-cover" />
|
||||||
|
<div
|
||||||
|
key={flash?.seq ?? 'idle'}
|
||||||
|
className={`absolute inset-x-8 top-1/2 -translate-y-1/2 h-40 rounded-2xl border-4
|
||||||
|
shadow-[0_0_0_9999px_rgba(0,0,0,0.45)] transition-colors ${
|
||||||
|
flash ? (ok ? 'border-emerald-400' : 'border-red-400') : 'border-white/80'}`}
|
||||||
|
style={flash ? { animation: 'scanRing 1.2s ease-out' } : undefined} />
|
||||||
|
{starting && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-white gap-2">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" /> Включаю камеру…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
|
<CameraOff className="w-8 h-8 text-white/70" />
|
||||||
|
<div className="text-white text-sm">{error}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative shrink-0 min-h-24 px-4 pb-6 pt-2">
|
||||||
|
{flash ? (
|
||||||
|
<div key={flash.seq}
|
||||||
|
style={{ animation: 'scanPop 1.6s ease-out forwards' }}
|
||||||
|
className={`rounded-2xl px-4 py-3 flex items-center gap-3 ${
|
||||||
|
ok ? 'bg-emerald-500 text-white' : 'bg-red-500 text-white'}`}>
|
||||||
|
<div className="shrink-0 rounded-full p-1.5 bg-white/25">
|
||||||
|
{ok ? <Check className="w-6 h-6" /> : <AlertTriangle className="w-6 h-6" />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-semibold leading-tight truncate">{flash.title}</div>
|
||||||
|
{flash.sub && <div className="text-sm opacity-90 truncate">{flash.sub}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-white/60 text-sm pt-3">
|
||||||
|
Сканируйте подряд — окно останется открытым
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
55
src/food-market.web/src/lib/offlineCatalog.ts
Normal file
55
src/food-market.web/src/lib/offlineCatalog.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
/// Каталог не влезает в localStorage (~5 МБ на домен), поэтому держим
|
||||||
|
/// в IndexedDB. По образцу ~/ref/sklad/sklad/ClientApp/src/lib/db.ts.
|
||||||
|
///
|
||||||
|
/// Ключ store'а — штрихкод (строка цифр). Значение — [productId, name].
|
||||||
|
/// Отдельный meta-store хранит { count, builtAt, groupId? } чтобы UI
|
||||||
|
/// показывал, когда последний раз обновляли и для какой группы.
|
||||||
|
|
||||||
|
const DB = 'fm-inventory', STORE = 'catalog', META = 'meta'
|
||||||
|
|
||||||
|
function open(): Promise<IDBDatabase> {
|
||||||
|
return new Promise((res, rej) => {
|
||||||
|
const r = indexedDB.open(DB, 1)
|
||||||
|
r.onupgradeneeded = () => {
|
||||||
|
const d = r.result
|
||||||
|
if (!d.objectStoreNames.contains(STORE)) d.createObjectStore(STORE)
|
||||||
|
if (!d.objectStoreNames.contains(META)) d.createObjectStore(META)
|
||||||
|
}
|
||||||
|
r.onsuccess = () => res(r.result)
|
||||||
|
r.onerror = () => rej(r.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function tx<T>(store: string, mode: IDBTransactionMode,
|
||||||
|
fn: (s: IDBObjectStore) => IDBRequest<T>): Promise<T> {
|
||||||
|
return open().then(d => new Promise<T>((res, rej) => {
|
||||||
|
const r = fn(d.transaction(store, mode).objectStore(store))
|
||||||
|
r.onsuccess = () => res(r.result)
|
||||||
|
r.onerror = () => rej(r.error)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CatMeta = { count: number; builtAt: string; groupId?: string | null; storeId?: string | null }
|
||||||
|
|
||||||
|
export async function saveCatalog(
|
||||||
|
rows: Array<[string, string, string]>,
|
||||||
|
meta: CatMeta,
|
||||||
|
): Promise<void> {
|
||||||
|
const d = await open()
|
||||||
|
await new Promise<void>((res, rej) => {
|
||||||
|
const t = d.transaction([STORE, META], 'readwrite')
|
||||||
|
const s = t.objectStore(STORE)
|
||||||
|
s.clear()
|
||||||
|
for (const [bc, id, name] of rows) s.put([id, name], bc)
|
||||||
|
t.objectStore(META).put(meta, 'catalog')
|
||||||
|
t.oncomplete = () => res()
|
||||||
|
t.onerror = () => rej(t.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findByBarcode = (bc: string) =>
|
||||||
|
tx<[string, string] | undefined>(STORE, 'readonly', s => s.get(bc))
|
||||||
|
|
||||||
|
export const catalogMeta = () => tx<CatMeta | undefined>(META, 'readonly', s => s.get('catalog'))
|
||||||
|
|
||||||
|
export const countRows = () => tx<number>(STORE, 'readonly', s => s.count())
|
||||||
413
src/food-market.web/src/pages/InventoryMobilePage.tsx
Normal file
413
src/food-market.web/src/pages/InventoryMobilePage.tsx
Normal file
|
|
@ -0,0 +1,413 @@
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { saveCatalog, findByBarcode, catalogMeta, type CatMeta } from '@/lib/offlineCatalog'
|
||||||
|
import CameraScanner, { type ScanFeedback } from '@/components/CameraScanner'
|
||||||
|
import {
|
||||||
|
ScanBarcode, Download, Camera, Trash2, Check, Loader2, AlertTriangle, WifiOff,
|
||||||
|
ArrowLeft, Keyboard, Filter,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
interface Store { id: string; name: string; isMain: boolean }
|
||||||
|
interface Group { id: string; name: string }
|
||||||
|
interface MobileCatalog { count: number; builtAt: string; rows: [string, string, string][] }
|
||||||
|
interface InventoryDto { id: string; number: string; status: number; lines: InventoryLineDto[] }
|
||||||
|
interface InventoryLineDto {
|
||||||
|
productId: string; productName?: string | null
|
||||||
|
bookQty: number; actualQty: number; diff: number; unitCost: number
|
||||||
|
}
|
||||||
|
interface BookDriftRow {
|
||||||
|
productId: string; productName: string; savedBook: number; currentBook: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocalRow = { id: string; name: string; qty: number }
|
||||||
|
const LS_ROWS = 'fm.inv-mobile.rows'
|
||||||
|
const LS_DOCID = 'fm.inv-mobile.docId'
|
||||||
|
|
||||||
|
/// Мобильная инвентаризация. Шаги:
|
||||||
|
/// 1) выбор склада (+ опц. группы = частичная инвентаризация);
|
||||||
|
/// 2) загрузка каталога в IndexedDB (работает затем без сети);
|
||||||
|
/// 3) сканирование / ручной ввод — счётчик +1 на штрихкод;
|
||||||
|
/// 4) создание черновика (сервер bookQty = текущий Stock);
|
||||||
|
/// 5) просмотр расхождений перед Post; drift-check показывает, если
|
||||||
|
/// учётный изменился с момента создания (были продажи/приёмки).
|
||||||
|
export function InventoryMobilePage() {
|
||||||
|
const nav = useNavigate()
|
||||||
|
const [stage, setStage] = useState<'setup' | 'scan' | 'review'>('setup')
|
||||||
|
const [stores, setStores] = useState<Store[]>([])
|
||||||
|
const [groups, setGroups] = useState<Group[]>([])
|
||||||
|
const [storeId, setStoreId] = useState('')
|
||||||
|
const [groupId, setGroupId] = useState<string>('') // пусто = полная
|
||||||
|
const [cat, setCat] = useState<CatMeta | null>(null)
|
||||||
|
const [catalogLoading, setCatalogLoading] = useState(false)
|
||||||
|
const [online, setOnline] = useState(typeof navigator !== 'undefined' ? navigator.onLine : true)
|
||||||
|
const [rows, setRows] = useState<LocalRow[]>(() => {
|
||||||
|
try { return JSON.parse(localStorage.getItem(LS_ROWS) || '[]') } catch { return [] }
|
||||||
|
})
|
||||||
|
const [unknown, setUnknown] = useState<{ code: string; n: number }[]>([])
|
||||||
|
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null)
|
||||||
|
const [cameraOn, setCameraOn] = useState(false)
|
||||||
|
const [fb, setFb] = useState<ScanFeedback | null>(null)
|
||||||
|
const seq = useRef(0)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [docId, setDocId] = useState<string | null>(() => localStorage.getItem(LS_DOCID))
|
||||||
|
const [doc, setDoc] = useState<InventoryDto | null>(null)
|
||||||
|
const [drift, setDrift] = useState<BookDriftRow[] | null>(null)
|
||||||
|
const [filterOnlyDiff, setFilterOnlyDiff] = useState(false)
|
||||||
|
const [posting, setPosting] = useState(false)
|
||||||
|
|
||||||
|
// online / offline listeners
|
||||||
|
useEffect(() => {
|
||||||
|
const up = () => setOnline(true), down = () => setOnline(false)
|
||||||
|
addEventListener('online', up); addEventListener('offline', down)
|
||||||
|
return () => { removeEventListener('online', up); removeEventListener('offline', down) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try { localStorage.setItem(LS_ROWS, JSON.stringify(rows)) } catch { /* storage full */ }
|
||||||
|
}, [rows])
|
||||||
|
|
||||||
|
// грузим справочники + инфо о каталоге из IndexedDB
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<{ items: Store[] }>('/api/catalog/stores?pageSize=100').then(r => {
|
||||||
|
setStores(r.data.items ?? [])
|
||||||
|
const main = r.data.items?.find(s => s.isMain) ?? r.data.items?.[0]
|
||||||
|
if (main && !storeId) setStoreId(main.id)
|
||||||
|
}).catch(() => {})
|
||||||
|
api.get<{ items: Group[] }>('/api/catalog/product-groups?pageSize=500').then(r => {
|
||||||
|
setGroups(r.data.items ?? [])
|
||||||
|
}).catch(() => {})
|
||||||
|
catalogMeta().then(m => m && setCat(m)).catch(() => {})
|
||||||
|
// если есть сохранённый docId — переходим сразу к обзору
|
||||||
|
if (docId) setStage('scan')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadCatalog = async () => {
|
||||||
|
setCatalogLoading(true); setMsg(null)
|
||||||
|
try {
|
||||||
|
const url = groupId
|
||||||
|
? `/api/inventory/inventories/mobile-catalog?groupId=${groupId}`
|
||||||
|
: '/api/inventory/inventories/mobile-catalog'
|
||||||
|
const { data } = await api.get<MobileCatalog>(url, { timeout: 180_000 })
|
||||||
|
const meta: CatMeta = { count: data.count, builtAt: data.builtAt, groupId: groupId || null, storeId }
|
||||||
|
await saveCatalog(data.rows, meta)
|
||||||
|
setCat(meta)
|
||||||
|
setMsg({ text: `Каталог загружен: ${data.count.toLocaleString('ru-RU')} штрихкодов`, ok: true })
|
||||||
|
} catch {
|
||||||
|
setMsg({ text: 'Не удалось загрузить каталог', ok: false })
|
||||||
|
} finally { setCatalogLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const createDocIfNeeded = async (): Promise<string | null> => {
|
||||||
|
if (docId) return docId
|
||||||
|
try {
|
||||||
|
const { data } = await api.post<{ id: string }>('/api/inventory/inventories', {
|
||||||
|
storeId, date: new Date().toISOString(), notes: groupId ? 'Инвентаризация с телефона (частичная)' : 'Инвентаризация с телефона',
|
||||||
|
lines: [], // пустые — сервер подтянет Stock автоматически (bookQty)
|
||||||
|
})
|
||||||
|
setDocId(data.id); localStorage.setItem(LS_DOCID, data.id)
|
||||||
|
return data.id
|
||||||
|
} catch {
|
||||||
|
setMsg({ text: 'Не удалось создать документ', ok: false })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const beep = (ok: boolean) => { try { navigator.vibrate?.(ok ? 40 : [60, 50, 60]) } catch { /* ignored */ } }
|
||||||
|
|
||||||
|
const addScan = async (raw: string) => {
|
||||||
|
const code = raw.replace(/\D/g, '')
|
||||||
|
if (code.length < 6) return
|
||||||
|
const hit = await findByBarcode(code).catch(() => undefined)
|
||||||
|
if (!hit) {
|
||||||
|
setUnknown(prev => {
|
||||||
|
const i = prev.findIndex(u => u.code === code)
|
||||||
|
return i < 0 ? [{ code, n: 1 }, ...prev]
|
||||||
|
: prev.map((u, j) => j === i ? { ...u, n: u.n + 1 } : u)
|
||||||
|
})
|
||||||
|
setMsg({ text: `${code}: нет в каталоге`, ok: false })
|
||||||
|
setFb({ kind: 'err', title: 'Нет в каталоге', sub: code, seq: ++seq.current })
|
||||||
|
beep(false); return
|
||||||
|
}
|
||||||
|
const [id, name] = hit
|
||||||
|
let qty = 1
|
||||||
|
setRows(prev => {
|
||||||
|
const i = prev.findIndex(r => r.id === id)
|
||||||
|
if (i < 0) return [{ id, name, qty: 1 }, ...prev]
|
||||||
|
const next = [...prev]
|
||||||
|
qty = next[i].qty + 1
|
||||||
|
next[i] = { ...next[i], qty }
|
||||||
|
return [next[i], ...next.filter((_, j) => j !== i)]
|
||||||
|
})
|
||||||
|
setMsg({ text: name, ok: true })
|
||||||
|
setFb({ kind: 'ok', title: name, sub: `теперь ${qty} шт`, seq: ++seq.current })
|
||||||
|
beep(true)
|
||||||
|
|
||||||
|
// Сервер уведомляем только когда онлайн. Офлайн — всё лежит в localStorage.
|
||||||
|
if (online) {
|
||||||
|
const did = await createDocIfNeeded()
|
||||||
|
if (did) {
|
||||||
|
try { await api.post(`/api/inventory/inventories/${did}/add-line`, { productId: id, actualQty: qty }) }
|
||||||
|
catch { /* ignored — повторим при синке */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = useMemo(() => rows.reduce((a, r) => a + r.qty, 0), [rows])
|
||||||
|
|
||||||
|
const goReview = async () => {
|
||||||
|
if (!online) { setMsg({ text: 'Нет связи — сначала подключитесь', ok: false }); return }
|
||||||
|
const did = await createDocIfNeeded()
|
||||||
|
if (!did) return
|
||||||
|
// Синхронизация rows → add-line для каждой (сервер идемпотентно обновляет)
|
||||||
|
for (const r of rows) {
|
||||||
|
try { await api.post(`/api/inventory/inventories/${did}/add-line`, { productId: r.id, actualQty: r.qty }) }
|
||||||
|
catch { /* ignored */ }
|
||||||
|
}
|
||||||
|
const { data } = await api.get<InventoryDto>(`/api/inventory/inventories/${did}`)
|
||||||
|
setDoc(data)
|
||||||
|
const { data: dr } = await api.get<BookDriftRow[]>(`/api/inventory/inventories/${did}/book-drift`)
|
||||||
|
setDrift(dr)
|
||||||
|
setStage('review')
|
||||||
|
}
|
||||||
|
|
||||||
|
const postDoc = async () => {
|
||||||
|
if (!doc) return
|
||||||
|
setPosting(true)
|
||||||
|
try {
|
||||||
|
await api.post(`/api/inventory/inventories/${doc.id}/post`)
|
||||||
|
try { localStorage.removeItem(LS_ROWS); localStorage.removeItem(LS_DOCID) } catch { /* ignored */ }
|
||||||
|
setRows([]); setUnknown([]); setDocId(null); setDoc(null); setDrift(null)
|
||||||
|
nav(`/inventory/inventories/${doc.id}`)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setMsg({ text: e instanceof Error ? e.message : 'Ошибка проведения', ok: false })
|
||||||
|
} finally { setPosting(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─ STAGE: SETUP ───────────────────────────────────────────────────────
|
||||||
|
if (stage === 'setup') {
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto p-3 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => nav(-1)} className="p-2 rounded hover:bg-slate-100"><ArrowLeft className="w-5 h-5" /></button>
|
||||||
|
<h1 className="text-xl font-semibold">Инвентаризация с телефона</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs text-slate-500">Склад</span>
|
||||||
|
<select value={storeId} onChange={e => setStoreId(e.target.value)}
|
||||||
|
className="w-full rounded border border-slate-300 px-2 py-2 text-base">
|
||||||
|
{stores.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs text-slate-500">Группа (пусто = полная инвентаризация)</span>
|
||||||
|
<select value={groupId} onChange={e => setGroupId(e.target.value)}
|
||||||
|
className="w-full rounded border border-slate-300 px-2 py-2 text-base">
|
||||||
|
<option value="">— вся номенклатура —</option>
|
||||||
|
{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{cat ? (
|
||||||
|
<div className="rounded-md bg-emerald-50 border border-emerald-200 text-emerald-800 px-3 py-2 text-sm">
|
||||||
|
Каталог готов: {cat.count.toLocaleString('ru-RU')} штрихкодов
|
||||||
|
{cat.groupId && <span className="text-xs"> · группа загружена</span>}
|
||||||
|
{' · '}<button onClick={loadCatalog} className="underline">обновить</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={loadCatalog} disabled={catalogLoading || !storeId}
|
||||||
|
className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-slate-800 text-white py-3 font-medium disabled:opacity-50">
|
||||||
|
{catalogLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
|
||||||
|
Загрузить каталог на телефон
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button onClick={() => setStage('scan')} disabled={!cat || !storeId}
|
||||||
|
className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 text-white py-3 font-medium disabled:opacity-50">
|
||||||
|
<ScanBarcode className="w-5 h-5" /> Начать сканирование
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─ STAGE: SCAN ────────────────────────────────────────────────────────
|
||||||
|
if (stage === 'scan') {
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto p-3 pb-28 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => setStage('setup')} className="p-2 rounded hover:bg-slate-100"><ArrowLeft className="w-5 h-5" /></button>
|
||||||
|
<h1 className="text-xl font-semibold flex-1">Сканирование</h1>
|
||||||
|
<span className="text-xs text-slate-500">{rows.length} · {total}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!online && (
|
||||||
|
<div className="flex items-start gap-2 rounded-md bg-amber-50 border border-amber-200 px-3 py-2 text-sm text-amber-900">
|
||||||
|
<WifiOff className="w-4 h-4 mt-0.5 shrink-0" />
|
||||||
|
Нет сети. Сканируйте — всё сохранится в телефоне и уйдёт на сервер, когда появится связь.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg border border-slate-200 p-3 space-y-2">
|
||||||
|
<button onClick={() => { setFb(null); setCameraOn(true) }}
|
||||||
|
className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 text-white py-4 text-lg font-medium">
|
||||||
|
<Camera className="w-6 h-6" /> Сканировать камерой
|
||||||
|
</button>
|
||||||
|
<details className="text-sm">
|
||||||
|
<summary className="text-slate-500 cursor-pointer select-none flex items-center gap-1">
|
||||||
|
<Keyboard className="w-4 h-4" /> Аппаратный сканер — ввод штрихкода
|
||||||
|
</summary>
|
||||||
|
<input ref={inputRef} inputMode="numeric"
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key !== 'Enter') return
|
||||||
|
e.preventDefault()
|
||||||
|
const el = e.target as HTMLInputElement
|
||||||
|
addScan(el.value); el.value = ''
|
||||||
|
}}
|
||||||
|
placeholder="Отсканируйте или введите штрихкод"
|
||||||
|
className="mt-2 w-full rounded-lg border-2 border-slate-300 px-4 py-3 text-lg tabular-nums outline-none focus:border-sky-500"
|
||||||
|
data-testid="inv-scan-input" />
|
||||||
|
</details>
|
||||||
|
{msg && (
|
||||||
|
<div className={`rounded px-3 py-2 text-sm ${msg.ok ? 'bg-emerald-50 text-emerald-800' : 'bg-red-50 text-red-700'}`}>
|
||||||
|
{msg.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{unknown.length > 0 && (
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-amber-900 font-medium text-sm">
|
||||||
|
<AlertTriangle className="w-4 h-4" /> Нет в каталоге ({unknown.length})
|
||||||
|
</div>
|
||||||
|
{unknown.map(u => <div key={u.code} className="text-sm text-amber-900 tabular-nums">{u.code} — {u.n} шт</div>)}
|
||||||
|
<div className="text-xs text-amber-800 pt-1">
|
||||||
|
Эти штрихкоды в документ не попадут. Заведите им карточки и пересчитайте.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||||
|
<div className="px-3 py-2 bg-slate-50 text-xs text-slate-600 flex justify-between">
|
||||||
|
<span>Позиций: {rows.length}</span>
|
||||||
|
<span>Единиц: {total}</span>
|
||||||
|
</div>
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<div className="px-4 py-10 text-center text-slate-400 text-sm">Пока ничего не отсканировано</div>
|
||||||
|
)}
|
||||||
|
{rows.map(r => (
|
||||||
|
<div key={r.id} className="px-3 py-2 border-t border-slate-100 flex items-center gap-2">
|
||||||
|
<div className="min-w-0 flex-1 text-sm truncate">{r.name}</div>
|
||||||
|
<input type="number" value={r.qty} min={0}
|
||||||
|
onChange={e => {
|
||||||
|
const v = parseFloat(e.target.value) || 0
|
||||||
|
setRows(p => p.map(x => x.id === r.id ? { ...x, qty: v } : x))
|
||||||
|
}}
|
||||||
|
className="w-20 text-right rounded border border-slate-200 px-2 py-1 tabular-nums text-lg" />
|
||||||
|
<button onClick={() => setRows(p => p.filter(x => x.id !== r.id))}
|
||||||
|
className="p-1 text-slate-300 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cameraOn && (
|
||||||
|
<CameraScanner onClose={() => setCameraOn(false)} onCode={addScan} feedback={fb}
|
||||||
|
counter={`позиций ${rows.length} · единиц ${total}`} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 p-3">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<button onClick={goReview} disabled={!online}
|
||||||
|
className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-slate-800 text-white py-3 font-medium disabled:opacity-50">
|
||||||
|
{online ? <Check className="w-4 h-4" /> : <WifiOff className="w-4 h-4" />}
|
||||||
|
{online ? `Проверить расхождения (${rows.length})` : 'Нет сети — отправить позже'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─ STAGE: REVIEW ──────────────────────────────────────────────────────
|
||||||
|
const visibleLines = (doc?.lines ?? [])
|
||||||
|
.filter(l => !filterOnlyDiff || l.diff !== 0)
|
||||||
|
.sort((a, b) => Math.abs(b.diff) - Math.abs(a.diff))
|
||||||
|
const surplus = (doc?.lines ?? []).filter(l => l.diff > 0).reduce((a, l) => a + l.diff * l.unitCost, 0)
|
||||||
|
const shortage = (doc?.lines ?? []).filter(l => l.diff < 0).reduce((a, l) => a + l.diff * l.unitCost, 0)
|
||||||
|
const diffs = (doc?.lines ?? []).filter(l => l.diff !== 0).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto p-3 pb-28 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => setStage('scan')} className="p-2 rounded hover:bg-slate-100"><ArrowLeft className="w-5 h-5" /></button>
|
||||||
|
<h1 className="text-xl font-semibold flex-1">Расхождения · {doc?.number}</h1>
|
||||||
|
<button onClick={() => setFilterOnlyDiff(f => !f)}
|
||||||
|
className={`inline-flex items-center gap-1 rounded px-2 py-1 text-xs ${filterOnlyDiff ? 'bg-sky-100 text-sky-800' : 'bg-slate-100 text-slate-600'}`}>
|
||||||
|
<Filter className="w-3.5 h-3.5" /> {filterOnlyDiff ? 'Только расхождения' : 'Все позиции'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{drift && drift.length > 0 && (
|
||||||
|
<div className="rounded-md border border-amber-300 bg-amber-50 text-amber-900 px-3 py-2 text-sm">
|
||||||
|
<div className="font-medium flex items-center gap-1"><AlertTriangle className="w-4 h-4" /> Учётный остаток изменился с момента создания документа</div>
|
||||||
|
<ul className="mt-1 text-xs space-y-0.5">
|
||||||
|
{drift.slice(0, 5).map(d => (
|
||||||
|
<li key={d.productId}><strong>{d.productName}</strong>: {d.savedBook} → {d.currentBook}</li>
|
||||||
|
))}
|
||||||
|
{drift.length > 5 && <li>… и ещё {drift.length - 5}</li>}
|
||||||
|
</ul>
|
||||||
|
<div className="text-xs mt-1">Это были продажи/приёмки между созданием и сейчас. Проверьте перед проведением.</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||||
|
<div className="bg-emerald-50 rounded p-2 text-emerald-900">
|
||||||
|
<div className="text-xs">Излишек</div>
|
||||||
|
<div className="font-semibold tabular-nums">+{surplus.toLocaleString('ru-RU', { maximumFractionDigits: 2 })}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-red-50 rounded p-2 text-red-800">
|
||||||
|
<div className="text-xs">Недостача</div>
|
||||||
|
<div className="font-semibold tabular-nums">{shortage.toLocaleString('ru-RU', { maximumFractionDigits: 2 })}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-100 rounded p-2 text-slate-800">
|
||||||
|
<div className="text-xs">Расхождений</div>
|
||||||
|
<div className="font-semibold">{diffs} из {doc?.lines.length ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||||
|
{visibleLines.map(l => (
|
||||||
|
<div key={l.productId} className="px-3 py-2 border-t border-slate-100 first:border-t-0">
|
||||||
|
<div className="text-sm truncate">{l.productName}</div>
|
||||||
|
<div className="text-xs flex items-center gap-3 tabular-nums">
|
||||||
|
<span className="text-slate-500">учёт {l.bookQty}</span>
|
||||||
|
<span className="text-slate-500">факт {l.actualQty}</span>
|
||||||
|
<span className={`font-semibold ${l.diff > 0 ? 'text-emerald-700' : l.diff < 0 ? 'text-red-700' : 'text-slate-500'}`}>
|
||||||
|
{l.diff > 0 ? '+' : ''}{l.diff}
|
||||||
|
</span>
|
||||||
|
{l.diff !== 0 && (
|
||||||
|
<span className={`text-xs ${l.diff > 0 ? 'text-emerald-700' : 'text-red-700'}`}>
|
||||||
|
= {(l.diff * l.unitCost).toLocaleString('ru-RU', { maximumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 p-3">
|
||||||
|
<div className="max-w-3xl mx-auto flex gap-2">
|
||||||
|
<button onClick={() => setStage('scan')}
|
||||||
|
className="flex-1 rounded-lg border border-slate-300 py-3 font-medium">Назад</button>
|
||||||
|
<button onClick={postDoc} disabled={posting}
|
||||||
|
className="flex-[1.5] inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 text-white py-3 font-medium disabled:opacity-50">
|
||||||
|
{posting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||||
|
Провести ({diffs} расх.)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
470
src/food-market.web/src/pages/InvoiceReviewPage.tsx
Normal file
470
src/food-market.web/src/pages/InvoiceReviewPage.tsx
Normal file
|
|
@ -0,0 +1,470 @@
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { useQuery, useMutation } from '@tanstack/react-query'
|
||||||
|
import { ArrowLeft, Save, Check, AlertTriangle, Search, Trash2, Barcode as BarcodeIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
interface InvoiceLine {
|
||||||
|
barcode?: string | null
|
||||||
|
supplierCode?: string | null
|
||||||
|
name: string
|
||||||
|
quantity: number
|
||||||
|
unit?: string | null
|
||||||
|
price: number
|
||||||
|
sum?: number | null
|
||||||
|
vat?: number | null
|
||||||
|
productId?: string | null
|
||||||
|
productName?: string | null
|
||||||
|
matchedBy: string // barcode | alias | manual | none
|
||||||
|
matchScore: number
|
||||||
|
}
|
||||||
|
interface InvoiceDetail {
|
||||||
|
id: string
|
||||||
|
fileName: string
|
||||||
|
status: string
|
||||||
|
uploadedAt: string
|
||||||
|
supplierName: string | null
|
||||||
|
invoiceNumber: string | null
|
||||||
|
invoiceDate: string | null
|
||||||
|
totalSum: number | null
|
||||||
|
linesTotal: number
|
||||||
|
linesMatched: number
|
||||||
|
supplyId: string | null
|
||||||
|
error: string | null
|
||||||
|
lines: InvoiceLine[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductSearchHit { id: string; name: string; article: string | null }
|
||||||
|
interface CounterpartyHit { id: string; name: string; bin: string | null }
|
||||||
|
interface Store { id: string; name: string; isMain: boolean }
|
||||||
|
interface Currency { id: string; code: string }
|
||||||
|
|
||||||
|
/// Экран проверки накладной. Карточками — чтобы телефон читался; на
|
||||||
|
/// desktop раскладывается в 2 колонки.
|
||||||
|
export function InvoiceReviewPage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const nav = useNavigate()
|
||||||
|
const [lines, setLines] = useState<InvoiceLine[]>([])
|
||||||
|
const [header, setHeader] = useState({ supplierName: '', invoiceNumber: '', invoiceDate: '', totalSum: '' })
|
||||||
|
const [barcodeInput, setBarcodeInput] = useState('')
|
||||||
|
const [searchHit, setSearchHit] = useState<{ row: number; q: string } | null>(null)
|
||||||
|
const [savedMsg, setSavedMsg] = useState<string | null>(null)
|
||||||
|
const [applyOpen, setApplyOpen] = useState(false)
|
||||||
|
|
||||||
|
const detail = useQuery({
|
||||||
|
queryKey: ['invoice', id],
|
||||||
|
queryFn: () => api.get<InvoiceDetail>(`/api/purchases/invoices/${id}`).then(r => r.data),
|
||||||
|
enabled: !!id,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const d = detail.data
|
||||||
|
if (!d) return
|
||||||
|
setLines(d.lines)
|
||||||
|
setHeader({
|
||||||
|
supplierName: d.supplierName ?? '',
|
||||||
|
invoiceNumber: d.invoiceNumber ?? '',
|
||||||
|
invoiceDate: d.invoiceDate ? d.invoiceDate.slice(0, 10) : '',
|
||||||
|
totalSum: d.totalSum != null ? String(d.totalSum) : '',
|
||||||
|
})
|
||||||
|
}, [detail.data])
|
||||||
|
|
||||||
|
const sumByLines = useMemo(() => lines.reduce((s, l) => s + (l.quantity * l.price || 0), 0), [lines])
|
||||||
|
const unmatched = useMemo(() => lines.filter(l => !l.productId).length, [lines])
|
||||||
|
const totalMismatch = useMemo(() => {
|
||||||
|
const t = parseFloat(header.totalSum || '0')
|
||||||
|
return t > 0 && Math.abs(sumByLines - t) > 1
|
||||||
|
}, [sumByLines, header.totalSum])
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
await api.put(`/api/purchases/invoices/${id}/lines`, {
|
||||||
|
lines, supplierName: header.supplierName, invoiceNumber: header.invoiceNumber,
|
||||||
|
invoiceDate: header.invoiceDate ? new Date(header.invoiceDate).toISOString() : null,
|
||||||
|
totalSum: header.totalSum ? parseFloat(header.totalSum) : null,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess: () => { setSavedMsg('Сохранено'); setTimeout(() => setSavedMsg(null), 2000) },
|
||||||
|
onError: (e) => setSavedMsg(e instanceof Error ? e.message : 'Ошибка'),
|
||||||
|
})
|
||||||
|
|
||||||
|
// scan в общий input: ищем товар, матчим в первую НЕсопоставленную подходящую строку
|
||||||
|
const scanToAny = async (code: string) => {
|
||||||
|
const clean = code.replace(/\D/g, '')
|
||||||
|
if (clean.length < 6) return
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<{ productId: string; name: string; barcode: string }>(
|
||||||
|
`/api/purchases/invoices/product-by-barcode?barcode=${clean}`)
|
||||||
|
// Применяем в первую несопоставленную строку, иначе создаём новую.
|
||||||
|
setLines(prev => {
|
||||||
|
const idx = prev.findIndex(l => !l.productId)
|
||||||
|
if (idx < 0) {
|
||||||
|
return [...prev, { name: data.name, barcode: clean, quantity: 1, price: 0,
|
||||||
|
matchedBy: 'manual', matchScore: 1, productId: data.productId, productName: data.name } as InvoiceLine]
|
||||||
|
}
|
||||||
|
const next = [...prev]
|
||||||
|
next[idx] = { ...next[idx], productId: data.productId, productName: data.name,
|
||||||
|
barcode: next[idx].barcode || clean, matchedBy: 'manual' }
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
} catch { /* не нашли — ignored */ }
|
||||||
|
setBarcodeInput('')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!detail.data) return <div className="p-4 text-slate-500">Загрузка…</div>
|
||||||
|
const d = detail.data
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto p-3 pb-28 space-y-3" data-testid="invoice-review">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => nav(-1)} className="p-2 rounded hover:bg-slate-100"><ArrowLeft className="w-5 h-5" /></button>
|
||||||
|
<h1 className="text-xl font-semibold flex-1 truncate">{d.supplierName || d.fileName}</h1>
|
||||||
|
<span className="text-xs text-slate-500">{d.status}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{d.supplyId && (
|
||||||
|
<div className="rounded-md border border-emerald-200 bg-emerald-50 text-emerald-800 px-3 py-2 text-sm">
|
||||||
|
Приёмка уже создана. <a href={`/purchases/supplies/${d.supplyId}`} className="underline">Открыть</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{d.status === 'failed' && d.error && (
|
||||||
|
<div className="rounded-md border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-sm">
|
||||||
|
Ошибка распознавания: {d.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* шапка */}
|
||||||
|
<div className="bg-white rounded-lg border border-slate-200 p-3 space-y-2">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<input className="rounded border border-slate-300 px-2 py-1.5 text-sm col-span-2"
|
||||||
|
placeholder="Поставщик" value={header.supplierName}
|
||||||
|
onChange={e => setHeader({ ...header, supplierName: e.target.value })} />
|
||||||
|
<input className="rounded border border-slate-300 px-2 py-1.5 text-sm"
|
||||||
|
placeholder="№" value={header.invoiceNumber}
|
||||||
|
onChange={e => setHeader({ ...header, invoiceNumber: e.target.value })} />
|
||||||
|
<input className="rounded border border-slate-300 px-2 py-1.5 text-sm" type="date"
|
||||||
|
value={header.invoiceDate}
|
||||||
|
onChange={e => setHeader({ ...header, invoiceDate: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-slate-500">Итого в накладной</span>
|
||||||
|
<input className="w-28 text-right tabular-nums rounded border border-slate-300 px-2 py-1 text-sm"
|
||||||
|
inputMode="decimal" placeholder="—" value={header.totalSum}
|
||||||
|
onChange={e => setHeader({ ...header, totalSum: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-slate-500">Сумма по строкам</span>
|
||||||
|
<span className="tabular-nums font-semibold">{sumByLines.toLocaleString('ru-RU', { maximumFractionDigits: 2 })}</span>
|
||||||
|
</div>
|
||||||
|
{totalMismatch && (
|
||||||
|
<div className="flex items-start gap-2 text-amber-800 bg-amber-50 border border-amber-200 rounded px-2 py-1.5 text-xs">
|
||||||
|
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||||
|
Сумма строк отличается от «Итого» больше чем на 1. Проверьте строки.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* сканер в общее поле */}
|
||||||
|
<div className="bg-white rounded-lg border border-slate-200 p-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs text-slate-500 flex items-center gap-1 mb-1">
|
||||||
|
<BarcodeIcon className="w-3.5 h-3.5" /> Скан штрихкода → в первую несопоставленную строку
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
inputMode="numeric"
|
||||||
|
value={barcodeInput}
|
||||||
|
onChange={e => setBarcodeInput(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); scanToAny((e.target as HTMLInputElement).value) } }}
|
||||||
|
placeholder="Отсканируйте или введите штрихкод"
|
||||||
|
className="w-full rounded border border-slate-300 px-2 py-2 text-base tabular-nums"
|
||||||
|
data-testid="invoice-scan-input"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{unmatched > 0 && (
|
||||||
|
<div className="rounded-md border border-amber-200 bg-amber-50 text-amber-800 px-3 py-2 text-sm">
|
||||||
|
Не сопоставлено строк: <strong>{unmatched}</strong> из {lines.length}. Карточки подсвечены оранжевым.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* строки */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{lines.map((l, i) => (
|
||||||
|
<InvoiceLineCard
|
||||||
|
key={i} line={l}
|
||||||
|
onChange={(patched) => setLines(prev => prev.map((x, j) => j === i ? patched : x))}
|
||||||
|
onRemove={() => setLines(prev => prev.filter((_, j) => j !== i))}
|
||||||
|
onSearch={(q) => setSearchHit({ row: i, q })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* bottom */}
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 p-3">
|
||||||
|
<div className="max-w-5xl mx-auto flex items-center gap-2">
|
||||||
|
<button onClick={() => save.mutate()} disabled={save.isPending}
|
||||||
|
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-white border border-slate-300 py-3 font-medium hover:bg-slate-50 disabled:opacity-50">
|
||||||
|
<Save className="w-4 h-4" /> {savedMsg ?? 'Сохранить правки'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setApplyOpen(true)} disabled={d.status === 'applied' || lines.filter(l => l.productId).length === 0}
|
||||||
|
className="flex-[1.2] inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 text-white py-3 font-medium hover:bg-sky-700 disabled:opacity-50">
|
||||||
|
<Check className="w-4 h-4" /> Создать приёмку
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{searchHit && (
|
||||||
|
<ProductSearchDialog q={searchHit.q} onClose={() => setSearchHit(null)}
|
||||||
|
onPick={(h) => {
|
||||||
|
setLines(prev => prev.map((x, j) => j === searchHit.row
|
||||||
|
? { ...x, productId: h.id, productName: h.name, matchedBy: 'manual' } : x))
|
||||||
|
setSearchHit(null)
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{applyOpen && (
|
||||||
|
<ApplyDialog invoiceId={id!} supplierName={header.supplierName}
|
||||||
|
onClose={() => setApplyOpen(false)}
|
||||||
|
onDone={(supplyId) => { setApplyOpen(false); nav(`/purchases/supplies/${supplyId}`) }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Карточка строки. Оранжевый бордер если не сопоставлена.
|
||||||
|
function InvoiceLineCard({ line, onChange, onRemove, onSearch }: {
|
||||||
|
line: InvoiceLine
|
||||||
|
onChange: (l: InvoiceLine) => void
|
||||||
|
onRemove: () => void
|
||||||
|
onSearch: (q: string) => void
|
||||||
|
}) {
|
||||||
|
const bad = !line.productId
|
||||||
|
const sum = line.quantity * line.price
|
||||||
|
return (
|
||||||
|
<div className={`bg-white rounded-lg border p-3 ${bad ? 'border-amber-300' : 'border-slate-200'}`}
|
||||||
|
data-testid={bad ? 'invoice-line-unmatched' : 'invoice-line-ok'}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm text-slate-500 truncate">
|
||||||
|
{line.supplierCode && <span className="font-mono bg-slate-100 px-1 rounded">{line.supplierCode}</span>}
|
||||||
|
{line.barcode && <span className="ml-1 font-mono text-xs">{line.barcode}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="font-medium text-slate-900 truncate">{line.name}</div>
|
||||||
|
{line.productId
|
||||||
|
? <div className="text-xs text-emerald-700 truncate">→ {line.productName}
|
||||||
|
<span className="ml-1 text-slate-400">({line.matchedBy})</span>
|
||||||
|
</div>
|
||||||
|
: <div className="text-xs text-amber-700">Товар не сопоставлен — найдите или создайте</div>}
|
||||||
|
</div>
|
||||||
|
<button onClick={onRemove} className="p-1 text-slate-400 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 grid grid-cols-3 gap-2 text-sm">
|
||||||
|
<label>
|
||||||
|
<div className="text-xs text-slate-500">Кол-во</div>
|
||||||
|
<input type="number" inputMode="decimal" value={line.quantity}
|
||||||
|
onChange={e => onChange({ ...line, quantity: parseFloat(e.target.value) || 0 })}
|
||||||
|
className="w-full rounded border border-slate-300 px-2 py-1.5 tabular-nums text-right" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div className="text-xs text-slate-500">Цена</div>
|
||||||
|
<input type="number" inputMode="decimal" value={line.price}
|
||||||
|
onChange={e => onChange({ ...line, price: parseFloat(e.target.value) || 0 })}
|
||||||
|
className="w-full rounded border border-slate-300 px-2 py-1.5 tabular-nums text-right" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div className="text-xs text-slate-500">Сумма</div>
|
||||||
|
<div className="w-full rounded border border-slate-100 bg-slate-50 px-2 py-1.5 tabular-nums text-right">
|
||||||
|
{sum.toLocaleString('ru-RU', { maximumFractionDigits: 2 })}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<button onClick={() => onSearch(line.name)}
|
||||||
|
className="flex-1 inline-flex items-center justify-center gap-1 rounded border border-slate-300 px-2 py-1.5 text-xs hover:bg-slate-50">
|
||||||
|
<Search className="w-3.5 h-3.5" /> Найти товар
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Диалог поиска товара (debounced). При выборе — колбэк наверх.
|
||||||
|
function ProductSearchDialog({ q, onClose, onPick }: {
|
||||||
|
q: string; onClose: () => void; onPick: (h: ProductSearchHit) => void
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState(q)
|
||||||
|
const search = useQuery({
|
||||||
|
queryKey: ['product-search', query],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (query.trim().length < 2) return []
|
||||||
|
// Используем общий products endpoint с q=
|
||||||
|
const { data } = await api.get<{ items: ProductSearchHit[] }>(`/api/catalog/products?q=${encodeURIComponent(query)}&pageSize=20`)
|
||||||
|
return data.items ?? []
|
||||||
|
},
|
||||||
|
enabled: query.trim().length >= 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-40 bg-black/40 flex items-end sm:items-center justify-center p-2">
|
||||||
|
<div className="bg-white rounded-xl w-full max-w-md max-h-[80vh] overflow-hidden flex flex-col">
|
||||||
|
<div className="p-3 border-b border-slate-200 flex items-center gap-2">
|
||||||
|
<input autoFocus value={query} onChange={e => setQuery(e.target.value)}
|
||||||
|
placeholder="Название товара…"
|
||||||
|
className="flex-1 rounded border border-slate-300 px-2 py-2 text-sm" />
|
||||||
|
<button onClick={onClose} className="px-2 py-1 text-sm text-slate-500 hover:text-slate-800">Отмена</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{search.data?.map(h => (
|
||||||
|
<button key={h.id} onClick={() => onPick(h)}
|
||||||
|
className="w-full text-left px-3 py-2 border-b border-slate-100 hover:bg-sky-50">
|
||||||
|
<div className="text-sm font-medium">{h.name}</div>
|
||||||
|
{h.article && <div className="text-xs text-slate-500">{h.article}</div>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{search.data && search.data.length === 0 && query.length >= 2 && (
|
||||||
|
<div className="p-4 text-sm text-slate-500 text-center">
|
||||||
|
Товар не найден.
|
||||||
|
<br />
|
||||||
|
<a href={`/products/new?name=${encodeURIComponent(query)}`} className="text-sky-600 underline">
|
||||||
|
Создать товар «{query}»
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Диалог «Создать приёмку»: выбор поставщика (поиск по БИН/имени),
|
||||||
|
/// склад, валюта. Создаём Draft, редиректим в SupplyEdit.
|
||||||
|
function ApplyDialog({ invoiceId, supplierName, onClose, onDone }: {
|
||||||
|
invoiceId: string; supplierName: string
|
||||||
|
onClose: () => void; onDone: (supplyId: string) => void
|
||||||
|
}) {
|
||||||
|
const [cp, setCp] = useState<CounterpartyHit | null>(null)
|
||||||
|
const [cpQ, setCpQ] = useState(supplierName)
|
||||||
|
const [storeId, setStoreId] = useState('')
|
||||||
|
const [currencyId, setCurrencyId] = useState('')
|
||||||
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const cpSearch = useQuery({
|
||||||
|
queryKey: ['cp-search', cpQ],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!cpQ.trim()) return []
|
||||||
|
const { data } = await api.get<CounterpartyHit[]>(
|
||||||
|
`/api/purchases/invoices/counterparty-search?q=${encodeURIComponent(cpQ)}`)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
enabled: !!cpQ.trim(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const stores = useQuery({
|
||||||
|
queryKey: ['stores-list'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<{ items: Store[] }>('/api/catalog/stores?pageSize=100')
|
||||||
|
return data.items ?? []
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const currencies = useQuery({
|
||||||
|
queryKey: ['currencies-list'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<Currency[]>('/api/catalog/currencies?pageSize=20')
|
||||||
|
return Array.isArray(data) ? data : []
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!storeId && stores.data?.length) setStoreId(stores.data.find(s => s.isMain)?.id ?? stores.data[0].id)
|
||||||
|
}, [stores.data, storeId])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currencyId && currencies.data?.length) {
|
||||||
|
const kzt = currencies.data.find(c => c.code === 'KZT')
|
||||||
|
setCurrencyId(kzt?.id ?? currencies.data[0].id)
|
||||||
|
}
|
||||||
|
}, [currencies.data, currencyId])
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
if (!cp) { setErr('Выберите поставщика'); return }
|
||||||
|
if (!storeId || !currencyId) { setErr('Выберите склад и валюту'); return }
|
||||||
|
setBusy(true); setErr(null)
|
||||||
|
try {
|
||||||
|
const { data } = await api.post<{ supplyId: string }>(`/api/purchases/invoices/${invoiceId}/apply`, {
|
||||||
|
supplierId: cp.id, storeId, currencyId,
|
||||||
|
})
|
||||||
|
onDone(data.supplyId)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setErr(e instanceof Error ? e.message : 'Не удалось создать')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-40 bg-black/40 flex items-end sm:items-center justify-center p-2">
|
||||||
|
<div className="bg-white rounded-xl w-full max-w-md p-4 space-y-3">
|
||||||
|
<h3 className="font-semibold">Создать приёмку</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-500">Поставщик</label>
|
||||||
|
{cp ? (
|
||||||
|
<div className="flex items-center gap-2 rounded border border-emerald-200 bg-emerald-50 px-2 py-1.5">
|
||||||
|
<span className="flex-1 text-sm">{cp.name}{cp.bin && <span className="text-xs text-slate-500 ml-2">БИН {cp.bin}</span>}</span>
|
||||||
|
<button onClick={() => setCp(null)} className="text-xs text-slate-500 underline">сменить</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<input autoFocus value={cpQ} onChange={e => setCpQ(e.target.value)}
|
||||||
|
placeholder="Начните вводить название или БИН…"
|
||||||
|
className="w-full rounded border border-slate-300 px-2 py-1.5 text-sm" />
|
||||||
|
{cpSearch.data?.map(h => (
|
||||||
|
<button key={h.id} onClick={() => setCp(h)}
|
||||||
|
className="w-full text-left px-2 py-1.5 border-t border-slate-100 hover:bg-sky-50 text-sm">
|
||||||
|
{h.name}{h.bin && <span className="text-xs text-slate-500 ml-2">БИН {h.bin}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{cpSearch.data && cpSearch.data.length === 0 && cpQ.length > 0 && (
|
||||||
|
<div className="pt-1 text-xs text-slate-500">
|
||||||
|
Не найден.{' '}
|
||||||
|
<a href={`/counterparties/new?name=${encodeURIComponent(cpQ)}&type=supplier`}
|
||||||
|
className="text-sky-600 underline">Создать «{cpQ}»</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs text-slate-500">Склад</span>
|
||||||
|
<select value={storeId} onChange={e => setStoreId(e.target.value)}
|
||||||
|
className="w-full rounded border border-slate-300 px-2 py-1.5 text-sm">
|
||||||
|
{stores.data?.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs text-slate-500">Валюта</span>
|
||||||
|
<select value={currencyId} onChange={e => setCurrencyId(e.target.value)}
|
||||||
|
className="w-full rounded border border-slate-300 px-2 py-1.5 text-sm">
|
||||||
|
{currencies.data?.map(c => <option key={c.id} value={c.id}>{c.code}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <div className="text-xs text-red-600">{err}</div>}
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<button onClick={onClose} className="flex-1 rounded border border-slate-300 py-2 text-sm">Отмена</button>
|
||||||
|
<button onClick={create} disabled={busy || !cp}
|
||||||
|
className="flex-[1.2] rounded bg-sky-600 text-white py-2 text-sm disabled:opacity-50">
|
||||||
|
{busy ? 'Создаём…' : 'Создать черновик'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
173
src/food-market.web/src/pages/InvoicesPage.tsx
Normal file
173
src/food-market.web/src/pages/InvoicesPage.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { Upload, Camera, FileText, Loader2, AlertTriangle, Check, Clock } from 'lucide-react'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
/// Жизненный цикл: pending → parsing → needs_review → applied | failed.
|
||||||
|
/// Статус-бейдж + подсказка внизу карточки.
|
||||||
|
interface InvoiceListRow {
|
||||||
|
id: string
|
||||||
|
fileName: string
|
||||||
|
status: string
|
||||||
|
uploadedAt: string
|
||||||
|
supplierName: string | null
|
||||||
|
invoiceNumber: string | null
|
||||||
|
invoiceDate: string | null
|
||||||
|
totalSum: number | null
|
||||||
|
linesTotal: number
|
||||||
|
linesMatched: number
|
||||||
|
supplyId: string | null
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS: Record<string, { label: string; cls: string; icon: typeof Clock }> = {
|
||||||
|
pending: { label: 'В очереди', cls: 'bg-slate-100 text-slate-700', icon: Clock },
|
||||||
|
parsing: { label: 'Распознаётся', cls: 'bg-sky-100 text-sky-700', icon: Loader2 },
|
||||||
|
needs_review: { label: 'На проверке', cls: 'bg-amber-100 text-amber-800', icon: FileText },
|
||||||
|
applied: { label: 'Приёмка создана', cls: 'bg-emerald-100 text-emerald-800', icon: Check },
|
||||||
|
failed: { label: 'Ошибка', cls: 'bg-red-100 text-red-700', icon: AlertTriangle },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InvoicesPage() {
|
||||||
|
const nav = useNavigate()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
const cameraRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Polling каждые 3 с, пока есть pending/parsing — чтобы юзер видел прогресс
|
||||||
|
// без ручного F5. Останавливается, когда все статусы терминальные.
|
||||||
|
const list = useQuery({
|
||||||
|
queryKey: ['invoices-list'],
|
||||||
|
queryFn: () => api.get<InvoiceListRow[]>('/api/purchases/invoices?take=50').then(r => r.data),
|
||||||
|
refetchInterval: (q) => {
|
||||||
|
const rows = q.state.data as InvoiceListRow[] | undefined
|
||||||
|
const active = rows?.some(r => r.status === 'pending' || r.status === 'parsing')
|
||||||
|
return active ? 3000 : false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const upload = useMutation({
|
||||||
|
mutationFn: async (files: FileList) => {
|
||||||
|
const fd = new FormData()
|
||||||
|
for (const f of Array.from(files)) fd.append('files', f)
|
||||||
|
await api.post('/api/purchases/invoices/upload', fd, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ['invoices-list'] }); setErr(null) },
|
||||||
|
onError: (e: unknown) => setErr(e instanceof Error ? e.message : 'Не удалось загрузить'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const onFiles = async (f: FileList | null) => {
|
||||||
|
if (!f || f.length === 0) return
|
||||||
|
setUploading(true)
|
||||||
|
try { await upload.mutateAsync(f) }
|
||||||
|
finally { setUploading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto p-4 space-y-4 pb-24">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-slate-900">Накладные</h1>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">
|
||||||
|
Загрузите фото или PDF — система распознает строки и создаст приёмку.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-[auto_auto_1fr]">
|
||||||
|
<button
|
||||||
|
onClick={() => cameraRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 hover:bg-sky-700 text-white font-medium py-3 px-4 disabled:opacity-60"
|
||||||
|
data-testid="invoice-upload-camera"
|
||||||
|
>
|
||||||
|
<Camera className="w-5 h-5" /> Снять камерой
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-lg bg-white border border-slate-300 hover:bg-slate-50 font-medium py-3 px-4 disabled:opacity-60"
|
||||||
|
data-testid="invoice-upload-file"
|
||||||
|
>
|
||||||
|
<Upload className="w-5 h-5" /> Загрузить файл
|
||||||
|
</button>
|
||||||
|
<input ref={cameraRef} type="file" accept="image/*" capture="environment" multiple
|
||||||
|
className="hidden" onChange={(e) => onFiles(e.target.files)} />
|
||||||
|
<input ref={fileRef} type="file" accept="image/*,application/pdf" multiple
|
||||||
|
className="hidden" onChange={(e) => onFiles(e.target.files)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && (
|
||||||
|
<div className="rounded-md border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-sm">{err}</div>
|
||||||
|
)}
|
||||||
|
{uploading && (
|
||||||
|
<div className="rounded-md border border-sky-200 bg-sky-50 text-sky-700 px-3 py-2 text-sm flex items-center gap-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" /> Загрузка…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{list.isLoading && <div className="text-sm text-slate-500">Загрузка…</div>}
|
||||||
|
{list.data?.length === 0 && !list.isLoading && (
|
||||||
|
<div className="text-sm text-slate-500 text-center py-16">
|
||||||
|
Накладных пока нет. Загрузите первое фото — результат появится здесь.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{list.data?.map(r => {
|
||||||
|
const S = STATUS[r.status] ?? STATUS.pending
|
||||||
|
const Icon = S.icon
|
||||||
|
const clickable = r.status === 'needs_review' || r.status === 'failed' || r.status === 'applied'
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
onClick={() => clickable && nav(`/purchases/invoices/${r.id}`)}
|
||||||
|
className={`bg-white border border-slate-200 rounded-lg p-3 ${clickable ? 'cursor-pointer hover:border-sky-300' : ''}`}
|
||||||
|
data-testid={`invoice-row-${r.id}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded ${S.cls}`}>
|
||||||
|
<Icon className={`w-3.5 h-3.5 ${r.status === 'parsing' ? 'animate-spin' : ''}`} /> {S.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-400">{new Date(r.uploadedAt).toLocaleString('ru')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-medium text-slate-900 truncate">
|
||||||
|
{r.supplierName || r.fileName}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-500 truncate">
|
||||||
|
{r.invoiceNumber ? `№ ${r.invoiceNumber}` : ''}
|
||||||
|
{r.invoiceDate ? ` от ${new Date(r.invoiceDate).toLocaleDateString('ru')}` : ''}
|
||||||
|
{r.supplierName && r.fileName ? ` · ${r.fileName}` : ''}
|
||||||
|
</div>
|
||||||
|
{r.linesTotal > 0 && (
|
||||||
|
<div className="text-xs text-slate-500 mt-1">
|
||||||
|
{r.linesTotal} строк · сопоставлено {r.linesMatched}
|
||||||
|
{r.linesMatched < r.linesTotal && <span className="text-amber-600"> · требует внимания</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{r.error && <div className="text-xs text-red-600 mt-1 line-clamp-2">{r.error}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="text-right whitespace-nowrap">
|
||||||
|
{r.totalSum != null && (
|
||||||
|
<div className="text-sm font-semibold tabular-nums">
|
||||||
|
{r.totalSum.toLocaleString('ru-RU', { maximumFractionDigits: 2 })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{r.supplyId && (
|
||||||
|
<Link to={`/purchases/supplies/${r.supplyId}`} className="text-xs text-sky-600 hover:underline">
|
||||||
|
Открыть приёмку
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
289
src/food-market.web/src/pages/SupplyScanPage.tsx
Normal file
289
src/food-market.web/src/pages/SupplyScanPage.tsx
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Camera, Keyboard, Trash2, Check, Loader2, ArrowLeft } from 'lucide-react'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import CameraScanner, { type ScanFeedback } from '@/components/CameraScanner'
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
productId: string
|
||||||
|
name: string
|
||||||
|
qty: number
|
||||||
|
price: number
|
||||||
|
barcode: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ByBarcodeResp {
|
||||||
|
productId: string
|
||||||
|
name: string
|
||||||
|
article: string | null
|
||||||
|
barcode: string
|
||||||
|
lastPurchasePrice: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Store { id: string; name: string; isMain: boolean }
|
||||||
|
interface Currency { id: string; code: string }
|
||||||
|
interface CounterpartyHit { id: string; name: string; bin: string | null }
|
||||||
|
|
||||||
|
const LS_ROWS = 'fm.supply-scan.rows'
|
||||||
|
|
||||||
|
/// Scan-mode для приёмки. Непрерывная камера или аппаратный сканер в
|
||||||
|
/// фокусе: каждый скан +1 к существующей строке, иначе новая. Цена
|
||||||
|
/// подставляется last-purchase-price. Неизвестный штрихкод отправляет
|
||||||
|
/// на /products/new, передавая штрихкод через query. Сохранение
|
||||||
|
/// — создание Supply в Draft через существующий POST /api/purchases/
|
||||||
|
/// supplies.
|
||||||
|
export function SupplyScanPage() {
|
||||||
|
const nav = useNavigate()
|
||||||
|
const [rows, setRows] = useState<Row[]>(() => {
|
||||||
|
try { return JSON.parse(localStorage.getItem(LS_ROWS) || '[]') } catch { return [] }
|
||||||
|
})
|
||||||
|
const [cameraOn, setCameraOn] = useState(false)
|
||||||
|
const [fb, setFb] = useState<ScanFeedback | null>(null)
|
||||||
|
const seq = useRef(0)
|
||||||
|
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// supplier/store/currency — тот же выбор, что в apply-диалоге накладной
|
||||||
|
const [storeId, setStoreId] = useState('')
|
||||||
|
const [currencyId, setCurrencyId] = useState('')
|
||||||
|
const [cp, setCp] = useState<CounterpartyHit | null>(null)
|
||||||
|
const [cpQ, setCpQ] = useState('')
|
||||||
|
const [cpResults, setCpResults] = useState<CounterpartyHit[]>([])
|
||||||
|
const [stores, setStores] = useState<Store[]>([])
|
||||||
|
const [currencies, setCurrencies] = useState<Currency[]>([])
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try { localStorage.setItem(LS_ROWS, JSON.stringify(rows)) } catch { /* storage full */ }
|
||||||
|
}, [rows])
|
||||||
|
|
||||||
|
// загрузка справочников
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<{ items: Store[] }>('/api/catalog/stores?pageSize=100').then(r => {
|
||||||
|
setStores(r.data.items ?? [])
|
||||||
|
const main = r.data.items?.find(s => s.isMain) ?? r.data.items?.[0]
|
||||||
|
if (main) setStoreId(main.id)
|
||||||
|
})
|
||||||
|
api.get<Currency[]>('/api/catalog/currencies?pageSize=20').then(r => {
|
||||||
|
const list = Array.isArray(r.data) ? r.data : []
|
||||||
|
setCurrencies(list)
|
||||||
|
const kzt = list.find(c => c.code === 'KZT') ?? list[0]
|
||||||
|
if (kzt) setCurrencyId(kzt.id)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cpQ.trim()) { setCpResults([]); return }
|
||||||
|
const t = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<CounterpartyHit[]>(
|
||||||
|
`/api/purchases/invoices/counterparty-search?q=${encodeURIComponent(cpQ)}`)
|
||||||
|
setCpResults(data)
|
||||||
|
} catch { /* ignored */ }
|
||||||
|
}, 200)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [cpQ])
|
||||||
|
|
||||||
|
const beep = (ok: boolean) => { try { navigator.vibrate?.(ok ? 40 : [60, 50, 60]) } catch { /* ignored */ } }
|
||||||
|
|
||||||
|
const addScan = async (raw: string) => {
|
||||||
|
const code = raw.replace(/\D/g, '')
|
||||||
|
if (code.length < 6) return
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<ByBarcodeResp>(`/api/purchases/invoices/product-by-barcode?barcode=${code}`)
|
||||||
|
let qty = 1
|
||||||
|
setRows(prev => {
|
||||||
|
const i = prev.findIndex(r => r.productId === data.productId)
|
||||||
|
if (i < 0) return [{
|
||||||
|
productId: data.productId, name: data.name,
|
||||||
|
qty: 1, price: data.lastPurchasePrice ?? 0, barcode: code,
|
||||||
|
}, ...prev]
|
||||||
|
const next = [...prev]
|
||||||
|
qty = next[i].qty + 1
|
||||||
|
next[i] = { ...next[i], qty }
|
||||||
|
// Переместить вверх, чтобы видно было что отсканировали
|
||||||
|
return [next[i], ...next.filter((_, j) => j !== i)]
|
||||||
|
})
|
||||||
|
setMsg({ text: `${data.name} · ${qty} шт`, ok: true })
|
||||||
|
setFb({ kind: 'ok', title: data.name, sub: `теперь ${qty} шт`, seq: ++seq.current })
|
||||||
|
beep(true)
|
||||||
|
} catch {
|
||||||
|
// Неизвестный штрихкод — предлагаем создать товар
|
||||||
|
setMsg({ text: `Штрихкод ${code} не найден — создайте товар`, ok: false })
|
||||||
|
setFb({ kind: 'err', title: 'Нет в каталоге', sub: code, seq: ++seq.current })
|
||||||
|
beep(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = useMemo(() => rows.reduce((s, r) => s + r.qty * r.price, 0), [rows])
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!cp) { setErr('Выберите поставщика'); return }
|
||||||
|
if (!storeId || !currencyId) { setErr('Выберите склад и валюту'); return }
|
||||||
|
if (rows.length === 0) { setErr('Нет позиций'); return }
|
||||||
|
setSaving(true); setErr(null)
|
||||||
|
try {
|
||||||
|
const { data } = await api.post<{ id: string }>('/api/purchases/supplies', {
|
||||||
|
supplierId: cp.id,
|
||||||
|
storeId, currencyId,
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
notes: 'Приёмка сканированием',
|
||||||
|
lines: rows.map((r, i) => ({
|
||||||
|
productId: r.productId,
|
||||||
|
quantity: r.qty,
|
||||||
|
unitPrice: r.price,
|
||||||
|
sortOrder: i,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
try { localStorage.removeItem(LS_ROWS) } catch { /* ignored */ }
|
||||||
|
setRows([])
|
||||||
|
nav(`/purchases/supplies/${data.id}`)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setErr(e instanceof Error ? e.message : 'Не удалось создать приёмку')
|
||||||
|
} finally { setSaving(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto p-3 pb-28 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => nav(-1)} className="p-2 rounded hover:bg-slate-100"><ArrowLeft className="w-5 h-5" /></button>
|
||||||
|
<h1 className="text-xl font-semibold flex-1">Приёмка сканированием</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* supplier/store */}
|
||||||
|
<div className="bg-white rounded-lg border border-slate-200 p-3 space-y-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-500">Поставщик</label>
|
||||||
|
{cp ? (
|
||||||
|
<div className="flex items-center gap-2 rounded border border-emerald-200 bg-emerald-50 px-2 py-1.5">
|
||||||
|
<span className="flex-1 text-sm">{cp.name}</span>
|
||||||
|
<button onClick={() => setCp(null)} className="text-xs text-slate-500 underline">сменить</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<input value={cpQ} onChange={e => setCpQ(e.target.value)}
|
||||||
|
placeholder="Начните вводить название или БИН…"
|
||||||
|
className="w-full rounded border border-slate-300 px-2 py-1.5 text-sm" />
|
||||||
|
{cpResults.map(h => (
|
||||||
|
<button key={h.id} onClick={() => { setCp(h); setCpQ(''); setCpResults([]) }}
|
||||||
|
className="w-full text-left px-2 py-1.5 border-t border-slate-100 hover:bg-sky-50 text-sm">
|
||||||
|
{h.name}{h.bin && <span className="text-xs text-slate-500 ml-2">БИН {h.bin}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<label>
|
||||||
|
<div className="text-xs text-slate-500">Склад</div>
|
||||||
|
<select value={storeId} onChange={e => setStoreId(e.target.value)}
|
||||||
|
className="w-full rounded border border-slate-300 px-2 py-1.5 text-sm">
|
||||||
|
{stores.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div className="text-xs text-slate-500">Валюта</div>
|
||||||
|
<select value={currencyId} onChange={e => setCurrencyId(e.target.value)}
|
||||||
|
className="w-full rounded border border-slate-300 px-2 py-1.5 text-sm">
|
||||||
|
{currencies.map(c => <option key={c.id} value={c.id}>{c.code}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* scan controls */}
|
||||||
|
<div className="bg-white rounded-lg border border-slate-200 p-3 space-y-2">
|
||||||
|
<button onClick={() => { setFb(null); setCameraOn(true) }}
|
||||||
|
className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 text-white py-4 text-lg font-medium hover:bg-sky-700">
|
||||||
|
<Camera className="w-6 h-6" /> Сканировать камерой
|
||||||
|
</button>
|
||||||
|
<details className="text-sm">
|
||||||
|
<summary className="text-slate-500 cursor-pointer select-none flex items-center gap-1">
|
||||||
|
<Keyboard className="w-4 h-4" /> Или аппаратный сканер — ввод штрихкода
|
||||||
|
</summary>
|
||||||
|
<input ref={inputRef} inputMode="numeric"
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key !== 'Enter') return
|
||||||
|
e.preventDefault()
|
||||||
|
const el = e.target as HTMLInputElement
|
||||||
|
addScan(el.value); el.value = ''
|
||||||
|
}}
|
||||||
|
placeholder="Отсканируйте или введите штрихкод"
|
||||||
|
className="mt-2 w-full rounded-lg border-2 border-slate-300 px-4 py-3 text-lg tabular-nums outline-none focus:border-sky-500"
|
||||||
|
data-testid="supply-scan-input" />
|
||||||
|
</details>
|
||||||
|
{msg && (
|
||||||
|
<div className={`rounded-md px-3 py-2 text-sm ${msg.ok
|
||||||
|
? 'bg-emerald-50 text-emerald-800' : 'bg-red-50 text-red-700'}`}>
|
||||||
|
{msg.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* rows */}
|
||||||
|
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||||
|
<div className="px-3 py-2 bg-slate-50 text-xs text-slate-600 flex justify-between">
|
||||||
|
<span>Позиций: {rows.length}</span>
|
||||||
|
<span>Сумма: {total.toLocaleString('ru-RU', { maximumFractionDigits: 2 })}</span>
|
||||||
|
</div>
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<div className="px-4 py-10 text-center text-slate-400 text-sm">Ничего ещё не отсканировано</div>
|
||||||
|
)}
|
||||||
|
{rows.map(r => (
|
||||||
|
<div key={r.productId} className="px-3 py-2.5 border-t border-slate-100">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm truncate">{r.name}</div>
|
||||||
|
<div className="text-xs text-slate-400 font-mono">{r.barcode}</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setRows(p => p.filter(x => x.productId !== r.productId))}
|
||||||
|
className="p-1 text-slate-300 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 grid grid-cols-3 gap-2 text-sm">
|
||||||
|
<label>
|
||||||
|
<div className="text-xs text-slate-500">Кол-во</div>
|
||||||
|
<input type="number" inputMode="decimal" value={r.qty} min={0}
|
||||||
|
onChange={e => setRows(p => p.map(x => x.productId === r.productId
|
||||||
|
? { ...x, qty: parseFloat(e.target.value) || 0 } : x))}
|
||||||
|
className="w-full rounded border border-slate-300 px-2 py-1 tabular-nums text-right" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div className="text-xs text-slate-500">Цена</div>
|
||||||
|
<input type="number" inputMode="decimal" value={r.price}
|
||||||
|
onChange={e => setRows(p => p.map(x => x.productId === r.productId
|
||||||
|
? { ...x, price: parseFloat(e.target.value) || 0 } : x))}
|
||||||
|
className="w-full rounded border border-slate-300 px-2 py-1 tabular-nums text-right" />
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-slate-500">Сумма</div>
|
||||||
|
<div className="w-full rounded border border-slate-100 bg-slate-50 px-2 py-1 tabular-nums text-right">
|
||||||
|
{(r.qty * r.price).toLocaleString('ru-RU', { maximumFractionDigits: 2 })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <div className="rounded-md border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-sm">{err}</div>}
|
||||||
|
|
||||||
|
{cameraOn && (
|
||||||
|
<CameraScanner onClose={() => setCameraOn(false)} onCode={addScan} feedback={fb}
|
||||||
|
counter={`позиций ${rows.length}`} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 p-3">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<button onClick={save} disabled={saving || !cp || !storeId}
|
||||||
|
className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 text-white py-3 font-medium hover:bg-sky-700 disabled:opacity-50">
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||||
|
{saving ? 'Создаём…' : `Создать черновик (${rows.length} поз.)`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
107
tests/food-market.IntegrationTests/InvoicePipelineTests.cs
Normal file
107
tests/food-market.IntegrationTests/InvoicePipelineTests.cs
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using foodmarket.Api.Infrastructure.Invoices;
|
||||||
|
using foodmarket.Domain.Purchases;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using foodmarket.IntegrationTests.Support;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace foodmarket.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>Sprint 31 — приёмка по фото. Проверяем pipeline без реального
|
||||||
|
/// Claude CLI: подставляем парсированный <see cref="ParsedInvoice"/>
|
||||||
|
/// напрямую через <c>InvoiceProcessor.BuildMatcherAsync</c>, т.е. тестим
|
||||||
|
/// matcher-on-real-catalog + alias upsert + apply → Supply Draft.</summary>
|
||||||
|
[Collection(ApiCollection.Name)]
|
||||||
|
public class InvoicePipelineTests
|
||||||
|
{
|
||||||
|
private readonly ApiFactory _factory;
|
||||||
|
public InvoicePipelineTests(ApiFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Matcher_hits_product_by_barcode_from_real_catalog()
|
||||||
|
{
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
// Берём любую org из seed'ов (Demo Market или первую не-архивную)
|
||||||
|
var demoOrg = await db.Organizations.IgnoreQueryFilters()
|
||||||
|
.FirstAsync(o => !o.IsArchived && o.Name == "Demo Market");
|
||||||
|
|
||||||
|
// Нам нужен товар + штрихкод в этой org. Если нет — создадим.
|
||||||
|
var (productId, barcode) = await EnsureProductWithBarcodeAsync(db, demoOrg.Id);
|
||||||
|
|
||||||
|
var matcher = await InvoiceProcessor.BuildMatcherAsync(db, demoOrg.Id, "ТОО Поставщик", CancellationToken.None);
|
||||||
|
var line = new InvoiceLine { Barcode = barcode, Name = "Что угодно" };
|
||||||
|
matcher.Match(line, "тоо поставщик");
|
||||||
|
|
||||||
|
line.MatchedBy.Should().Be("barcode");
|
||||||
|
line.ProductId.Should().Be(productId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Multi_tenant_isolation_matcher_does_not_see_other_orgs_barcodes()
|
||||||
|
{
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
var orgA = await db.Organizations.IgnoreQueryFilters()
|
||||||
|
.FirstAsync(o => !o.IsArchived && o.Name == "Demo Market");
|
||||||
|
var orgB = await db.Organizations.IgnoreQueryFilters()
|
||||||
|
.Where(o => !o.IsArchived && o.Id != orgA.Id).FirstOrDefaultAsync();
|
||||||
|
if (orgB is null)
|
||||||
|
{
|
||||||
|
// создадим вторую org для теста изоляции
|
||||||
|
orgB = new foodmarket.Domain.Organizations.Organization
|
||||||
|
{
|
||||||
|
Name = $"InvOrgB-{Guid.NewGuid():N}", CountryCode = "KZ",
|
||||||
|
Bin = "999999999999",
|
||||||
|
};
|
||||||
|
db.Organizations.Add(orgB);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var (_, bcA) = await EnsureProductWithBarcodeAsync(db, orgA.Id);
|
||||||
|
// Matcher для orgB — не должен находить barcode из orgA.
|
||||||
|
var matcher = await InvoiceProcessor.BuildMatcherAsync(db, orgB.Id, null, CancellationToken.None);
|
||||||
|
var line = new InvoiceLine { Barcode = bcA, Name = "X" };
|
||||||
|
matcher.Match(line, "any");
|
||||||
|
line.MatchedBy.Should().Be("none");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(Guid productId, string barcode)> EnsureProductWithBarcodeAsync(
|
||||||
|
AppDbContext db, Guid orgId)
|
||||||
|
{
|
||||||
|
var existing = await (from pb in db.ProductBarcodes.IgnoreQueryFilters()
|
||||||
|
where pb.OrganizationId == orgId
|
||||||
|
select new { pb.ProductId, pb.Code }).FirstOrDefaultAsync();
|
||||||
|
if (existing is not null) return (existing.ProductId, existing.Code);
|
||||||
|
|
||||||
|
// Создаём минимальный товар + штрихкод (идемпотентный barcode).
|
||||||
|
var unit = await db.UnitsOfMeasure.IgnoreQueryFilters()
|
||||||
|
.FirstAsync(u => u.OrganizationId == null && u.Code == "796");
|
||||||
|
var group = await db.ProductGroups.IgnoreQueryFilters()
|
||||||
|
.FirstAsync(g => g.OrganizationId == orgId && g.IsSystem);
|
||||||
|
var p = new foodmarket.Domain.Catalog.Product
|
||||||
|
{
|
||||||
|
OrganizationId = orgId,
|
||||||
|
Name = $"ТестТовар-{Guid.NewGuid():N}",
|
||||||
|
UnitOfMeasureId = unit.Id,
|
||||||
|
ProductGroupId = group.Id,
|
||||||
|
Vat = 12m,
|
||||||
|
};
|
||||||
|
db.Products.Add(p);
|
||||||
|
var bc = $"4870{Random.Shared.Next(100000000, 999999999)}";
|
||||||
|
db.ProductBarcodes.Add(new foodmarket.Domain.Catalog.ProductBarcode
|
||||||
|
{
|
||||||
|
OrganizationId = orgId, ProductId = p.Id, Code = bc,
|
||||||
|
Type = foodmarket.Domain.Catalog.BarcodeType.Ean13, IsPrimary = true,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return (p.Id, bc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
using FluentAssertions;
|
||||||
|
using foodmarket.Api.Infrastructure.Invoices;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace foodmarket.UnitTests;
|
||||||
|
|
||||||
|
/// <summary>Проверка парсинга ответа Claude CLI. CLI отдаёт конверт
|
||||||
|
/// <c>{ result: "…json…", usage: {...} }</c>; result иногда обёрнут в
|
||||||
|
/// <c>```json</c>. Нужно вытащить payload и привести к ParsedInvoice.</summary>
|
||||||
|
public class ClaudeCliInvoiceExtractorParseTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Parse_plain_json_payload()
|
||||||
|
{
|
||||||
|
var json = """{"supplier":"ТОО Фуд","number":"Н-1","date":"2026-10-04","total":1500.00,"lines":[{"barcode":"4870123456789","supplier_code":"ABC","name":"Молоко 3.2%","qty":10,"unit":"шт","price":150.00,"sum":1500.00,"vat":12}]}""";
|
||||||
|
var inv = ClaudeCliInvoiceExtractor.Parse(json);
|
||||||
|
inv.SupplierName.Should().Be("ТОО Фуд");
|
||||||
|
inv.InvoiceNumber.Should().Be("Н-1");
|
||||||
|
inv.TotalSum.Should().Be(1500m);
|
||||||
|
inv.Lines.Should().HaveCount(1);
|
||||||
|
var l = inv.Lines[0];
|
||||||
|
l.Barcode.Should().Be("4870123456789");
|
||||||
|
l.SupplierCode.Should().Be("ABC");
|
||||||
|
l.Quantity.Should().Be(10m);
|
||||||
|
l.Price.Should().Be(150m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_envelope_with_result_string_and_markdown_fence()
|
||||||
|
{
|
||||||
|
// Payload wrapped в ```json ``` — Claude иногда так возвращает.
|
||||||
|
var fence = "`" + "`" + "`";
|
||||||
|
var payloadInside = fence + "json\n{\"supplier\":\"X\",\"lines\":[{\"name\":\"А\",\"qty\":1,\"price\":10}]}\n" + fence;
|
||||||
|
var envelope = System.Text.Json.JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
result = payloadInside,
|
||||||
|
usage = new { input_tokens = 123, output_tokens = 45 }
|
||||||
|
});
|
||||||
|
var inv = ClaudeCliInvoiceExtractor.Parse(envelope);
|
||||||
|
inv.SupplierName.Should().Be("X");
|
||||||
|
inv.Lines.Should().ContainSingle();
|
||||||
|
inv.InputTokens.Should().Be(123);
|
||||||
|
inv.OutputTokens.Should().Be(45);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_barcode_with_spaces_is_stripped()
|
||||||
|
{
|
||||||
|
var json = """{"lines":[{"name":"X","qty":1,"price":1,"barcode":"4870 123 456 789"}]}""";
|
||||||
|
var inv = ClaudeCliInvoiceExtractor.Parse(json);
|
||||||
|
inv.Lines[0].Barcode.Should().Be("4870123456789");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_null_fields_tolerated()
|
||||||
|
{
|
||||||
|
var json = """{"supplier":null,"number":null,"date":null,"total":null,"lines":[{"name":"X","qty":"2,5","price":"100.5"}]}""";
|
||||||
|
var inv = ClaudeCliInvoiceExtractor.Parse(json);
|
||||||
|
inv.SupplierName.Should().BeNull();
|
||||||
|
inv.TotalSum.Should().BeNull();
|
||||||
|
inv.Lines[0].Quantity.Should().Be(2.5m); // «2,5» → 2.5
|
||||||
|
inv.Lines[0].Price.Should().Be(100.5m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_empty_raises()
|
||||||
|
{
|
||||||
|
var act = () => ClaudeCliInvoiceExtractor.Parse("");
|
||||||
|
act.Should().Throw<InvoiceExtractionException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_lines_without_name_are_skipped()
|
||||||
|
{
|
||||||
|
var json = """{"lines":[{"name":"A","qty":1,"price":1},{"name":"","qty":2,"price":2},{"name":null,"qty":3,"price":3}]}""";
|
||||||
|
var inv = ClaudeCliInvoiceExtractor.Parse(json);
|
||||||
|
inv.Lines.Should().ContainSingle(l => l.Name == "A");
|
||||||
|
}
|
||||||
|
}
|
||||||
116
tests/food-market.UnitTests/InvoiceMatcherTests.cs
Normal file
116
tests/food-market.UnitTests/InvoiceMatcherTests.cs
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
using FluentAssertions;
|
||||||
|
using foodmarket.Api.Infrastructure.Invoices;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace foodmarket.UnitTests;
|
||||||
|
|
||||||
|
/// <summary>Sprint 31: порядок сопоставления строки накладной с товаром.
|
||||||
|
/// (1) штрихкод, (2) alias (запомненное сопоставление), (3) по названию
|
||||||
|
/// — НЕ автоматически (sklad-урок: 2-5 из 10 ошибок).</summary>
|
||||||
|
public class InvoiceMatcherTests
|
||||||
|
{
|
||||||
|
private static InvoiceMatcher.CatalogItem Prod(Guid id, string name, params string[] barcodes) =>
|
||||||
|
new() { Id = id, Name = name, Barcodes = barcodes.ToList() };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Match_by_barcode_wins()
|
||||||
|
{
|
||||||
|
var p1 = Guid.NewGuid(); var p2 = Guid.NewGuid();
|
||||||
|
var matcher = new InvoiceMatcher(new[] {
|
||||||
|
Prod(p1, "Молоко 3.2%", "4870123456789"),
|
||||||
|
Prod(p2, "Хлеб ржаной"),
|
||||||
|
});
|
||||||
|
var line = new InvoiceLine { Barcode = "4870 123 456 789", Name = "Молоко что-то совсем другое" };
|
||||||
|
matcher.Match(line, "ТОО Поставщик");
|
||||||
|
|
||||||
|
line.MatchedBy.Should().Be("barcode");
|
||||||
|
line.ProductId.Should().Be(p1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Short_or_missing_barcode_falls_through()
|
||||||
|
{
|
||||||
|
var p1 = Guid.NewGuid();
|
||||||
|
var matcher = new InvoiceMatcher(new[] { Prod(p1, "Молоко", "4870123456789") });
|
||||||
|
var line = new InvoiceLine { Barcode = "12345", Name = "Молоко" };
|
||||||
|
matcher.Match(line, "ТОО Поставщик");
|
||||||
|
line.MatchedBy.Should().Be("none");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Alias_by_supplier_code_matches_on_second_invoice()
|
||||||
|
{
|
||||||
|
var p1 = Guid.NewGuid();
|
||||||
|
var matcher = new InvoiceMatcher(new[] { Prod(p1, "Молоко 3.2%") });
|
||||||
|
matcher.AddAlias("тоо поставщик", sourceCode: "ABC-123", sourceNameNorm: null, p1, "Молоко 3.2%");
|
||||||
|
|
||||||
|
var line = new InvoiceLine { SupplierCode = "ABC-123", Name = "Что угодно" };
|
||||||
|
matcher.Match(line, "тоо поставщик");
|
||||||
|
line.MatchedBy.Should().Be("alias");
|
||||||
|
line.ProductId.Should().Be(p1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Alias_by_normalized_name_matches()
|
||||||
|
{
|
||||||
|
var p1 = Guid.NewGuid();
|
||||||
|
var matcher = new InvoiceMatcher(new[] { Prod(p1, "Хлеб ржаной 500 г") });
|
||||||
|
var nameNorm = InvoiceMatcher.NormalizeName("Хлеб ржаной 500 г");
|
||||||
|
matcher.AddAlias("хлебокомбинат астана", sourceCode: null, sourceNameNorm: nameNorm, p1, "Хлеб ржаной 500 г");
|
||||||
|
|
||||||
|
var line = new InvoiceLine { Name = "Хлеб ржаной 500 г" };
|
||||||
|
matcher.Match(line, "хлебокомбинат астана");
|
||||||
|
line.MatchedBy.Should().Be("alias");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Alias_is_supplier_scoped()
|
||||||
|
{
|
||||||
|
var p1 = Guid.NewGuid();
|
||||||
|
var matcher = new InvoiceMatcher(new[] { Prod(p1, "Молоко") });
|
||||||
|
matcher.AddAlias("supplier a", "CODE1", null, p1, "Молоко");
|
||||||
|
|
||||||
|
var line = new InvoiceLine { SupplierCode = "CODE1", Name = "Молоко" };
|
||||||
|
matcher.Match(line, "supplier b"); // другой поставщик
|
||||||
|
line.MatchedBy.Should().Be("none");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Does_not_match_by_name_automatically()
|
||||||
|
{
|
||||||
|
var p1 = Guid.NewGuid();
|
||||||
|
var matcher = new InvoiceMatcher(new[] { Prod(p1, "LD Super Slims") });
|
||||||
|
var line = new InvoiceLine { Name = "LD Violet Super Slims" };
|
||||||
|
matcher.Match(line, "any");
|
||||||
|
// Это sklad-урок: по названию с частичным совпадением matches приводят
|
||||||
|
// к подмене товара. Политика — НЕ матчить, подсветить как «none».
|
||||||
|
line.MatchedBy.Should().Be("none");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Хлеб ржаной «Бородинский» 500 г", "ХЛЕБ РЖАНОЙ БОРОДИНСКИЙ 500Г")]
|
||||||
|
[InlineData("молоко 3,2%", "МОЛОКО 3,2%")]
|
||||||
|
[InlineData("Йогурт ё-мобиль", "ЙОГУРТ Е МОБИЛЬ")]
|
||||||
|
public void NormalizeName_normalizes_whitespace_quotes_unit_glue_and_yo(string input, string expected)
|
||||||
|
{
|
||||||
|
InvoiceMatcher.NormalizeName(input).Should().Be(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("4870 123 456 789", "4870123456789")]
|
||||||
|
[InlineData("abc-123", "123")]
|
||||||
|
[InlineData("", "")]
|
||||||
|
[InlineData(null, "")]
|
||||||
|
public void OnlyDigits_strips(string? input, string expected)
|
||||||
|
{
|
||||||
|
InvoiceMatcher.OnlyDigits(input).Should().Be(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AliasKeys_emits_only_for_non_empty_fields()
|
||||||
|
{
|
||||||
|
InvoiceMatcher.AliasKeys("sup", "C1", "N1").Should().HaveCount(2);
|
||||||
|
InvoiceMatcher.AliasKeys("sup", "C1", null).Should().HaveCount(1);
|
||||||
|
InvoiceMatcher.AliasKeys("sup", null, null).Should().BeEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue