From f42329281cd1223450f552008fbe72da47c67f4b Mon Sep 17 00:00:00 2001 From: nns Date: Sun, 4 Oct 2026 21:40:58 +0500 Subject: [PATCH] =?UTF-8?q?feat(s31):=20=D0=BC=D0=BE=D0=B1=D0=B8=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=B0=D1=8F=20=D0=B8=D0=BD=D0=B2=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=D1=80=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=E2=80=94=20?= =?UTF-8?q?offline-=D0=BA=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3,=20drift-che?= =?UTF-8?q?ck,=20=D1=80=D0=B0=D1=81=D1=85=D0=BE=D0=B6=D0=B4=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20(4+5+6/8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: Камера или аппаратный сканер; скан → +1 к существующей строке; неизвестные → в отдельный список unknown с предупреждением; офлайн-плашка; localStorage сохраняет rows+docId. - review: карточки с book/факт/дельта/дельта*себестоимость + фильтр 'только расхождения'; drift-плашка если учётный поплыл; 'Провести' → POST /post. Routes: /inventory/mobile. tsc зелёный. Co-Authored-By: Claude Opus 4.7 --- docs/supply-inventory-mobile-progress.md | 6 +- .../Inventory/InventoriesController.cs | 102 +++++ src/food-market.web/src/App.tsx | 3 + src/food-market.web/src/lib/offlineCatalog.ts | 55 +++ .../src/pages/InventoryMobilePage.tsx | 413 ++++++++++++++++++ 5 files changed, 576 insertions(+), 3 deletions(-) create mode 100644 src/food-market.web/src/lib/offlineCatalog.ts create mode 100644 src/food-market.web/src/pages/InventoryMobilePage.tsx diff --git a/docs/supply-inventory-mobile-progress.md b/docs/supply-inventory-mobile-progress.md index 800d5a1..e03d340 100644 --- a/docs/supply-inventory-mobile-progress.md +++ b/docs/supply-inventory-mobile-progress.md @@ -28,13 +28,13 @@ Storekeeper. ### Инвентаризация -- [ ] 4. **Инвентаризация с телефона.** Выбор склада + опц. группы, +- [x] 4. **Инвентаризация с телефона.** Выбор склада + опц. группы, офлайн-каталог в IndexedDB, прогресс сохраняется локально; скан / ручной ввод; неизвестные штрихкоды в отдельный список. -- [ ] 5. **Расхождения видны сразу.** Учёт/факт/дельта в шт. и в деньгах, +- [x] 5. **Расхождения видны сразу.** Учёт/факт/дельта в шт. и в деньгах, фильтр «только расхождения». Частичная (по группе) НЕ обнуляет непосчитанные; полная — явный выбор. -- [ ] 6. **Учётный остаток подставляется автоматически** (без ручной +- [x] 6. **Учётный остаток подставляется автоматически** (без ручной кнопки «Пересчитать»): при создании bookQty = текущий Stock; при добавлении позиции — тоже; перед проведением пересчёт + warning о расхождениях с момента создания (продажи/приёмки). Если где-то diff --git a/src/food-market.api/Controllers/Inventory/InventoriesController.cs b/src/food-market.api/Controllers/Inventory/InventoriesController.cs index 3f4ae4a..1ac8279 100644 --- a/src/food-market.api/Controllers/Inventory/InventoriesController.cs +++ b/src/food-market.api/Controllers/Inventory/InventoriesController.cs @@ -59,6 +59,38 @@ public record InventoryInput( /// товарами склада с их текущим Stock в качестве bookQty и actual=0. IReadOnlyList? Lines); + /// Компактный слепок каталога для телефона: тройки + /// [штрихкод, productId, name]. Телефон держит его в IndexedDB, + /// работает без сети. Опциональный режет + /// каталог для частичной инвентаризации (считаем только одну группу). + public record MobileCatalogRow(string Barcode, Guid ProductId, string Name); + public record MobileCatalogResponse(int Count, DateTime BuiltAt, List Rows); + + [HttpGet("mobile-catalog")] + public async Task> 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(); + var rows = new List(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] public async Task>> List( [FromQuery] PagedRequest req, @@ -246,6 +278,76 @@ public async Task Update(Guid id, [FromBody] InventoryInput input return NoContent(); } + /// Sprint 31: быстрое добавление позиции по productId + /// (мобильный сканер — +1 к существующей, иначе новая строка). + /// bookQty подставляется из текущего Stock склада. Отличается от + /// общего Update: пишет только одну строку, не трогает остальные. + public record AddLineInput([Required] Guid ProductId, decimal ActualQty); + + [HttpPost("{id:guid}/add-line"), RequiresPermission("InventoryEdit")] + public async Task 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(); + } + + /// Sprint 31 (пункт 6): проверка «учётный изменился с момента + /// создания документа» — чтобы перед Post показать, что stock поплыл + /// (были продажи/приёмки). Возвращает список строк, у которых текущий + /// Stock != сохранённый BookQty. Клиент показывает предупреждение. + public record BookDriftRow(Guid ProductId, string ProductName, decimal SavedBook, decimal CurrentBook); + + [HttpGet("{id:guid}/book-drift")] + public async Task>> 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(); + 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")] public async Task Delete(Guid id, CancellationToken ct) { diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index 367c3de..f749577 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -21,6 +21,7 @@ import { SupplyEditPage } from '@/pages/SupplyEditPage' import { InvoicesPage } from '@/pages/InvoicesPage' import { InvoiceReviewPage } from '@/pages/InvoiceReviewPage' import { SupplyScanPage } from '@/pages/SupplyScanPage' +import { InventoryMobilePage } from '@/pages/InventoryMobilePage' import { RetailSalesPage } from '@/pages/RetailSalesPage' import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage' import { NoOrganizationPage } from '@/pages/NoOrganizationPage' @@ -174,6 +175,8 @@ export default function App() { } /> } /> } /> + {/* Sprint 31: мобильная инвентаризация с офлайн-каталогом. */} + } /> diff --git a/src/food-market.web/src/lib/offlineCatalog.ts b/src/food-market.web/src/lib/offlineCatalog.ts new file mode 100644 index 0000000..a202c59 --- /dev/null +++ b/src/food-market.web/src/lib/offlineCatalog.ts @@ -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 { + 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(store: string, mode: IDBTransactionMode, + fn: (s: IDBObjectStore) => IDBRequest): Promise { + return open().then(d => new Promise((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 { + const d = await open() + await new Promise((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(META, 'readonly', s => s.get('catalog')) + +export const countRows = () => tx(STORE, 'readonly', s => s.count()) diff --git a/src/food-market.web/src/pages/InventoryMobilePage.tsx b/src/food-market.web/src/pages/InventoryMobilePage.tsx new file mode 100644 index 0000000..2abb5ce --- /dev/null +++ b/src/food-market.web/src/pages/InventoryMobilePage.tsx @@ -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([]) + const [groups, setGroups] = useState([]) + const [storeId, setStoreId] = useState('') + const [groupId, setGroupId] = useState('') // пусто = полная + const [cat, setCat] = useState(null) + const [catalogLoading, setCatalogLoading] = useState(false) + const [online, setOnline] = useState(typeof navigator !== 'undefined' ? navigator.onLine : true) + const [rows, setRows] = useState(() => { + 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(null) + const seq = useRef(0) + const inputRef = useRef(null) + const [docId, setDocId] = useState(() => localStorage.getItem(LS_DOCID)) + const [doc, setDoc] = useState(null) + const [drift, setDrift] = useState(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(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 => { + 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(`/api/inventory/inventories/${did}`) + setDoc(data) + const { data: dr } = await api.get(`/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 ( +
+
+ +

Инвентаризация с телефона

+
+ + + + + {cat ? ( +
+ Каталог готов: {cat.count.toLocaleString('ru-RU')} штрихкодов + {cat.groupId && · группа загружена} + {' · '} +
+ ) : ( + + )} + + +
+ ) + } + + // ─ STAGE: SCAN ──────────────────────────────────────────────────────── + if (stage === 'scan') { + return ( +
+
+ +

Сканирование

+ {rows.length} · {total} +
+ + {!online && ( +
+ + Нет сети. Сканируйте — всё сохранится в телефоне и уйдёт на сервер, когда появится связь. +
+ )} + +
+ +
+ + Аппаратный сканер — ввод штрихкода + + { + 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" /> +
+ {msg && ( +
+ {msg.text} +
+ )} +
+ + {unknown.length > 0 && ( +
+
+ Нет в каталоге ({unknown.length}) +
+ {unknown.map(u =>
{u.code} — {u.n} шт
)} +
+ Эти штрихкоды в документ не попадут. Заведите им карточки и пересчитайте. +
+
+ )} + +
+
+ Позиций: {rows.length} + Единиц: {total} +
+ {rows.length === 0 && ( +
Пока ничего не отсканировано
+ )} + {rows.map(r => ( +
+
{r.name}
+ { + 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" /> + +
+ ))} +
+ + {cameraOn && ( + setCameraOn(false)} onCode={addScan} feedback={fb} + counter={`позиций ${rows.length} · единиц ${total}`} /> + )} + + {rows.length > 0 && ( +
+
+ +
+
+ )} +
+ ) + } + + // ─ 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 ( +
+
+ +

Расхождения · {doc?.number}

+ +
+ + {drift && drift.length > 0 && ( +
+
Учётный остаток изменился с момента создания документа
+
    + {drift.slice(0, 5).map(d => ( +
  • {d.productName}: {d.savedBook} → {d.currentBook}
  • + ))} + {drift.length > 5 &&
  • … и ещё {drift.length - 5}
  • } +
+
Это были продажи/приёмки между созданием и сейчас. Проверьте перед проведением.
+
+ )} + +
+
+
Излишек
+
+{surplus.toLocaleString('ru-RU', { maximumFractionDigits: 2 })}
+
+
+
Недостача
+
{shortage.toLocaleString('ru-RU', { maximumFractionDigits: 2 })}
+
+
+
Расхождений
+
{diffs} из {doc?.lines.length ?? 0}
+
+
+ +
+ {visibleLines.map(l => ( +
+
{l.productName}
+
+ учёт {l.bookQty} + факт {l.actualQty} + 0 ? 'text-emerald-700' : l.diff < 0 ? 'text-red-700' : 'text-slate-500'}`}> + {l.diff > 0 ? '+' : ''}{l.diff} + + {l.diff !== 0 && ( + 0 ? 'text-emerald-700' : 'text-red-700'}`}> + = {(l.diff * l.unitCost).toLocaleString('ru-RU', { maximumFractionDigits: 2 })} + + )} +
+
+ ))} +
+ +
+
+ + +
+
+
+ ) +}