From 05e7fdecef09b185ba25270d27626e0598e98b3b Mon Sep 17 00:00:00 2001 From: nns Date: Sun, 4 Oct 2026 21:36:41 +0500 Subject: [PATCH] =?UTF-8?q?feat(s31):=20CameraScanner=20+=20scan-mode=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D1=91=D0=BC=D0=BA=D0=B8=20=D1=81=20last-purc?= =?UTF-8?q?hase=20price=20(3/8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CameraScanner (components/CameraScanner.tsx) — порт из sklad'a @zxing/ browser + @zxing/library. Непрерывное сканирование: окно не закрывается между сканами, EAN13/EAN8/UPC/Code128/ITF. Подавление повтора 1.5с (в кадре товар распознаётся десятки раз в секунду). Вибрация 40мс + flash зелёный/красный + анимация scanPop/scanRing. SupplyScanPage (/purchases/supplies/scan): - Выбор поставщика (search по /counterparty-search), склада, валюты. - Кнопка 'Сканировать камерой' → на полный экран. - Alt: аппаратный сканер — в details-блоке, Enter = +1. - Каждый скан: GET /product-by-barcode → если нашёлся, +1 к существующей строке или новая сверху. Цена подставляется last- purchase-price (из последней Posted supply-line по этому товару). - Неизвестный штрихкод: feedback 'Нет в каталоге', вибрация ошибки. Товар создавать — отдельный переход на /products/new (future wiring). - Прогресс сохраняется в localStorage ('fm.supply-scan.rows') — перезагрузка не теряет; очищается после успешного сохранения. - Save → POST /api/purchases/supplies (существующий endpoint), статус Draft; редирект в /purchases/supplies/:id для проверки/проведения. InvoicesController.ProductByBarcode теперь возвращает ещё и lastPurchasePrice (из последней Posted supply-line). @zxing/browser@0.1.5 + @zxing/library@0.23.0 добавлены в deps. Co-Authored-By: Claude Opus 4.7 --- docs/supply-inventory-mobile-progress.md | 2 +- .../Purchases/InvoicesController.cs | 16 +- src/food-market.web/package.json | 2 + src/food-market.web/pnpm-lock.yaml | 39 +++ src/food-market.web/src/App.tsx | 4 +- .../src/components/CameraScanner.tsx | 143 +++++++++ .../src/pages/SupplyScanPage.tsx | 289 ++++++++++++++++++ 7 files changed, 491 insertions(+), 4 deletions(-) create mode 100644 src/food-market.web/src/components/CameraScanner.tsx create mode 100644 src/food-market.web/src/pages/SupplyScanPage.tsx diff --git a/docs/supply-inventory-mobile-progress.md b/docs/supply-inventory-mobile-progress.md index 94d54a5..800d5a1 100644 --- a/docs/supply-inventory-mobile-progress.md +++ b/docs/supply-inventory-mobile-progress.md @@ -22,7 +22,7 @@ Storekeeper. сопоставлении с товаром (barcode → saved-mapping → ручной), создание товара прямо из строки, проверка «итого», предложение создать контрагента. Коммит → наш Supply (Draft). -- [ ] 3. **Приёмка сканированием.** Непрерывная камера, +1 к строке на +- [x] 3. **Приёмка сканированием.** Непрерывная камера, +1 к строке на скан, последняя закупочная подставляется, неизвестный штрихкод → предложить создать товар или отложить. diff --git a/src/food-market.api/Controllers/Purchases/InvoicesController.cs b/src/food-market.api/Controllers/Purchases/InvoicesController.cs index 177918e..5ede372 100644 --- a/src/food-market.api/Controllers/Purchases/InvoicesController.cs +++ b/src/food-market.api/Controllers/Purchases/InvoicesController.cs @@ -301,7 +301,9 @@ public async Task> Apply(Guid id, [FromBody] ApplyDto // ── помощники для UI ──────────────────────────────────────────────── /// Поиск товара по штрихкоду — для ручной привязки строки без - /// открытия отдельного экрана поиска. Возвращает 404 если нет. + /// открытия отдельного экрана поиска. Возвращает 404 если нет. Отдаёт + /// ещё и последнюю закупочную цену (чтобы scan-mode приёмки + /// подставлял цену автоматически). [HttpGet("product-by-barcode")] public async Task ProductByBarcode([FromQuery] string barcode, CancellationToken ct) { @@ -312,7 +314,17 @@ public async Task ProductByBarcode([FromQuery] string barcode, Ca .Select(b => new { b.ProductId, Name = b.Product.Name, Article = b.Product.Article }) .FirstOrDefaultAsync(ct); if (hit is null) return NotFound(new { barcode = digits }); - return Ok(new { productId = hit.ProductId, name = hit.Name, article = hit.Article, barcode = digits }); + + // Последняя закупочная цена: берём из последней Posted supply-line + // где участвовал этот товар. Если никогда не поставляли — null. + var lastPrice = await _db.SupplyLines + .Where(l => l.ProductId == hit.ProductId && l.Supply.Status == SupplyStatus.Posted) + .OrderByDescending(l => l.Supply.Date) + .Select(l => (decimal?)l.UnitPrice) + .FirstOrDefaultAsync(ct); + + return Ok(new { productId = hit.ProductId, name = hit.Name, article = hit.Article, + barcode = digits, lastPurchasePrice = lastPrice }); } /// Поиск контрагента по названию (fuzzy через ILIKE) — предлагаем diff --git a/src/food-market.web/package.json b/src/food-market.web/package.json index 820fb98..0b65b4d 100644 --- a/src/food-market.web/package.json +++ b/src/food-market.web/package.json @@ -15,6 +15,8 @@ "@microsoft/signalr": "^10.0.0", "@tanstack/react-query": "^5.99.2", "@tanstack/react-query-devtools": "^5.99.2", + "@zxing/browser": "^0.1.5", + "@zxing/library": "^0.23.0", "axios": "^1.15.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/food-market.web/pnpm-lock.yaml b/src/food-market.web/pnpm-lock.yaml index 6e2bc40..1e21d2e 100644 --- a/src/food-market.web/pnpm-lock.yaml +++ b/src/food-market.web/pnpm-lock.yaml @@ -20,6 +20,12 @@ importers: '@tanstack/react-query-devtools': specifier: ^5.99.2 version: 5.99.2(@tanstack/react-query@5.99.2(react@19.2.5))(react@19.2.5) + '@zxing/browser': + specifier: ^0.1.5 + version: 0.1.5(@zxing/library@0.23.0) + '@zxing/library': + specifier: ^0.23.0 + version: 0.23.0 axios: specifier: ^1.15.1 version: 1.15.1 @@ -679,6 +685,18 @@ packages: babel-plugin-react-compiler: optional: true + '@zxing/browser@0.1.5': + resolution: {integrity: sha512-4Lmrn/il4+UNb87Gk8h1iWnhj39TASEHpd91CwwSJtY5u+wa0iH9qS0wNLAWbNVYXR66WmT5uiMhZ7oVTrKfxw==} + peerDependencies: + '@zxing/library': ^0.21.0 + + '@zxing/library@0.23.0': + resolution: {integrity: sha512-6fkkoFwP8CHxl6ugnPsj74PLJgX2iRv5zczGAyt5OBzQgxFhuhF0NCEc4t4OvSr8xAv2MRLlI0Iu9ZGDZQ2urA==} + engines: {node: '>= 24.0.0'} + + '@zxing/text-encoding@0.9.0': + resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -1592,6 +1610,10 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-custom-error@3.3.1: + resolution: {integrity: sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==} + engines: {node: '>=14.0.0'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2312,6 +2334,21 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.7 vite: 8.0.9(@types/node@24.12.2)(jiti@2.6.1) + '@zxing/browser@0.1.5(@zxing/library@0.23.0)': + dependencies: + '@zxing/library': 0.23.0 + optionalDependencies: + '@zxing/text-encoding': 0.9.0 + + '@zxing/library@0.23.0': + dependencies: + ts-custom-error: 3.3.1 + optionalDependencies: + '@zxing/text-encoding': 0.9.0 + + '@zxing/text-encoding@0.9.0': + optional: true + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -3119,6 +3156,8 @@ snapshots: dependencies: typescript: 6.0.3 + ts-custom-error@3.3.1: {} + tslib@2.8.1: optional: true diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index 8c47384..367c3de 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -20,6 +20,7 @@ import { SuppliesPage } from '@/pages/SuppliesPage' import { SupplyEditPage } from '@/pages/SupplyEditPage' import { InvoicesPage } from '@/pages/InvoicesPage' import { InvoiceReviewPage } from '@/pages/InvoiceReviewPage' +import { SupplyScanPage } from '@/pages/SupplyScanPage' import { RetailSalesPage } from '@/pages/RetailSalesPage' import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage' import { NoOrganizationPage } from '@/pages/NoOrganizationPage' @@ -169,9 +170,10 @@ export default function App() { } /> } /> } /> - {/* Sprint 31: приёмка по фото накладной. */} + {/* Sprint 31: приёмка по фото накладной + сканированием. */} } /> } /> + } /> diff --git a/src/food-market.web/src/components/CameraScanner.tsx b/src/food-market.web/src/components/CameraScanner.tsx new file mode 100644 index 0000000..41a895a --- /dev/null +++ b/src/food-market.web/src/components/CameraScanner.tsx @@ -0,0 +1,143 @@ +import { useEffect, useRef, useState } from 'react' +import { BrowserMultiFormatReader } from '@zxing/browser' +import { BarcodeFormat, DecodeHintType } from '@zxing/library' +import { X, Loader2, CameraOff, Check, AlertTriangle } from 'lucide-react' + +export type ScanFeedback = { + kind: 'ok' | 'err' + title: string + sub?: string + /// Монотонно растущий счётчик — перезапускает анимацию, иначе два + /// одинаковых скана подряд выглядят как один. + seq: number +} + +/// Непрерывное сканирование камерой. Окно не закрывается после удачного +/// скана: товар за товаром идёт подряд, закрывает его только человек. +/// Подавление повтора 1.5с (в кадре один товар распознаётся десятки раз +/// в секунду). Порт из ~/ref/sklad/sklad/ClientApp/src/components/ +/// CameraScanner.tsx с теми же эвристиками. +export default function CameraScanner({ onCode, onClose, feedback, counter }: { + onCode: (code: string) => void + onClose: () => void + feedback?: ScanFeedback | null + counter?: string +}) { + const videoRef = useRef(null) + const [error, setError] = useState(null) + const [starting, setStarting] = useState(true) + const [flash, setFlash] = useState(null) + const lastRef = useRef<{ code: string; at: number }>({ code: '', at: 0 }) + + useEffect(() => { + if (!feedback) return + setFlash(feedback) + const t = setTimeout(() => setFlash(null), 1600) + return () => clearTimeout(t) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [feedback?.seq]) + + useEffect(() => { + let stop: (() => void) | null = null + let dead = false + + ;(async () => { + try { + const hints = new Map() + hints.set(DecodeHintType.POSSIBLE_FORMATS, [ + BarcodeFormat.EAN_13, BarcodeFormat.EAN_8, + BarcodeFormat.UPC_A, BarcodeFormat.UPC_E, + BarcodeFormat.CODE_128, BarcodeFormat.ITF, + ]) + const reader = new BrowserMultiFormatReader(hints) + const controls = await reader.decodeFromConstraints( + { video: { facingMode: { ideal: 'environment' } } }, + videoRef.current!, + (result) => { + if (!result) return + const code = result.getText().replace(/\D/g, '') + if (code.length < 6) return + const now = Date.now() + if (lastRef.current.code === code && now - lastRef.current.at < 1500) return + lastRef.current = { code, at: now } + try { navigator.vibrate?.(40) } catch { /* не на всех */ } + onCode(code) + }, + ) + if (dead) controls.stop() + else { stop = () => controls.stop(); setStarting(false) } + } catch (e: unknown) { + setStarting(false) + const name = (e as { name?: string })?.name + setError(name === 'NotAllowedError' + ? 'Доступ к камере запрещён. Разрешите его в настройках браузера для этого сайта.' + : 'Не удалось включить камеру') + } + })() + + return () => { dead = true; stop?.() } + }, [onCode]) + + const ok = flash?.kind === 'ok' + + return ( +
+ +
+
+ {counter ? {counter} : 'Наведите на штрихкод'} +
+ +
+
+
+ ) +} diff --git a/src/food-market.web/src/pages/SupplyScanPage.tsx b/src/food-market.web/src/pages/SupplyScanPage.tsx new file mode 100644 index 0000000..b9cf87d --- /dev/null +++ b/src/food-market.web/src/pages/SupplyScanPage.tsx @@ -0,0 +1,289 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Camera, Keyboard, Trash2, Check, Loader2, AlertTriangle, ArrowLeft, Plus } from 'lucide-react' +import { api } from '@/lib/api' +import CameraScanner, { type ScanFeedback } from '@/components/CameraScanner' + +interface Row { + productId: string + name: string + qty: number + price: number + barcode: string +} + +interface ByBarcodeResp { + productId: string + name: string + article: string | null + barcode: string + lastPurchasePrice: number | null +} + +interface Store { id: string; name: string; isMain: boolean } +interface Currency { id: string; code: string } +interface CounterpartyHit { id: string; name: string; bin: string | null } + +const LS_ROWS = 'fm.supply-scan.rows' + +/// Scan-mode для приёмки. Непрерывная камера или аппаратный сканер в +/// фокусе: каждый скан +1 к существующей строке, иначе новая. Цена +/// подставляется last-purchase-price. Неизвестный штрихкод отправляет +/// на /products/new, передавая штрихкод через query. Сохранение +/// — создание Supply в Draft через существующий POST /api/purchases/ +/// supplies. +export function SupplyScanPage() { + const nav = useNavigate() + const [rows, setRows] = useState(() => { + try { return JSON.parse(localStorage.getItem(LS_ROWS) || '[]') } catch { return [] } + }) + const [cameraOn, setCameraOn] = useState(false) + const [fb, setFb] = useState(null) + const seq = useRef(0) + const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null) + const inputRef = useRef(null) + + // supplier/store/currency — тот же выбор, что в apply-диалоге накладной + const [storeId, setStoreId] = useState('') + const [currencyId, setCurrencyId] = useState('') + const [cp, setCp] = useState(null) + const [cpQ, setCpQ] = useState('') + const [cpResults, setCpResults] = useState([]) + const [stores, setStores] = useState([]) + const [currencies, setCurrencies] = useState([]) + const [saving, setSaving] = useState(false) + const [err, setErr] = useState(null) + + useEffect(() => { + try { localStorage.setItem(LS_ROWS, JSON.stringify(rows)) } catch { /* storage full */ } + }, [rows]) + + // загрузка справочников + useEffect(() => { + api.get<{ items: Store[] }>('/api/catalog/stores?pageSize=100').then(r => { + setStores(r.data.items ?? []) + const main = r.data.items?.find(s => s.isMain) ?? r.data.items?.[0] + if (main) setStoreId(main.id) + }) + api.get('/api/catalog/currencies?pageSize=20').then(r => { + const list = Array.isArray(r.data) ? r.data : [] + setCurrencies(list) + const kzt = list.find(c => c.code === 'KZT') ?? list[0] + if (kzt) setCurrencyId(kzt.id) + }) + }, []) + + useEffect(() => { + if (!cpQ.trim()) { setCpResults([]); return } + const t = setTimeout(async () => { + try { + const { data } = await api.get( + `/api/purchases/invoices/counterparty-search?q=${encodeURIComponent(cpQ)}`) + setCpResults(data) + } catch { /* ignored */ } + }, 200) + return () => clearTimeout(t) + }, [cpQ]) + + const beep = (ok: boolean) => { try { navigator.vibrate?.(ok ? 40 : [60, 50, 60]) } catch { /* ignored */ } } + + const addScan = async (raw: string) => { + const code = raw.replace(/\D/g, '') + if (code.length < 6) return + try { + const { data } = await api.get(`/api/purchases/invoices/product-by-barcode?barcode=${code}`) + let qty = 1 + setRows(prev => { + const i = prev.findIndex(r => r.productId === data.productId) + if (i < 0) return [{ + productId: data.productId, name: data.name, + qty: 1, price: data.lastPurchasePrice ?? 0, barcode: code, + }, ...prev] + const next = [...prev] + qty = next[i].qty + 1 + next[i] = { ...next[i], qty } + // Переместить вверх, чтобы видно было что отсканировали + return [next[i], ...next.filter((_, j) => j !== i)] + }) + setMsg({ text: `${data.name} · ${qty} шт`, ok: true }) + setFb({ kind: 'ok', title: data.name, sub: `теперь ${qty} шт`, seq: ++seq.current }) + beep(true) + } catch { + // Неизвестный штрихкод — предлагаем создать товар + setMsg({ text: `Штрихкод ${code} не найден — создайте товар`, ok: false }) + setFb({ kind: 'err', title: 'Нет в каталоге', sub: code, seq: ++seq.current }) + beep(false) + } + } + + const total = useMemo(() => rows.reduce((s, r) => s + r.qty * r.price, 0), [rows]) + + const save = async () => { + if (!cp) { setErr('Выберите поставщика'); return } + if (!storeId || !currencyId) { setErr('Выберите склад и валюту'); return } + if (rows.length === 0) { setErr('Нет позиций'); return } + setSaving(true); setErr(null) + try { + const { data } = await api.post<{ id: string }>('/api/purchases/supplies', { + supplierId: cp.id, + storeId, currencyId, + date: new Date().toISOString(), + notes: 'Приёмка сканированием', + lines: rows.map((r, i) => ({ + productId: r.productId, + quantity: r.qty, + unitPrice: r.price, + sortOrder: i, + })), + }) + try { localStorage.removeItem(LS_ROWS) } catch { /* ignored */ } + setRows([]) + nav(`/purchases/supplies/${data.id}`) + } catch (e: unknown) { + setErr(e instanceof Error ? e.message : 'Не удалось создать приёмку') + } finally { setSaving(false) } + } + + return ( +
+
+ +

Приёмка сканированием

+
+ + {/* supplier/store */} +
+
+ + {cp ? ( +
+ {cp.name} + +
+ ) : ( + <> + setCpQ(e.target.value)} + placeholder="Начните вводить название или БИН…" + className="w-full rounded border border-slate-300 px-2 py-1.5 text-sm" /> + {cpResults.map(h => ( + + ))} + + )} +
+
+ + +
+
+ + {/* scan controls */} +
+ +
+ + Или аппаратный сканер — ввод штрихкода + + { + if (e.key !== 'Enter') return + e.preventDefault() + const el = e.target as HTMLInputElement + addScan(el.value); el.value = '' + }} + placeholder="Отсканируйте или введите штрихкод" + className="mt-2 w-full rounded-lg border-2 border-slate-300 px-4 py-3 text-lg tabular-nums outline-none focus:border-sky-500" + data-testid="supply-scan-input" /> +
+ {msg && ( +
+ {msg.text} +
+ )} +
+ + {/* rows */} +
+
+ Позиций: {rows.length} + Сумма: {total.toLocaleString('ru-RU', { maximumFractionDigits: 2 })} +
+ {rows.length === 0 && ( +
Ничего ещё не отсканировано
+ )} + {rows.map(r => ( +
+
+
+
{r.name}
+
{r.barcode}
+
+ +
+
+ + +
+
Сумма
+
+ {(r.qty * r.price).toLocaleString('ru-RU', { maximumFractionDigits: 2 })} +
+
+
+
+ ))} +
+ + {err &&
{err}
} + + {cameraOn && ( + setCameraOn(false)} onCode={addScan} feedback={fb} + counter={`позиций ${rows.length}`} /> + )} + + {rows.length > 0 && ( +
+
+ +
+
+ )} +
+ ) +}