From 39176d269475838a83dae1ad5d9ba2031166a0f6 Mon Sep 17 00:00:00 2001 From: vladtechno Date: Sat, 8 Aug 2026 06:53:29 +1000 Subject: [PATCH] =?UTF-8?q?feat(=D0=B8=D0=BD=D0=B2=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F):=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=D0=BA=D1=83=20=D0=BC=D0=BE=D0=B6=D0=BD=D0=BE?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20?= =?UTF-8?q?=D0=B8=20=D1=83=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20=D0=B4=D0=BE=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Механик ошибся в количестве при пересчёте — раньше единственным выходом было бросить инвентаризацию и начать заново. Теперь строку находки можно исправить или удалить, пока пересчёт в черновике. Правка идёт через тот же фасад склада, что и остальные складские действия приложения, — отдельного пути в обход правил остатков не появилось. Co-Authored-By: Claude Opus 5 --- mechanic-pwa/frontend/src/api/warehouse.ts | 65 +++ .../pages/warehouse/StocktakeSessionPage.tsx | 435 ++++++++++++++++-- 2 files changed, 453 insertions(+), 47 deletions(-) diff --git a/mechanic-pwa/frontend/src/api/warehouse.ts b/mechanic-pwa/frontend/src/api/warehouse.ts index eddff4b..b9186d6 100644 --- a/mechanic-pwa/frontend/src/api/warehouse.ts +++ b/mechanic-pwa/frontend/src/api/warehouse.ts @@ -5,8 +5,34 @@ * дополнительно прячет плитки по me.is_admin — чтобы механик не упирался в * ошибку там, куда ему не надо. */ +import { HTTPError } from "ky"; import { api } from "./client"; +/** + * Текст отказа, который сервер написал для человека. + * + * ky кладёт в Error.message служебное «Request failed with status code 409», а + * понятную причину бэкенд пишет в detail («Удалить находку нельзя: деталь + * выдали после пересчёта»). Механику у стеллажа нужна именно она: по номеру + * статуса он не поймёт, что делать дальше, и повторит то же действие. + * + * Запасной текст обязателен: тело читается один раз (при повторном вызове поток + * уже пуст), у прокси-ошибок его может не быть вовсе, а валидационный 422 от + * pydantic кладёт в detail список объектов — человеку он ничего не говорит. + * Не-HTTP ошибка (связь отвалилась, таймаут) тоже уходит в запасной: у ky там + * английское «Failed to fetch». + */ +export async function apiErrorText(e: unknown, fallback: string): Promise { + if (!(e instanceof HTTPError)) return fallback; + try { + const { detail } = (await e.response.json()) as { detail?: unknown }; + if (typeof detail === "string" && detail.trim()) return detail.trim(); + } catch { + // тело не JSON или уже прочитано — остаётся запасной текст + } + return fallback; +} + // ── Справочники ──────────────────────────────────────────────────────────── export interface WhRef { @@ -278,6 +304,45 @@ export async function addFind( return api.post(`wh/stocktakes/${stocktakeId}/finds`, { json: body }).json(); } +/** + * Поправить находку в черновике. + * + * Отдельная ручка, а не countLine: сервер пишет в аудит «было → стало» и не + * даёт свести количество к нулю — от находки отказываются удалением строки, + * иначе она остаётся в документе пустышкой «числилось 0 — столько и нашли». + * Поля опциональны: шлём только то, что правим. + */ +export async function patchFind( + stocktakeId: number, + lineId: number, + body: { + qty?: number; + unit_price?: number; + condition?: "new" | "used"; + seasons_left?: number | null; + comment?: string | null; + reason_code?: string | null; + }, +): Promise { + return api + .patch(`wh/stocktakes/${stocktakeId}/finds/${lineId}`, { json: body }) + .json(); +} + +/** + * Убрать строку из черновика. Сервер разрешает только находку: обычную строку + * не заводил человек — она означает «эту позицию надо обойти», и её удаление + * молча вычеркнуло бы позицию из пересчёта. + */ +export async function deleteStocktakeLine( + stocktakeId: number, + lineId: number, +): Promise<{ ok: boolean }> { + return api + .delete(`wh/stocktakes/${stocktakeId}/lines/${lineId}`) + .json<{ ok: boolean }>(); +} + export async function applyStocktake(id: number): Promise { return api.post(`wh/stocktakes/${id}/apply`).json(); } diff --git a/mechanic-pwa/frontend/src/pages/warehouse/StocktakeSessionPage.tsx b/mechanic-pwa/frontend/src/pages/warehouse/StocktakeSessionPage.tsx index 6a83f2d..294c7cc 100644 --- a/mechanic-pwa/frontend/src/pages/warehouse/StocktakeSessionPage.tsx +++ b/mechanic-pwa/frontend/src/pages/warehouse/StocktakeSessionPage.tsx @@ -9,26 +9,44 @@ * Пустое поле — это «ещё не считали», а не ноль. Ноль означает «дошёл и не * нашёл» и превратится в недостачу, поэтому он вводится явной кнопкой. * + * Находки живут ОТДЕЛЬНЫМ блоком, а не строками рабочего списка. Причина не + * косметическая: у находки количество проставлено с рождения, поэтому фильтр + * «только непосчитанные» прятал её сразу после добавления — со стороны это + * читалось как «нажал, и оно пропало». Вынести их из-под фильтра исключением + * значило бы держать в одном списке две разные сущности: обход («что надо + * проверить», и его-то и считает прогресс) и находки («что появилось сверх + * списка»). Отдельный блок сохраняет смысл фильтра и прогресса и ставит + * находку прямо под кнопкой, которой её добавили. + * + * Строка, в поле которой стоит курсор, из списка не исчезает, даже если её + * значение уже ушло на сервер. Иначе фильтр выдёргивает строку из-под пальца + * посреди набора — и в документе остаётся то, что успели напечатать. Отсюда + * focusedId на этом уровне: сам фильтр живёт здесь, а знает про фокус строка. + * * Проведённая сессия сюда не доходит: она уже не рабочий список, а документ, * и показывается экраном итогов (StocktakeResult). Адрес один на одну сущность * — id до и после проведения тот же, поэтому разводим не роутом, а статусом: * так работает и ссылка из списка, и переход сразу после «Провести». */ -import { useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import { ChevronLeft, Loader2, Plus, RefreshCw } from "lucide-react"; +import { ChevronLeft, Loader2, Plus, RefreshCw, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { useQuery } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import PartsPickerModal from "@/components/repairs/PartsPickerModal"; +import QtyStepper from "@/components/repairs/QtyStepper"; import { fmtMoney } from "@/lib/repairsFeed"; import { addFind, + apiErrorText, applyStocktake, countLine, deleteStocktake, + deleteStocktakeLine, getStocktake, + patchFind, type StocktakeDetail, type StocktakeLine, } from "@/api/warehouse"; @@ -44,6 +62,11 @@ export default function StocktakeSessionPage() { const [onlyPending, setOnlyPending] = useState(true); const [busy, setBusy] = useState(false); const [pickerOpen, setPickerOpen] = useState(false); + // Выбранная в пикере деталь ждёт количество: сколько нашли, спрашиваем ДО + // отправки — иначе коробка из восьми уходит в учёт одной штукой. + const [findDraft, setFindDraft] = useState(null); + // Строка, в поле которой сейчас курсор: её фильтр не трогает (см. шапку). + const [focusedId, setFocusedId] = useState(null); const query = useQuery({ queryKey: ["wh-stocktake", stId], @@ -56,13 +79,28 @@ export default function StocktakeSessionPage() { const st = data ?? query.data ?? null; const isDraft = st?.status === "draft"; - const visible = useMemo(() => { - const lines = st?.lines ?? []; - return onlyPending ? lines.filter((l) => l.qty_counted == null) : lines; - }, [st, onlyPending]); + // Обход и находки — разные списки (см. шапку файла). В черновике находка + // опознаётся по batch_id = null; после проведения признак затирается, но + // проведённая сессия сюда не доходит. + const { walk, finds } = useMemo(() => { + const walk: StocktakeLine[] = []; + const finds: StocktakeLine[] = []; + for (const l of st?.lines ?? []) (l.batch_id == null ? finds : walk).push(l); + return { walk, finds }; + }, [st]); - const counted = (st?.lines ?? []).filter((l) => l.qty_counted != null).length; - const totalLines = st?.lines.length ?? 0; + // Посчитанная строка уходит из списка — но не та, в которой сейчас курсор: + // её значение могло уйти на сервер из-под пальца (погас экран), и исчезни + // она сейчас — механик дописывал бы вторую цифру в никуда. + const visible = useMemo( + () => (onlyPending ? walk.filter((l) => l.qty_counted == null || l.id === focusedId) : walk), + [walk, onlyPending, focusedId], + ); + + // Прогресс — про обход: находки в него не входят, иначе доля обойдённого + // растёт от того, что человек нашёл лишнего (так же считает экран итогов). + const counted = walk.filter((l) => l.qty_counted != null).length; + const totalLines = walk.length; const totals = useMemo(() => { let shortQty = 0, shortAmt = 0, surQty = 0, surAmt = 0; @@ -87,25 +125,59 @@ export default function StocktakeSessionPage() { return { ...base, lines: base.lines.map((l) => (l.id === updated.id ? updated : l)) }; }); - const setCount = async (line: StocktakeLine, value: number | null) => { + const dropLine = (lineId: number) => + setData((cur) => { + const base = cur ?? query.data ?? null; + if (!base) return cur; + return { ...base, lines: base.lines.filter((l) => l.id !== lineId) }; + }); + + // Сохранилось или нет — строка обязана знать: после отказа её поле должно + // вернуться к тому, что реально лежит в документе. + const setCount = async (line: StocktakeLine, value: number | null): Promise => { try { applyLine(await countLine(stId, line.id, { qty_counted: value })); + return true; } catch (e) { - toast.error((e as Error).message || "Не удалось сохранить"); + toast.error(await apiErrorText(e, "Не удалось сохранить")); + return false; } }; - const onFind = async (item: PartCatalogItem) => { - setPickerOpen(false); + // Количество находки правим её собственной ручкой: она пишет в аудит + // «было → стало» — исправление должно быть видно, а не выглядеть так, будто + // находку сразу такой и вносили. + const setFindQty = async (line: StocktakeLine, qty: number): Promise => { try { - const line = await addFind(stId, { part_id: item.id, qty: 1 }); + applyLine(await patchFind(stId, line.id, { qty })); + return true; + } catch (e) { + toast.error(await apiErrorText(e, "Не удалось сохранить количество")); + return false; + } + }; + + const removeFind = async (line: StocktakeLine) => { + if (!window.confirm(`Убрать находку «${line.part_name}» из пересчёта?`)) return; + try { + await deleteStocktakeLine(stId, line.id); + dropLine(line.id); + } catch (e) { + toast.error(await apiErrorText(e, "Не удалось убрать находку")); + } + }; + + const onFind = async (item: PartCatalogItem, qty: number) => { + setFindDraft(null); + try { + const line = await addFind(stId, { part_id: item.id, qty }); setData((cur) => { const base = cur ?? query.data ?? null; return base ? { ...base, lines: [...base.lines, line] } : cur; }); - toast.success(`Находка добавлена: ${item.name}`); + toast.success(`Находка добавлена: ${item.name}, ${qty} шт`); } catch (e) { - toast.error((e as Error).message || "Не удалось добавить находку"); + toast.error(await apiErrorText(e, "Не удалось добавить находку")); } }; @@ -121,7 +193,7 @@ export default function StocktakeSessionPage() { setData(await applyStocktake(stId)); toast.success("Инвентаризация проведена"); } catch (e) { - toast.error((e as Error).message || "Не удалось провести"); + toast.error(await apiErrorText(e, "Не удалось провести")); } finally { setBusy(false); } @@ -134,7 +206,7 @@ export default function StocktakeSessionPage() { await deleteStocktake(stId); navigate("/wh/stocktake", { replace: true }); } catch (e) { - toast.error((e as Error).message || "Не удалось удалить"); + toast.error(await apiErrorText(e, "Не удалось удалить")); setBusy(false); } }; @@ -160,7 +232,10 @@ export default function StocktakeSessionPage() {
- посчитано {counted} из {totalLines} + + посчитано {counted} из {totalLines} + {finds.length > 0 && ` · находок ${finds.length}`} + {isDraft && (
{isDraft && ( - )} @@ -255,28 +359,202 @@ export default function StocktakeSessionPage() { void onFind(item)} + onPick={(item) => { setPickerOpen(false); setFindDraft(item); }} onClose={() => setPickerOpen(false)} /> + + {findDraft && ( + setFindDraft(null)} + onConfirm={(qty) => void onFind(findDraft, qty)} + /> + )} ); } +/** + * Сколько нашли — спрашиваем до отправки, отдельным шагом после выбора детали. + * + * Раньше находка уходила жёсткой единицей: механик нашёл коробку из восьми, в + * учёт попадала одна, и поправить было нечем. Ввод берём тот же, что у деталей + * в ремонте (QtyStepper): −/+ нажимаются в перчатках, а пустое поле остаётся + * пустым, пока набирают, — иначе «8» не набрать поверх единицы. + */ +function FindQtyDialog({ + item, onCancel, onConfirm, +}: { + item: PartCatalogItem; + onCancel: () => void; + onConfirm: (qty: number) => void; +}) { + const [qty, setQty] = useState(1); + const value = qty === "" ? 1 : qty; + + return ( +
+
+
+
Нашли на полке
+
{item.name}
+
+ {item.article ? `арт. ${item.article}` : "без артикула"} + {item.brand ? ` · ${item.brand}` : ""} +
+
+
+ Сколько штук + +
+
+ + +
+
+
+ ); +} + +/** + * Отправка набранного количества: один запрос на весь ввод, и ни одного — + * пока в поле стоит курсор. + * + * Здесь была пауза в 700 мс, и она портила склад. Промежуточное значение + * уходило на сервер, строка переставала быть непосчитанной, фильтр «только + * непосчитанные» убирал её из списка прямо посреди набора: механик вводил + * «12», в документе оставалась «1» — и это уходило в списание недостачи. + * + * Поэтому hold: пока с полем работают, набранное только удерживается, а на + * сервер уходит по завершению ввода (sendNow/flush на уходе с поля). Терять + * удержанное нельзя — ровно от этого автосохранение и делалось, — поэтому на + * время удержания висят слушатели «страницу прячут»: гашение экрана и + * сворачивание приложения дают ровно эти события. Размонтирование строки + * (ушли с экрана) отправляет удержанное тем же flush. + * + * schedule (пауза) остался для правок, у которых нет момента «ушёл с поля», — + * это кнопки −/+ у находки на мобильном: они фокус не берут, ждать нечего. + * + * Слушатели вешаем только пока есть несохранённое значение: на пересчёте всего + * склада строк сотни, и постоянный слушатель у каждой — лишний хвост на каждом + * сворачивании. + */ +function useDeferredSave(commit: (value: T) => void | Promise, delayMs = 700) { + const commitRef = useRef(commit); + const pending = useRef<{ value: T } | null>(null); + const timer = useRef | null>(null); + const unlisten = useRef<(() => void) | null>(null); + + useEffect(() => { commitRef.current = commit; }); + + const stopTimer = useCallback(() => { + if (timer.current) { clearTimeout(timer.current); timer.current = null; } + }, []); + + const stopListen = useCallback(() => { + unlisten.current?.(); + unlisten.current = null; + }, []); + + const flush = useCallback(() => { + const p = pending.current; + pending.current = null; + stopTimer(); + stopListen(); + if (p) void commitRef.current(p.value); + }, [stopListen, stopTimer]); + + /** Запомнить набранное и ждать — с подстраховкой на случай «экран погас». */ + const hold = useCallback((value: T) => { + stopTimer(); + pending.current = { value }; + if (unlisten.current) return; + const onHidden = () => { if (document.visibilityState === "hidden") flush(); }; + document.addEventListener("visibilitychange", onHidden); + window.addEventListener("pagehide", flush); + unlisten.current = () => { + document.removeEventListener("visibilitychange", onHidden); + window.removeEventListener("pagehide", flush); + }; + }, [flush, stopTimer]); + + const schedule = useCallback((value: T) => { + hold(value); + timer.current = setTimeout(flush, delayMs); + }, [delayMs, flush, hold]); + + const sendNow = useCallback((value: T) => { + pending.current = { value }; + flush(); + }, [flush]); + + const cancel = useCallback(() => { + pending.current = null; + stopTimer(); + stopListen(); + }, [stopListen, stopTimer]); + + // Размонтирование (ушли с экрана, список перерисовался) — та же потеря. + useEffect(() => flush, [flush]); + + return { hold, schedule, sendNow, flush, cancel }; +} + +/** + * Что набрано в поле: число, null — «ещё не считали» (пустое поле), + * undefined — мусор, отправлять нечего. + */ +function parseQty(raw: string): number | null | undefined { + const s = raw.trim(); + if (s === "") return null; + const n = Math.floor(Number(s)); + return Number.isFinite(n) && n >= 0 ? n : undefined; +} + function LineRow({ - line, editable, onSet, + line, editable, onSet, onFocusChange, }: { line: StocktakeLine; editable: boolean; - onSet: (v: number | null) => void; + onSet: (v: number | null) => Promise; + onFocusChange: (focused: boolean) => void; }) { const [draft, setDraft] = useState(line.qty_counted?.toString() ?? ""); + // Сервер отказал — в документе осталось прежнее, и поле обязано показывать + // его же: иначе на экране висит количество, которого в базе нет, и строка + // выглядит обойдённой. + const save = useDeferredSave(async (v) => { + if (!(await onSet(v))) setDraft(line.qty_counted?.toString() ?? ""); + }); const tone = line.resolution === "shortage" ? "border-destructive/40 bg-destructive/5" - : line.resolution === "surplus" ? "border-emerald-500/40 bg-emerald-500/5" + : line.resolution === "surplus" ? "border-border bg-accent/40" : line.resolution === "match" ? "border-border bg-muted/30" : "border-border bg-card"; + const onType = (raw: string) => { + setDraft(raw); + const v = parseQty(raw); + // Пустое поле посреди набора — ещё не решение «не считали»: стирание + // отправляем только когда человек ушёл с поля. + if (v == null || v === line.qty_counted) save.cancel(); + else save.hold(v); + }; + + const onLeave = () => { + onFocusChange(false); + const v = parseQty(draft); + if (v === undefined || v === line.qty_counted) save.cancel(); + else save.sendNow(v); + }; + + const tap = (v: number) => { setDraft(String(v)); save.sendNow(v); }; + return (
  • @@ -284,14 +562,14 @@ function LineRow({
    {line.part_name}
    {line.article ? `арт. ${line.article} · ` : ""} - {line.batch_id == null ? "находка" : `числится ${line.qty_expected}`} + числится {line.qty_expected} {line.place_code ? ` · ${line.place_code}` : ""}
    {line.delta !== 0 && ( {line.delta > 0 ? `+${line.delta}` : line.delta} @@ -307,27 +585,15 @@ function LineRow({ min={0} placeholder="сколько нашли" value={draft} - onChange={(e) => setDraft(e.target.value)} - onBlur={() => { - const v = draft.trim() === "" ? null : Math.max(0, Number(draft)); - if (v !== line.qty_counted) onSet(v); - }} + onChange={(e) => onType(e.target.value)} + onFocus={() => onFocusChange(true)} + onBlur={onLeave} className="flex-1" /> - {line.batch_id != null && ( - - )} - + @@ -335,3 +601,78 @@ function LineRow({
  • ); } + +/** + * Находка в черновике: количество правится, ошибочную строку убирают. + * + * Кнопок «Сходится»/«Нет» здесь нет намеренно. Сходиться находке не с чем — в + * учёте её не числилось, — а «Нет» раньше служил удалением и ставил ноль: + * строка навсегда оставалась в документе сошедшейся пустышкой «числилось 0 — + * столько и нашли» и завышала счётчик совпавших. Ошибочная находка теперь + * удаляется по-настоящему. + */ +function FindRow({ + line, onQty, onRemove, +}: { + line: StocktakeLine; + onQty: (qty: number) => Promise; + onRemove: () => void; +}) { + const [qty, setQty] = useState(line.qty_counted ?? 1); + // Сервер отказал (например, находку уже провели) — поле возвращается к тому, + // что реально лежит в документе, а не остаётся с непринятым числом. + const save = useDeferredSave(async (next) => { + if (!(await onQty(next))) setQty(line.qty_counted ?? 1); + }); + // Курсор внутри строки: пока правят, число только удерживается. Сервер пишет + // каждую правку в аудит «было → стало», и промежуточная «1» из «12» осталась + // бы там навсегда. + const editing = useRef(false); + + const onChange = (next: number | "") => { + setQty(next); + // "" — промежуточное состояние ввода (см. QtyStepper), отправлять нечего; + // QtyStepper сам вернёт минимум, когда человек уйдёт с поля. + if (next === "" || next < 1 || next === line.qty_counted) save.cancel(); + else if (editing.current) save.hold(next); + // Кнопки −/+ на мобильном фокуса не берут: уходить неоткуда, шлём по паузе. + else save.schedule(next); + }; + + return ( +
  • +
    +
    +
    {line.part_name}
    +
    + {line.article ? `арт. ${line.article} · ` : ""} + находка + {line.place_code ? ` · ${line.place_code}` : ""} +
    +
    + + +{line.qty_counted ?? 0} + +
    + +
    + {/* Границы правки — сам счётчик: переход поле ↔ −/+ её не завершает, + а уход из счётчика (в том числе табом на «Убрать») отправляет. */} +
    { editing.current = true; }} + onBlur={(e) => { + if (e.relatedTarget instanceof Node && e.currentTarget.contains(e.relatedTarget)) return; + editing.current = false; + save.flush(); + }} + > + +
    + +
    +
  • + ); +}