From 26c02617840b11f8358b71fa53f4057fb834a76a Mon Sep 17 00:00:00 2001 From: vladtechno Date: Thu, 30 Jul 2026 15:23:06 +1000 Subject: [PATCH] =?UTF-8?q?wh:=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D1=8B=20=C2=AB=D0=9F=D1=80=D0=B8=D1=91=D0=BC=D0=BA=D0=B0=C2=BB?= =?UTF-8?q?=20=D0=B8=20=C2=AB=D0=98=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=C2=BB=20=D0=B2=20PWA?= =?UTF-8?q?=20(=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=B0=D0=BC,=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B1=D0=B8=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20=D0=B2=D1=91=D1=80?= =?UTF-8?q?=D1=81=D1=82=D0=BA=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Приёмка. Главное действие — снять накладную/чек камерой; несколько листов уходят одним документом. Кадр ужимается до 2200 px на клиенте: сервер всё равно приводит к этому размеру, а на мобильном интернете в гараже разница в секундах загрузки заметная. Распознанное проверяется рядом с фото документа — строки правятся на месте, поставщик выбирается или заводится прямо с накладной. «Провести» приходует товар в выбранную зону. Инвентаризация. Запуск спрашивает зону: весь склад одной сессией — это сотни строк, которые никто не обойдёт за раз. На обходе по умолчанию видны только непосчитанные, посчитанная сразу уходит из списка. Пустое поле — «ещё не считали», ноль вводится кнопкой «Нет» (это уже недостача). Находки подбираются из складского каталога общим пикером. Внизу постоянно висят недостача и излишек в штуках и рублях. Доступ: плитки в меню и маршруты закрыты RequireAdmin (тот же гейт на сервере — клиентская проверка нужна, чтобы прямая ссылка не открывала экран с ошибкой вместо «вам сюда нельзя»). Co-Authored-By: Claude Opus 4.8 --- mechanic-pwa/frontend/src/App.tsx | 25 ++ mechanic-pwa/frontend/src/api/warehouse.ts | 250 ++++++++++++ .../frontend/src/lib/imageDownscale.ts | 43 ++ .../frontend/src/pages/ActionMenu.tsx | 20 + .../pages/warehouse/ReceivingDraftPage.tsx | 375 ++++++++++++++++++ .../src/pages/warehouse/ReceivingPage.tsx | 189 +++++++++ .../src/pages/warehouse/StocktakePage.tsx | 196 +++++++++ .../pages/warehouse/StocktakeSessionPage.tsx | 309 +++++++++++++++ 8 files changed, 1407 insertions(+) create mode 100644 mechanic-pwa/frontend/src/api/warehouse.ts create mode 100644 mechanic-pwa/frontend/src/lib/imageDownscale.ts create mode 100644 mechanic-pwa/frontend/src/pages/warehouse/ReceivingDraftPage.tsx create mode 100644 mechanic-pwa/frontend/src/pages/warehouse/ReceivingPage.tsx create mode 100644 mechanic-pwa/frontend/src/pages/warehouse/StocktakePage.tsx create mode 100644 mechanic-pwa/frontend/src/pages/warehouse/StocktakeSessionPage.tsx diff --git a/mechanic-pwa/frontend/src/App.tsx b/mechanic-pwa/frontend/src/App.tsx index 9ab210c..a930a43 100644 --- a/mechanic-pwa/frontend/src/App.tsx +++ b/mechanic-pwa/frontend/src/App.tsx @@ -1,5 +1,7 @@ import { Routes, Route, Navigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { useAuth } from "@/store/auth"; +import { getMe } from "@/api/me"; import { useMeSync } from "@/hooks/useMeSync"; import Login from "@/pages/Login"; import ActionMenu from "@/pages/ActionMenu"; @@ -13,6 +15,10 @@ import RepairDetailPage from "@/pages/repairs/RepairDetailPage"; import EditRepairPage from "@/pages/repairs/EditRepairPage"; import CarSelectPage from "@/pages/repairs/CarSelectPage"; import CreateRepairPage from "@/pages/repairs/CreateRepairPage"; +import ReceivingPage from "@/pages/warehouse/ReceivingPage"; +import ReceivingDraftPage from "@/pages/warehouse/ReceivingDraftPage"; +import StocktakePage from "@/pages/warehouse/StocktakePage"; +import StocktakeSessionPage from "@/pages/warehouse/StocktakeSessionPage"; function RequireAuth({ children }: { children: React.ReactNode }) { const token = useAuth((s) => s.token); @@ -20,6 +26,20 @@ function RequireAuth({ children }: { children: React.ReactNode }) { return <>{children}; } +/** + * Складские разделы пока только админам. Сервер и так отдаёт 403, но без + * клиентской проверки прямой ссылкой можно было бы открыть пустой экран с + * ошибкой — это читается как поломка, а не как «вам сюда нельзя». + */ +function RequireAdmin({ children }: { children: React.ReactNode }) { + const token = useAuth((s) => s.token); + const meQuery = useQuery({ queryKey: ["me"], queryFn: getMe, staleTime: 60_000, enabled: !!token }); + if (!token) return ; + if (meQuery.isLoading) return null; + if (!meQuery.data?.is_admin) return ; + return <>{children}; +} + export default function App() { // У35: фоновая сверка прав механика раз в 5 минут — выкидывает на /login // с сообщением «Доступ отозван», если админ снял can_login_pwa или вывел @@ -42,6 +62,11 @@ export default function App() { } /> } /> } /> + {/* Склад — приёмка и инвентаризация с телефона (пока только админы). */} + } /> + } /> + } /> + } /> } /> ); diff --git a/mechanic-pwa/frontend/src/api/warehouse.ts b/mechanic-pwa/frontend/src/api/warehouse.ts new file mode 100644 index 0000000..4ab3a25 --- /dev/null +++ b/mechanic-pwa/frontend/src/api/warehouse.ts @@ -0,0 +1,250 @@ +/** + * Склад в PWA — приёмка и инвентаризация (эндпоинты /v1/mechanic/wh/*). + * + * Разделы админские: сервер отдаёт 403 всем остальным, поэтому клиент + * дополнительно прячет плитки по me.is_admin — чтобы механик не упирался в + * ошибку там, куда ему не надо. + */ +import { api } from "./client"; + +// ── Справочники ──────────────────────────────────────────────────────────── + +export interface WhRef { + id: number; + code: string; + name: string; +} + +export async function getPlaces(): Promise { + return api.get("wh/places").json(); +} + +export async function getSuppliers(): Promise { + return api.get("wh/suppliers").json(); +} + +// ── Приёмка ──────────────────────────────────────────────────────────────── + +export interface DraftLine { + id: number; + brand: string | null; + article: string | null; + name: string; + qty: number; + unit_price: number | string; + matched_part_id: number | null; + matched_part_name: string | null; + thumb_url: string | null; +} + +export interface Draft { + id: number; + source: string; + ext_order_id: string; + supplier_id: number | null; + supplier_name: string | null; + legal_entity: string | null; + order_date: string | null; + total_price: number | string | null; + status: string; + receipt_id: number | null; + created_at: string; + photo_urls: string[]; + lines: DraftLine[]; +} + +export interface DraftListResponse { + items: Draft[]; + total: number; +} + +/** Фото накладной/чека → распознавание → черновик (приход НЕ создаётся). */ +export async function scanInvoice(files: Blob[]): Promise { + const fd = new FormData(); + files.forEach((f, i) => fd.append("files", f, `page${i + 1}.jpg`)); + // Распознавание идёт на vision-модели и занимает десятки секунд — + // клиентский таймаут по умолчанию (30 с) её не дожидается. + return api.post("wh/receiving/scan", { body: fd, timeout: 240_000 }).json(); +} + +export async function getDrafts(status = "pending"): Promise { + return api.get(`wh/receiving/drafts?status=${status}`).json(); +} + +export async function getDraft(id: number): Promise { + return api.get(`wh/receiving/drafts/${id}`).json(); +} + +export interface DraftPatch { + supplier_id?: number | null; + order_date?: string | null; + doc_number?: string | null; + comment?: string | null; +} + +export async function patchDraft(id: number, body: DraftPatch): Promise { + return api.patch(`wh/receiving/drafts/${id}`, { json: body }).json(); +} + +export async function createDraftSupplier( + id: number, + body: { name: string; legal_entity?: string | null }, +): Promise { + return api.post(`wh/receiving/drafts/${id}/supplier`, { json: body }).json(); +} + +export interface DraftLinePatch { + article?: string | null; + name?: string | null; + qty?: number; + unit_price?: number; + matched_part_id?: number | null; +} + +export async function addDraftLine(id: number, body: DraftLinePatch): Promise { + return api.post(`wh/receiving/drafts/${id}/lines`, { json: body }).json(); +} + +export async function patchDraftLine( + id: number, + lineId: number, + body: DraftLinePatch, +): Promise { + return api.patch(`wh/receiving/drafts/${id}/lines/${lineId}`, { json: body }).json(); +} + +export async function deleteDraftLine(id: number, lineId: number): Promise { + return api.delete(`wh/receiving/drafts/${id}/lines/${lineId}`).json(); +} + +export interface PostDraftResult { + ok: boolean; + receipt_id: number; + lines: number; +} + +export async function postDraft( + id: number, + body: { place_id: number | null; doc_date?: string | null; exclude_line_ids?: number[] }, +): Promise { + return api.post(`wh/receiving/drafts/${id}/post`, { json: body }).json(); +} + +export async function rejectDraft(id: number): Promise<{ ok: boolean }> { + return api.post(`wh/receiving/drafts/${id}/reject`).json<{ ok: boolean }>(); +} + +export interface ReceiptPreviewLine { + part_id: number; + name: string; + article: string | null; + qty: number; + thumb_url: string | null; +} + +export interface Receipt { + id: number; + supplier_name: string | null; + doc_number: string | null; + doc_date: string; + total_amount: number | string; + lines_count: number; + posted_by_name: string | null; + preview_lines: ReceiptPreviewLine[]; +} + +export interface ReceiptListResponse { + items: Receipt[]; + total: number; + total_sum: number | string; + month_total: number | string; + month_label: string; +} + +export async function getReceipts(limit = 30): Promise { + return api.get(`wh/receipts?limit=${limit}`).json(); +} + +// ── Инвентаризация ───────────────────────────────────────────────────────── + +export interface Stocktake { + id: number; + status: string; // draft | done + place_id: number | null; + place_code: string | null; + author_name: string | null; + started_at: string; + finished_at: string | null; + comment: string | null; + lines_total: number; + lines_counted: number; + shortage_qty: number; + shortage_amount: number | string; + surplus_qty: number; + surplus_amount: number | string; +} + +export interface StocktakeLine { + id: number; + batch_id: number | null; // null = находка + part_id: number; + part_name: string; + article: string | null; + place_code: string | null; + qty_expected: number; + qty_counted: number | null; + unit_price: number | string | null; + resolution: "not_counted" | "match" | "shortage" | "surplus"; + delta: number; + delta_amount: number | string; + comment: string | null; +} + +export interface StocktakeDetail extends Stocktake { + lines: StocktakeLine[]; +} + +export interface StocktakeListResponse { + items: Stocktake[]; + total: number; +} + +export async function getStocktakes(): Promise { + return api.get("wh/stocktakes").json(); +} + +export async function getStocktake(id: number): Promise { + return api.get(`wh/stocktakes/${id}`).json(); +} + +export async function createStocktake(body: { + place_id: number | null; + comment?: string | null; +}): Promise { + return api.post("wh/stocktakes", { json: body }).json(); +} + +export async function countLine( + stocktakeId: number, + lineId: number, + body: { qty_counted: number | null; comment?: string | null }, +): Promise { + return api + .patch(`wh/stocktakes/${stocktakeId}/lines/${lineId}`, { json: body }) + .json(); +} + +export async function addFind( + stocktakeId: number, + body: { part_id: number; qty: number; place_id?: number | null; comment?: string | null }, +): Promise { + return api.post(`wh/stocktakes/${stocktakeId}/finds`, { json: body }).json(); +} + +export async function applyStocktake(id: number): Promise { + return api.post(`wh/stocktakes/${id}/apply`).json(); +} + +export async function deleteStocktake(id: number): Promise<{ ok: boolean }> { + return api.delete(`wh/stocktakes/${id}`).json<{ ok: boolean }>(); +} diff --git a/mechanic-pwa/frontend/src/lib/imageDownscale.ts b/mechanic-pwa/frontend/src/lib/imageDownscale.ts new file mode 100644 index 0000000..92afcb2 --- /dev/null +++ b/mechanic-pwa/frontend/src/lib/imageDownscale.ts @@ -0,0 +1,43 @@ +/** + * Ужать снимок перед отправкой на распознавание. + * + * Телефон отдаёт кадр на 4000+ px и 3–5 МБ, а сервер всё равно приводит + * накладную к 2200 px по длинной стороне — значит всё, что выше, это чистые + * секунды загрузки на мобильном интернете в гараже. Ужимаем на клиенте до + * того же предела: качество распознавания не меняется, отправка ускоряется + * в разы. + * + * 2200 — именно то, на чём работает vision-модель (см. invoice_ocr.py). + * Мельче нельзя: текст накладной и так на грани читаемости. + */ +const MAX_EDGE = 2200; +const QUALITY = 0.9; + +export async function downscaleImage(file: File, maxEdge = MAX_EDGE): Promise { + // Если что-то в цепочке не поддержано (старый WebView) — шлём оригинал: + // медленно, но рабочее лучше, чем ошибка. + try { + const bitmap = await createImageBitmap(file); + const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height)); + if (scale >= 1) { + bitmap.close(); + return file; + } + const canvas = document.createElement("canvas"); + canvas.width = Math.round(bitmap.width * scale); + canvas.height = Math.round(bitmap.height * scale); + const ctx = canvas.getContext("2d"); + if (!ctx) { + bitmap.close(); + return file; + } + ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height); + bitmap.close(); + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, "image/jpeg", QUALITY), + ); + return blob ?? file; + } catch { + return file; + } +} diff --git a/mechanic-pwa/frontend/src/pages/ActionMenu.tsx b/mechanic-pwa/frontend/src/pages/ActionMenu.tsx index b3d43c6..da2db62 100644 --- a/mechanic-pwa/frontend/src/pages/ActionMenu.tsx +++ b/mechanic-pwa/frontend/src/pages/ActionMenu.tsx @@ -34,6 +34,26 @@ export default function ActionMenu() { emoji: "🔧", onClick: () => navigate("/repairs"), }, + // Складские разделы правят остатки и деньги — пока только админам + // (тот же гейт стоит на сервере, здесь просто не показываем лишнего). + ...(meQuery.data?.is_admin + ? [ + { + key: "receiving", + title: "Приёмка", + description: "Снять накладную камерой и оприходовать товар", + emoji: "📦", + onClick: () => navigate("/wh/receiving"), + }, + { + key: "stocktake", + title: "Инвентаризация", + description: "Пересчитать зону склада и провести расхождения", + emoji: "📋", + onClick: () => navigate("/wh/stocktake"), + }, + ] + : []), ]; return ( diff --git a/mechanic-pwa/frontend/src/pages/warehouse/ReceivingDraftPage.tsx b/mechanic-pwa/frontend/src/pages/warehouse/ReceivingDraftPage.tsx new file mode 100644 index 0000000..27b2fd0 --- /dev/null +++ b/mechanic-pwa/frontend/src/pages/warehouse/ReceivingDraftPage.tsx @@ -0,0 +1,375 @@ +/** + * Проверка распознанной накладной перед проведением. + * + * Распознавание почти никогда не идеально: путается артикул, съезжает цена, + * поставщик может быть новым. Поэтому экран построен как сверка — фото + * документа сверху всегда под рукой, а каждая строка правится на месте. + * + * Приход создаётся только по кнопке «Провести»: до неё черновик ничего не + * меняет на складе, и его можно спокойно бросить. + */ +import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronLeft, Loader2, Plus, Trash2, X } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { fmtMoney } from "@/lib/repairsFeed"; +import { + addDraftLine, + createDraftSupplier, + deleteDraftLine, + getDraft, + getPlaces, + getSuppliers, + patchDraft, + patchDraftLine, + postDraft, + rejectDraft, + type Draft, +} from "@/api/warehouse"; + +function lineSum(qty: number, price: number | string): number { + return qty * Number(price || 0); +} + +export default function ReceivingDraftPage() { + const { id } = useParams<{ id: string }>(); + const draftId = Number(id); + const navigate = useNavigate(); + + const [draft, setDraft] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [placeId, setPlaceId] = useState(null); + const [lightbox, setLightbox] = useState(null); + + const placesQuery = useQuery({ queryKey: ["wh-places"], queryFn: getPlaces, staleTime: 300_000 }); + const suppliersQuery = useQuery({ + queryKey: ["wh-suppliers"], + queryFn: getSuppliers, + staleTime: 300_000, + }); + + useEffect(() => { + if (!draftId) return; + setLoading(true); + getDraft(draftId) + .then(setDraft) + .catch((e: Error) => toast.error(e.message || "Не удалось загрузить черновик")) + .finally(() => setLoading(false)); + }, [draftId]); + + // Обёртка над правками: сервер всегда возвращает черновик целиком, поэтому + // локально ничего не пересчитываем — берём его ответ как истину. + const mutate = async (fn: () => Promise) => { + setBusy(true); + try { + setDraft(await fn()); + } catch (e) { + toast.error((e as Error).message || "Не удалось сохранить"); + } finally { + setBusy(false); + } + }; + + const total = (draft?.lines ?? []).reduce((s, l) => s + lineSum(l.qty, l.unit_price), 0); + const unmatched = (draft?.lines ?? []).filter((l) => !l.matched_part_id).length; + + const onPost = async () => { + if (!draft) return; + if (!draft.supplier_id) { + toast.error("Укажите поставщика — без него приход не провести"); + return; + } + if (draft.lines.length === 0) { + toast.error("В накладной нет позиций"); + return; + } + setBusy(true); + try { + const r = await postDraft(draft.id, { place_id: placeId, doc_date: draft.order_date }); + toast.success(`Приход проведён · позиций ${r.lines}`); + navigate("/wh/receiving", { replace: true }); + } catch (e) { + toast.error((e as Error).message || "Не удалось провести приход"); + setBusy(false); + } + }; + + const onReject = async () => { + if (!draft) return; + if (!window.confirm("Отклонить накладную? Остатки не изменятся, черновик уйдёт из списка.")) return; + setBusy(true); + try { + await rejectDraft(draft.id); + toast.success("Черновик отклонён"); + navigate("/wh/receiving", { replace: true }); + } catch (e) { + toast.error((e as Error).message || "Не удалось отклонить"); + setBusy(false); + } + }; + + return ( +
+
+
+ +

Проверка накладной

+ +
+
+ +
+ {loading &&
} + + {draft && ( + <> + {/* Фото документа — сверять глазами по ходу правки */} + {draft.photo_urls.length > 0 && ( +
+ {draft.photo_urls.map((u) => ( + + ))} +
+ )} + + {/* Шапка документа */} +
+
+ + {draft.supplier_id ? ( + + ) : ( +
+
+ Не определён{draft.legal_entity ? ` — в документе «${draft.legal_entity}»` : ""} +
+
+ + {draft.legal_entity && ( + + )} +
+
+ )} +
+ +
+
+ + e.target.value !== draft.ext_order_id && + void mutate(() => patchDraft(draft.id, { doc_number: e.target.value }))} + /> +
+
+ + void mutate(() => + patchDraft(draft.id, { order_date: e.target.value || null }))} + /> +
+
+ +
+ + +
+
+ + {/* Позиции */} +
+
+

Позиции · {draft.lines.length}

+ {unmatched > 0 && ( + + новых карточек: {unmatched} + + )} +
+ + {draft.lines.map((ln) => ( +
+
+ {ln.thumb_url && ( + + )} + e.target.value !== ln.name && + void mutate(() => patchDraftLine(draft.id, ln.id, { name: e.target.value }))} + /> + +
+
+
+ + (e.target.value || null) !== ln.article && + void mutate(() => patchDraftLine(draft.id, ln.id, { article: e.target.value || null }))} + /> +
+
+ + Number(e.target.value) !== ln.qty && + void mutate(() => patchDraftLine(draft.id, ln.id, { + qty: Math.max(1, Number(e.target.value) || 1), + }))} + /> +
+
+ + Number(e.target.value) !== Number(ln.unit_price) && + void mutate(() => patchDraftLine(draft.id, ln.id, { + unit_price: Number(e.target.value) || 0, + }))} + /> +
+
+
+ + {ln.matched_part_id + ? `на складе: ${ln.matched_part_name ?? "карточка найдена"}` + : "новая карточка — заведётся при проведении"} + + + {fmtMoney(lineSum(ln.qty, ln.unit_price))} ₽ + +
+
+ ))} + + +
+ + + + )} +
+ + {/* Итого + проведение */} + {draft && ( +
+
+
+
Итого по накладной
+
{fmtMoney(total)} ₽
+
+ +
+
+ )} + + {lightbox && ( +
setLightbox(null)}> +
+ +
+
+ Накладная +
+
+ )} +
+ ); +} diff --git a/mechanic-pwa/frontend/src/pages/warehouse/ReceivingPage.tsx b/mechanic-pwa/frontend/src/pages/warehouse/ReceivingPage.tsx new file mode 100644 index 0000000..141541a --- /dev/null +++ b/mechanic-pwa/frontend/src/pages/warehouse/ReceivingPage.tsx @@ -0,0 +1,189 @@ +/** + * Приёмка товара — точка входа раздела. + * + * Главное действие вынесено наверх и занимает пол-экрана: снять накладную + * камерой. Всё остальное (черновики на проверке, последние приходы) — списки + * под ним. Приёмка делается стоя у коробок с телефоном в одной руке, поэтому + * кнопки крупные, а информации на строку минимум. + */ +import { useCallback, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { Camera, ChevronLeft, ChevronRight, FileText, Loader2, PackageCheck } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { downscaleImage } from "@/lib/imageDownscale"; +import { fmtMoney } from "@/lib/repairsFeed"; +import { getDrafts, getReceipts, scanInvoice } from "@/api/warehouse"; + +function fmtDate(iso: string | null): string { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return new Intl.DateTimeFormat("ru-RU", { day: "2-digit", month: "long" }).format(d); +} + +export default function ReceivingPage() { + const navigate = useNavigate(); + const [scanning, setScanning] = useState(false); + const fileRef = useRef(null); + + const draftsQuery = useQuery({ + queryKey: ["wh-drafts"], + queryFn: () => getDrafts("pending"), + staleTime: 15_000, + }); + const receiptsQuery = useQuery({ + queryKey: ["wh-receipts"], + queryFn: () => getReceipts(20), + staleTime: 60_000, + }); + + const onFiles = useCallback( + async (files: FileList | null) => { + if (!files || files.length === 0) return; + setScanning(true); + try { + // Накладная бывает на 2-3 листа — снимаем всё разом, распознаём как + // один документ (модель видит все страницы в одном запросе). + const prepared = await Promise.all( + Array.from(files).slice(0, 6).map((f) => downscaleImage(f)), + ); + const draft = await scanInvoice(prepared); + toast.success(`Распознано позиций: ${draft.lines.length}`); + navigate(`/wh/receiving/${draft.id}`); + } catch (e) { + toast.error((e as Error).message || "Не удалось распознать накладную"); + } finally { + setScanning(false); + if (fileRef.current) fileRef.current.value = ""; + } + }, + [navigate], + ); + + const drafts = draftsQuery.data?.items ?? []; + const receipts = receiptsQuery.data?.items ?? []; + + return ( +
+
+
+ +

Приёмка

+ +
+
+ +
+ {/* Главное действие */} + + + {/* Черновики на проверке */} +
+

+ На проверке{drafts.length > 0 ? ` · ${drafts.length}` : ""} +

+ {draftsQuery.isLoading ? ( +
+ ) : drafts.length === 0 ? ( +
+ Нечего проверять — все накладные проведены. +
+ ) : ( +
    + {drafts.map((d) => ( +
  • + +
  • + ))} +
+ )} +
+ + {/* Последние приходы */} +
+

Последние приходы

+ {receiptsQuery.isLoading ? ( +
+ ) : receipts.length === 0 ? ( +
Приходов пока нет.
+ ) : ( +
    + {receipts.map((r) => ( +
  • + +
    +
    {r.supplier_name || "—"}
    +
    + {r.doc_number ? `${r.doc_number} · ` : ""} + {fmtDate(r.doc_date)} · позиций {r.lines_count} + {r.posted_by_name ? ` · ${r.posted_by_name}` : ""} +
    +
    +
    + {fmtMoney(Number(r.total_amount))} ₽ +
    +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/mechanic-pwa/frontend/src/pages/warehouse/StocktakePage.tsx b/mechanic-pwa/frontend/src/pages/warehouse/StocktakePage.tsx new file mode 100644 index 0000000..5189d02 --- /dev/null +++ b/mechanic-pwa/frontend/src/pages/warehouse/StocktakePage.tsx @@ -0,0 +1,196 @@ +/** + * Инвентаризация — список сессий и запуск новой. + * + * Пересчёт делают по зонам (стеллаж за стеллажом), поэтому запуск сразу + * спрашивает зону: «весь склад одной сессией» — это несколько сотен строк, + * которые никто не обойдёт за раз. + */ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronLeft, ChevronRight, ClipboardList, Plus } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { fmtMoney } from "@/lib/repairsFeed"; +import { createStocktake, getPlaces, getStocktakes, type Stocktake } from "@/api/warehouse"; + +function fmtDateTime(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return new Intl.DateTimeFormat("ru-RU", { + day: "2-digit", month: "long", hour: "2-digit", minute: "2-digit", + }).format(d); +} + +export default function StocktakePage() { + const navigate = useNavigate(); + const [creating, setCreating] = useState(false); + const [newOpen, setNewOpen] = useState(false); + const [placeId, setPlaceId] = useState(null); + + const listQuery = useQuery({ + queryKey: ["wh-stocktakes"], + queryFn: getStocktakes, + staleTime: 15_000, + }); + const placesQuery = useQuery({ queryKey: ["wh-places"], queryFn: getPlaces, staleTime: 300_000 }); + + const start = async () => { + setCreating(true); + try { + const st = await createStocktake({ place_id: placeId }); + if (st.lines_total === 0) { + toast.message("В этой зоне нечего считать — сессия пустая"); + } + navigate(`/wh/stocktake/${st.id}`); + } catch (e) { + toast.error((e as Error).message || "Не удалось начать инвентаризацию"); + setCreating(false); + } + }; + + const items = listQuery.data?.items ?? []; + + return ( +
+
+
+ +

Инвентаризация

+ +
+
+ +
+ {listQuery.isLoading &&
} + + {!listQuery.isLoading && items.length === 0 && ( +
+
+ +
+

Пересчётов ещё не было

+

+ Начните с одной зоны — приложение соберёт список того, что там числится. +

+ +
+ )} + +
    + {items.map((st) => ( +
  • + +
  • + ))} +
+
+ + {/* Лист выбора зоны */} + {newOpen && ( +
setNewOpen(false)}> +
e.stopPropagation()} + > +

Что пересчитываем

+
+ + +

+ Список соберётся из того, что числится в зоне. Найденное сверх списка + добавляется прямо во время обхода. +

+
+
+ + +
+
+
+ )} +
+ ); +} + +function StatusChip({ status }: { status: string }) { + const done = status !== "draft"; + return ( + + {done ? "проведена" : "идёт"} + + ); +} + +function Progress({ st }: { st: Stocktake }) { + const pct = st.lines_total > 0 ? Math.round((st.lines_counted / st.lines_total) * 100) : 0; + return ( +
+
+
+
+
+ + посчитано {st.lines_counted} из {st.lines_total} + + + {st.shortage_qty > 0 && ( + + −{st.shortage_qty} ({fmtMoney(Number(st.shortage_amount))} ₽) + + )} + {st.surplus_qty > 0 && ( + + +{st.surplus_qty} ({fmtMoney(Number(st.surplus_amount))} ₽) + + )} + +
+
+ ); +} diff --git a/mechanic-pwa/frontend/src/pages/warehouse/StocktakeSessionPage.tsx b/mechanic-pwa/frontend/src/pages/warehouse/StocktakeSessionPage.tsx new file mode 100644 index 0000000..0d87f01 --- /dev/null +++ b/mechanic-pwa/frontend/src/pages/warehouse/StocktakeSessionPage.tsx @@ -0,0 +1,309 @@ +/** + * Обход зоны: ввод факта по строкам + находки. + * + * Экран рассчитан на «иду вдоль стеллажа с телефоном»: по умолчанию показаны + * только непосчитанные строки, посчитанная сразу уходит из списка — видно, + * сколько ещё осталось обойти. Переключатель возвращает весь список, если + * надо что-то перепроверить. + * + * Пустое поле — это «ещё не считали», а не ноль. Ноль означает «дошёл и не + * нашёл» и превратится в недостачу, поэтому он вводится явной кнопкой. + */ +import { useMemo, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { ChevronLeft, Loader2, Plus } 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 { fmtMoney } from "@/lib/repairsFeed"; +import { + addFind, + applyStocktake, + countLine, + deleteStocktake, + getStocktake, + type StocktakeDetail, + type StocktakeLine, +} from "@/api/warehouse"; +import type { PartCatalogItem } from "@/api/repairsCreate"; + +export default function StocktakeSessionPage() { + const { id } = useParams<{ id: string }>(); + const stId = Number(id); + const navigate = useNavigate(); + + const [data, setData] = useState(null); + const [onlyPending, setOnlyPending] = useState(true); + const [busy, setBusy] = useState(false); + const [pickerOpen, setPickerOpen] = useState(false); + + const query = useQuery({ + queryKey: ["wh-stocktake", stId], + queryFn: () => getStocktake(stId), + staleTime: 5_000, + }); + + // Локальная копия — чтобы строка исчезала из списка сразу после ввода, не + // дожидаясь перезапроса всей сессии (их бывает несколько сотен). + 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]); + + const counted = (st?.lines ?? []).filter((l) => l.qty_counted != null).length; + const totalLines = st?.lines.length ?? 0; + + const totals = useMemo(() => { + let shortQty = 0, shortAmt = 0, surQty = 0, surAmt = 0; + for (const l of st?.lines ?? []) { + if (l.delta < 0) { shortQty += -l.delta; shortAmt += -Number(l.delta_amount || 0); } + if (l.delta > 0) { surQty += l.delta; surAmt += Number(l.delta_amount || 0); } + } + return { shortQty, shortAmt, surQty, surAmt }; + }, [st]); + + const applyLine = (updated: StocktakeLine) => + setData((cur) => { + const base = cur ?? query.data ?? null; + if (!base) return cur; + return { ...base, lines: base.lines.map((l) => (l.id === updated.id ? updated : l)) }; + }); + + const setCount = async (line: StocktakeLine, value: number | null) => { + try { + applyLine(await countLine(stId, line.id, { qty_counted: value })); + } catch (e) { + toast.error((e as Error).message || "Не удалось сохранить"); + } + }; + + const onFind = async (item: PartCatalogItem) => { + setPickerOpen(false); + try { + const line = await addFind(stId, { part_id: item.id, qty: 1 }); + setData((cur) => { + const base = cur ?? query.data ?? null; + return base ? { ...base, lines: [...base.lines, line] } : cur; + }); + toast.success(`Находка добавлена: ${item.name}`); + } catch (e) { + toast.error((e as Error).message || "Не удалось добавить находку"); + } + }; + + const onApply = async () => { + if (!st) return; + const notCounted = totalLines - counted; + const msg = notCounted > 0 + ? `Не посчитано строк: ${notCounted}. Они будут пропущены (останутся как есть). Провести?` + : "Провести инвентаризацию? Расхождения станут движениями склада."; + if (!window.confirm(msg)) return; + setBusy(true); + try { + setData(await applyStocktake(stId)); + toast.success("Инвентаризация проведена"); + } catch (e) { + toast.error((e as Error).message || "Не удалось провести"); + } finally { + setBusy(false); + } + }; + + const onDelete = async () => { + if (!window.confirm("Удалить инвентаризацию? Введённые данные пропадут.")) return; + setBusy(true); + try { + await deleteStocktake(stId); + navigate("/wh/stocktake", { replace: true }); + } catch (e) { + toast.error((e as Error).message || "Не удалось удалить"); + setBusy(false); + } + }; + + const pct = totalLines > 0 ? Math.round((counted / totalLines) * 100) : 0; + + return ( +
+
+
+ +

+ {st?.place_code ? `Зона ${st.place_code}` : "Весь склад"} +

+ +
+ {st && ( +
+
+
+
+
+ посчитано {counted} из {totalLines} + {isDraft && ( + + )} +
+
+ )} +
+ +
+ {query.isLoading &&
} + + {st && !isDraft && ( +
+ Инвентаризация проведена — расхождения уже в журнале склада, правки закрыты. +
+ )} + + {st && isDraft && ( + + )} + + {st && visible.length === 0 && ( +
+ {onlyPending && totalLines > 0 + ? "Всё обошли. Проверьте итог внизу и проводите." + : "В этой зоне ничего не числится — добавьте находки, если что-то лежит."} +
+ )} + +
    + {visible.map((l) => ( + void setCount(l, v)} /> + ))} +
+ + {st && isDraft && ( + + )} +
+ + {st && ( +
+
+
+
Расхождения
+
+ + недостача {totals.shortQty} · {fmtMoney(totals.shortAmt)} ₽ + + + излишек {totals.surQty} · {fmtMoney(totals.surAmt)} ₽ + +
+
+ {isDraft && ( + + )} +
+
+ )} + + {/* Находка выбирается из общего каталога — машины тут нет, фильтр + применимости не при чём. */} + void onFind(item)} + onClose={() => setPickerOpen(false)} + /> +
+ ); +} + +function LineRow({ + line, editable, onSet, +}: { + line: StocktakeLine; + editable: boolean; + onSet: (v: number | null) => void; +}) { + const [draft, setDraft] = useState(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 === "match" ? "border-border bg-muted/30" + : "border-border bg-card"; + + return ( +
  • +
    +
    +
    {line.part_name}
    +
    + {line.article ? `арт. ${line.article} · ` : ""} + {line.batch_id == null ? "находка" : `числится ${line.qty_expected}`} + {line.place_code ? ` · ${line.place_code}` : ""} +
    +
    + {line.delta !== 0 && ( + + {line.delta > 0 ? `+${line.delta}` : line.delta} + + )} +
    + + {editable && ( +
    + setDraft(e.target.value)} + onBlur={() => { + const v = draft.trim() === "" ? null : Math.max(0, Number(draft)); + if (v !== line.qty_counted) onSet(v); + }} + className="flex-1" + /> + {line.batch_id != null && ( + + )} + +
    + )} +
  • + ); +}