diff --git a/docs/supply-inventory-mobile-progress.md b/docs/supply-inventory-mobile-progress.md index ec7ce96..94d54a5 100644 --- a/docs/supply-inventory-mobile-progress.md +++ b/docs/supply-inventory-mobile-progress.md @@ -18,7 +18,7 @@ Storekeeper. промпт из `ClaudeCliInvoiceExtractor.cs`), запуск локально или через ssh на RemoteHost. Статусы: `queued → extracting → review | failed`. Платные API-ключи не заводим. -- [ ] 2. **Экран проверки накладной.** Редактируемые строки, помощь в +- [x] 2. **Экран проверки накладной.** Редактируемые строки, помощь в сопоставлении с товаром (barcode → saved-mapping → ручной), создание товара прямо из строки, проверка «итого», предложение создать контрагента. Коммит → наш Supply (Draft). diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index 81382bf..8c47384 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -18,6 +18,8 @@ import { CounterpartiesPage } from '@/pages/CounterpartiesPage' import { StockPage } from '@/pages/StockPage' import { SuppliesPage } from '@/pages/SuppliesPage' import { SupplyEditPage } from '@/pages/SupplyEditPage' +import { InvoicesPage } from '@/pages/InvoicesPage' +import { InvoiceReviewPage } from '@/pages/InvoiceReviewPage' import { RetailSalesPage } from '@/pages/RetailSalesPage' import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage' import { NoOrganizationPage } from '@/pages/NoOrganizationPage' @@ -167,6 +169,9 @@ export default function App() { } /> } /> } /> + {/* Sprint 31: приёмка по фото накладной. */} + } /> + } /> diff --git a/src/food-market.web/src/pages/InvoiceReviewPage.tsx b/src/food-market.web/src/pages/InvoiceReviewPage.tsx new file mode 100644 index 0000000..681c02e --- /dev/null +++ b/src/food-market.web/src/pages/InvoiceReviewPage.tsx @@ -0,0 +1,470 @@ +import { useEffect, useMemo, useState } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { api } from '@/lib/api' +import { useQuery, useMutation } from '@tanstack/react-query' +import { ArrowLeft, Save, Check, AlertTriangle, Search, Trash2, Barcode as BarcodeIcon } from 'lucide-react' + +interface InvoiceLine { + barcode?: string | null + supplierCode?: string | null + name: string + quantity: number + unit?: string | null + price: number + sum?: number | null + vat?: number | null + productId?: string | null + productName?: string | null + matchedBy: string // barcode | alias | manual | none + matchScore: number +} +interface InvoiceDetail { + id: string + fileName: string + status: string + uploadedAt: string + supplierName: string | null + invoiceNumber: string | null + invoiceDate: string | null + totalSum: number | null + linesTotal: number + linesMatched: number + supplyId: string | null + error: string | null + lines: InvoiceLine[] +} + +interface ProductSearchHit { id: string; name: string; article: string | null } +interface CounterpartyHit { id: string; name: string; bin: string | null } +interface Store { id: string; name: string; isMain: boolean } +interface Currency { id: string; code: string } + +/// Экран проверки накладной. Карточками — чтобы телефон читался; на +/// desktop раскладывается в 2 колонки. +export function InvoiceReviewPage() { + const { id } = useParams<{ id: string }>() + const nav = useNavigate() + const [lines, setLines] = useState([]) + const [header, setHeader] = useState({ supplierName: '', invoiceNumber: '', invoiceDate: '', totalSum: '' }) + const [barcodeInput, setBarcodeInput] = useState('') + const [searchHit, setSearchHit] = useState<{ row: number; q: string } | null>(null) + const [savedMsg, setSavedMsg] = useState(null) + const [applyOpen, setApplyOpen] = useState(false) + + const detail = useQuery({ + queryKey: ['invoice', id], + queryFn: () => api.get(`/api/purchases/invoices/${id}`).then(r => r.data), + enabled: !!id, + }) + + useEffect(() => { + const d = detail.data + if (!d) return + setLines(d.lines) + setHeader({ + supplierName: d.supplierName ?? '', + invoiceNumber: d.invoiceNumber ?? '', + invoiceDate: d.invoiceDate ? d.invoiceDate.slice(0, 10) : '', + totalSum: d.totalSum != null ? String(d.totalSum) : '', + }) + }, [detail.data]) + + const sumByLines = useMemo(() => lines.reduce((s, l) => s + (l.quantity * l.price || 0), 0), [lines]) + const unmatched = useMemo(() => lines.filter(l => !l.productId).length, [lines]) + const totalMismatch = useMemo(() => { + const t = parseFloat(header.totalSum || '0') + return t > 0 && Math.abs(sumByLines - t) > 1 + }, [sumByLines, header.totalSum]) + + const save = useMutation({ + mutationFn: async () => { + await api.put(`/api/purchases/invoices/${id}/lines`, { + lines, supplierName: header.supplierName, invoiceNumber: header.invoiceNumber, + invoiceDate: header.invoiceDate ? new Date(header.invoiceDate).toISOString() : null, + totalSum: header.totalSum ? parseFloat(header.totalSum) : null, + }) + }, + onSuccess: () => { setSavedMsg('Сохранено'); setTimeout(() => setSavedMsg(null), 2000) }, + onError: (e) => setSavedMsg(e instanceof Error ? e.message : 'Ошибка'), + }) + + // scan в общий input: ищем товар, матчим в первую НЕсопоставленную подходящую строку + const scanToAny = async (code: string) => { + const clean = code.replace(/\D/g, '') + if (clean.length < 6) return + try { + const { data } = await api.get<{ productId: string; name: string; barcode: string }>( + `/api/purchases/invoices/product-by-barcode?barcode=${clean}`) + // Применяем в первую несопоставленную строку, иначе создаём новую. + setLines(prev => { + const idx = prev.findIndex(l => !l.productId) + if (idx < 0) { + return [...prev, { name: data.name, barcode: clean, quantity: 1, price: 0, + matchedBy: 'manual', matchScore: 1, productId: data.productId, productName: data.name } as InvoiceLine] + } + const next = [...prev] + next[idx] = { ...next[idx], productId: data.productId, productName: data.name, + barcode: next[idx].barcode || clean, matchedBy: 'manual' } + return next + }) + } catch { /* не нашли — ignored */ } + setBarcodeInput('') + } + + if (!detail.data) return
Загрузка…
+ const d = detail.data + + return ( +
+
+ +

{d.supplierName || d.fileName}

+ {d.status} +
+ + {d.supplyId && ( +
+ Приёмка уже создана. Открыть +
+ )} + {d.status === 'failed' && d.error && ( +
+ Ошибка распознавания: {d.error} +
+ )} + + {/* шапка */} +
+
+ setHeader({ ...header, supplierName: e.target.value })} /> + setHeader({ ...header, invoiceNumber: e.target.value })} /> + setHeader({ ...header, invoiceDate: e.target.value })} /> +
+
+ Итого в накладной + setHeader({ ...header, totalSum: e.target.value })} /> +
+
+ Сумма по строкам + {sumByLines.toLocaleString('ru-RU', { maximumFractionDigits: 2 })} +
+ {totalMismatch && ( +
+ + Сумма строк отличается от «Итого» больше чем на 1. Проверьте строки. +
+ )} +
+ + {/* сканер в общее поле */} +
+ +
+ + {unmatched > 0 && ( +
+ Не сопоставлено строк: {unmatched} из {lines.length}. Карточки подсвечены оранжевым. +
+ )} + + {/* строки */} +
+ {lines.map((l, i) => ( + setLines(prev => prev.map((x, j) => j === i ? patched : x))} + onRemove={() => setLines(prev => prev.filter((_, j) => j !== i))} + onSearch={(q) => setSearchHit({ row: i, q })} + /> + ))} +
+ + {/* bottom */} +
+
+ + +
+
+ + {searchHit && ( + setSearchHit(null)} + onPick={(h) => { + setLines(prev => prev.map((x, j) => j === searchHit.row + ? { ...x, productId: h.id, productName: h.name, matchedBy: 'manual' } : x)) + setSearchHit(null) + }} /> + )} + + {applyOpen && ( + setApplyOpen(false)} + onDone={(supplyId) => { setApplyOpen(false); nav(`/purchases/supplies/${supplyId}`) }} /> + )} +
+ ) +} + +/// Карточка строки. Оранжевый бордер если не сопоставлена. +function InvoiceLineCard({ line, onChange, onRemove, onSearch }: { + line: InvoiceLine + onChange: (l: InvoiceLine) => void + onRemove: () => void + onSearch: (q: string) => void +}) { + const bad = !line.productId + const sum = line.quantity * line.price + return ( +
+
+
+
+ {line.supplierCode && {line.supplierCode}} + {line.barcode && {line.barcode}} +
+
{line.name}
+ {line.productId + ?
→ {line.productName} + ({line.matchedBy}) +
+ :
Товар не сопоставлен — найдите или создайте
} +
+ +
+ +
+ + + +
+ +
+ +
+
+ ) +} + +/// Диалог поиска товара (debounced). При выборе — колбэк наверх. +function ProductSearchDialog({ q, onClose, onPick }: { + q: string; onClose: () => void; onPick: (h: ProductSearchHit) => void +}) { + const [query, setQuery] = useState(q) + const search = useQuery({ + queryKey: ['product-search', query], + queryFn: async () => { + if (query.trim().length < 2) return [] + // Используем общий products endpoint с q= + const { data } = await api.get<{ items: ProductSearchHit[] }>(`/api/catalog/products?q=${encodeURIComponent(query)}&pageSize=20`) + return data.items ?? [] + }, + enabled: query.trim().length >= 2, + }) + + return ( +
+
+
+ setQuery(e.target.value)} + placeholder="Название товара…" + className="flex-1 rounded border border-slate-300 px-2 py-2 text-sm" /> + +
+
+ {search.data?.map(h => ( + + ))} + {search.data && search.data.length === 0 && query.length >= 2 && ( +
+ Товар не найден. +
+ + Создать товар «{query}» + +
+ )} +
+
+
+ ) +} + +/// Диалог «Создать приёмку»: выбор поставщика (поиск по БИН/имени), +/// склад, валюта. Создаём Draft, редиректим в SupplyEdit. +function ApplyDialog({ invoiceId, supplierName, onClose, onDone }: { + invoiceId: string; supplierName: string + onClose: () => void; onDone: (supplyId: string) => void +}) { + const [cp, setCp] = useState(null) + const [cpQ, setCpQ] = useState(supplierName) + const [storeId, setStoreId] = useState('') + const [currencyId, setCurrencyId] = useState('') + const [err, setErr] = useState(null) + const [busy, setBusy] = useState(false) + + const cpSearch = useQuery({ + queryKey: ['cp-search', cpQ], + queryFn: async () => { + if (!cpQ.trim()) return [] + const { data } = await api.get( + `/api/purchases/invoices/counterparty-search?q=${encodeURIComponent(cpQ)}`) + return data + }, + enabled: !!cpQ.trim(), + }) + + const stores = useQuery({ + queryKey: ['stores-list'], + queryFn: async () => { + const { data } = await api.get<{ items: Store[] }>('/api/catalog/stores?pageSize=100') + return data.items ?? [] + }, + }) + const currencies = useQuery({ + queryKey: ['currencies-list'], + queryFn: async () => { + const { data } = await api.get('/api/catalog/currencies?pageSize=20') + return Array.isArray(data) ? data : [] + }, + }) + + useEffect(() => { + if (!storeId && stores.data?.length) setStoreId(stores.data.find(s => s.isMain)?.id ?? stores.data[0].id) + }, [stores.data, storeId]) + useEffect(() => { + if (!currencyId && currencies.data?.length) { + const kzt = currencies.data.find(c => c.code === 'KZT') + setCurrencyId(kzt?.id ?? currencies.data[0].id) + } + }, [currencies.data, currencyId]) + + const create = async () => { + if (!cp) { setErr('Выберите поставщика'); return } + if (!storeId || !currencyId) { setErr('Выберите склад и валюту'); return } + setBusy(true); setErr(null) + try { + const { data } = await api.post<{ supplyId: string }>(`/api/purchases/invoices/${invoiceId}/apply`, { + supplierId: cp.id, storeId, currencyId, + }) + onDone(data.supplyId) + } catch (e: unknown) { + setErr(e instanceof Error ? e.message : 'Не удалось создать') + } finally { + setBusy(false) + } + } + + return ( +
+
+

Создать приёмку

+ +
+ + {cp ? ( +
+ {cp.name}{cp.bin && БИН {cp.bin}} + +
+ ) : ( + <> + setCpQ(e.target.value)} + placeholder="Начните вводить название или БИН…" + className="w-full rounded border border-slate-300 px-2 py-1.5 text-sm" /> + {cpSearch.data?.map(h => ( + + ))} + {cpSearch.data && cpSearch.data.length === 0 && cpQ.length > 0 && ( +
+ Не найден.{' '} + Создать «{cpQ}» +
+ )} + + )} +
+ +
+ + +
+ + {err &&
{err}
} + +
+ + +
+
+
+ ) +} diff --git a/src/food-market.web/src/pages/InvoicesPage.tsx b/src/food-market.web/src/pages/InvoicesPage.tsx new file mode 100644 index 0000000..c61443f --- /dev/null +++ b/src/food-market.web/src/pages/InvoicesPage.tsx @@ -0,0 +1,173 @@ +import { useRef, useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { Upload, Camera, FileText, Loader2, AlertTriangle, Check, Clock } from 'lucide-react' +import { api } from '@/lib/api' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' + +/// Жизненный цикл: pending → parsing → needs_review → applied | failed. +/// Статус-бейдж + подсказка внизу карточки. +interface InvoiceListRow { + id: string + fileName: string + status: string + uploadedAt: string + supplierName: string | null + invoiceNumber: string | null + invoiceDate: string | null + totalSum: number | null + linesTotal: number + linesMatched: number + supplyId: string | null + error: string | null +} + +const STATUS: Record = { + pending: { label: 'В очереди', cls: 'bg-slate-100 text-slate-700', icon: Clock }, + parsing: { label: 'Распознаётся', cls: 'bg-sky-100 text-sky-700', icon: Loader2 }, + needs_review: { label: 'На проверке', cls: 'bg-amber-100 text-amber-800', icon: FileText }, + applied: { label: 'Приёмка создана', cls: 'bg-emerald-100 text-emerald-800', icon: Check }, + failed: { label: 'Ошибка', cls: 'bg-red-100 text-red-700', icon: AlertTriangle }, +} + +export function InvoicesPage() { + const nav = useNavigate() + const qc = useQueryClient() + const fileRef = useRef(null) + const cameraRef = useRef(null) + const [uploading, setUploading] = useState(false) + const [err, setErr] = useState(null) + + // Polling каждые 3 с, пока есть pending/parsing — чтобы юзер видел прогресс + // без ручного F5. Останавливается, когда все статусы терминальные. + const list = useQuery({ + queryKey: ['invoices-list'], + queryFn: () => api.get('/api/purchases/invoices?take=50').then(r => r.data), + refetchInterval: (q) => { + const rows = q.state.data as InvoiceListRow[] | undefined + const active = rows?.some(r => r.status === 'pending' || r.status === 'parsing') + return active ? 3000 : false + }, + }) + + const upload = useMutation({ + mutationFn: async (files: FileList) => { + const fd = new FormData() + for (const f of Array.from(files)) fd.append('files', f) + await api.post('/api/purchases/invoices/upload', fd, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + }, + onSuccess: () => { qc.invalidateQueries({ queryKey: ['invoices-list'] }); setErr(null) }, + onError: (e: unknown) => setErr(e instanceof Error ? e.message : 'Не удалось загрузить'), + }) + + const onFiles = async (f: FileList | null) => { + if (!f || f.length === 0) return + setUploading(true) + try { await upload.mutateAsync(f) } + finally { setUploading(false) } + } + + return ( +
+
+

Накладные

+

+ Загрузите фото или PDF — система распознает строки и создаст приёмку. +

+
+ +
+ + + onFiles(e.target.files)} /> + onFiles(e.target.files)} /> +
+ + {err && ( +
{err}
+ )} + {uploading && ( +
+ Загрузка… +
+ )} + +
+ {list.isLoading &&
Загрузка…
} + {list.data?.length === 0 && !list.isLoading && ( +
+ Накладных пока нет. Загрузите первое фото — результат появится здесь. +
+ )} + {list.data?.map(r => { + const S = STATUS[r.status] ?? STATUS.pending + const Icon = S.icon + const clickable = r.status === 'needs_review' || r.status === 'failed' || r.status === 'applied' + return ( +
clickable && nav(`/purchases/invoices/${r.id}`)} + className={`bg-white border border-slate-200 rounded-lg p-3 ${clickable ? 'cursor-pointer hover:border-sky-300' : ''}`} + data-testid={`invoice-row-${r.id}`} + > +
+ + {S.label} + + {new Date(r.uploadedAt).toLocaleString('ru')} +
+
+
+
+ {r.supplierName || r.fileName} +
+
+ {r.invoiceNumber ? `№ ${r.invoiceNumber}` : ''} + {r.invoiceDate ? ` от ${new Date(r.invoiceDate).toLocaleDateString('ru')}` : ''} + {r.supplierName && r.fileName ? ` · ${r.fileName}` : ''} +
+ {r.linesTotal > 0 && ( +
+ {r.linesTotal} строк · сопоставлено {r.linesMatched} + {r.linesMatched < r.linesTotal && · требует внимания} +
+ )} + {r.error &&
{r.error}
} +
+
+ {r.totalSum != null && ( +
+ {r.totalSum.toLocaleString('ru-RU', { maximumFractionDigits: 2 })} +
+ )} + {r.supplyId && ( + + Открыть приёмку + + )} +
+
+
+ ) + })} +
+
+ ) +}