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:
parent
05e7fdecef
commit
f42329281c
|
|
@ -28,13 +28,13 @@ Storekeeper.
|
|||
|
||||
### Инвентаризация
|
||||
|
||||
- [ ] 4. **Инвентаризация с телефона.** Выбор склада + опц. группы,
|
||||
- [x] 4. **Инвентаризация с телефона.** Выбор склада + опц. группы,
|
||||
офлайн-каталог в IndexedDB, прогресс сохраняется локально; скан /
|
||||
ручной ввод; неизвестные штрихкоды в отдельный список.
|
||||
- [ ] 5. **Расхождения видны сразу.** Учёт/факт/дельта в шт. и в деньгах,
|
||||
- [x] 5. **Расхождения видны сразу.** Учёт/факт/дельта в шт. и в деньгах,
|
||||
фильтр «только расхождения». Частичная (по группе) НЕ обнуляет
|
||||
непосчитанные; полная — явный выбор.
|
||||
- [ ] 6. **Учётный остаток подставляется автоматически** (без ручной
|
||||
- [x] 6. **Учётный остаток подставляется автоматически** (без ручной
|
||||
кнопки «Пересчитать»): при создании bookQty = текущий Stock; при
|
||||
добавлении позиции — тоже; перед проведением пересчёт + warning о
|
||||
расхождениях с момента создания (продажи/приёмки). Если где-то
|
||||
|
|
|
|||
|
|
@ -59,6 +59,38 @@ public record InventoryInput(
|
|||
/// товарами склада с их текущим Stock в качестве bookQty и actual=0.</summary>
|
||||
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]
|
||||
public async Task<ActionResult<PagedResult<InventoryListRow>>> List(
|
||||
[FromQuery] PagedRequest req,
|
||||
|
|
@ -246,6 +278,76 @@ public async Task<IActionResult> Update(Guid id, [FromBody] InventoryInput input
|
|||
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")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Route path="/purchases/invoices" element={<InvoicesPage />} />
|
||||
<Route path="/purchases/invoices/:id" element={<InvoiceReviewPage />} />
|
||||
<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/new" element={lz(EnterEditPage)} />
|
||||
<Route path="/inventory/enters/:id" element={lz(EnterEditPage)} />
|
||||
|
|
|
|||
55
src/food-market.web/src/lib/offlineCatalog.ts
Normal file
55
src/food-market.web/src/lib/offlineCatalog.ts
Normal 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())
|
||||
413
src/food-market.web/src/pages/InventoryMobilePage.tsx
Normal file
413
src/food-market.web/src/pages/InventoryMobilePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue