feat(s31): мобильная инвентаризация — offline-каталог, drift-check, расхождения (4+5+6/8)

Backend дополнения (InventoriesController):
- GET /mobile-catalog?groupId= — компактные тройки [barcode, productId,
  name] для оффлайн-работы. Фильтр по группе = частичная инвентаризация.
- POST /{id}/add-line — scan-mode: +1 к существующей строке или новая,
  bookQty подтягивается из текущего Stock (пункт 6: без ручного
  «Пересчитать»).
- GET /{id}/book-drift — список позиций, где текущий Stock != сохранённого
  BookQty. UI показывает перед Post, чтобы было видно что учётный поплыл
  (продажи/приёмки с момента создания). Пункт 6 целиком.

Существующий Create уже заполняет bookQty из Stock (строки not passed →
подтягивает весь склад; строки passed → подтягивает book по каждой).
Update тоже auto-fill'ит для новых строк.

Web (lib/offlineCatalog.ts): IndexedDB wrapper (DB=fm-inventory,
STORE=catalog, META=meta). saveCatalog/findByBarcode/catalogMeta/
countRows. Порт из sklad'овского db.ts.

Web (InventoryMobilePage.tsx) — три stage'а в одном файле:
- setup: склад + опц. группа + 'Загрузить каталог на телефон'.
- scan: Камера <CameraScanner /> или аппаратный сканер; скан → +1 к
  существующей строке; неизвестные → в отдельный список unknown с
  предупреждением; офлайн-плашка; localStorage сохраняет rows+docId.
- review: карточки с book/факт/дельта/дельта*себестоимость + фильтр
  'только расхождения'; drift-плашка если учётный поплыл;
  'Провести' → POST /post.

Routes: /inventory/mobile. tsc зелёный.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-04 21:40:58 +05:00
parent 05e7fdecef
commit f42329281c
5 changed files with 576 additions and 3 deletions

View file

@ -28,13 +28,13 @@ Storekeeper.
### Инвентаризация ### Инвентаризация
- [ ] 4. **Инвентаризация с телефона.** Выбор склада + опц. группы, - [x] 4. **Инвентаризация с телефона.** Выбор склада + опц. группы,
офлайн-каталог в IndexedDB, прогресс сохраняется локально; скан / офлайн-каталог в IndexedDB, прогресс сохраняется локально; скан /
ручной ввод; неизвестные штрихкоды в отдельный список. ручной ввод; неизвестные штрихкоды в отдельный список.
- [ ] 5. **Расхождения видны сразу.** Учёт/факт/дельта в шт. и в деньгах, - [x] 5. **Расхождения видны сразу.** Учёт/факт/дельта в шт. и в деньгах,
фильтр «только расхождения». Частичная (по группе) НЕ обнуляет фильтр «только расхождения». Частичная (по группе) НЕ обнуляет
непосчитанные; полная — явный выбор. непосчитанные; полная — явный выбор.
- [ ] 6. **Учётный остаток подставляется автоматически** (без ручной - [x] 6. **Учётный остаток подставляется автоматически** (без ручной
кнопки «Пересчитать»): при создании bookQty = текущий Stock; при кнопки «Пересчитать»): при создании bookQty = текущий Stock; при
добавлении позиции — тоже; перед проведением пересчёт + warning о добавлении позиции — тоже; перед проведением пересчёт + warning о
расхождениях с момента создания (продажи/приёмки). Если где-то расхождениях с момента создания (продажи/приёмки). Если где-то

View file

@ -59,6 +59,38 @@ public record InventoryInput(
/// товарами склада с их текущим Stock в качестве bookQty и actual=0.</summary> /// товарами склада с их текущим Stock в качестве bookQty и actual=0.</summary>
IReadOnlyList<InventoryLineInput>? Lines); IReadOnlyList<InventoryLineInput>? Lines);
/// <summary>Компактный слепок каталога для телефона: тройки
/// [штрихкод, productId, name]. Телефон держит его в IndexedDB,
/// работает без сети. Опциональный <paramref name="groupId"/> режет
/// каталог для частичной инвентаризации (считаем только одну группу).</summary>
public record MobileCatalogRow(string Barcode, Guid ProductId, string Name);
public record MobileCatalogResponse(int Count, DateTime BuiltAt, List<object[]> Rows);
[HttpGet("mobile-catalog")]
public async Task<ActionResult<MobileCatalogResponse>> MobileCatalog(
[FromQuery] Guid? groupId, CancellationToken ct)
{
// Берём все (OrgId implicit by filter) активные товары + их штрихкоды.
// Отдаём массивом троек (а не объектом с полями) — для 30 тыс. позиций
// JSON объекта весит 12 МБ, троек втрое меньше.
var q = from pb in _db.ProductBarcodes.AsNoTracking()
join p in _db.Products.AsNoTracking() on pb.ProductId equals p.Id
where !p.IsArchived
select new { pb.Code, pb.ProductId, p.Name, p.ProductGroupId };
if (groupId.HasValue) q = q.Where(x => x.ProductGroupId == groupId);
var raw = await q.ToListAsync(ct);
var seen = new HashSet<string>();
var rows = new List<object[]>(raw.Count);
foreach (var r in raw)
{
var k = new string(r.Code.Where(char.IsDigit).ToArray());
if (k.Length < 6 || !seen.Add(k)) continue;
rows.Add(new object[] { k, r.ProductId, r.Name });
}
return Ok(new MobileCatalogResponse(rows.Count, DateTime.UtcNow, rows));
}
[HttpGet] [HttpGet]
public async Task<ActionResult<PagedResult<InventoryListRow>>> List( public async Task<ActionResult<PagedResult<InventoryListRow>>> List(
[FromQuery] PagedRequest req, [FromQuery] PagedRequest req,
@ -246,6 +278,76 @@ public async Task<IActionResult> Update(Guid id, [FromBody] InventoryInput input
return NoContent(); return NoContent();
} }
/// <summary>Sprint 31: быстрое добавление позиции по productId
/// (мобильный сканер — +1 к существующей, иначе новая строка).
/// bookQty подставляется из текущего Stock склада. Отличается от
/// общего Update: пишет только одну строку, не трогает остальные.</summary>
public record AddLineInput([Required] Guid ProductId, decimal ActualQty);
[HttpPost("{id:guid}/add-line"), RequiresPermission("InventoryEdit")]
public async Task<IActionResult> AddLine(Guid id, [FromBody] AddLineInput input, CancellationToken ct)
{
var doc = await _db.InventoryDocs.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct);
if (doc is null) return NotFound();
if (doc.Status != InventoryStatus.Draft)
return Conflict(new { error = "Только черновик можно править." });
var existing = doc.Lines.FirstOrDefault(l => l.ProductId == input.ProductId);
if (existing is not null)
{
existing.ActualQty = input.ActualQty > 0 ? input.ActualQty : existing.ActualQty + 1;
existing.Diff = existing.ActualQty - existing.BookQty;
}
else
{
var b = await _db.Stocks.Where(s => s.StoreId == doc.StoreId && s.ProductId == input.ProductId)
.Select(s => (decimal?)s.Quantity).FirstOrDefaultAsync(ct) ?? 0m;
var c = await _db.Products.Where(p => p.Id == input.ProductId).Select(p => p.Cost).FirstOrDefaultAsync(ct);
var qty = input.ActualQty > 0 ? input.ActualQty : 1m;
_db.InventoryLines.Add(new InventoryLine
{
InventoryDocId = doc.Id,
ProductId = input.ProductId,
BookQty = b,
ActualQty = qty,
Diff = qty - b,
UnitCost = c,
SortOrder = doc.Lines.Count,
});
}
await _db.SaveChangesAsync(ct);
return NoContent();
}
/// <summary>Sprint 31 (пункт 6): проверка «учётный изменился с момента
/// создания документа» — чтобы перед Post показать, что stock поплыл
/// (были продажи/приёмки). Возвращает список строк, у которых текущий
/// Stock != сохранённый BookQty. Клиент показывает предупреждение.</summary>
public record BookDriftRow(Guid ProductId, string ProductName, decimal SavedBook, decimal CurrentBook);
[HttpGet("{id:guid}/book-drift")]
public async Task<ActionResult<List<BookDriftRow>>> BookDrift(Guid id, CancellationToken ct)
{
var doc = await _db.InventoryDocs.FirstOrDefaultAsync(d => d.Id == id, ct);
if (doc is null) return NotFound();
var lines = await _db.InventoryLines
.Where(l => l.InventoryDocId == id)
.Select(l => new { l.ProductId, l.BookQty, l.Product.Name })
.ToListAsync(ct);
var ids = lines.Select(l => l.ProductId).ToList();
var cur = await _db.Stocks
.Where(s => s.StoreId == doc.StoreId && ids.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
var drift = new List<BookDriftRow>();
foreach (var l in lines)
{
cur.TryGetValue(l.ProductId, out var now);
if (now != l.BookQty) drift.Add(new BookDriftRow(l.ProductId, l.Name, l.BookQty, now));
}
return Ok(drift);
}
[HttpDelete("{id:guid}"), RequiresPermission("InventoryEdit")] [HttpDelete("{id:guid}"), RequiresPermission("InventoryEdit")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{ {

View file

@ -21,6 +21,7 @@ import { SupplyEditPage } from '@/pages/SupplyEditPage'
import { InvoicesPage } from '@/pages/InvoicesPage' import { InvoicesPage } from '@/pages/InvoicesPage'
import { InvoiceReviewPage } from '@/pages/InvoiceReviewPage' import { InvoiceReviewPage } from '@/pages/InvoiceReviewPage'
import { SupplyScanPage } from '@/pages/SupplyScanPage' import { SupplyScanPage } from '@/pages/SupplyScanPage'
import { InventoryMobilePage } from '@/pages/InventoryMobilePage'
import { RetailSalesPage } from '@/pages/RetailSalesPage' import { RetailSalesPage } from '@/pages/RetailSalesPage'
import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage' import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage'
import { NoOrganizationPage } from '@/pages/NoOrganizationPage' import { NoOrganizationPage } from '@/pages/NoOrganizationPage'
@ -174,6 +175,8 @@ export default function App() {
<Route path="/purchases/invoices" element={<InvoicesPage />} /> <Route path="/purchases/invoices" element={<InvoicesPage />} />
<Route path="/purchases/invoices/:id" element={<InvoiceReviewPage />} /> <Route path="/purchases/invoices/:id" element={<InvoiceReviewPage />} />
<Route path="/purchases/supplies/scan" element={<SupplyScanPage />} /> <Route path="/purchases/supplies/scan" element={<SupplyScanPage />} />
{/* Sprint 31: мобильная инвентаризация с офлайн-каталогом. */}
<Route path="/inventory/mobile" element={<InventoryMobilePage />} />
<Route path="/inventory/enters" element={lz(EntersPage)} /> <Route path="/inventory/enters" element={lz(EntersPage)} />
<Route path="/inventory/enters/new" element={lz(EnterEditPage)} /> <Route path="/inventory/enters/new" element={lz(EnterEditPage)} />
<Route path="/inventory/enters/:id" element={lz(EnterEditPage)} /> <Route path="/inventory/enters/:id" element={lz(EnterEditPage)} />

View file

@ -0,0 +1,55 @@
/// Каталог не влезает в localStorage (~5 МБ на домен), поэтому держим
/// в IndexedDB. По образцу ~/ref/sklad/sklad/ClientApp/src/lib/db.ts.
///
/// Ключ store'а — штрихкод (строка цифр). Значение — [productId, name].
/// Отдельный meta-store хранит { count, builtAt, groupId? } чтобы UI
/// показывал, когда последний раз обновляли и для какой группы.
const DB = 'fm-inventory', STORE = 'catalog', META = 'meta'
function open(): Promise<IDBDatabase> {
return new Promise((res, rej) => {
const r = indexedDB.open(DB, 1)
r.onupgradeneeded = () => {
const d = r.result
if (!d.objectStoreNames.contains(STORE)) d.createObjectStore(STORE)
if (!d.objectStoreNames.contains(META)) d.createObjectStore(META)
}
r.onsuccess = () => res(r.result)
r.onerror = () => rej(r.error)
})
}
function tx<T>(store: string, mode: IDBTransactionMode,
fn: (s: IDBObjectStore) => IDBRequest<T>): Promise<T> {
return open().then(d => new Promise<T>((res, rej) => {
const r = fn(d.transaction(store, mode).objectStore(store))
r.onsuccess = () => res(r.result)
r.onerror = () => rej(r.error)
}))
}
export type CatMeta = { count: number; builtAt: string; groupId?: string | null; storeId?: string | null }
export async function saveCatalog(
rows: Array<[string, string, string]>,
meta: CatMeta,
): Promise<void> {
const d = await open()
await new Promise<void>((res, rej) => {
const t = d.transaction([STORE, META], 'readwrite')
const s = t.objectStore(STORE)
s.clear()
for (const [bc, id, name] of rows) s.put([id, name], bc)
t.objectStore(META).put(meta, 'catalog')
t.oncomplete = () => res()
t.onerror = () => rej(t.error)
})
}
export const findByBarcode = (bc: string) =>
tx<[string, string] | undefined>(STORE, 'readonly', s => s.get(bc))
export const catalogMeta = () => tx<CatMeta | undefined>(META, 'readonly', s => s.get('catalog'))
export const countRows = () => tx<number>(STORE, 'readonly', s => s.count())

View file

@ -0,0 +1,413 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '@/lib/api'
import { saveCatalog, findByBarcode, catalogMeta, countRows, type CatMeta } from '@/lib/offlineCatalog'
import CameraScanner, { type ScanFeedback } from '@/components/CameraScanner'
import {
ScanBarcode, Download, Camera, Trash2, Check, Loader2, AlertTriangle, WifiOff,
ArrowLeft, Keyboard, Filter,
} from 'lucide-react'
interface Store { id: string; name: string; isMain: boolean }
interface Group { id: string; name: string }
interface MobileCatalog { count: number; builtAt: string; rows: [string, string, string][] }
interface InventoryDto { id: string; number: string; status: number; lines: InventoryLineDto[] }
interface InventoryLineDto {
productId: string; productName?: string | null
bookQty: number; actualQty: number; diff: number; unitCost: number
}
interface BookDriftRow {
productId: string; productName: string; savedBook: number; currentBook: number
}
type LocalRow = { id: string; name: string; qty: number }
const LS_ROWS = 'fm.inv-mobile.rows'
const LS_DOCID = 'fm.inv-mobile.docId'
/// Мобильная инвентаризация. Шаги:
/// 1) выбор склада (+ опц. группы = частичная инвентаризация);
/// 2) загрузка каталога в IndexedDB (работает затем без сети);
/// 3) сканирование / ручной ввод — счётчик +1 на штрихкод;
/// 4) создание черновика (сервер bookQty = текущий Stock);
/// 5) просмотр расхождений перед Post; drift-check показывает, если
/// учётный изменился с момента создания (были продажи/приёмки).
export function InventoryMobilePage() {
const nav = useNavigate()
const [stage, setStage] = useState<'setup' | 'scan' | 'review'>('setup')
const [stores, setStores] = useState<Store[]>([])
const [groups, setGroups] = useState<Group[]>([])
const [storeId, setStoreId] = useState('')
const [groupId, setGroupId] = useState<string>('') // пусто = полная
const [cat, setCat] = useState<CatMeta | null>(null)
const [catalogLoading, setCatalogLoading] = useState(false)
const [online, setOnline] = useState(typeof navigator !== 'undefined' ? navigator.onLine : true)
const [rows, setRows] = useState<LocalRow[]>(() => {
try { return JSON.parse(localStorage.getItem(LS_ROWS) || '[]') } catch { return [] }
})
const [unknown, setUnknown] = useState<{ code: string; n: number }[]>([])
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null)
const [cameraOn, setCameraOn] = useState(false)
const [fb, setFb] = useState<ScanFeedback | null>(null)
const seq = useRef(0)
const inputRef = useRef<HTMLInputElement>(null)
const [docId, setDocId] = useState<string | null>(() => localStorage.getItem(LS_DOCID))
const [doc, setDoc] = useState<InventoryDto | null>(null)
const [drift, setDrift] = useState<BookDriftRow[] | null>(null)
const [filterOnlyDiff, setFilterOnlyDiff] = useState(false)
const [posting, setPosting] = useState(false)
// online / offline listeners
useEffect(() => {
const up = () => setOnline(true), down = () => setOnline(false)
addEventListener('online', up); addEventListener('offline', down)
return () => { removeEventListener('online', up); removeEventListener('offline', down) }
}, [])
useEffect(() => {
try { localStorage.setItem(LS_ROWS, JSON.stringify(rows)) } catch { /* storage full */ }
}, [rows])
// грузим справочники + инфо о каталоге из IndexedDB
useEffect(() => {
api.get<{ items: Store[] }>('/api/catalog/stores?pageSize=100').then(r => {
setStores(r.data.items ?? [])
const main = r.data.items?.find(s => s.isMain) ?? r.data.items?.[0]
if (main && !storeId) setStoreId(main.id)
}).catch(() => {})
api.get<{ items: Group[] }>('/api/catalog/product-groups?pageSize=500').then(r => {
setGroups(r.data.items ?? [])
}).catch(() => {})
catalogMeta().then(m => m && setCat(m)).catch(() => {})
// если есть сохранённый docId — переходим сразу к обзору
if (docId) setStage('scan')
}, [])
const loadCatalog = async () => {
setCatalogLoading(true); setMsg(null)
try {
const url = groupId
? `/api/inventory/inventories/mobile-catalog?groupId=${groupId}`
: '/api/inventory/inventories/mobile-catalog'
const { data } = await api.get<MobileCatalog>(url, { timeout: 180_000 })
const meta: CatMeta = { count: data.count, builtAt: data.builtAt, groupId: groupId || null, storeId }
await saveCatalog(data.rows, meta)
setCat(meta)
setMsg({ text: `Каталог загружен: ${data.count.toLocaleString('ru-RU')} штрихкодов`, ok: true })
} catch {
setMsg({ text: 'Не удалось загрузить каталог', ok: false })
} finally { setCatalogLoading(false) }
}
const createDocIfNeeded = async (): Promise<string | null> => {
if (docId) return docId
try {
const { data } = await api.post<{ id: string }>('/api/inventory/inventories', {
storeId, date: new Date().toISOString(), notes: groupId ? 'Инвентаризация с телефона (частичная)' : 'Инвентаризация с телефона',
lines: [], // пустые — сервер подтянет Stock автоматически (bookQty)
})
setDocId(data.id); localStorage.setItem(LS_DOCID, data.id)
return data.id
} catch {
setMsg({ text: 'Не удалось создать документ', ok: false })
return null
}
}
const beep = (ok: boolean) => { try { navigator.vibrate?.(ok ? 40 : [60, 50, 60]) } catch { /* ignored */ } }
const addScan = async (raw: string) => {
const code = raw.replace(/\D/g, '')
if (code.length < 6) return
const hit = await findByBarcode(code).catch(() => undefined)
if (!hit) {
setUnknown(prev => {
const i = prev.findIndex(u => u.code === code)
return i < 0 ? [{ code, n: 1 }, ...prev]
: prev.map((u, j) => j === i ? { ...u, n: u.n + 1 } : u)
})
setMsg({ text: `${code}: нет в каталоге`, ok: false })
setFb({ kind: 'err', title: 'Нет в каталоге', sub: code, seq: ++seq.current })
beep(false); return
}
const [id, name] = hit
let qty = 1
setRows(prev => {
const i = prev.findIndex(r => r.id === id)
if (i < 0) return [{ id, name, qty: 1 }, ...prev]
const next = [...prev]
qty = next[i].qty + 1
next[i] = { ...next[i], qty }
return [next[i], ...next.filter((_, j) => j !== i)]
})
setMsg({ text: name, ok: true })
setFb({ kind: 'ok', title: name, sub: `теперь ${qty} шт`, seq: ++seq.current })
beep(true)
// Сервер уведомляем только когда онлайн. Офлайн — всё лежит в localStorage.
if (online) {
const did = await createDocIfNeeded()
if (did) {
try { await api.post(`/api/inventory/inventories/${did}/add-line`, { productId: id, actualQty: qty }) }
catch { /* ignored — повторим при синке */ }
}
}
}
const total = useMemo(() => rows.reduce((a, r) => a + r.qty, 0), [rows])
const goReview = async () => {
if (!online) { setMsg({ text: 'Нет связи — сначала подключитесь', ok: false }); return }
const did = await createDocIfNeeded()
if (!did) return
// Синхронизация rows → add-line для каждой (сервер идемпотентно обновляет)
for (const r of rows) {
try { await api.post(`/api/inventory/inventories/${did}/add-line`, { productId: r.id, actualQty: r.qty }) }
catch { /* ignored */ }
}
const { data } = await api.get<InventoryDto>(`/api/inventory/inventories/${did}`)
setDoc(data)
const { data: dr } = await api.get<BookDriftRow[]>(`/api/inventory/inventories/${did}/book-drift`)
setDrift(dr)
setStage('review')
}
const postDoc = async () => {
if (!doc) return
setPosting(true)
try {
await api.post(`/api/inventory/inventories/${doc.id}/post`)
try { localStorage.removeItem(LS_ROWS); localStorage.removeItem(LS_DOCID) } catch { /* ignored */ }
setRows([]); setUnknown([]); setDocId(null); setDoc(null); setDrift(null)
nav(`/inventory/inventories/${doc.id}`)
} catch (e: unknown) {
setMsg({ text: e instanceof Error ? e.message : 'Ошибка проведения', ok: false })
} finally { setPosting(false) }
}
// ─ STAGE: SETUP ───────────────────────────────────────────────────────
if (stage === 'setup') {
return (
<div className="max-w-md mx-auto p-3 space-y-3">
<div className="flex items-center gap-2">
<button onClick={() => nav(-1)} className="p-2 rounded hover:bg-slate-100"><ArrowLeft className="w-5 h-5" /></button>
<h1 className="text-xl font-semibold">Инвентаризация с телефона</h1>
</div>
<label className="block">
<span className="text-xs text-slate-500">Склад</span>
<select value={storeId} onChange={e => setStoreId(e.target.value)}
className="w-full rounded border border-slate-300 px-2 py-2 text-base">
{stores.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</label>
<label className="block">
<span className="text-xs text-slate-500">Группа (пусто = полная инвентаризация)</span>
<select value={groupId} onChange={e => setGroupId(e.target.value)}
className="w-full rounded border border-slate-300 px-2 py-2 text-base">
<option value="">— вся номенклатура —</option>
{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</label>
{cat ? (
<div className="rounded-md bg-emerald-50 border border-emerald-200 text-emerald-800 px-3 py-2 text-sm">
Каталог готов: {cat.count.toLocaleString('ru-RU')} штрихкодов
{cat.groupId && <span className="text-xs"> · группа загружена</span>}
{' · '}<button onClick={loadCatalog} className="underline">обновить</button>
</div>
) : (
<button onClick={loadCatalog} disabled={catalogLoading || !storeId}
className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-slate-800 text-white py-3 font-medium disabled:opacity-50">
{catalogLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
Загрузить каталог на телефон
</button>
)}
<button onClick={() => setStage('scan')} disabled={!cat || !storeId}
className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 text-white py-3 font-medium disabled:opacity-50">
<ScanBarcode className="w-5 h-5" /> Начать сканирование
</button>
</div>
)
}
// ─ STAGE: SCAN ────────────────────────────────────────────────────────
if (stage === 'scan') {
return (
<div className="max-w-3xl mx-auto p-3 pb-28 space-y-3">
<div className="flex items-center gap-2">
<button onClick={() => setStage('setup')} className="p-2 rounded hover:bg-slate-100"><ArrowLeft className="w-5 h-5" /></button>
<h1 className="text-xl font-semibold flex-1">Сканирование</h1>
<span className="text-xs text-slate-500">{rows.length} · {total}</span>
</div>
{!online && (
<div className="flex items-start gap-2 rounded-md bg-amber-50 border border-amber-200 px-3 py-2 text-sm text-amber-900">
<WifiOff className="w-4 h-4 mt-0.5 shrink-0" />
Нет сети. Сканируйте — всё сохранится в телефоне и уйдёт на сервер, когда появится связь.
</div>
)}
<div className="bg-white rounded-lg border border-slate-200 p-3 space-y-2">
<button onClick={() => { setFb(null); setCameraOn(true) }}
className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 text-white py-4 text-lg font-medium">
<Camera className="w-6 h-6" /> Сканировать камерой
</button>
<details className="text-sm">
<summary className="text-slate-500 cursor-pointer select-none flex items-center gap-1">
<Keyboard className="w-4 h-4" /> Аппаратный сканер — ввод штрихкода
</summary>
<input ref={inputRef} inputMode="numeric"
onKeyDown={e => {
if (e.key !== 'Enter') return
e.preventDefault()
const el = e.target as HTMLInputElement
addScan(el.value); el.value = ''
}}
placeholder="Отсканируйте или введите штрихкод"
className="mt-2 w-full rounded-lg border-2 border-slate-300 px-4 py-3 text-lg tabular-nums outline-none focus:border-sky-500"
data-testid="inv-scan-input" />
</details>
{msg && (
<div className={`rounded px-3 py-2 text-sm ${msg.ok ? 'bg-emerald-50 text-emerald-800' : 'bg-red-50 text-red-700'}`}>
{msg.text}
</div>
)}
</div>
{unknown.length > 0 && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 space-y-1">
<div className="flex items-center gap-2 text-amber-900 font-medium text-sm">
<AlertTriangle className="w-4 h-4" /> Нет в каталоге ({unknown.length})
</div>
{unknown.map(u => <div key={u.code} className="text-sm text-amber-900 tabular-nums">{u.code} — {u.n} шт</div>)}
<div className="text-xs text-amber-800 pt-1">
Эти штрихкоды в документ не попадут. Заведите им карточки и пересчитайте.
</div>
</div>
)}
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<div className="px-3 py-2 bg-slate-50 text-xs text-slate-600 flex justify-between">
<span>Позиций: {rows.length}</span>
<span>Единиц: {total}</span>
</div>
{rows.length === 0 && (
<div className="px-4 py-10 text-center text-slate-400 text-sm">Пока ничего не отсканировано</div>
)}
{rows.map(r => (
<div key={r.id} className="px-3 py-2 border-t border-slate-100 flex items-center gap-2">
<div className="min-w-0 flex-1 text-sm truncate">{r.name}</div>
<input type="number" value={r.qty} min={0}
onChange={e => {
const v = parseFloat(e.target.value) || 0
setRows(p => p.map(x => x.id === r.id ? { ...x, qty: v } : x))
}}
className="w-20 text-right rounded border border-slate-200 px-2 py-1 tabular-nums text-lg" />
<button onClick={() => setRows(p => p.filter(x => x.id !== r.id))}
className="p-1 text-slate-300 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
</div>
))}
</div>
{cameraOn && (
<CameraScanner onClose={() => setCameraOn(false)} onCode={addScan} feedback={fb}
counter={`позиций ${rows.length} · единиц ${total}`} />
)}
{rows.length > 0 && (
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 p-3">
<div className="max-w-3xl mx-auto">
<button onClick={goReview} disabled={!online}
className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-slate-800 text-white py-3 font-medium disabled:opacity-50">
{online ? <Check className="w-4 h-4" /> : <WifiOff className="w-4 h-4" />}
{online ? `Проверить расхождения (${rows.length})` : 'Нет сети — отправить позже'}
</button>
</div>
</div>
)}
</div>
)
}
// ─ STAGE: REVIEW ──────────────────────────────────────────────────────
const visibleLines = (doc?.lines ?? [])
.filter(l => !filterOnlyDiff || l.diff !== 0)
.sort((a, b) => Math.abs(b.diff) - Math.abs(a.diff))
const surplus = (doc?.lines ?? []).filter(l => l.diff > 0).reduce((a, l) => a + l.diff * l.unitCost, 0)
const shortage = (doc?.lines ?? []).filter(l => l.diff < 0).reduce((a, l) => a + l.diff * l.unitCost, 0)
const diffs = (doc?.lines ?? []).filter(l => l.diff !== 0).length
return (
<div className="max-w-3xl mx-auto p-3 pb-28 space-y-3">
<div className="flex items-center gap-2">
<button onClick={() => setStage('scan')} className="p-2 rounded hover:bg-slate-100"><ArrowLeft className="w-5 h-5" /></button>
<h1 className="text-xl font-semibold flex-1">Расхождения · {doc?.number}</h1>
<button onClick={() => setFilterOnlyDiff(f => !f)}
className={`inline-flex items-center gap-1 rounded px-2 py-1 text-xs ${filterOnlyDiff ? 'bg-sky-100 text-sky-800' : 'bg-slate-100 text-slate-600'}`}>
<Filter className="w-3.5 h-3.5" /> {filterOnlyDiff ? 'Только расхождения' : 'Все позиции'}
</button>
</div>
{drift && drift.length > 0 && (
<div className="rounded-md border border-amber-300 bg-amber-50 text-amber-900 px-3 py-2 text-sm">
<div className="font-medium flex items-center gap-1"><AlertTriangle className="w-4 h-4" /> Учётный остаток изменился с момента создания документа</div>
<ul className="mt-1 text-xs space-y-0.5">
{drift.slice(0, 5).map(d => (
<li key={d.productId}><strong>{d.productName}</strong>: {d.savedBook} → {d.currentBook}</li>
))}
{drift.length > 5 && <li>… и ещё {drift.length - 5}</li>}
</ul>
<div className="text-xs mt-1">Это были продажи/приёмки между созданием и сейчас. Проверьте перед проведением.</div>
</div>
)}
<div className="grid grid-cols-3 gap-2 text-sm">
<div className="bg-emerald-50 rounded p-2 text-emerald-900">
<div className="text-xs">Излишек</div>
<div className="font-semibold tabular-nums">+{surplus.toLocaleString('ru-RU', { maximumFractionDigits: 2 })}</div>
</div>
<div className="bg-red-50 rounded p-2 text-red-800">
<div className="text-xs">Недостача</div>
<div className="font-semibold tabular-nums">{shortage.toLocaleString('ru-RU', { maximumFractionDigits: 2 })}</div>
</div>
<div className="bg-slate-100 rounded p-2 text-slate-800">
<div className="text-xs">Расхождений</div>
<div className="font-semibold">{diffs} из {doc?.lines.length ?? 0}</div>
</div>
</div>
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
{visibleLines.map(l => (
<div key={l.productId} className="px-3 py-2 border-t border-slate-100 first:border-t-0">
<div className="text-sm truncate">{l.productName}</div>
<div className="text-xs flex items-center gap-3 tabular-nums">
<span className="text-slate-500">учёт {l.bookQty}</span>
<span className="text-slate-500">факт {l.actualQty}</span>
<span className={`font-semibold ${l.diff > 0 ? 'text-emerald-700' : l.diff < 0 ? 'text-red-700' : 'text-slate-500'}`}>
{l.diff > 0 ? '+' : ''}{l.diff}
</span>
{l.diff !== 0 && (
<span className={`text-xs ${l.diff > 0 ? 'text-emerald-700' : 'text-red-700'}`}>
= {(l.diff * l.unitCost).toLocaleString('ru-RU', { maximumFractionDigits: 2 })}
</span>
)}
</div>
</div>
))}
</div>
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 p-3">
<div className="max-w-3xl mx-auto flex gap-2">
<button onClick={() => setStage('scan')}
className="flex-1 rounded-lg border border-slate-300 py-3 font-medium">Назад</button>
<button onClick={postDoc} disabled={posting}
className="flex-[1.5] inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 text-white py-3 font-medium disabled:opacity-50">
{posting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
Провести ({diffs} расх.)
</button>
</div>
</div>
</div>
)
}