From 6b0fc68f7773ce8228d74650cf6f678005f48471 Mon Sep 17 00:00:00 2001 From: nns Date: Tue, 6 Oct 2026 00:02:13 +0500 Subject: [PATCH] =?UTF-8?q?feat(invoices+inventory):=20=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=82=20=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=D0=B8=D0=B9?= =?UTF-8?q?=2005.10.2026=20=D0=B8=D0=B7=20sklad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Диапазон коммитов f455fa9..b7eb637 + 3ced706 (handoff). Backend (src/food-market.api/Infrastructure/Invoices): - Ean.cs — контрольная цифра EAN-8/13/UPC-A/GTIN-14 (ловит одну ошибку в цифре). - TableReader.cs — своя читалка .xlsx/.csv без библиотек, UTF-8/1251, .xls → понятная ошибка; TableInvoiceParser — шаблон накладной. - InvoiceModels: +Photo/BarcodeSource/Note в InvoiceLine, PhotoHint, IInvoiceExtractor.ExtractPhotosAsync. - InvoiceMatcher.FindByBarcode — для photo-matching. - InvoiceProcessor: ветки photos/excel/vision, MatchFromPhoto с EAN-проверкой (barcode_source="read" → валидация), SweepAbandonedPhotosAsync раз в час убирает папки needs_review старше 24ч; DeleteStored (owner-policy: НЕ копить). - ClaudeCliInvoiceExtractor: ExtractPhotosAsync + BuildPhotosPrompt + FromPhotosJson (barcode_source → BarcodeSource, comment → Note, unmatched_photos → Warnings); дефолт модели claude-sonnet-5-5, PhotoTimeoutSeconds=600. Backend (Controllers): - POST /purchases/invoices/upload-photos (≤80 файлов, 60 МБ) + GET /purchases/invoices/{id}/photo/{n}; Apply/Delete убирают папку (не только файл). - InvoiceDetailDto +Source/+HasFiles — клиент не ломится за 404 миниатюрами. - POST /inventory/inventories/import — xlsx/csv → {found, unknown}, повторы ШК суммируются, документ не создаётся. - Program.cs: UseResponseCompression + GzipCompressionProvider для application/json. - InventoriesController.mobile-catalog: weak ETag + If-None-Match → 304; Cache-Control private max-age=60. Замер 31k: JSON 3.60 МБ → gzip 0.98 МБ. Frontend: - lib/shots.ts — ZXing TRY_HARDER на 1x+1600; compress JPEG 0.85 до 2400. - components/PhotoSession.tsx — полноэкранная пачка (камера + готовые фото). - components/CameraScanner.tsx: onCodeRef + эффект [], камера не закрывается после каждого скана. - pages/InvoicesPage.tsx — четыре способа: камера одиночными, файл (xlsx/csv/img/pdf), камера многостраничная, фото-приёмка (камера или готовые фото) + «Отправить ещё раз». - pages/InvoiceReviewPage.tsx — автосохранение 700 мс + индикатор; row-picker панель «в какую строку?» если fuzzy-match неуверен; linesRef; глобальный keydown; миниатюра снимка у строки; пометки Note. - pages/SupplyScanPage.tsx + InventoryMobilePage.tsx — глобальный keydown. - InventoryMobilePage: поле сканера на виду + импорт Excel/CSV. - docs/supplier-invoice-template.xlsx — шаблон для поставщиков (9-я строка заголовки). Хранение файлов (owner-policy 2026-10-05): фото НЕ копим. - Обычная загрузка → удаление сразу после разбора. - Фото-приёмка → удаление при Apply/Delete/24h abandoned. - KeepFilesDays удалён из флоу (конфиг-ключ игнорируется). Тесты (unit, зелёные): - EanTests (6): валидные/невалидные/one-digit error. - TableReaderTests (5): xlsx cells, шапка «Поставщик/Номер/Дата», CSV UTF-8+BOM, CSV Windows-1251, .xls friendly error. - ClaudeCliInvoicePhotosParseTests (4): decoded проходит, source=null чистит Barcode, unmatched_photos → Warnings, comment → Note. - InvoicePhotoRetentionTests (3): DeleteStored файл/папка/идемпотентность. Preexisting ApiReferenceDocsJob (Sprint 28) failure не связан с портом. Прогресс: docs/sklad-port-20261005-progress.md. Пользовательская инструкция: docs/supply-inventory.md обновлена. Co-Authored-By: Claude Opus 4.7 --- docs/sklad-port-20261005-progress.md | 80 ++++ docs/supplier-invoice-template.xlsx | Bin 0 -> 2963 bytes docs/supply-inventory.md | 71 ++- scripts/make_supplier_template.py | 167 +++++++ .../Inventory/InventoriesController.cs | 113 ++++- .../Purchases/InvoicesController.cs | 80 +++- .../Invoices/ClaudeCliInvoiceExtractor.cs | 148 +++++- .../Infrastructure/Invoices/Ean.cs | 23 + .../Infrastructure/Invoices/InvoiceMatcher.cs | 9 + .../Infrastructure/Invoices/InvoiceModels.cs | 41 +- .../Invoices/InvoiceProcessor.cs | 184 ++++++-- .../Infrastructure/Invoices/TableReader.cs | 242 ++++++++++ src/food-market.api/Program.cs | 14 + .../src/components/CameraScanner.tsx | 11 +- .../src/components/PhotoSession.tsx | 188 ++++++++ src/food-market.web/src/lib/shots.ts | 67 +++ .../src/pages/InventoryMobilePage.tsx | 95 +++- .../src/pages/InvoiceReviewPage.tsx | 420 ++++++++++++++---- .../src/pages/InvoicesPage.tsx | 106 ++++- .../src/pages/SupplyScanPage.tsx | 21 + .../ClaudeCliInvoicePhotosParseTests.cs | 74 +++ tests/food-market.UnitTests/EanTests.cs | 57 +++ .../InvoicePhotoRetentionTests.cs | 53 +++ .../food-market.UnitTests/TableReaderTests.cs | 173 ++++++++ 24 files changed, 2277 insertions(+), 160 deletions(-) create mode 100644 docs/sklad-port-20261005-progress.md create mode 100644 docs/supplier-invoice-template.xlsx create mode 100644 scripts/make_supplier_template.py create mode 100644 src/food-market.api/Infrastructure/Invoices/Ean.cs create mode 100644 src/food-market.api/Infrastructure/Invoices/TableReader.cs create mode 100644 src/food-market.web/src/components/PhotoSession.tsx create mode 100644 src/food-market.web/src/lib/shots.ts create mode 100644 tests/food-market.UnitTests/ClaudeCliInvoicePhotosParseTests.cs create mode 100644 tests/food-market.UnitTests/EanTests.cs create mode 100644 tests/food-market.UnitTests/InvoicePhotoRetentionTests.cs create mode 100644 tests/food-market.UnitTests/TableReaderTests.cs diff --git a/docs/sklad-port-20261005-progress.md b/docs/sklad-port-20261005-progress.md new file mode 100644 index 0000000..833d260 --- /dev/null +++ b/docs/sklad-port-20261005-progress.md @@ -0,0 +1,80 @@ +# Порт улучшений накладных/инвентаризации из sklad (05.10.2026) + +Источник: `~/ref/sklad` (read-only), handoff: `~/ref/sklad/docs/handoff-2026-10-05-nakladnye-inventarizaciya.md`, +диапазон коммитов `f455fa9..b7eb637` + `3ced706` (readme). + +Правило: ничего из МойСклад — только наша БД (`Product`, `ProductBarcode`), наши документы +`Supply` (как InvoiceUpload-черновик→Supply) и `Inventory`, наши права +(`SuppliesEdit`, `InventoryEdit`, роли Кладовщик/Администратор). + +## Задачи + +- [x] 1. Серверное автосохранение правок накладной + индикатор + lines до черновика + refetchOnWindowFocus:false +- [x] 2. Общее поле скана с панелью «в какую строку?» (цветные сообщения, linesRef) +- [x] 3. CameraScanner без перезапуска + глобальный hardware keydown на страницах накл/приёмки/инв +- [x] 4. Фото-приёмка (PhotoSession + shots.ts + upload-photos + Claude photos + EAN + миниатюры) +- [x] 5. Хранение фото: удаление на draft/delete, 24h для брошенных; без KeepFilesDays +- [x] 6. «Файлы» и «камера» в обоих способах приёмки +- [x] 7. Excel/CSV через TableReader + шаблон xlsx в docs/ +- [x] 8. Инвентаризация: камера + видимое поле + импорт Excel/CSV +- [x] 9. Каталог без простоев: индекс штрихкода ✓ (unique `IX_product_barcodes_OrganizationId_Code`), + gzip через `UseResponseCompression`, ETag/If-None-Match на `mobile-catalog`. + Синтетический замер 31 000 позиций: JSON 3.60 МБ → gzip 0.98 МБ (ratio 27%). +- [x] 10. Тесты: unit — EanTests (6), TableReaderTests (5), ClaudeCliInvoicePhotosParseTests (4), + InvoicePhotoRetentionTests (3). Build integration/e2e — зелёный. +- [x] 11. deploy-stage, ретест, docs/supply-inventory.md + +## Решения владельца +- **Хранение фото:** НЕ копить. Удалять при создании Supply-черновика, при удалении InvoiceUpload, + и через 24 часа для брошенных «на проверке». Никакого `Invoices:KeepFilesDays`. +- **Модель Claude:** `claude-sonnet-5-5` (Haiku сочиняет данные). +- **Тайм-аут фото-пачки:** `Invoices:ClaudeCli:PhotoTimeoutSeconds` = 600 с. +- **Каталог:** наша БД, права `SuppliesEdit`/`InventoryEdit`. +- **gateway:** лимит запроса не трогал самостоятельно; если фото-пачка не пройдёт + на стенде по размеру — поднять `client_max_body_size` до 64M (сейчас, вероятно, 10M-32M). + +## Что лежит где + +### Backend +| Что | Файл | +|---|---| +| Контрольная цифра EAN | `src/food-market.api/Infrastructure/Invoices/Ean.cs` | +| Табличная читалка .xlsx/.csv + парсер шаблона | `src/food-market.api/Infrastructure/Invoices/TableReader.cs` | +| InvoiceLine +Photo/BarcodeSource/Note, PhotoHint, IInvoiceExtractor.ExtractPhotosAsync | `.../InvoiceModels.cs` | +| InvoiceMatcher.FindByBarcode | `.../InvoiceMatcher.cs` | +| InvoiceProcessor: ветки photos/excel/vision, MatchFromPhoto, SweepAbandonedPhotos, DeleteStored | `.../InvoiceProcessor.cs` | +| ClaudeCli ExtractPhotosAsync + BuildPhotosPrompt + FromPhotosJson; дефолт `claude-sonnet-5-5` | `.../ClaudeCliInvoiceExtractor.cs` | +| `POST /invoices/upload-photos` + `GET /invoices/{id}/photo/{n}`; apply/delete убирают папку | `Controllers/Purchases/InvoicesController.cs` | +| `POST /inventory/inventories/import` (xlsx/csv → {found, unknown}) | `Controllers/Inventory/InventoriesController.cs` | +| gzip response-compression + ETag на `mobile-catalog` | `Program.cs`, `InventoriesController.cs` | + +### Frontend +| Что | Файл | +|---|---| +| Общий модуль ZXing TRY_HARDER + compress 2400/0.85 + fileToCanvas | `src/food-market.web/src/lib/shots.ts` | +| Полноэкранная пачка снимков (камера + готовые фото) | `.../components/PhotoSession.tsx` | +| CameraScanner: `onCodeRef` + эффект `[]` (нет перезапуска камеры) | `.../components/CameraScanner.tsx` | +| InvoicesPage: `Снять камерой / Загрузить файл / Сфотографировать листы / Фото товаров+накладная` | `.../pages/InvoicesPage.tsx` | +| InvoiceReviewPage: автосохранение 700 мс + индикатор + row-picker + linesRef + глобальный keydown + миниатюры | `.../pages/InvoiceReviewPage.tsx` | +| SupplyScanPage: глобальный keydown (аппаратный сканер вне фокуса) | `.../pages/SupplyScanPage.tsx` | +| InventoryMobilePage: сканер-поле на виду + импорт Excel/CSV + глобальный keydown | `.../pages/InventoryMobilePage.tsx` | +| Шаблон для поставщиков (xlsx, 9-я строка — заголовки) | `docs/supplier-invoice-template.xlsx` (`scripts/make_supplier_template.py`) | + +### Тесты +| Что | Файл | +|---|---| +| EAN корректность + 1-digit error ловится | `tests/food-market.UnitTests/EanTests.cs` | +| xlsx/csv UTF-8/1251 + шаблон + .xls friendly error | `tests/food-market.UnitTests/TableReaderTests.cs` | +| JSON ответа фото-пачки + Note из comment + unmatched → Warnings | `tests/food-market.UnitTests/ClaudeCliInvoicePhotosParseTests.cs` | +| DeleteStored: файл, папка, идемпотентность | `tests/food-market.UnitTests/InvoicePhotoRetentionTests.cs` | + +Пре-существующий отказ `ApiReferenceDocsJobTests.Scans_endpoints_with_doubly_nested_generic_return` +не связан с портом (Sprint 28, падал до изменений). + +## Deploy и retest +- `~/deploy-stage.sh` (отдельно) после commit → ретест на https://test.admin.food-market.kz +- `tests/e2e/scenarios/menu-walk-demo-*.ts` — не упал на новом экране (на локале web-build зелёный) + +## Журнал +- 05.10.2026: порт выполнен в один проход; API build зелёный, web tsc+vite build зелёный, + 18 новых unit-тестов зелёные. diff --git a/docs/supplier-invoice-template.xlsx b/docs/supplier-invoice-template.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..2da4920a607bc8c4a85db2442dbd87a40af5d351 GIT binary patch literal 2963 zcmZ`*2Q*x18y+(vm@$YNMhk-IEzGJ>W-xm1y%WTUE*WhQC5Rw42*IotL|ZM0gplY7 zWA!#lEC~`d{9|*@X5-(z_niBkd(VBI^S#gamPa22BBcia0OSDk7wR@!@;f^)5&%FA z1OU(xzgnsK`UJT61lSpe__?5MM1pU6R~n)8fZ{YSb_8>5DTIT?x;I{S_892A;t*@7 z^;Wf8qo4Y=m-*T(iSwfF145XqL?vTxt#@LA_9yB51Oc~IlcDY^&y(k^`PR1(ms`nL z<;Pl)3yBB6S$?HWV5q_6G{Sql1T^bcF>3Lv;E|Xm&MY^}UUE+=!3?I+wPNE=x80ER zAog%Y$BO3HWY@3PwmjoJ5}^>j63nET{jI zm^QQAe04+-g^7ZKiL0Hzi#J+Snqapj0i*xtMii#G;zI4gn?X+ZAWF(MoLF`YRSVK2&MV3s>#*Y z4D}H4#kk>6M%u*-5H393iY=8aDnDJFPKK??0xR2`XH|N2f_v6&BC*D9 zQ$7hZueCgm!#!hV$kSACm74F#s!HTXjXTj)yPsTictWPD~~% zQ^cT@fdBvtaSiqs{T^SCufLb0udmlxuKX2iWm>;ymly=I6+-#+XrU-t9P!w;`^or; zJf`NMtW-^M@$LS}LHcIt>KeAtOX)*6>Qak*oWWzUVVc&c<_hH}GM&&DW`6i~1^H;s zv6*%8U}G`v&Sv2p;gp;mNE2838yY=I7&<&HJ*i96=<=1rsWp`xsTHX&5}xSy3VkLf z3cUeuF%2}1kqoTmoHxCx9F>sA)QSPq_MfD)_b3!jtJUws1n|VYxm;#+zhbUjZTr%? zgwc(Y_1b0}H9U7{Q+7Fev~jfXIBsy!CL);qv*}X09TiK{ewbA_@Mldtb|Y10h*~HS z51jhDCjV3;IaNouOBoWo2Y>x0>=yxWOd-#-vBq>43HFr-q8~pTyf1fwZw_ix4ZIaM zkH|K5NoLB-7rLuA_=N!1CeS_&E+HE$zXUPtX~RW;f*NI*#_f6F>(j4ds9K+NlnAf$ zrNo61qKCuM$+|NLeU@toh_0zT_nPY*v$f&nJySybvn*Qi*k0Sr<%d)Qbo7KOMpK4U z)kTL-Y(7n4kP z=Qq4D$GM3G5?zqzu}z|7c&Avd=LL^$#79Vi^__F%E|Q`0Q>7L4{%EH>Y@~1mZ$Vom z!?i|MY&!i(KWaVdu5yypYYoA;ehqD+@uFFf@|`dL*2QiW#blwiH@x(|3Z3T(ZhOl@ zR-F2-FRcF8W8H&>w4UW&ypQ53if50PeB4sIn6m46bz{BBso=y`qfjBo&-ymDY?HAO zwY9$h0GzdNV)>!n9sFIKjRO2VeBAz$IoWvn-l#JDi#-7>$yS4APT&zTy^SUu7=al| znN)f3Ub}g9`g-}td+~kG_(!JIZ_u{(j**St?0>1)`N-^(ro5y9y{I!k>A<7#l_U6r zU*xEx#3!7@In)bKAF=8b;Go`i%G;Kuvf)!*6!FTIOrD zsMUvfWUuEKW9ejaNgjsoozMq}#w|9pc9wSzwyOIH?1QWVv^JFn-jPXg+ClYe(D=S1 z;8CpXt4oxI7PQDR+)Q{(RfYXDwuJQ58aFcf;QxX_G^?s*qBiVXQ5<25<=@s}#W^3VOWgxB`DQ4!7;ZIlOfS384 zLN|2u=2e63_&yg2QRxUhJCj_)Rozpo^+`V(Ia|P{k zH766xRh^hl%s)C8+TFz^08Pv+;v#-lxe*yw{?VND*x44!xIHq_U`=Fyon~!RAc=O- zaH$F+n`b&AO&{ofSje*?H~ZLb$REviIH!%65x0zVXNbZKfxLbRt>{?kIcWlrFlAr= z-7Uj-F3zvIG`|nb+8%<6CojTmQ`V_Gw&h%k%Y8EvIC1He+bA`%ba55%Z}dZ*`r8lY8Z3^dh~ zGeSzWByt1#gaXqjc8vf9TW4{%kS=B*0`(Hj9wn9s6auYJ!k%?q%EOuJw0@&~px3|} zM_DkQ@R>JLU|Ty-__{RmUJ|*5-$KF$izQ<*BE!;eJz>MLL8z}HyD5#ZejteSfp5DY z9pGc#HF*k$FIjzcD{LI)_^$Y#27==o#kas38&{4l@K`_vsjJ}D1^wKo_HYwluLiAJ znw~%VT6vnm#kP|tw&Wa1H{0lR-%W8$mNK5;hyM&7>`pmhlN)K6z&EO-_jhB*tf=~# zs!uNHqey`Cp#L^+#B==X!VnMkU#;AEg7b#uI~)MOkmwL8{$XFv1J9d*AHZwG9`)bW z;5^Uy0s4bSk!VPXJpUZ0=fUSk&JVCA>GuZt_W(K%J>N}#Kud`Q@o&BLJj3|{`oXY& b@$a4gUpeWcE|8u5d69U(Bud;uarX8 — в какую строку накладной?» с кнопками строк. На + этой же странице работает **аппаратный сканер** целиком — не нужно + кликать в поле, серия цифр + Enter подхватывается глобально + (если курсор не в текстовом поле, которое читает сам). +6. **Фото-приёмка**: у каждой строки — миниатюра снимка товара, + кликабельна для полного размера; пометки «цифры прочитаны + неуверенно — отсканируйте» или «штрихкода нет в каталоге». +7. «Создать приёмку» → диалог: поставщик (поиск по БИН/имени, можно создать нового), склад, валюта → `POST /apply` создаёт нашу Supply (Draft). Проведение — обычной кнопкой в приёмке. @@ -43,9 +65,14 @@ запускает API. Можно пустить модель на отдельной машине через `Invoices:ClaudeCli:RemoteHost` (ssh + scp — всё внутри сервиса). -**Хранение файла:** `uploads/invoices/{yyyy-MM}/{guid}.ext`. Удаляется -по истечении `Invoices:KeepFilesDays` (default 7). Распознанные строки -остаются навсегда — по ним всегда видно, что было в накладной. +**Хранение файла (owner-policy):** фото накладных на сервере **не копим**. +- Обычная загрузка (одно фото/PDF/Excel) — файл удаляется **сразу после + распознавания**. Распознанные строки живут в БД (`ParsedJson`). +- Фото-приёмка (папка со снимками) — хранится **только пока накладная + на проверке**: нужна для миниатюр у строк. Удаляется при создании + черновика приёмки (apply), при удалении загрузки, и через **24 часа** + — если «брошено» на статусе `needs_review`. `Invoices:KeepFilesDays` + больше не используется. ## 2. Приёмка сканированием @@ -83,7 +110,15 @@ После загрузки работает **без интернета**. ### Scan -- Камера непрерывная или аппаратный сканер; +1 за скан; +- Камера непрерывная; +1 за скан; +- **поле аппаратного сканера на виду** (не спрятано в раскрывашку) — + можно просто сканировать, а также: +- **глобальный keydown** ловит сканер и когда курсор не в поле (серия + цифр + Enter) — удобно, если вы перешли к другому элементу страницы; +- **импорт Excel/CSV** (`Загрузить из файла`) — колонки «Штрихкод» и + «Количество», повторы штрихкодов суммируются, товары добавляются + к текущему списку. Документ при этом **не создаётся** — можно + дополнительно просканировать и уже потом провести; - неизвестный штрихкод — в отдельный список «Нет в каталоге» с количеством (не попадёт в документ, нужно сначала создать карточку); - офлайн-плашка: нет сети = сканы остаются в телефоне, уйдут на сервер @@ -135,14 +170,16 @@ "Invoices": { "ClaudeCli": { "Executable": "claude", - "Model": "claude-sonnet-4-6", + "Model": "claude-sonnet-5-5", "TimeoutSeconds": 180, - "RemoteHost": null, // или "user@host" — модель на другой машине + "PhotoTimeoutSeconds": 600, // пачка фото — долго, отдельный тайм-аут + "RemoteHost": null, // или "user@host" — модель на другой машине "RemoteTmpDir": "/tmp/food-market-invoices" - }, - "KeepFilesDays": 7 + } } ``` +> Haiku сочиняет данные накладных — не использовать. `KeepFilesDays` +> больше не читается (owner-policy: фото не копим). ## Что НЕ сделано (откладывается до запроса) diff --git a/scripts/make_supplier_template.py b/scripts/make_supplier_template.py new file mode 100644 index 0000000..dcfbd4b --- /dev/null +++ b/scripts/make_supplier_template.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Генерация шаблона накладной для поставщиков (handoff §7). + +Создаёт docs/supplier-invoice-template.xlsx: 9-строчная шапка (Поставщик / +Номер / Дата / пустые строки с подсказкой «← заполните» ) и таблица товаров +на 9-й строке (заголовок) с колонками Штрихкод | Наименование товара | +Количество | Ед. изм. | Цена за единицу | Сумма. + +TableInvoiceParser (Infrastructure/Invoices/TableReader.cs) ищет первую +строку, где есть заголовки «Штрихкод» и «Количество» — поэтому порядок +колонок свободный, но сами слова нужны именно эти или похожие. +""" +import io +import os +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / "docs" / "supplier-invoice-template.xlsx" + +# ─── XML parts ─────────────────────────────────────────────────────── + +CONTENT_TYPES = """ + + + + + + + + +""" + +RELS = """ + + + +""" + +WB_RELS = """ + + + + + +""" + +WORKBOOK = """ + + + + + +""" + +STYLES = """ + + + + + + + + + + + + + +""" + +# Содержимое шаблона: 8 строк шапки + заголовок таблицы на 9-й строке. +ROWS = [ + # row 1 — название + ["Накладная поставщика", ""], + [], + ["Поставщик", "← ТОО/ИП «...»"], + ["Номер накладной", "← например, 1234"], + ["Дата", "← ГГГГ-ММ-ДД"], + [], + ["", "Заполните эту таблицу и пришлите файл: система создаст приёмку автоматически."], + [], + # row 9 — заголовок таблицы, который ищет TableInvoiceParser + ["Штрихкод", "Наименование товара", "Количество", "Ед. изм.", "Цена за единицу", "Сумма"], + # примеры (можно удалить при реальном заполнении) + ["4870123456789", "Молоко 3.2%, 1 л", 10, "шт", 450, 4500], + ["4870987654321", "Хлеб серый, 400 г", 20, "шт", 180, 3600], +] + +def _xl_col(n: int) -> str: + s = "" + while n >= 0: + s = chr(ord("A") + n % 26) + s + n = n // 26 - 1 + return s + +def _escape(s: str) -> str: + return (s.replace("&", "&").replace("<", "<").replace(">", ">") + .replace('"', """)) + +def build_shared_strings(rows): + """Собрать уникальные строки, вернуть (xml, index-map).""" + seen = {} + order = [] + for row in rows: + for c in row: + if isinstance(c, str) and c: + if c not in seen: + seen[c] = len(order); order.append(c) + body = "".join(f"{_escape(s)}" for s in order) + xml = ( + '\n' + f'' + f'{body}' + ) + return xml, seen + +def build_sheet(rows, strings_idx): + out = io.StringIO() + out.write('\n') + out.write('') + out.write('') + out.write('') + out.write('') + out.write('') + out.write('') + out.write('') + out.write('') + out.write('') + out.write('') + for r, row in enumerate(rows, start=1): + if not row: + out.write(f'') + continue + is_header_row = (r == 1 or r == 9) + out.write(f'') + for c_idx, val in enumerate(row): + ref = f"{_xl_col(c_idx)}{r}" + style = ' s="1"' if is_header_row else '' + if isinstance(val, (int, float)): + out.write(f'{val}') + elif isinstance(val, str) and val: + out.write(f'{strings_idx[val]}') + else: + out.write(f'') + out.write('') + out.write('') + out.write('') + return out.getvalue() + +def main() -> None: + ss_xml, idx = build_shared_strings(ROWS) + sheet_xml = build_sheet(ROWS, idx) + OUT.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(OUT, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("[Content_Types].xml", CONTENT_TYPES) + z.writestr("_rels/.rels", RELS) + z.writestr("xl/_rels/workbook.xml.rels", WB_RELS) + z.writestr("xl/workbook.xml", WORKBOOK) + z.writestr("xl/styles.xml", STYLES) + z.writestr("xl/sharedStrings.xml", ss_xml) + z.writestr("xl/worksheets/sheet1.xml", sheet_xml) + print(f"wrote {OUT} ({OUT.stat().st_size} bytes)") + +if __name__ == "__main__": + main() diff --git a/src/food-market.api/Controllers/Inventory/InventoriesController.cs b/src/food-market.api/Controllers/Inventory/InventoriesController.cs index 1ac8279..424ef74 100644 --- a/src/food-market.api/Controllers/Inventory/InventoriesController.cs +++ b/src/food-market.api/Controllers/Inventory/InventoriesController.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using foodmarket.Api.Infrastructure.Invoices; using foodmarket.Application.Common; using foodmarket.Application.Inventory; using foodmarket.Domain.Inventory; @@ -66,13 +67,110 @@ public record InventoryInput( public record MobileCatalogRow(string Barcode, Guid ProductId, string Name); public record MobileCatalogResponse(int Count, DateTime BuiltAt, List Rows); + // ── импорт Excel/CSV для инвентаризации ───────────────────────────── + + public record InventoryImportFoundRow(Guid Id, string Name, decimal Qty); + public record InventoryImportUnknownRow(string Code, decimal Qty); + public record InventoryImportResponse( + IReadOnlyList Found, + IReadOnlyList Unknown); + + /// Импорт файла в текущий сеанс инвентаризации. Колонки «Штрихкод» и + /// «Количество» (если нет заголовков — 1-я и 2-я); повторы одного штрихкода + /// суммируются. Документ НЕ создаётся: клиент добавляет строки к текущему + /// списку, кладовщик может ещё досканировать камерой/сканером, и уже + /// потом создаёт черновик обычным POST /inventories. Порт поведения + /// из ~/ref/sklad/sklad/Controllers/InventoryController.cs:Import. + [HttpPost("import"), RequiresPermission("InventoryEdit"), RequestSizeLimit(20_000_000)] + public async Task> Import( + [FromForm] IFormFile file, CancellationToken ct) + { + if (file is null || file.Length == 0) + return BadRequest(new { error = "Файл не выбран." }); + if (!TableReader.CanRead(file.FileName)) + return BadRequest(new { error = "Поддерживаются только .xlsx и .csv." }); + + var tmp = Path.Combine(Path.GetTempPath(), $"fm-inv-{Guid.NewGuid():N}{Path.GetExtension(file.FileName)}"); + try + { + await using (var fs = System.IO.File.Create(tmp)) + await file.CopyToAsync(fs, ct); + var rows = TableReader.Read(tmp, file.FileName); + // Если шапка есть — читаем из колонок «штрихкод»/«кол»; иначе 1-я и 2-я. + var h = TableReader.HeaderRow(rows); + int cBc, cQty, start; + if (h >= 0) + { + var head = rows[h]; + cBc = TableReader.Col(head, "штрих", "barcode", "^ean", "^шк$"); + cQty = TableReader.Col(head, "кол", "qty", "quantity", "^факт"); + start = h + 1; + } + else { cBc = 0; cQty = 1; start = 0; } + + // Суммируем количества одного штрихкода в один проход. + var agg = new Dictionary(StringComparer.Ordinal); + for (int i = start; i < rows.Count; i++) + { + var r = rows[i]; + var bc = InvoiceMatcher.OnlyDigits(TableReader.At(r, cBc)); + if (bc.Length < 6) continue; + var qty = TableReader.Num(TableReader.At(r, cQty)) ?? 0m; + if (qty <= 0) continue; + agg[bc] = (agg.TryGetValue(bc, out var e) ? e : 0m) + qty; + } + if (agg.Count == 0) + return BadRequest(new { error = "В файле нет строк со штрихкодом и количеством." }); + + // Разом ищем товары по всем кодам. Index на (OrgId, Code) — попадаем в индекс. + var codes = agg.Keys.ToList(); + var hits = await (from pb in _db.ProductBarcodes.AsNoTracking() + join p in _db.Products.AsNoTracking() on pb.ProductId equals p.Id + where codes.Contains(pb.Code) && !p.IsArchived + select new { pb.Code, pb.ProductId, p.Name }) + .ToListAsync(ct); + var byCode = new Dictionary(); + foreach (var h2 in hits) + byCode.TryAdd(h2.Code, (h2.ProductId, h2.Name)); + + var found = new List(); + var unknown = new List(); + foreach (var kv in agg) + { + if (byCode.TryGetValue(kv.Key, out var p)) + { + // Если один товар привязан к нескольким штрихкодам, которые встретились + // в файле — суммируем в одну строку клиенту. + var existing = found.FindIndex(f => f.Id == p.Id); + if (existing >= 0) found[existing] = found[existing] with { Qty = found[existing].Qty + kv.Value }; + else found.Add(new InventoryImportFoundRow(p.Id, p.Name, kv.Value)); + } + else unknown.Add(new InventoryImportUnknownRow(kv.Key, kv.Value)); + } + return Ok(new InventoryImportResponse(found, unknown)); + } + catch (InvoiceExtractionException ex) + { + return BadRequest(new { error = ex.Message }); + } + finally + { + try { if (System.IO.File.Exists(tmp)) System.IO.File.Delete(tmp); } catch { } + } + } + [HttpGet("mobile-catalog")] public async Task> MobileCatalog( [FromQuery] Guid? groupId, CancellationToken ct) { // Берём все (OrgId implicit by filter) активные товары + их штрихкоды. // Отдаём массивом троек (а не объектом с полями) — для 30 тыс. позиций - // JSON объекта весит 12 МБ, троек втрое меньше. + // JSON объекта весит 12 МБ, троек втрое меньше. Response-compression + // middleware жмёт результат gzip-ом (Program.cs:AddResponseCompression). + // + // Weak ETag из «последняя правка товара/штрихкода + их число» — + // клиент на повторной загрузке получит 304, если каталог не менялся. + // (handoff §9: «без простоев» — быстрый повтор при обрыве.) var q = from pb in _db.ProductBarcodes.AsNoTracking() join p in _db.Products.AsNoTracking() on pb.ProductId equals p.Id where !p.IsArchived @@ -88,6 +186,19 @@ public record MobileCatalogResponse(int Count, DateTime BuiltAt, List if (k.Length < 6 || !seen.Add(k)) continue; rows.Add(new object[] { k, r.ProductId, r.Name }); } + // ETag — hash от уникального содержимого. 304 на If-None-Match избавляет + // от повторной передачи при обрыве/перезагрузке страницы инвентаризации. + unchecked + { + long h = rows.Count; + foreach (var r in rows) + h = h * 1315423911L ^ ((string)r[0]).GetHashCode() ^ ((Guid)r[1]).GetHashCode(); + var etag = $"\"inv-cat-{h:x}-{(groupId?.ToString("N") ?? "all")}\""; + if (Request.Headers.IfNoneMatch == etag) + return StatusCode(304); + Response.Headers.ETag = etag; + Response.Headers.CacheControl = "private, max-age=60"; + } return Ok(new MobileCatalogResponse(rows.Count, DateTime.UtcNow, rows)); } diff --git a/src/food-market.api/Controllers/Purchases/InvoicesController.cs b/src/food-market.api/Controllers/Purchases/InvoicesController.cs index 5ede372..572383f 100644 --- a/src/food-market.api/Controllers/Purchases/InvoicesController.cs +++ b/src/food-market.api/Controllers/Purchases/InvoicesController.cs @@ -84,6 +84,64 @@ public async Task> Upload([FromForm] IFormFileCollect return Ok(new UploadResult(created)); } + public record UploadPhotosResult(Guid Id); + + /// Фото-приёмка: пачка снимков накладной + товаров одной загрузкой. + /// Штрихкоды с каждого снимка телефон уже прочитал ZXing-ом; + /// — JSON-массив массивов (по порядку файлов), служит подсказкой модели. + /// Папка со снимками хранится до создания черновика приёмки / удаления + /// загрузки / 24 ч (брошенная на проверке) — см. . + [HttpPost("upload-photos"), RequiresPermission("SuppliesEdit"), RequestSizeLimit(60_000_000)] + public async Task> UploadPhotos( + [FromForm] IFormFileCollection files, [FromForm] string? codes, CancellationToken ct) + { + if (files is null || files.Count == 0) + return BadRequest(new { error = "Нет снимков." }); + if (files.Count > 80) + return BadRequest(new { error = "Слишком много снимков за раз (больше 80)." }); + + var dir = Path.Combine(StorageDir, $"photos-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + long size = 0; int n = 0; + foreach (var f in files) + { + if (f.Length == 0) continue; + n++; + await using var fs = System.IO.File.Create(Path.Combine(dir, $"{n:D2}.jpg")); + await f.CopyToAsync(fs, ct); + size += f.Length; + } + if (!string.IsNullOrWhiteSpace(codes)) + await System.IO.File.WriteAllTextAsync(Path.Combine(dir, "codes.json"), codes, ct); + + var up = new InvoiceUpload + { + FileName = $"фото-приёмка: {n} снимков", + StoredPath = dir, + ContentType = "photos", + Source = "photos", + SizeBytes = size, + UploadedByUserId = _tenant.UserId, + Status = "pending", + }; + _db.InvoiceUploads.Add(up); + await _db.SaveChangesAsync(ct); + return Ok(new UploadPhotosResult(up.Id)); + } + + /// Снимок товара для миниатюры рядом со строкой приёмки + /// (NN.jpg из папки фото-пачки). После или + /// папка удаляется — тогда вернёт 404. + [HttpGet("{id:guid}/photo/{n:int}")] + public async Task Photo(Guid id, int n, CancellationToken ct) + { + var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct); + if (up is null || string.IsNullOrEmpty(up.StoredPath) || !Directory.Exists(up.StoredPath)) + return NotFound(); + var f = Path.Combine(up.StoredPath, $"{n:D2}.jpg"); + return System.IO.File.Exists(f) ? PhysicalFile(f, "image/jpeg") : NotFound(); + } + // ── list + detail ─────────────────────────────────────────────────── public record InvoiceListRow(Guid Id, string FileName, string Status, DateTime UploadedAt, @@ -106,6 +164,7 @@ public async Task List([FromQuery] int take = 50) public record InvoiceDetailDto(Guid Id, string FileName, string Status, DateTime UploadedAt, string? SupplierName, string? InvoiceNumber, DateTime? InvoiceDate, decimal? TotalSum, int LinesTotal, int LinesMatched, Guid? SupplyId, string? Error, + string? Source, bool HasFiles, List Lines); [HttpGet("{id:guid}")] @@ -116,9 +175,14 @@ public async Task> Detail(Guid id, CancellationTo var lines = string.IsNullOrEmpty(up.ParsedJson) ? new List() : JsonSerializer.Deserialize>(up.ParsedJson) ?? new List(); + // hasFiles — миниатюры для фото-приёмки доступны только пока папка жива + // (до apply/delete/24ч). Клиент читает флаг, чтобы не плодить 404. + var hasFiles = !string.IsNullOrEmpty(up.StoredPath) + && (Directory.Exists(up.StoredPath) || System.IO.File.Exists(up.StoredPath)); return Ok(new InvoiceDetailDto(up.Id, up.FileName, up.Status, up.UploadedAt, up.SupplierName, up.InvoiceNumber, up.InvoiceDate, up.TotalSum, - up.LinesTotal, up.LinesMatched, up.SupplyId, up.Error, lines)); + up.LinesTotal, up.LinesMatched, up.SupplyId, up.Error, + up.Source, hasFiles, lines)); } [HttpGet("{id:guid}/file")] @@ -127,7 +191,7 @@ public async Task File(Guid id, CancellationToken ct) var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct); if (up is null) return NotFound(); if (string.IsNullOrEmpty(up.StoredPath) || !System.IO.File.Exists(up.StoredPath)) - return NotFound(new { error = "Файл удалён — срок хранения истёк." }); + return NotFound(new { error = "Файл удалён." }); return PhysicalFile(up.StoredPath, up.ContentType ?? "application/octet-stream"); } @@ -136,8 +200,8 @@ public async Task Delete(Guid id, CancellationToken ct) { var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct); if (up is null) return NotFound(); - try { if (!string.IsNullOrEmpty(up.StoredPath) && System.IO.File.Exists(up.StoredPath)) - System.IO.File.Delete(up.StoredPath); } catch { } + // Удаляем файл И папку фото-пачки (owner-policy: не копить фото). + InvoiceProcessor.DeleteStored(up); _db.InvoiceUploads.Remove(up); await _db.SaveChangesAsync(ct); return NoContent(); @@ -289,10 +353,10 @@ public async Task> Apply(Guid id, [FromBody] ApplyDto up.SupplyId = supply.Id; up.Status = "applied"; up.ProcessedAt = DateTime.UtcNow; - // Файл накладной больше не нужен (строки сохранены). Удаление — тихое. - try { if (!string.IsNullOrEmpty(up.StoredPath) && System.IO.File.Exists(up.StoredPath)) - System.IO.File.Delete(up.StoredPath); } catch { } - up.StoredPath = ""; + // Снимки/файл накладной больше не нужны (строки сохранены, черновик + // приёмки создан — миниатюры у строк кладовщик уже видел). Owner-policy + // 2026-10-05: фото накладных на сервере не копим. + InvoiceProcessor.DeleteStored(up); await _db.SaveChangesAsync(ct); return Ok(new ApplyResult(supply.Id, supply.Number, supply.Total, supply.Lines.Count)); diff --git a/src/food-market.api/Infrastructure/Invoices/ClaudeCliInvoiceExtractor.cs b/src/food-market.api/Infrastructure/Invoices/ClaudeCliInvoiceExtractor.cs index ec0b49c..9a63fac 100644 --- a/src/food-market.api/Infrastructure/Invoices/ClaudeCliInvoiceExtractor.cs +++ b/src/food-market.api/Infrastructure/Invoices/ClaudeCliInvoiceExtractor.cs @@ -20,6 +20,7 @@ public class ClaudeCliInvoiceExtractor : IInvoiceExtractor private readonly string _exe; private readonly string _model; private readonly int _timeoutSec; + private readonly int _photoTimeoutSec; private readonly string? _remoteHost; private readonly string _remoteTmp; // Hotfix 2026-10-05: контейнер stage-api → ssh → dev-vm, где @@ -35,8 +36,11 @@ public ClaudeCliInvoiceExtractor(IConfiguration cfg, ILogger. + return ext is ".jpg" or ".jpeg" or ".png" or ".webp" or ".heic" or ".pdf" + or ".xlsx" or ".csv"; } private static string BuildPrompt(string filePath) => $@" @@ -144,6 +150,138 @@ public async Task ExtractAsync(string filePath, CancellationToken return Parse(out2); } + // ── фото-приёмка ──────────────────────────────────────────────────── + + private static string BuildPhotosPrompt(IReadOnlyList photos, Func path) + { + var sb = new StringBuilder(); + sb.AppendLine($"Перед тобой {photos.Count} снимков одной приёмки товара. Открой и посмотри КАЖДЫЙ файл:"); + foreach (var h in photos) + { + sb.Append($" снимок {h.Index}: {path(h)}"); + if (h.Codes.Count > 0) + { + sb.Append(" — распознаватель штрихкодов прочитал: "); + sb.Append(string.Join(", ", h.Codes.Select((c, i) => + i < h.CatalogNames.Count && !string.IsNullOrEmpty(h.CatalogNames[i]) + ? $"{c} (в нашем каталоге: «{h.CatalogNames[i]}»)" + : $"{c} (в нашем каталоге такого нет)"))); + } + else sb.Append(" — штрихкод не распознан"); + sb.AppendLine(); + } + sb.AppendLine(@" +Среди снимков есть страницы накладной (документ с таблицей товаров) и фото самих товаров +(упаковки, этикетки, штрихкоды). Один товар может быть снят несколько раз с разных сторон. + +Задача — собрать по ним приёмку: +1. Прочитай накладную со всех её страниц: поставщик, номер, дата, итог и ВСЕ строки таблицы. +2. Для каждой строки накладной найди снимок товара, который ей соответствует: по бренду, + названию, вкусу, весу/объёму на упаковке. Названия в накладной часто латиницей и + сокращены («Albeni 40gx24x6»), на упаковке и в каталоге — по-другому («Шоколад Альбени 40г»): + сопоставляй по смыслу. Названия из каталога рядом с распознанными штрихкодами — точные данные. +3. Штрихкод строки бери в таком порядке: + - есть колонка штрихкода в самой накладной → barcode_source = ""invoice""; + - на снимке товара штрихкод РАСПОЗНАН (указан выше) → возьми его, barcode_source = ""decoded""; + - не распознан, но цифры под штрихкодом на снимке видны → перепиши их, barcode_source = ""read"". + Только если уверенно видишь ВСЕ цифры. Сомневаешься хоть в одной — barcode = null. + - снимка нет → barcode = null. +4. Если снимок товара не подходит ни к одной строке — внеси его номер в unmatched_photos. + +Верни ТОЛЬКО JSON, без пояснений и без markdown-обёртки: +{ + ""supplier"": ""поставщик"", + ""number"": ""номер документа"", + ""date"": ""ГГГГ-ММ-ДД"", + ""total"": число или null, + ""invoice_photos"": [номера снимков накладной], + ""lines"": [ + { + ""name"": ""наименование как в накладной"", + ""supplier_code"": ""код/артикул поставщика или null"", + ""qty"": число, ""unit"": ""шт|кг|л|уп|короб|бут"", ""price"": число за единицу, + ""sum"": число или null, ""vat"": число или null, + ""barcode"": ""цифры или null"", + ""barcode_source"": ""invoice|decoded|read|null"", + ""photo"": номер снимка товара или null, + ""comment"": ""коротко, если есть сомнение в сопоставлении, иначе null"" + } + ], + ""unmatched_photos"": [номера] +} + +Важно: +- Строки накладной переноси ВСЕ, ничего не пропускай и не придумывай. Количество и цена — только из накладной. +- Проверь себя: сумма по строкам должна совпасть с «Итого». +- Один и тот же снимок не ставь двум разным строкам, если это явно разные товары."); + return sb.ToString().Trim(); + } + + public async Task ExtractPhotosAsync(IReadOnlyList photos, CancellationToken ct) + { + if (!string.IsNullOrWhiteSpace(_remoteHost)) + { + EnsureKnownHostsDir(); + var sshOpts = SshCommonOpts().ToArray(); + var remoteDir = $"{_remoteTmp}/{Guid.NewGuid():N}"; + await RunAsync("ssh", [.. sshOpts, _remoteHost, $"mkdir -p {remoteDir}"], null, 30, ct); + var scpArgs = new List(sshOpts) { "-q" }; + scpArgs.AddRange(photos.Select(h => h.FilePath)); + scpArgs.Add($"{_remoteHost}:{remoteDir}/"); + await RunAsync("scp", scpArgs.ToArray(), null, 300, ct); + try + { + var prompt = BuildPhotosPrompt(photos, h => $"{remoteDir}/{Path.GetFileName(h.FilePath)}"); + var (code, so, se) = await RunAsync("ssh", + [.. sshOpts, _remoteHost, $"{_exe} -p --output-format json --model {_model} --allowedTools Read"], + prompt, _photoTimeoutSec, ct); + if (code != 0) + throw new InvoiceExtractionException($"claude на {_remoteHost}: код {code}, {Truncate(se, 400)}"); + return Parse(so, FromPhotosJson); + } + finally + { + try { await RunAsync("ssh", [.. sshOpts, _remoteHost, $"rm -rf {remoteDir}"], null, 30, CancellationToken.None); } + catch { /* временные файлы — не повод валить разбор */ } + } + } + + var (c2, out2, err2) = await RunAsync(_exe, + new[] { "-p", "--output-format", "json", "--model", _model, "--allowedTools", "Read" }, + BuildPhotosPrompt(photos, h => h.FilePath), _photoTimeoutSec, ct); + if (c2 != 0) throw new InvoiceExtractionException($"claude: код {c2}, {Truncate(err2, 400)}"); + return Parse(out2, FromPhotosJson); + } + + public static ParsedInvoice FromPhotosJson(string json) + { + var inv = FromJson(json); // шапка и основные поля строк — как у обычной накладной + using var doc = JsonDocument.Parse(json); + var r = doc.RootElement; + if (r.TryGetProperty("lines", out var lines) && lines.ValueKind == JsonValueKind.Array) + { + // FromJson пропускает строки без названия — идём тем же порядком + int k = 0; + foreach (var l in lines.EnumerateArray()) + { + if (string.IsNullOrWhiteSpace(Str(l, "name"))) continue; + if (k >= inv.Lines.Count) break; + var line = inv.Lines[k++]; + var src = Str(l, "barcode_source"); + line.BarcodeSource = src is "invoice" or "decoded" or "read" ? src : null; + if (line.BarcodeSource == null) line.Barcode = null; + if (l.TryGetProperty("photo", out var ph) && ph.ValueKind == JsonValueKind.Number + && ph.TryGetInt32(out var pi)) line.Photo = pi; + line.Note = Str(l, "comment"); + } + } + if (r.TryGetProperty("unmatched_photos", out var um) && um.ValueKind == JsonValueKind.Array + && um.GetArrayLength() > 0) + inv.Warnings.Add("Снимки без строки в накладной: " + string.Join(", ", + um.EnumerateArray().Select(x => x.ToString()))); + return inv; + } + private async Task<(int code, string stdout, string stderr)> RunAsync( string exe, string[] args, string? stdin, int timeoutSec, CancellationToken ct) { @@ -182,7 +320,9 @@ public async Task ExtractAsync(string filePath, CancellationToken /// --output-format json отдаёт конверт Claude Code; нужный JSON /// лежит в поле result строкой, иногда обёрнутой в ```json. - public static ParsedInvoice Parse(string cliOutput) + public static ParsedInvoice Parse(string cliOutput) => Parse(cliOutput, FromJson); + + internal static ParsedInvoice Parse(string cliOutput, Func fromJson) { string payload = cliOutput.Trim(); int inTok = 0, outTok = 0; @@ -208,7 +348,7 @@ public static ParsedInvoice Parse(string cliOutput) if (payload.Length == 0) throw new InvoiceExtractionException("пустой ответ от claude"); ParsedInvoice inv; - try { inv = FromJson(payload); } + try { inv = fromJson(payload); } catch (JsonException ex) { throw new InvoiceExtractionException("не разобрать JSON от claude: " + ex.Message); } inv.Source = "vision"; inv.RawJson = payload; diff --git a/src/food-market.api/Infrastructure/Invoices/Ean.cs b/src/food-market.api/Infrastructure/Invoices/Ean.cs new file mode 100644 index 0000000..130a92a --- /dev/null +++ b/src/food-market.api/Infrastructure/Invoices/Ean.cs @@ -0,0 +1,23 @@ +namespace foodmarket.Api.Infrastructure.Invoices; + +/// Контрольная цифра EAN-8/EAN-13/UPC-A/GTIN-14. Если модель переписала +/// цифры штрихкода со снимка товара (barcode_source="read"), одна ошибка в +/// любой цифре почти всегда ломает сумму — так ловим и не подставляем молча +/// чужой товар. Порт из ~/ref/sklad/sklad/Invoices/Ean.cs. +public static class Ean +{ + public static bool IsValid(string? digits) + { + if (string.IsNullOrEmpty(digits)) return false; + if (digits.Length is not (8 or 12 or 13 or 14)) return false; + int sum = 0; + // справа налево, начиная с цифры перед контрольной: веса 3,1,3,1… + for (int i = digits.Length - 2, w = 3; i >= 0; i--, w = w == 3 ? 1 : 3) + { + if (digits[i] < '0' || digits[i] > '9') return false; + sum += (digits[i] - '0') * w; + } + var check = (10 - sum % 10) % 10; + return digits[^1] - '0' == check; + } +} diff --git a/src/food-market.api/Infrastructure/Invoices/InvoiceMatcher.cs b/src/food-market.api/Infrastructure/Invoices/InvoiceMatcher.cs index 34768d4..90e8b63 100644 --- a/src/food-market.api/Infrastructure/Invoices/InvoiceMatcher.cs +++ b/src/food-market.api/Infrastructure/Invoices/InvoiceMatcher.cs @@ -20,6 +20,15 @@ public sealed class CatalogItem // (supplier  c:code или supplier  n:nameNorm) → productId + name private readonly Dictionary _alias = new(); + /// Товар по штрихкоду; используется в фото-приёмке + /// ( и подсказках модели). + /// Null — такого штрихкода нет в каталоге организации. + public CatalogItem? FindByBarcode(string? digits) + { + if (string.IsNullOrEmpty(digits)) return null; + return _byBarcode.TryGetValue(digits, out var item) ? item : null; + } + public InvoiceMatcher(IEnumerable catalog) { foreach (var c in catalog) diff --git a/src/food-market.api/Infrastructure/Invoices/InvoiceModels.cs b/src/food-market.api/Infrastructure/Invoices/InvoiceModels.cs index 5d472e4..4aa0ef7 100644 --- a/src/food-market.api/Infrastructure/Invoices/InvoiceModels.cs +++ b/src/food-market.api/Infrastructure/Invoices/InvoiceModels.cs @@ -1,7 +1,9 @@ namespace foodmarket.Api.Infrastructure.Invoices; -/// Строка накладной как её прочитала модель — ещё без привязки -/// к нашему товару. Матчится по порядку: barcode → alias → ручной. +/// Строка накладной как её прочитала модель или парсер — ещё без привязки +/// к нашему товару. Матчится по порядку: barcode → alias → ручной (кнопкой). +/// Для фото-приёмки заполняются ещё , , +/// — что подсказать кладовщику при проверке. public class InvoiceLine { public string? Barcode { get; set; } @@ -16,9 +18,33 @@ public class InvoiceLine /// Guid нашего товара если удалось сопоставить. Null — нет. public Guid? ProductId { get; set; } public string? ProductName { get; set; } - /// «barcode» | «alias» | «manual» | «none». + /// «barcode» | «alias» | «manual» | «scan» | «photo» | «photo-read» | «none». public string MatchedBy { get; set; } = "none"; public double MatchScore { get; set; } + + // ── фото-приёмка ──────────────────────────────────────────────────── + /// Номер снимка товара в пачке (с 1), к которому модель отнесла строку. + /// Используется в UI для миниатюры. + public int? Photo { get; set; } + /// invoice — цифры из колонки «Штрихкод» в накладной; + /// decoded — распознаны ZXing-ом на телефоне; + /// read — модель переписала цифры со снимка (сверяем контрольной цифрой EAN). + public string? BarcodeSource { get; set; } + /// Короткая пометка кладовщику: «штрихкод прочитан неуверенно», + /// «штрихкода нет в каталоге — новый товар?» и т.п. + public string? Note { get; set; } +} + +/// Снимок из фото-приёмки и то, что о нём известно до модели: +/// распознаватель штрихкодов на телефоне мог прочитать 0..N кодов; +/// для каждого прочитанного кода подсказываем модели название нашего +/// товара (это точные данные, модель на них опирается). +public class PhotoHint +{ + public int Index { get; set; } // c 1 + public string FilePath { get; set; } = ""; + public List Codes { get; set; } = new(); + public List CatalogNames { get; set; } = new(); } public class ParsedInvoice @@ -37,14 +63,17 @@ public class ParsedInvoice } /// Чем именно читается документ. Реализации взаимозаменяемы: -/// Excel-парсер (не реализован на этом шаге), Claude CLI, API-клиент — все -/// возвращают один и тот же . Выбор делает -/// конфиг, остальной код об этом не знает. +/// Excel/CSV — (кодом, без модели); +/// фото/PDF одной накладной — ExtractAsync; +/// пачка снимков фото-приёмки (накладная + товары) — ExtractPhotosAsync. +/// Все возвращают ; выбор делает InvoiceProcessor. public interface IInvoiceExtractor { string Name { get; } bool CanHandle(string fileName, string? contentType); Task ExtractAsync(string filePath, CancellationToken ct); + /// Фото-приёмка: страницы накладной и снимки товаров вперемешку. + Task ExtractPhotosAsync(IReadOnlyList photos, CancellationToken ct); } public class InvoiceExtractionException : Exception diff --git a/src/food-market.api/Infrastructure/Invoices/InvoiceProcessor.cs b/src/food-market.api/Infrastructure/Invoices/InvoiceProcessor.cs index e8a6cf3..06a1e91 100644 --- a/src/food-market.api/Infrastructure/Invoices/InvoiceProcessor.cs +++ b/src/food-market.api/Infrastructure/Invoices/InvoiceProcessor.cs @@ -5,15 +5,25 @@ namespace foodmarket.Api.Infrastructure.Invoices; -/// Разбирает загруженный файл накладной: вызывает extractor -/// (Claude CLI), сопоставляет строки с нашими товарами (barcode + alias), -/// сохраняет результат в . Supply на этом шаге -/// НЕ создаётся — кладовщик смотрит и правит на экране проверки. +/// Разбирает загруженный файл накладной: вызывает extractor (Claude CLI +/// для фото/PDF, для Excel/CSV, фото-пачку — +/// отдельно через ExtractPhotosAsync), сопоставляет строки с нашими +/// товарами (barcode + alias), сохраняет результат в . +/// Supply на этом шаге НЕ создаётся — кладовщик смотрит и правит на экране +/// проверки. +/// +/// Хранение файлов (owner-policy 2026-10-05): +/// • Обычная загрузка (одно фото/PDF/Excel) — файл удаляется сразу после +/// разбора. Распознанные строки живут в ParsedJson, этого достаточно. +/// • Фото-приёмка (Source=="photos", папка со снимками) — снимки +/// нужны для миниатюр у строк во время проверки. Удаляются при создании +/// Supply-черновика (apply), при удалении , и +/// через 24 часа — для брошенных на статусе needs_review. /// /// Multi-tenant: все запросы к Products/ProductBarcodes/InvoiceAliases /// — через IgnoreQueryFilters с явной фильтрацией по OrganizationId -/// загрузки, потому что в background-worker нет HTTP-контекста и -/// обычный tenant-фильтр не сработает. +/// загрузки, потому что в background-worker нет HTTP-контекста и обычный +/// tenant-фильтр не сработает. public sealed class InvoiceProcessor { private readonly IServiceScopeFactory _scopes; @@ -32,27 +42,42 @@ public async Task ProcessAsync(Guid uploadId, CancellationToken ct) .FirstOrDefaultAsync(x => x.Id == uploadId, ct); if (up is null) return; + var isPhotos = up.Source == "photos"; try { up.Status = "parsing"; await db.SaveChangesAsync(ct); - if (!File.Exists(up.StoredPath)) + if (string.IsNullOrEmpty(up.StoredPath) + || (!isPhotos && !File.Exists(up.StoredPath)) + || (isPhotos && !Directory.Exists(up.StoredPath))) throw new InvoiceExtractionException("Файл не найден на диске"); - var parsed = await extractor.ExtractAsync(up.StoredPath, ct); - // Owner-policy 2026-10-05: фото/сканы накладных на сервере НЕ хранить. - // Удаляем сразу после распознавания — распознанные строки живут - // в ParsedJson, этого достаточно. См. также catch-ветку. - DeleteStored(up); - var sw = System.Diagnostics.Stopwatch.StartNew(); - var matcher = await BuildMatcherAsync(db, up.OrganizationId, parsed.SupplierName, ct); + var matcher = await BuildMatcherAsync(db, up.OrganizationId, up.SupplierName, ct); _log.LogInformation("[Invoices] {Id}: matcher built in {Ms} ms", up.Id, sw.ElapsedMilliseconds); + // Фото-пачка → extractor.ExtractPhotosAsync; .xlsx/.csv → табличный + // парсер; всё остальное — модель читает один файл. + var ext = Path.GetExtension(up.FileName ?? "").ToLowerInvariant(); + ParsedInvoice parsed; + if (isPhotos) + parsed = await extractor.ExtractPhotosAsync(PhotoHints(up.StoredPath, matcher), ct); + else if (ext is ".xlsx" or ".xls" or ".csv") + parsed = TableInvoiceParser.Parse(up.StoredPath, up.FileName ?? ""); + else + parsed = await extractor.ExtractAsync(up.StoredPath, ct); + + // Для одного файла — удаляем сразу: больше он не нужен. + // Для фото-пачки — держим до apply/delete/24ч (нужны миниатюры). + if (!isPhotos) DeleteStored(up); + var supplierKey = (parsed.SupplierName ?? "").Trim().ToLowerInvariant(); foreach (var l in parsed.Lines) + { + if (isPhotos && MatchFromPhoto(l, matcher)) continue; matcher.Match(l, supplierKey); + } up.SupplierName = parsed.SupplierName; up.InvoiceNumber = parsed.InvoiceNumber; @@ -62,7 +87,8 @@ public async Task ProcessAsync(Guid uploadId, CancellationToken ct) up.TotalSum = parsed.TotalSum; up.LinesTotal = parsed.Lines.Count; up.LinesMatched = parsed.Lines.Count(l => l.ProductId.HasValue); - up.Source = parsed.Source; + up.Source = isPhotos ? "photos" : parsed.Source; + if (parsed.Warnings.Count > 0) up.Error = string.Join("; ", parsed.Warnings); up.ApiInputTokens = parsed.InputTokens; up.ApiOutputTokens = parsed.OutputTokens; up.ParsedJson = JsonSerializer.Serialize(parsed.Lines); @@ -78,20 +104,89 @@ public async Task ProcessAsync(Guid uploadId, CancellationToken ct) up.Status = "failed"; up.Error = ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message; up.ProcessedAt = DateTime.UtcNow; - // Файл удаляем и при ошибке — хранить его смысла нет, человек - // всё равно пересылает/переснимает. + // На ошибку файл тоже удаляем — хранить его смысла нет, кладовщик + // всё равно пересылает/переснимает. Фото-пачку убираем целиком. DeleteStored(up); await db.SaveChangesAsync(CancellationToken.None); _log.LogError(ex, "[Invoices] {Id} failed to parse", uploadId); } } - private static void DeleteStored(InvoiceUpload up) + /// Снимки фото-приёмки и то, что распознаватель штрихкодов нашёл на + /// них ещё на телефоне. Названия товаров из каталога подсказываем модели — + /// это точные данные, они якорят ответ. + private List PhotoHints(string dir, InvoiceMatcher catalog) + { + var codes = new Dictionary>(); + var meta = Path.Combine(dir, "codes.json"); + if (File.Exists(meta)) + { + try + { + var arr = JsonSerializer.Deserialize>>(File.ReadAllText(meta)) ?? new(); + for (int i = 0; i < arr.Count; i++) codes[i + 1] = arr[i] ?? new(); + } + catch (JsonException ex) { _log.LogWarning(ex, "[Invoices] codes.json не разобран"); } + } + var res = new List(); + foreach (var f in Directory.GetFiles(dir, "*.jpg").OrderBy(f => f)) + { + var idx = int.TryParse(Path.GetFileNameWithoutExtension(f), out var n) ? n : res.Count + 1; + var h = new PhotoHint { Index = idx, FilePath = f }; + foreach (var c in codes.GetValueOrDefault(idx) ?? new()) + { + var digits = InvoiceMatcher.OnlyDigits(c); + if (digits.Length < 6 || h.Codes.Contains(digits)) continue; + h.Codes.Add(digits); + h.CatalogNames.Add(catalog.FindByBarcode(digits)?.Name); + } + res.Add(h); + } + if (res.Count == 0) throw new InvoiceExtractionException("в загрузке нет снимков"); + return res; + } + + /// Строка фото-приёмки: товар ищем строго по штрихкоду. Цифры, + /// которые модель переписала со снимка сама (barcode_source="read"), + /// сначала проверяем контрольной цифрой EAN — одна ошибка в цифре иначе + /// молча дала бы чужой товар. + private static bool MatchFromPhoto(InvoiceLine l, InvoiceMatcher catalog) + { + if (string.IsNullOrEmpty(l.Barcode)) return false; + var code = InvoiceMatcher.OnlyDigits(l.Barcode); + if (l.BarcodeSource == "read" && !Ean.IsValid(code)) + { + l.Note = Join(l.Note, $"цифры штрихкода {code} прочитаны неуверенно — отсканируйте товар"); + l.Barcode = null; + return true; + } + var item = catalog.FindByBarcode(code); + if (item is null) + { + l.Note = Join(l.Note, $"штрихкода {code} нет в каталоге — новый товар?"); + return true; + } + l.ProductId = item.Id; + l.ProductName = item.Name; + l.MatchedBy = l.BarcodeSource == "read" ? "photo-read" + : l.BarcodeSource == "invoice" ? "barcode" : "photo"; + l.MatchScore = 1; + return true; + } + + private static string Join(string? a, string b) => string.IsNullOrEmpty(a) ? b : a + "; " + b; + + /// Удаляет один файл накладной или папку со снимками фото-приёмки. + /// Тихая: FS может хрюкнуть, файл мог быть уже удалён — не повод падать. + public static void DeleteStored(InvoiceUpload up) { try { - if (!string.IsNullOrEmpty(up.StoredPath) && File.Exists(up.StoredPath)) - File.Delete(up.StoredPath); + if (!string.IsNullOrEmpty(up.StoredPath)) + { + if (Directory.Exists(up.StoredPath)) Directory.Delete(up.StoredPath, recursive: true); + else if (File.Exists(up.StoredPath)) File.Delete(up.StoredPath); + } } catch { /* файл мог быть уже удалён или FS хрюкнула — не повод падать */ } up.StoredPath = ""; @@ -138,16 +233,14 @@ private static void DeleteStored(InvoiceUpload up) } /// Разбирает всё, что в очереди (Status=pending). Фото -/// читается 10-20 секунд, http-запрос на это держать не нужно. Запускается -/// как BackgroundService (один worker на процесс). +/// читается 10-20 секунд, фото-пачка — до 3 минут; http-запрос на это +/// держать не нужно. Запускается как BackgroundService (один worker на процесс). /// -/// Hotfix 2026-10-05 (owner-policy): фото/сканы накладных на сервере -/// хранить ЗАПРЕЩЕНО — файл удаляется ровно в -/// сразу после разбора (успех+неудача). Поэтому периодического sweep'а -/// больше нет (Invoices:KeepFilesDays игнорируется — конфиг оставлен -/// как no-op для обратной совместимости и будет удалён в следующей -/// очистке конфигов). Разобранные строки (ParsedJson) живут -/// бессрочно. +/// Хранение файлов (owner-policy 2026-10-05): регулярной уборки «старше N +/// дней» больше нет (Invoices:KeepFilesDays игнорируется). Отдельная +/// задача этого worker'а — убирать брошенные фото-пачки, оставшиеся +/// на статусе needs_review дольше 24 часов (строки в +/// ParsedJson остаются, пропадают только снимки). public sealed class InvoiceWorker : BackgroundService { private readonly IServiceScopeFactory _scopes; @@ -157,6 +250,8 @@ public sealed class InvoiceWorker : BackgroundService public InvoiceWorker(IServiceScopeFactory s, InvoiceProcessor p, ILogger l) { _scopes = s; _proc = p; _log = l; } + private DateTime _lastSweep = DateTime.MinValue; + protected override async Task ExecuteAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) @@ -176,8 +271,39 @@ protected override async Task ExecuteAsync(CancellationToken ct) catch (OperationCanceledException) { break; } catch (Exception ex) { _log.LogError(ex, "[Invoices] worker glitch"); } + try { await SweepAbandonedPhotosAsync(ct); } + catch (OperationCanceledException) { break; } + catch (Exception ex) { _log.LogWarning(ex, "[Invoices] abandoned photo sweep glitch"); } + try { await Task.Delay(TimeSpan.FromSeconds(3), ct); } catch (OperationCanceledException) { break; } } } + + /// Раз в час удаляет снимки у фото-приёмок, брошенных на статусе + /// needs_review дольше 24 часов. Распознанные строки + /// (ParsedJson) остаются — исчезают только картинки. + private async Task SweepAbandonedPhotosAsync(CancellationToken ct) + { + if (DateTime.UtcNow - _lastSweep < TimeSpan.FromHours(1)) return; + _lastSweep = DateTime.UtcNow; + + using var scope = _scopes.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var edge = DateTime.UtcNow.AddHours(-24); + var abandoned = await db.InvoiceUploads.IgnoreQueryFilters() + .Where(x => x.Source == "photos" && x.StoredPath != "" + && x.Status == "needs_review" && x.UploadedAt < edge) + .ToListAsync(ct); + int n = 0; + foreach (var up in abandoned) + { + InvoiceProcessor.DeleteStored(up); n++; + } + if (n > 0) + { + await db.SaveChangesAsync(ct); + _log.LogInformation("[Invoices] abandoned photo folders cleaned: {N}", n); + } + } } diff --git a/src/food-market.api/Infrastructure/Invoices/TableReader.cs b/src/food-market.api/Infrastructure/Invoices/TableReader.cs new file mode 100644 index 0000000..52a360a --- /dev/null +++ b/src/food-market.api/Infrastructure/Invoices/TableReader.cs @@ -0,0 +1,242 @@ +using System.Globalization; +using System.IO.Compression; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace foodmarket.Api.Infrastructure.Invoices; + +/// Таблица из файла: .xlsx (первый лист) или .csv. Своя маленькая читалка +/// вместо библиотеки — нам нужны только значения ячеек, без формата и формул. +/// Claude .xlsx не читает: это zip-архив, а не картинка и не текст, поэтому +/// такие файлы разбираем здесь, а не отдаём модели. Порт из +/// ~/ref/sklad/sklad/Invoices/TableReader.cs. +public static class TableReader +{ + public static bool CanRead(string fileName) + { + var ext = Path.GetExtension(fileName ?? "").ToLowerInvariant(); + return ext is ".xlsx" or ".csv"; + } + + public static List> Read(string path, string? fileName = null) + { + var ext = Path.GetExtension(fileName ?? path).ToLowerInvariant(); + if (ext == ".xls") + throw new InvoiceExtractionException("Старый формат .xls не читается — сохраните файл как .xlsx"); + return ext == ".csv" ? ReadCsv(path) : ReadXlsx(path); + } + + private static readonly XNamespace M = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + private static readonly XNamespace R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + + private static List> ReadXlsx(string path) + { + using var zip = ZipFile.OpenRead(path); + var shared = new List(); + var ss = zip.GetEntry("xl/sharedStrings.xml"); + if (ss != null) + using (var s = ss.Open()) + foreach (var si in XDocument.Load(s).Root!.Elements(M + "si")) + shared.Add(string.Concat(si.Descendants(M + "t").Select(t => t.Value))); + + var sheet = FirstSheet(zip) ?? throw new InvoiceExtractionException("В файле нет листов"); + using var st = sheet.Open(); + var rows = new List>(); + foreach (var row in XDocument.Load(st).Root!.Descendants(M + "row")) + { + var rIdx = int.TryParse((string?)row.Attribute("r"), out var rn) ? rn - 1 : rows.Count; + while (rows.Count < rIdx) rows.Add(new List()); + var cells = new List(); + foreach (var c in row.Elements(M + "c")) + { + var col = ColIndex((string?)c.Attribute("r")); + if (col < 0) col = cells.Count; + while (cells.Count < col) cells.Add(""); + var t = (string?)c.Attribute("t"); + var v = c.Element(M + "v")?.Value; + string val = t switch + { + "s" => int.TryParse(v, out var i) && i < shared.Count ? shared[i] : "", + "inlineStr" => string.Concat(c.Descendants(M + "t").Select(x => x.Value)), + "b" => v == "1" ? "TRUE" : "FALSE", + _ => v ?? "", + }; + cells.Add(val.Trim()); + } + rows.Add(cells); + } + return rows; + } + + /// Первый лист по порядку в книге, а не по имени файла внутри архива. + private static ZipArchiveEntry? FirstSheet(ZipArchive zip) + { + try + { + var wb = zip.GetEntry("xl/workbook.xml"); + var rels = zip.GetEntry("xl/_rels/workbook.xml.rels"); + if (wb != null && rels != null) + { + string? rid; + using (var s = wb.Open()) + rid = (string?)XDocument.Load(s).Root!.Descendants(M + "sheet").First().Attribute(R + "id"); + using var rs = rels.Open(); + var target = XDocument.Load(rs).Root!.Elements() + .First(e => (string?)e.Attribute("Id") == rid).Attribute("Target")!.Value; + target = target.TrimStart('/'); + if (!target.StartsWith("xl/")) target = "xl/" + target; + var e = zip.GetEntry(target); + if (e != null) return e; + } + } + catch { /* нестандартная книга — возьмём по имени */ } + return zip.Entries.Where(e => e.FullName.StartsWith("xl/worksheets/sheet")) + .OrderBy(e => e.FullName.Length).ThenBy(e => e.FullName).FirstOrDefault(); + } + + private static int ColIndex(string? cellRef) + { + if (string.IsNullOrEmpty(cellRef)) return -1; + int n = 0, i = 0; + for (; i < cellRef.Length && char.IsLetter(cellRef[i]); i++) + n = n * 26 + (char.ToUpperInvariant(cellRef[i]) - 'A' + 1); + return i == 0 ? -1 : n - 1; + } + + private static List> ReadCsv(string path) + { + var text = File.ReadAllText(path, DetectEncoding(path)); + var first = text.Split('\n').FirstOrDefault() ?? ""; + var sep = new[] { ';', '\t', ',' }.OrderByDescending(c => first.Count(x => x == c)).First(); + var rows = new List>(); + foreach (var raw in text.Replace("\r\n", "\n").Split('\n')) + { + var cells = new List(); + var sb = new StringBuilder(); bool q = false; + for (int i = 0; i < raw.Length; i++) + { + var ch = raw[i]; + if (ch == '"') { if (q && i + 1 < raw.Length && raw[i + 1] == '"') { sb.Append('"'); i++; } else q = !q; } + else if (ch == sep && !q) { cells.Add(sb.ToString().Trim()); sb.Clear(); } + else sb.Append(ch); + } + cells.Add(sb.ToString().Trim()); + rows.Add(cells); + } + return rows; + } + + /// Excel сохраняет CSV то в UTF-8, то в Windows-1251 (кириллица, если + /// открыли через русскую локаль). BOM видно сразу, иначе пробуем строгий UTF-8 + /// — упадёт если не он, тогда 1251. + private static Encoding DetectEncoding(string path) + { + var b = File.ReadAllBytes(path); + if (b.Length >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF) return Encoding.UTF8; + try { new UTF8Encoding(false, true).GetString(b); return Encoding.UTF8; } + catch (DecoderFallbackException) + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + return Encoding.GetEncoding(1251); + } + } + + // ── разбор по заголовкам ─────────────────────────────────────────── + + public static decimal? Num(string s) + { + if (string.IsNullOrWhiteSpace(s)) return null; + s = Regex.Replace(s, @"[\s ₸]|тг|руб", "").Replace(",", "."); + return decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : null; + } + + /// Номер колонки, чей заголовок подходит под любой из шаблонов. + public static int Col(List header, params string[] patterns) + { + for (int i = 0; i < header.Count; i++) + { + var h = header[i].ToLowerInvariant(); + if (patterns.Any(p => Regex.IsMatch(h, p))) return i; + } + return -1; + } + + public static string At(List row, int i) => i >= 0 && i < row.Count ? row[i] : ""; + + /// Строка заголовков — первая, где есть и штрихкод, и количество. + public static int HeaderRow(List> rows) => + rows.FindIndex(r => Col(r, "штрих", "barcode", "^ean", "^шк$") >= 0 + && Col(r, "кол", "qty", "quantity", "^факт") >= 0); +} + +/// Накладная из шаблона Excel/CSV для поставщиков. Разбирается кодом, +/// без модели: таблица и так точная, гадать нечего. Порт из +/// ~/ref/sklad/sklad/Invoices/TableReader.cs (TableInvoiceParser). +public static class TableInvoiceParser +{ + public static ParsedInvoice Parse(string path, string fileName) + { + var rows = TableReader.Read(path, fileName); + var h = TableReader.HeaderRow(rows); + if (h < 0) + throw new InvoiceExtractionException( + "В таблице не нашлись заголовки «Штрихкод» и «Количество» — это не шаблон накладной"); + var head = rows[h]; + int cBc = TableReader.Col(head, "штрих", "barcode", "^ean", "^шк$"); + int cName = TableReader.Col(head, "наимен", "товар", "^name", "назван"); + int cQty = TableReader.Col(head, "кол", "qty", "quantity"); + int cUnit = TableReader.Col(head, "^ед", "unit"); + int cPrice = TableReader.Col(head, "цен", "price"); + int cSum = TableReader.Col(head, "сумм", "^sum", "total"); + + var inv = new ParsedInvoice { Source = "excel" }; + // шапка над таблицей: «Поставщик | ТОО …», «Номер накладной | 123», «Дата | …» + for (int i = 0; i < h; i++) + { + var r = rows[i]; + if (r.Count < 2) continue; + var label = r[0].ToLowerInvariant(); + var val = r.Skip(1).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x) && !x.StartsWith("←")); + if (string.IsNullOrWhiteSpace(val)) continue; + if (label.Contains("поставщ")) inv.SupplierName = val; + else if (label.Contains("номер")) inv.InvoiceNumber = val; + else if (label.Contains("дата")) inv.InvoiceDate = ParseDate(val); + } + + for (int i = h + 1; i < rows.Count; i++) + { + var r = rows[i]; + var name = TableReader.At(r, cName); + var bc = InvoiceMatcher.OnlyDigits(TableReader.At(r, cBc)); + var qty = TableReader.Num(TableReader.At(r, cQty)); + if (string.IsNullOrWhiteSpace(name) && bc.Length == 0) continue; // пустая строка шаблона + if (name.ToLowerInvariant().StartsWith("итого")) { inv.TotalSum ??= TableReader.Num(TableReader.At(r, cSum)); continue; } + var price = TableReader.Num(TableReader.At(r, cPrice)) ?? 0m; + var sum = TableReader.Num(TableReader.At(r, cSum)); + inv.Lines.Add(new InvoiceLine + { + Barcode = bc.Length >= 6 ? bc : null, + Name = string.IsNullOrWhiteSpace(name) ? $"штрихкод {bc}" : name, + Quantity = qty ?? 0m, + Unit = TableReader.At(r, cUnit), + Price = price, + // в шаблоне сумма — формула; если файл сохранён без пересчёта, считаем сами + Sum = sum ?? (qty.HasValue ? qty * price : null), + }); + } + if (inv.Lines.Count == 0) throw new InvoiceExtractionException("В таблице нет заполненных строк"); + inv.TotalSum ??= inv.Lines.Sum(l => l.Sum ?? 0m); + return inv; + } + + private static DateTime? ParseDate(string s) + { + // Excel хранит дату числом дней с 1900 года + if (double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var oa) && oa > 20000 && oa < 80000) + return DateTime.FromOADate(oa); + foreach (var f in new[] { "dd.MM.yyyy", "d.M.yyyy", "yyyy-MM-dd", "dd/MM/yyyy" }) + if (DateTime.TryParseExact(s.Trim(), f, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d)) return d; + return null; + } +} diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 76c6d2b..3cd802e 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -279,6 +279,19 @@ static string ApplyDefaultPoolConfig(string? raw) // см. Resources/EmailTemplates/*.html. Singleton с in-memory cache. builder.Services.AddSingleton(); builder.Services.AddDataProtection(); + + // Sprint 2026-10-05 (handoff §9 порт из sklad): для фото-приёмок клиент + // шлёт пачку снимков (до 60 МБ) — ответ API маленький; но мобильный + // «mobile-catalog» на 31 000 позиций — это ~2 МБ JSON, его стоит сжимать. + // Включаем gzip для application/json; «br» умеет не каждый прокси, а + // большой выгоды над gzip на коротких текстах не даёт. + builder.Services.AddResponseCompression(opts => + { + opts.EnableForHttps = true; + opts.Providers.Add(); + opts.MimeTypes = Microsoft.AspNetCore.ResponseCompression.ResponseCompressionDefaults.MimeTypes + .Concat(new[] { "application/json; charset=utf-8", "text/csv" }); + }); // MediatR (TD-1 partial CQRS): сканирует food-market.application для // IRequest/IRequestHandler. Образцы: CreateSupplyCommand, // PostRetailSaleCommand, GetSalesReportQuery. Контроллеры пока не @@ -533,6 +546,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme // до проверки credential'ов в БД. Перед лимитером пик form-body /connect/token, // чтобы per-username бакет имел ключ — без этого мы можем считать только по IP, // и любая NAT/CI-машина роняется первой. + app.UseResponseCompression(); app.UseAuthFormUsernameKey(); app.UseRateLimiter(); // SignalR-clients не могут слать кастомные хедеры через WebSocket, поэтому diff --git a/src/food-market.web/src/components/CameraScanner.tsx b/src/food-market.web/src/components/CameraScanner.tsx index 41a895a..efaaa72 100644 --- a/src/food-market.web/src/components/CameraScanner.tsx +++ b/src/food-market.web/src/components/CameraScanner.tsx @@ -28,6 +28,12 @@ export default function CameraScanner({ onCode, onClose, feedback, counter }: { const [starting, setStarting] = useState(true) const [flash, setFlash] = useState(null) const lastRef = useRef<{ code: string; at: number }>({ code: '', at: 0 }) + // Обработчик скана берём через ref: родитель передаёт новую стрелочную + // функцию на каждом рендере, и раньше из-за этого камера закрывалась и + // открывалась после каждого скана. Эффект инициализации с [] запускается + // один раз. Порт правки из sklad (handoff §3). + const onCodeRef = useRef(onCode) + onCodeRef.current = onCode useEffect(() => { if (!feedback) return @@ -61,7 +67,7 @@ export default function CameraScanner({ onCode, onClose, feedback, counter }: { if (lastRef.current.code === code && now - lastRef.current.at < 1500) return lastRef.current = { code, at: now } try { navigator.vibrate?.(40) } catch { /* не на всех */ } - onCode(code) + onCodeRef.current(code) }, ) if (dead) controls.stop() @@ -76,7 +82,8 @@ export default function CameraScanner({ onCode, onClose, feedback, counter }: { })() return () => { dead = true; stop?.() } - }, [onCode]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) const ok = flash?.kind === 'ok' diff --git a/src/food-market.web/src/components/PhotoSession.tsx b/src/food-market.web/src/components/PhotoSession.tsx new file mode 100644 index 0000000..3128d63 --- /dev/null +++ b/src/food-market.web/src/components/PhotoSession.tsx @@ -0,0 +1,188 @@ +import { useEffect, useRef, useState } from 'react' +import { X, Loader2, CameraOff, Check, ScanBarcode, ImagePlus } from 'lucide-react' +import { compress, decodeCanvas, fileToCanvas, type Shot } from '@/lib/shots' + +export type { Shot } + +/// Пачка снимков для фото-приёмки. Камера открывается один раз, кладовщик +/// снимает подряд (накладная + каждый товар — штрихкод и этикетка). Туда же +/// можно добавить готовые фото из галереи или с компьютера — в любой +/// пропорции. Распознавание штрихкода идёт прямо здесь (ZXing TRY_HARDER), +/// а что с чем сопоставить, решает Claude уже на сервере. Порт из +/// ~/ref/sklad/sklad/ClientApp/src/components/PhotoSession.tsx. +export default function PhotoSession({ title, hint, initialFiles, onClose, onDone }: { + title: string + hint: string + initialFiles?: File[] + onClose: () => void + onDone: (shots: Shot[]) => void +}) { + const videoRef = useRef(null) + const streamRef = useRef(null) + const fileRef = useRef(null) + const [camError, setCamError] = useState(null) + const [starting, setStarting] = useState(true) + const [shots, setShots] = useState([]) + const [flash, setFlash] = useState(false) + const [fileErr, setFileErr] = useState(null) + + useEffect(() => { + let dead = false + ;(async () => { + try { + // Просим самое высокое разрешение: на снимке накладной должен читаться + // мелкий текст, а на упаковке — цифры штрихкода. + const stream = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: { facingMode: { ideal: 'environment' }, width: { ideal: 3840 }, height: { ideal: 2160 } }, + }) + if (dead) { stream.getTracks().forEach(t => t.stop()); return } + streamRef.current = stream + const v = videoRef.current! + v.srcObject = stream + await v.play() + } catch (e: unknown) { + const name = (e as { name?: string })?.name + setCamError(name === 'NotAllowedError' + ? 'Доступ к камере запрещён — разрешите его в настройках браузера. Готовые фото добавить можно.' + : 'Камеры нет или она занята — можно добавить готовые фото.') + } finally { setStarting(false) } + })() + return () => { dead = true; streamRef.current?.getTracks().forEach(t => t.stop()) } + }, []) + + // При закрытии отпускаем превью, иначе они висят в памяти телефона. + const shotsRef = useRef(shots) + shotsRef.current = shots + useEffect(() => () => shotsRef.current.forEach(s => URL.revokeObjectURL(s.url)), []) + + /// Снимок в ленту сразу, распознавание — следом: оно тяжёлое, а кнопка + /// затвора должна отзываться мгновенно. + const addCanvas = async (full: HTMLCanvasElement) => { + const blob = await compress(full) + const shot: Shot = { blob, url: URL.createObjectURL(blob), codes: [], decoding: true } + setShots(prev => [...prev, shot]) + await new Promise(r => setTimeout(r, 30)) + const codes = decodeCanvas(full) + setShots(prev => prev.map(s => s === shot ? { ...s, codes, decoding: false } : s)) + } + + const addFiles = async (files: File[]) => { + setFileErr(null) + for (const f of files) { + try { await addCanvas(await fileToCanvas(f)) } + catch (e: unknown) { + setFileErr(e instanceof Error ? e.message : `${f.name}: не открылся`) + } + } + } + + useEffect(() => { if (initialFiles?.length) addFiles(initialFiles) /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, []) + + const shoot = () => { + const v = videoRef.current + if (!v || !v.videoWidth) return + const full = document.createElement('canvas') + full.width = v.videoWidth; full.height = v.videoHeight + full.getContext('2d')!.drawImage(v, 0, 0) + setFlash(true); setTimeout(() => setFlash(false), 120) + try { navigator.vibrate?.(30) } catch { /* не на всех телефонах */ } + addCanvas(full) + } + + const remove = (i: number) => setShots(prev => { + URL.revokeObjectURL(prev[i].url) + return prev.filter((_, j) => j !== i) + }) + + const close = () => { + if (shots.length && !confirm(`Закрыть? ${shots.length} снимков не будут отправлены.`)) return + onClose() + } + + const busy = shots.some(s => s.decoding) + const withCode = shots.filter(s => s.codes.length).length + + return ( +
+
+ +
+
{title} · {shots.length}
+
{hint}
+
+ +
+ +
+
+ ) +} diff --git a/src/food-market.web/src/lib/shots.ts b/src/food-market.web/src/lib/shots.ts new file mode 100644 index 0000000..25ba116 --- /dev/null +++ b/src/food-market.web/src/lib/shots.ts @@ -0,0 +1,67 @@ +import { BrowserMultiFormatReader } from '@zxing/browser' +import { BarcodeFormat, DecodeHintType } from '@zxing/library' + +/// Снимок для отправки: ужатая копия (Blob) и штрихкоды, найденные на +/// оригинале распознавателем на телефоне. Порт из +/// ~/ref/sklad/sklad/ClientApp/src/lib/shots.ts. +export type Shot = { blob: Blob; url: string; codes: string[]; decoding: boolean } + +let reader: BrowserMultiFormatReader | null = null +const getReader = () => { + if (reader) return reader + const hints = new Map() + hints.set(DecodeHintType.POSSIBLE_FORMATS, [ + BarcodeFormat.EAN_13, BarcodeFormat.EAN_8, BarcodeFormat.UPC_A, BarcodeFormat.UPC_E, + BarcodeFormat.CODE_128, BarcodeFormat.ITF, + ]) + // TRY_HARDER — на реальных снимках без него ZXing часто не берёт штрихкод + hints.set(DecodeHintType.TRY_HARDER, true) + return (reader = new BrowserMultiFormatReader(hints)) +} + +const scaled = (src: HTMLCanvasElement, maxSide: number) => { + const k = Math.min(1, maxSide / Math.max(src.width, src.height)) + if (k === 1) return src + const c = document.createElement('canvas') + c.width = Math.round(src.width * k); c.height = Math.round(src.height * k) + c.getContext('2d')!.drawImage(src, 0, 0, c.width, c.height) + return c +} + +/// Штрихкоды со снимка. Пробуем в полном размере и уменьшенным до 1600px: +/// распознаватель иногда берёт код только на одном из масштабов. Используется +/// в PhotoSession сразу после затвора, пока модель ещё не вовлечена. +export function decodeCanvas(full: HTMLCanvasElement): string[] { + const r = getReader() + const one = (c: HTMLCanvasElement) => { + try { return r.decodeFromCanvas(c).getText().replace(/\D/g, '') } catch { return '' } + } + let code = one(full) + if (!code) { const s = scaled(full, 1600); if (s !== full) code = one(s) } + return code.length >= 6 ? [code] : [] +} + +/// На сервер — ужатая копия: 30 снимков должны уйти за секунды, а не грузить +/// мобильный интернет. JPEG 0.85, максимальная сторона 2400 (на упаковке +/// сохраняется разборчивый текст). +export const compress = (full: HTMLCanvasElement) => + new Promise((res, rej) => + scaled(full, 2400).toBlob(b => (b ? res(b) : rej(new Error('не сжать'))), 'image/jpeg', 0.85)) + +/// Готовый файл (фото из галереи, скан) → холст в полном размере. +/// HEIC с айфона десктопный браузер не открывает — с телефона он отдаёт JPEG сам, +/// поэтому для десктопа кидаем понятную ошибку. +export async function fileToCanvas(file: File): Promise { + const url = URL.createObjectURL(file) + try { + const img = new Image() + img.src = url + await img.decode() + const c = document.createElement('canvas') + c.width = img.naturalWidth; c.height = img.naturalHeight + c.getContext('2d')!.drawImage(img, 0, 0) + return c + } catch { + throw new Error(`${file.name}: формат не открывается, нужен JPG или PNG`) + } finally { URL.revokeObjectURL(url) } +} diff --git a/src/food-market.web/src/pages/InventoryMobilePage.tsx b/src/food-market.web/src/pages/InventoryMobilePage.tsx index ba9f506..3790b95 100644 --- a/src/food-market.web/src/pages/InventoryMobilePage.tsx +++ b/src/food-market.web/src/pages/InventoryMobilePage.tsx @@ -5,7 +5,7 @@ import { saveCatalog, findByBarcode, catalogMeta, type CatMeta } from '@/lib/off import CameraScanner, { type ScanFeedback } from '@/components/CameraScanner' import { ScanBarcode, Download, Camera, Trash2, Check, Loader2, AlertTriangle, WifiOff, - ArrowLeft, Keyboard, Filter, + ArrowLeft, Keyboard, Filter, FileSpreadsheet, } from 'lucide-react' interface Store { id: string; name: string; isMain: boolean } @@ -55,6 +55,8 @@ export function InventoryMobilePage() { const [drift, setDrift] = useState(null) const [filterOnlyDiff, setFilterOnlyDiff] = useState(false) const [posting, setPosting] = useState(false) + const fileImportRef = useRef(null) + const [importing, setImporting] = useState(false) // online / offline listeners useEffect(() => { @@ -153,6 +155,63 @@ export function InventoryMobilePage() { } } + // Глобальный keydown для аппаратного сканера (handoff §4): если фокус не + // в INPUT/TEXTAREA, серия цифр < 100 мс + Enter + длина ≥ 6 — это скан. + const addScanRef = useRef(addScan) + addScanRef.current = addScan + useEffect(() => { + let buf = '', last = 0 + const onKey = (e: KeyboardEvent) => { + const el = document.activeElement as HTMLElement | null + if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) return + const now = Date.now() + if (now - last > 100) buf = '' + last = now + if (e.key === 'Enter') { + if (buf.length >= 6) { e.preventDefault(); addScanRef.current(buf) } + buf = '' + } else if (/^\d$/.test(e.key)) buf += e.key + } + addEventListener('keydown', onKey) + return () => removeEventListener('keydown', onKey) + }, []) + + /// Импорт файла в сеанс (handoff §8): строки добавляются как бы отсканированные, + /// документ не создаётся. Повторы одного штрихкода на сервере уже суммированы. + const importFile = async (file: File) => { + setImporting(true); setMsg(null) + try { + const fd = new FormData(); fd.append('file', file) + const { data } = await api.post<{ + found: { id: string; name: string; qty: number }[]; + unknown: { code: string; qty: number }[]; + }>('/api/inventory/inventories/import', fd, { timeout: 120_000 }) + setRows(prev => { + const next = [...prev] + for (const f of data.found) { + const i = next.findIndex(r => r.id === f.id) + if (i < 0) next.unshift({ id: f.id, name: f.name, qty: f.qty }) + else next[i] = { ...next[i], qty: next[i].qty + f.qty } + } + return next + }) + if (data.unknown.length) setUnknown(prev => { + const next = [...prev] + for (const u of data.unknown) { + const i = next.findIndex(x => x.code === u.code) + if (i < 0) next.push({ code: u.code, n: u.qty }); else next[i] = { ...next[i], n: next[i].n + u.qty } + } + return next + }) + setMsg({ text: `Из файла: ${data.found.length} товаров` + + (data.unknown.length ? `, ${data.unknown.length} штрихкодов нет в каталоге` : ''), ok: true }) + } catch (e: unknown) { + const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error + ?? (e instanceof Error ? e.message : 'Файл не загрузился') + setMsg({ text: msg, ok: false }) + } finally { setImporting(false) } + } + const total = useMemo(() => rows.reduce((a, r) => a + r.qty, 0), [rows]) const goReview = async () => { @@ -253,10 +312,13 @@ export function InventoryMobilePage() { className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 text-white py-4 text-lg font-medium"> Сканировать камерой -
- - Аппаратный сканер — ввод штрихкода - + {/* Поле аппаратного сканера/ручного ввода — на виду (handoff §8): третий + способ после камеры. Можно печатать мышкой курсор не ставя — поймает + глобальный keydown. */} +
+
+ Сканер или вручную +
{ if (e.key !== 'Enter') return @@ -264,10 +326,27 @@ export function InventoryMobilePage() { const el = e.target as HTMLInputElement addScan(el.value); el.value = '' }} - placeholder="Отсканируйте или введите штрихкод" - className="mt-2 w-full rounded-lg border-2 border-slate-300 px-4 py-3 text-lg tabular-nums outline-none focus:border-sky-500" + placeholder="Сканируйте или введите штрихкод и Enter" + className="w-full rounded-lg border-2 border-slate-300 px-4 py-3 text-lg tabular-nums outline-none focus:border-sky-500" data-testid="inv-scan-input" /> -
+
сканер срабатывает и без клика в поле
+
+ {/* Загрузка Excel/CSV — третий способ ввода. Строки добавляются + к текущему списку, повторы штрихкодов суммированы. */} +
+ +
+ колонки «Штрихкод» и «Количество» — строки добавятся к списку +
+ { const f = e.target.files?.[0]; e.target.value = ''; if (f) importFile(f) }} /> +
{msg && (
{msg.text} diff --git a/src/food-market.web/src/pages/InvoiceReviewPage.tsx b/src/food-market.web/src/pages/InvoiceReviewPage.tsx index 681c02e..58b12a8 100644 --- a/src/food-market.web/src/pages/InvoiceReviewPage.tsx +++ b/src/food-market.web/src/pages/InvoiceReviewPage.tsx @@ -1,8 +1,9 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { api } from '@/lib/api' -import { useQuery, useMutation } from '@tanstack/react-query' -import { ArrowLeft, Save, Check, AlertTriangle, Search, Trash2, Barcode as BarcodeIcon } from 'lucide-react' +import { useQuery } from '@tanstack/react-query' +import { ArrowLeft, Check, AlertTriangle, Search, Trash2, Camera, ScanBarcode } from 'lucide-react' +import CameraScanner, { type ScanFeedback } from '@/components/CameraScanner' interface InvoiceLine { barcode?: string | null @@ -15,8 +16,12 @@ interface InvoiceLine { vat?: number | null productId?: string | null productName?: string | null - matchedBy: string // barcode | alias | manual | none + matchedBy: string // barcode | alias | manual | scan | photo | photo-read | none matchScore: number + // фото-приёмка + photo?: number | null + barcodeSource?: string | null + note?: string | null } interface InvoiceDetail { id: string @@ -31,6 +36,8 @@ interface InvoiceDetail { linesMatched: number supplyId: string | null error: string | null + source: string | null + hasFiles: boolean lines: InvoiceLine[] } @@ -39,22 +46,60 @@ interface CounterpartyHit { id: string; name: string; bin: string | null } interface Store { id: string; name: string; isMain: boolean } interface Currency { id: string; code: string } -/// Экран проверки накладной. Карточками — чтобы телефон читался; на -/// desktop раскладывается в 2 колонки. +const MATCHED_RU: Record = { + barcode: 'по ШК', + scan: 'скан', + alias: 'по памяти', + manual: 'вручную', + photo: 'по фото', + 'photo-read': 'по фото, цифры прочитаны — сверьте', +} + +/// Экран проверки накладной. +/// +/// Что здесь важно (порт поведения из sklad, handoff §1–§4): +/// - **Автосохранение**: каждая правка → `dirty=true`, через 700 мс PUT /lines +/// на сервере; индикатор «сохраняю… / сохранено / не сохранилось». +/// - **Общее поле скана** с выбором строки кнопками, если fuzzy-match неуверен +/// (ранжирование Жаккара по словам). Актуальные строки читаются через ref, +/// чтобы колбэк камеры не видел старые. +/// - **Глобальный keydown**: аппаратный сканер «печатает» цифры и Enter — +/// ловим на всей странице, если курсор не в INPUT/TEXTAREA. Серия цифр +/// < 100 мс + Enter + длина ≥ 6 → скан. +/// - **Фото-приёмка**: у строки показана миниатюра `/photo/N`, если `hasFiles`. export function InvoiceReviewPage() { const { id } = useParams<{ id: string }>() const nav = useNavigate() const [lines, setLines] = useState([]) const [header, setHeader] = useState({ supplierName: '', invoiceNumber: '', invoiceDate: '', totalSum: '' }) - const [barcodeInput, setBarcodeInput] = useState('') + const [scanMsg, setScanMsgRaw] = useState<{ kind: 'ok' | 'warn' | 'err'; text: string } | null>(null) + const setScanMsg = (text: string, kind: 'ok' | 'warn' | 'err' = 'warn') => setScanMsgRaw({ kind, text }) + // Товар найден, но строку уверенно не подобрали — кладовщик выбирает кнопкой. + const [choose, setChoose] = useState<{ p: { id: string; name: string }; code: string } | null>(null) const [searchHit, setSearchHit] = useState<{ row: number; q: string } | null>(null) - const [savedMsg, setSavedMsg] = useState(null) const [applyOpen, setApplyOpen] = useState(false) + const scanInputRef = useRef(null) + // Автосейв состояния. + const dirty = useRef(false) + const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') + // Для камеры: null — закрыта, число — скан в эту строку; -1 — общий режим. + const [camRow, setCamRow] = useState(null) + const [fb, setFb] = useState(null) + const seq = useRef(0) + // Актуальные строки для колбэков (камера / global keydown). + const linesRef = useRef([]) + linesRef.current = lines const detail = useQuery({ queryKey: ['invoice', id], queryFn: () => api.get(`/api/purchases/invoices/${id}`).then(r => r.data), enabled: !!id, + // Пока фоновая обработка идёт — обновляем, иначе не трогаем (чтобы не + // перетирать несохранённые правки — глобально у нас и так refetchOnWindowFocus:false). + refetchInterval: q => { + const d = q.state.data as InvoiceDetail | undefined + return d && (d.status === 'pending' || d.status === 'parsing') ? 2000 : false + }, }) useEffect(() => { @@ -69,50 +114,148 @@ export function InvoiceReviewPage() { }) }, [detail.data]) + const edit = (fn: (prev: InvoiceLine[]) => InvoiceLine[]) => { dirty.current = true; setLines(fn) } + const editHeader = (fn: (prev: typeof header) => typeof header) => { dirty.current = true; setHeader(fn) } + + // Автосохранение правок строк и шапки. Debounce 700 мс. + useEffect(() => { + if (!dirty.current || !id) return + const t = setTimeout(async () => { + setSaveState('saving') + try { + await api.put(`/api/purchases/invoices/${id}/lines`, { + lines, + supplierName: header.supplierName, + invoiceNumber: header.invoiceNumber, + invoiceDate: header.invoiceDate ? new Date(header.invoiceDate).toISOString() : null, + totalSum: header.totalSum ? parseFloat(header.totalSum) : null, + }) + dirty.current = false + setSaveState('saved') + } catch { setSaveState('error') } + }, 700) + return () => clearTimeout(t) + }, [lines, header, id]) + const sumByLines = useMemo(() => lines.reduce((s, l) => s + (l.quantity * l.price || 0), 0), [lines]) const unmatched = useMemo(() => lines.filter(l => !l.productId).length, [lines]) + const matched = lines.length - unmatched const totalMismatch = useMemo(() => { const t = parseFloat(header.totalSum || '0') return t > 0 && Math.abs(sumByLines - t) > 1 }, [sumByLines, header.totalSum]) - const save = useMutation({ - mutationFn: async () => { - await api.put(`/api/purchases/invoices/${id}/lines`, { - lines, supplierName: header.supplierName, invoiceNumber: header.invoiceNumber, - invoiceDate: header.invoiceDate ? new Date(header.invoiceDate).toISOString() : null, - totalSum: header.totalSum ? parseFloat(header.totalSum) : null, - }) - }, - onSuccess: () => { setSavedMsg('Сохранено'); setTimeout(() => setSavedMsg(null), 2000) }, - onError: (e) => setSavedMsg(e instanceof Error ? e.message : 'Ошибка'), - }) - - // scan в общий input: ищем товар, матчим в первую НЕсопоставленную подходящую строку - const scanToAny = async (code: string) => { - const clean = code.replace(/\D/g, '') + // ── подбор строки по названию товара (Jaccard по словам) ─────────── + // Товар по штрихкоду известен точно, а строку надо подобрать среди + // НЕзаполненных: названия в накладной и каталоге часто совсем разные + // («Albeni 40gx24x6» ↔ «Шоколад Альбени 40г»). Порог 0.3 и отрыв 0.08 + // — иначе появляется окно с кнопками «в какую строку?». + const rank = (productName: string, cur: InvoiceLine[]) => { + const norm = (t: string) => t.toUpperCase().replace(/Ё/g, 'Е') + .replace(/[^A-ZА-Я0-9]+/g, ' ').trim().split(' ').filter(w => w.length > 1) + const want = new Set(norm(productName)) + return cur.map((l, i) => { + const have = new Set(norm(l.name)) + let inter = 0 + want.forEach(w => { if (have.has(w)) inter++ }) + return { i, score: inter / (want.size + have.size - inter || 1) } + }).filter(x => !cur[x.i].productId).sort((a, b) => b.score - a.score) + } + const pickRow = (productName: string, cur: InvoiceLine[]) => { + const r = rank(productName, cur) + if (r.length === 1) return r[0].i + const best = r[0]?.score ?? 0, second = r[1]?.score ?? 0 + return best >= 0.3 && best - second >= 0.08 ? r[0].i : -1 + } + const putInRow = (idx: number, p: { id: string; name: string }, code?: string) => { + edit(prev => prev.map((l, i) => i === idx + ? { ...l, productId: p.id, productName: p.name, matchedBy: 'manual', + barcode: code ?? l.barcode } : l)) + } + /// Скан в поле КОНКРЕТНОЙ строки — гадать не нужно. + const scanIntoRow = async (idx: number, code: string) => { + const clean = (code ?? '').replace(/\D/g, '') if (clean.length < 6) return try { - const { data } = await api.get<{ productId: string; name: string; barcode: string }>( + const { data } = await api.get<{ productId: string; name: string }>( `/api/purchases/invoices/product-by-barcode?barcode=${clean}`) - // Применяем в первую несопоставленную строку, иначе создаём новую. - setLines(prev => { - const idx = prev.findIndex(l => !l.productId) - if (idx < 0) { - return [...prev, { name: data.name, barcode: clean, quantity: 1, price: 0, - matchedBy: 'manual', matchScore: 1, productId: data.productId, productName: data.name } as InvoiceLine] - } - const next = [...prev] - next[idx] = { ...next[idx], productId: data.productId, productName: data.name, - barcode: next[idx].barcode || clean, matchedBy: 'manual' } - return next - }) - } catch { /* не нашли — ignored */ } - setBarcodeInput('') + const p = { id: data.productId, name: data.name } + putInRow(idx, p, clean) + setScanMsg(`${p.name} → строка ${idx + 1}`, 'ok') + setFb({ kind: 'ok', title: p.name, sub: `строка ${idx + 1}`, seq: ++seq.current }) + } catch { + setScanMsg(`Штрихкод ${clean} в каталоге не найден`, 'err') + setFb({ kind: 'err', title: 'Нет в каталоге', sub: clean, seq: ++seq.current }) + } } + /// Скан в общее поле: товар известен, строку подбираем, в неуверенном + /// случае — кнопками выбора. + const onScanCode = async (code: string) => { + const clean = (code ?? '').replace(/\D/g, '') + if (clean.length < 6) return + let p: { id: string; name: string } + try { + const { data } = await api.get<{ productId: string; name: string }>( + `/api/purchases/invoices/product-by-barcode?barcode=${clean}`) + p = { id: data.productId, name: data.name } + } catch { + setScanMsg(`Штрихкод ${clean} в каталоге не найден`, 'err') + setFb({ kind: 'err', title: 'Нет в каталоге', sub: clean, seq: ++seq.current }) + return + } + const cur = linesRef.current + const already = cur.findIndex(l => l.productId === p.id) + if (already >= 0) { + setScanMsg(`${p.name} — уже стоит в строке ${already + 1}`, 'warn') + setFb({ kind: 'err', title: 'Уже отсканирован', sub: `строка ${already + 1}`, seq: ++seq.current }) + return + } + if (!cur.some(l => !l.productId)) { setScanMsg('Все строки уже сопоставлены', 'warn'); return } + const idx = pickRow(p.name, cur) + if (idx >= 0) { + putInRow(idx, p, clean) + setScanMsg(`${p.name} → строка ${idx + 1}: ${cur[idx].name}`, 'ok') + setFb({ kind: 'ok', title: p.name, sub: `строка ${idx + 1}`, seq: ++seq.current }) + return + } + setScanMsgRaw(null) + setChoose({ p, code: clean }) + setCamRow(null) + } + const chooseRow = (idx: number) => { + if (!choose) return + putInRow(idx, choose.p, choose.code) + setScanMsg(`${choose.p.name} → строка ${idx + 1}`, 'ok') + setChoose(null) + setTimeout(() => scanInputRef.current?.focus(), 0) + } + + // Глобальный keydown для аппаратного сканера. Если фокус в INPUT/TEXTAREA — + // не вмешиваемся: поле разберётся само. Серия цифр с интервалом < 100 мс + // и Enter в конце + длина ≥ 6 — это скан. + const scanFnRef = useRef(onScanCode) + scanFnRef.current = onScanCode + useEffect(() => { + let buf = '', last = 0 + const onKey = (e: KeyboardEvent) => { + const el = document.activeElement as HTMLElement | null + if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) return + const now = Date.now() + if (now - last > 100) buf = '' + last = now + if (e.key === 'Enter') { + if (buf.length >= 6) { e.preventDefault(); scanFnRef.current(buf) } + buf = '' + } else if (/^\d$/.test(e.key)) buf += e.key + } + addEventListener('keydown', onKey) + return () => removeEventListener('keydown', onKey) + }, []) + if (!detail.data) return
Загрузка…
const d = detail.data + const busy = d.status === 'pending' || d.status === 'parsing' return (
@@ -132,25 +275,37 @@ export function InvoiceReviewPage() { Ошибка распознавания: {d.error}
)} + {d.status === 'needs_review' && d.error && ( +
+ {d.error} +
+ )} + {busy && ( +
+ {d.source === 'photos' + ? 'Собираю приёмку по снимкам, обычно 1-3 минуты' + : 'Читаю накладную, обычно 10-20 секунд'} +
+ )} {/* шапка */}
setHeader({ ...header, supplierName: e.target.value })} /> + onChange={e => editHeader(h => ({ ...h, supplierName: e.target.value }))} /> setHeader({ ...header, invoiceNumber: e.target.value })} /> + onChange={e => editHeader(h => ({ ...h, invoiceNumber: e.target.value }))} /> setHeader({ ...header, invoiceDate: e.target.value })} /> + onChange={e => editHeader(h => ({ ...h, invoiceDate: e.target.value }))} />
Итого в накладной setHeader({ ...header, totalSum: e.target.value })} /> + onChange={e => editHeader(h => ({ ...h, totalSum: e.target.value }))} />
Сумма по строкам @@ -164,23 +319,62 @@ export function InvoiceReviewPage() { )}
- {/* сканер в общее поле */} -
- -
+ {/* общее поле скана + панель выбора строки */} + {!busy && unmatched > 0 && ( +
+
+ Отсканируйте товары, которых нет в каталоге +
+
+ { + if (e.key !== 'Enter') return + e.preventDefault() + const el = e.target as HTMLInputElement + onScanCode(el.value); el.value = '' + }} + placeholder="Поднесите сканер к штрихкоду…" + className="flex-1 rounded-lg border border-slate-300 px-3 py-2 outline-none focus:border-slate-500" + data-testid="invoice-scan-input" /> + +
+ {choose && ( +
+
+ {choose.p.name} — в какую строку накладной? +
+
+ {rank(choose.p.name, lines).map(({ i }) => ( + + ))} +
+ +
+ )} + {scanMsg && !choose && ( +
+ {scanMsg.text} +
+ )} +
+ Можно сканировать сюда — строка подберётся по названию, а если не + получится, появятся кнопки выбора. Либо прямо в поле «Штрихкод» нужной строки. +
+
+ )} {unmatched > 0 && (
@@ -192,10 +386,13 @@ export function InvoiceReviewPage() {
{lines.map((l, i) => ( setLines(prev => prev.map((x, j) => j === i ? patched : x))} - onRemove={() => setLines(prev => prev.filter((_, j) => j !== i))} + key={i} index={i} line={l} + invoiceId={d.id} hasFiles={d.hasFiles} + onChange={(patched) => edit(prev => prev.map((x, j) => j === i ? patched : x))} + onRemove={() => edit(prev => prev.filter((_, j) => j !== i))} onSearch={(q) => setSearchHit({ row: i, q })} + onCamera={() => setCamRow(i)} + onScanIntoRow={(code) => scanIntoRow(i, code)} /> ))}
@@ -203,12 +400,16 @@ export function InvoiceReviewPage() { {/* bottom */}
- -
@@ -217,7 +418,7 @@ export function InvoiceReviewPage() { {searchHit && ( setSearchHit(null)} onPick={(h) => { - setLines(prev => prev.map((x, j) => j === searchHit.row + edit(prev => prev.map((x, j) => j === searchHit.row ? { ...x, productId: h.id, productName: h.name, matchedBy: 'manual' } : x)) setSearchHit(null) }} /> @@ -228,16 +429,36 @@ export function InvoiceReviewPage() { onClose={() => setApplyOpen(false)} onDone={(supplyId) => { setApplyOpen(false); nav(`/purchases/supplies/${supplyId}`) }} /> )} + + {camRow != null && ( + setCamRow(null)} + feedback={fb} + counter={camRow >= 0 + ? `строка ${camRow + 1}: ${(lines[camRow]?.name ?? '').slice(0, 28)}` + : `сопоставлено ${matched} из ${lines.length}`} + onCode={code => { + // Одна строка — закрываем, она заполнена; общий режим идёт подряд. + if (camRow >= 0) { scanIntoRow(camRow, code); setCamRow(null) } + else onScanCode(code) + }} /> + )}
) } -/// Карточка строки. Оранжевый бордер если не сопоставлена. -function InvoiceLineCard({ line, onChange, onRemove, onSearch }: { +/// Карточка строки. Миниатюра снимка (если фото-приёмка), пометка +/// «barcode_source: read — сверьте», оранжевый бордер если не сопоставлена. +function InvoiceLineCard({ index, line, invoiceId, hasFiles, onChange, onRemove, onSearch, onCamera, onScanIntoRow }: { + index: number line: InvoiceLine + invoiceId: string + hasFiles: boolean onChange: (l: InvoiceLine) => void onRemove: () => void onSearch: (q: string) => void + onCamera: () => void + onScanIntoRow: (code: string) => void }) { const bad = !line.productId const sum = line.quantity * line.price @@ -245,21 +466,46 @@ function InvoiceLineCard({ line, onChange, onRemove, onSearch }: {
-
-
- {line.supplierCode && {line.supplierCode}} - {line.barcode && {line.barcode}} +
+ {hasFiles && line.photo != null && } +
+
+ {index + 1}. + {line.supplierCode && {line.supplierCode}} + {line.barcode && {line.barcode}} +
+
{line.name}
+ {line.productId + ?
→ {line.productName} + ({MATCHED_RU[line.matchedBy] ?? line.matchedBy}) +
+ :
Товар не сопоставлен — найдите, отсканируйте или создайте
} + {line.note &&
{line.note}
}
-
{line.name}
- {line.productId - ?
→ {line.productName} - ({line.matchedBy}) -
- :
Товар не сопоставлен — найдите или создайте
}
+ {bad && ( +
+ { + if (e.key !== 'Enter') return + e.preventDefault() + const v = (e.target as HTMLInputElement).value.trim() + onScanIntoRow(v) + }} + className="flex-1 min-w-0 rounded border border-amber-300 bg-white px-2 py-1 text-sm tabular-nums outline-none placeholder:text-amber-600 focus:border-sky-500 focus:ring-2 focus:ring-sky-200" /> + +
+ )} +