From 3cc9ffb3e6d88b18e0ad7bab910e44dea8f305c1 Mon Sep 17 00:00:00 2001 From: vladtechno Date: Fri, 7 Aug 2026 23:13:14 +1000 Subject: [PATCH] =?UTF-8?q?feat(warehouse):=20=D0=B8=D1=82=D0=BE=D0=B3?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D0=B4=D1=91=D0=BD=D0=BD?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=B8=D0=BD=D0=B2=D0=B5=D0=BD=D1=82=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B2=D0=BC=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BE=20=D1=82=D1=83=D0=BF=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Владелец: «чтобы по клику по инвентаризации я видел не просто информацию о том, что я не могу её поменять, а итоги. Какие позиции найдены, какие списаны». Клик по проведённой сессии открывал тот же экран обхода со списком, отфильтрованным на «только непосчитанные». У проведённой посчитано всё, поэтому список оказывался пуст, а сверху висел баннер «правки закрыты»: экран показывал ровно то, чего не просили, и прятал всё остальное. Теперь отдельный экран итогов: шапка (зона, кто и когда провёл, счётчики, недостача и излишек в штуках и рублях) и список группами — не нашли, не хватает, излишек, находки; «сошлось» и «не дошли» свёрнуты числом, чтобы не листать мимо совпавших строк. Только просмотр. Числа на этом экране решают, поэтому отдельно закрыто: - «Всё сошлось — расхождений не было» показывалось при любом числе НЕПОСЧИТАННЫХ позиций. Механик обошёл одну из ста, провёл — и сверху документа зелёным «зона чистая». Теперь это отдельное предупреждение: расхождений среди посчитанного нет, но обойдено не всё. - Шапка считается по тем же строкам, из которых собираются секции, а не берётся готовым агрегатом с бэкенда — сходимость по построению, а не по совпадению определений. «Недостача» и «Излишек и находки» названы так, чтобы не совпадать с именем секции ниже: одноимённость заставляла сверять и не сходиться в разы. - Позиции без цены партии больше не выпадают из денег молча — помечены в строке, и в шапке сказано, что реальная сумма больше. - «Обошли X из Y» больше не включает находки: раньше они завышали долю обойдённого и противоречили чипу «не дошли» в той же шапке. Идущая инвентаризация не тронута: ввод количества, «Сходится», «Нет», находки и проведение работают как раньше. Co-Authored-By: Claude Opus 5 --- mechanic-pwa/frontend/src/api/warehouse.ts | 15 +- .../src/pages/warehouse/StocktakeResult.tsx | 301 ++++++++++++++++++ .../pages/warehouse/StocktakeSessionPage.tsx | 36 ++- .../pages/warehouse/stocktakeSummary.test.ts | 199 ++++++++++++ .../src/pages/warehouse/stocktakeSummary.ts | 245 ++++++++++++++ 5 files changed, 790 insertions(+), 6 deletions(-) create mode 100644 mechanic-pwa/frontend/src/pages/warehouse/StocktakeResult.tsx create mode 100644 mechanic-pwa/frontend/src/pages/warehouse/stocktakeSummary.test.ts create mode 100644 mechanic-pwa/frontend/src/pages/warehouse/stocktakeSummary.ts diff --git a/mechanic-pwa/frontend/src/api/warehouse.ts b/mechanic-pwa/frontend/src/api/warehouse.ts index d90cba2..eddff4b 100644 --- a/mechanic-pwa/frontend/src/api/warehouse.ts +++ b/mechanic-pwa/frontend/src/api/warehouse.ts @@ -195,7 +195,8 @@ export async function getReceipt(id: number): Promise { export interface Stocktake { id: number; - status: string; // draft | done + /** Статусов ровно два — сужаем до union, чтобы «проведена» проверялась типом. */ + status: "draft" | "done"; place_id: number | null; place_code: string | null; author_name: string | null; @@ -212,18 +213,28 @@ export interface Stocktake { export interface StocktakeLine { id: number; - batch_id: number | null; // null = находка + /** + * null = находка, но ТОЛЬКО до проведения: apply создаёт находке партию и + * прописывает её id сюда. На проведённой сессии находку узнаём иначе — + * см. isFind() в pages/warehouse/stocktakeSummary.ts. + */ + batch_id: number | null; part_id: number; part_name: string; article: string | null; + place_id: number | null; place_code: string | null; qty_expected: number; qty_counted: number | null; unit_price: number | string | null; + condition: "new" | "used"; + seasons_left: number | null; resolution: "not_counted" | "match" | "shortage" | "surplus"; delta: number; delta_amount: number | string; comment: string | null; + /** Код причины расхождения (курируемый список, зеркалит stock_reasons.py). */ + reason_code: string | null; } export interface StocktakeDetail extends Stocktake { diff --git a/mechanic-pwa/frontend/src/pages/warehouse/StocktakeResult.tsx b/mechanic-pwa/frontend/src/pages/warehouse/StocktakeResult.tsx new file mode 100644 index 0000000..2dcc33b --- /dev/null +++ b/mechanic-pwa/frontend/src/pages/warehouse/StocktakeResult.tsx @@ -0,0 +1,301 @@ +/** + * Итоги проведённой инвентаризации — только просмотр. + * + * Проведённая сессия перестаёт быть рабочим списком и становится документом: + * расхождения уже ушли движениями в журнал склада, менять нечего. Раньше на + * этом месте показывался рабочий список с фильтром «только непосчитанные» — + * то есть ровно то, чего человек НЕ делал, а сам итог был скрыт. + * + * Вид держим в родстве с карточкой прихода (ReceiptDetailPage): шапка + * документа, секции-списки, объяснительная сноска внизу. + * + * Строки разложены по исходам, а не сплошняком: на пересчёте всего склада их + * бывают сотни, и «сошлось» с «не дошли» свёрнуты числом — открываются по + * желанию. Группы без строк не показываются вовсе. + */ +import { useMemo, useState } from "react"; +import { AlertTriangle, CheckCircle2, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { fmtMoney } from "@/lib/repairsFeed"; +import type { StocktakeDetail, StocktakeLine } from "@/api/warehouse"; +import { + GROUPS, + buildSummary, + countPhrase, + lineAmount, + pluralPositions, + reasonLabel, + type GroupMeta, + type MoneyBlock, +} from "./stocktakeSummary"; + +function fmtDateTime(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", year: "numeric", hour: "2-digit", minute: "2-digit", + }).format(d); +} + +export default function StocktakeResult({ + st, onBack, +}: { + st: StocktakeDetail; + onBack: () => void; +}) { + const s = useMemo(() => buildSummary(st.lines), [st.lines]); + const groups = s.groups; + const notCounted = s.walked.left; + + // Счётчики шапки: бэкенд отдаёт только «всего/посчитано», разбивку по + // исходам считаем сами. + const chips: { label: string; count: number }[] = [ + { label: "сошлось", count: groups.match.length }, + { label: "не нашли", count: groups.missing.length }, + { label: "не хватает", count: groups.shortage.length }, + { label: "излишек", count: groups.surplus.length }, + { label: "находок", count: groups.find.length }, + { label: "не дошли", count: notCounted }, + ].filter((c) => c.count > 0); + + return ( +
+
+
+ +

Итоги пересчёта

+ +
+
+ +
+
+
+ {st.place_code ? `Зона ${st.place_code}` : "Весь склад"} +
+
+ Завершён {fmtDateTime(st.finished_at ?? st.started_at)} +
+ {/* author_name — кто ОТКРЫЛ сессию; отдельного «кто провёл» бэкенд не + отдаёт, поэтому и подписываем честно. */} + {st.author_name && ( +
Пересчёт вёл: {st.author_name}
+ )} + {st.comment &&
{st.comment}
} + +
+ {/* Считаем по строкам, а не по lines_counted/lines_total: те включают + находки и завышают долю обойдённого, противореча чипу «не дошли». */} +
+ Обошли {s.walked.done} из{" "} + {s.walked.total} {pluralPositions(s.walked.total)} + {s.finds > 0 && ( + + {" "}· сверх списка нашли {s.finds} + + )} +
+ {chips.length > 0 && ( +
+ {chips.map((c) => ( + + {c.label} {c.count} + + ))} +
+ )} + + + {/* Не просто «Излишек»: ниже есть секция с таким названием, а сюда + входят ещё и находки. Одноимённость заставляет сверять шапку + с секцией и не сходиться — ровно то, что уже лечили слева. */} + + +

+ Суммы — по закупочной цене партии. +

+ {s.unpriced > 0 && ( +

+ У {s.unpriced} {pluralPositions(s.unpriced)} с расхождением нет цены партии — + в суммы они не вошли, реальные деньги больше. +

+ )} +
+
+ + {st.lines.length === 0 ? ( +
+ В этой сессии не было ни одной позиции — считать было нечего. +
+ ) : ( + <> + {s.headline && ( +
+ {s.headline.tone === "warn" ? ( + + ) : ( + + )} + {s.headline.text} +
+ )} + + {GROUPS.map((meta) => + groups[meta.key].length > 0 ? ( + + ) : null, + )} + + {notCounted > 0 && ( +

+ Непосчитанные позиции при проведении пропущены: остатки по ним + остались прежними, ничего не списывалось. +

+ )} + + )} + +

+ Инвентаризация проведена — расхождения уже стали движениями склада. + Правка только в CRM, чтобы не разошлись остатки и журнал. +

+
+
+ ); +} + +/** + * Строка денег в шапке. Слагаемые подписаны названиями групп с этого же экрана + * — человек должен уметь сверить шапку с секциями ниже и сойтись. + */ +function MoneyRow({ label, block, bad }: { label: string; block: MoneyBlock; bad?: boolean }) { + const parts = block.parts.filter((p) => p.totals.qty > 0); + + return ( +
+
+ {label} + + {block.total.qty} шт · {fmtMoney(block.total.amount)} ₽ + +
+ {/* Расшифровку показываем не только когда слагаемых несколько, но и когда + единственное называется иначе, чем строка шапки. Иначе самый частый + случай — только находки, без обычного излишка — оставался без + объяснения: в шапке сумма есть, а секции с таким названием ниже нет. */} + {(parts.length > 1 || + (parts.length === 1 && parts[0].title.toLowerCase() !== label.toLowerCase())) && ( +
+ {parts + .map((p) => `${p.title.toLowerCase()} ${fmtMoney(p.totals.amount)} ₽`) + .join(" + ")} +
+ )} +
+ ); +} + +/** Сколько строк рисуем за раз — пересчёт всего склада это сотни позиций. */ +const PAGE = 100; + +function Group({ meta, lines }: { meta: GroupMeta; lines: StocktakeLine[] }) { + // Свёрнутая группа не рендерит строки вовсе — на пересчёте всего склада + // «сошлось» это сотни элементов, и открывать их без нужды незачем. + const [open, setOpen] = useState(!meta.collapsed); + const [limit, setLimit] = useState(PAGE); + const shown = lines.slice(0, limit); + const rest = lines.length - shown.length; + + return ( +
+ + + {open && ( + <> +
    + {shown.map((l) => )} +
+ {rest > 0 && ( + + )} + + )} +
+ ); +} + +function LineItem({ line }: { line: StocktakeLine }) { + const reason = reasonLabel(line.reason_code, line.delta > 0); + const amount = lineAmount(line); + + return ( +
  • +
    +
    {line.part_name}
    +
    + {line.article ? `арт. ${line.article}` : "без артикула"} + {line.place_code ? ` · ${line.place_code}` : ""} + {line.condition === "used" + ? ` · Б/У${line.seasons_left ? `, ${line.seasons_left} сез.` : ""}` + : ""} +
    +
    {countPhrase(line)}
    + {reason && ( +
    Причина: {reason}
    + )} + {line.comment && ( +
    {line.comment}
    + )} +
    + + {line.delta !== 0 && ( +
    +
    + {line.delta > 0 ? `+${line.delta}` : line.delta} +
    + {/* Цена партии не проставлена — расхождение настоящее, а денег ноль. + Молча пропустить нельзя: шапку примут за полную картину. */} + {amount > 0 ? ( +
    {fmtMoney(amount)} ₽
    + ) : ( +
    без цены
    + )} +
    + )} +
  • + ); +} diff --git a/mechanic-pwa/frontend/src/pages/warehouse/StocktakeSessionPage.tsx b/mechanic-pwa/frontend/src/pages/warehouse/StocktakeSessionPage.tsx index 0d87f01..6a83f2d 100644 --- a/mechanic-pwa/frontend/src/pages/warehouse/StocktakeSessionPage.tsx +++ b/mechanic-pwa/frontend/src/pages/warehouse/StocktakeSessionPage.tsx @@ -8,10 +8,15 @@ * * Пустое поле — это «ещё не считали», а не ноль. Ноль означает «дошёл и не * нашёл» и превратится в недостачу, поэтому он вводится явной кнопкой. + * + * Проведённая сессия сюда не доходит: она уже не рабочий список, а документ, + * и показывается экраном итогов (StocktakeResult). Адрес один на одну сущность + * — id до и после проведения тот же, поэтому разводим не роутом, а статусом: + * так работает и ссылка из списка, и переход сразу после «Провести». */ import { useMemo, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import { ChevronLeft, Loader2, Plus } from "lucide-react"; +import { ChevronLeft, Loader2, Plus, RefreshCw } from "lucide-react"; import { toast } from "sonner"; import { useQuery } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; @@ -28,6 +33,7 @@ import { type StocktakeLine, } from "@/api/warehouse"; import type { PartCatalogItem } from "@/api/repairsCreate"; +import StocktakeResult from "./StocktakeResult"; export default function StocktakeSessionPage() { const { id } = useParams<{ id: string }>(); @@ -67,6 +73,13 @@ export default function StocktakeSessionPage() { return { shortQty, shortAmt, surQty, surAmt }; }, [st]); + // Дальше — только черновик. Проведённую отдаём экрану итогов: править в ней + // нечего, а рабочий список с фильтром «только непосчитанные» показывал бы + // ровно то, чего человек НЕ делал. + if (st && !isDraft) { + return navigate("/wh/stocktake")} />; + } + const applyLine = (updated: StocktakeLine) => setData((cur) => { const base = cur ?? query.data ?? null; @@ -165,9 +178,24 @@ export default function StocktakeSessionPage() {
    {query.isLoading &&
    } - {st && !isDraft && ( -
    - Инвентаризация проведена — расхождения уже в журнале склада, правки закрыты. + {/* Обрыв связи — это не «пустая инвентаризация»: показываем отдельно, + иначе человек решит, что данные пропали. Повтор кнопкой: жеста + «потянуть экран» в приложении нет, без неё в дальнем углу склада + остаётся только перезапускать PWA. */} + {query.isError && !st && ( +
    +
    Не удалось загрузить инвентаризацию — проверьте связь.
    +
    )} diff --git a/mechanic-pwa/frontend/src/pages/warehouse/stocktakeSummary.test.ts b/mechanic-pwa/frontend/src/pages/warehouse/stocktakeSummary.test.ts new file mode 100644 index 0000000..34f1936 --- /dev/null +++ b/mechanic-pwa/frontend/src/pages/warehouse/stocktakeSummary.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; +import type { StocktakeLine } from "@/api/warehouse"; +import { + buildSummary, + countPhrase, + groupOf, + isFind, + isUnpriced, + reasonLabel, + splitByGroup, +} from "./stocktakeSummary"; + +function line(over: Partial): StocktakeLine { + return { + id: 1, + batch_id: 10, + part_id: 100, + part_name: "Фильтр масляный", + article: "C-110", + place_id: null, + place_code: "А-1", + qty_expected: 5, + qty_counted: 5, + unit_price: "100.00", + condition: "new", + seasons_left: null, + resolution: "match", + delta: 0, + delta_amount: "0.00", + comment: null, + reason_code: null, + ...over, + }; +} + +describe("группировка итогов инвентаризации", () => { + it("«дошли и не нашли ни одной» — отдельная группа от «нашли меньше»", () => { + const none = line({ qty_counted: 0, resolution: "shortage", delta: -5 }); + const less = line({ qty_counted: 3, resolution: "shortage", delta: -2 }); + expect(groupOf(none)).toBe("missing"); + expect(groupOf(less)).toBe("shortage"); + }); + + it("не дошли — это не «сошлось», хотя дельта тоже ноль", () => { + expect(groupOf(line({ qty_counted: null, resolution: "not_counted", delta: 0 }))).toBe("skipped"); + expect(groupOf(line({ resolution: "match", delta: 0 }))).toBe("match"); + }); + + it("находка узнаётся после проведения, когда batch_id уже подставлен", () => { + // apply прописывает находке id созданной партии — признак «batch_id === null» + // к этому моменту потерян, остаётся только qty_expected === 0. + const find = line({ batch_id: 77, qty_expected: 0, qty_counted: 3, resolution: "surplus", delta: 3 }); + expect(isFind(find)).toBe(true); + expect(groupOf(find)).toBe("find"); + }); + + it("обычный излишек находкой не считается", () => { + const surplus = line({ qty_expected: 2, qty_counted: 5, resolution: "surplus", delta: 3 }); + expect(isFind(surplus)).toBe(false); + expect(groupOf(surplus)).toBe("surplus"); + }); + + it("splitByGroup раскладывает все строки без потерь", () => { + const lines = [ + line({ id: 1, qty_counted: 0, resolution: "shortage", delta: -5 }), + line({ id: 2, qty_counted: 3, resolution: "shortage", delta: -2 }), + line({ id: 3, qty_expected: 0, qty_counted: 1, resolution: "surplus", delta: 1 }), + line({ id: 4, resolution: "match" }), + line({ id: 5, qty_counted: null, resolution: "not_counted" }), + ]; + const g = splitByGroup(lines); + expect(g.missing.map((l) => l.id)).toEqual([1]); + expect(g.shortage.map((l) => l.id)).toEqual([2]); + expect(g.find.map((l) => l.id)).toEqual([3]); + expect(g.match.map((l) => l.id)).toEqual([4]); + expect(g.skipped.map((l) => l.id)).toEqual([5]); + expect(g.surplus).toHaveLength(0); + }); +}); + +describe("формулировки", () => { + it("говорят числами, а не терминами", () => { + expect(countPhrase(line({ qty_counted: 3, resolution: "shortage", delta: -2 }))) + .toBe("числилось 5 → нашли 3, не хватает 2"); + expect(countPhrase(line({ qty_counted: 8, resolution: "surplus", delta: 3 }))) + .toBe("числилось 5 → нашли 8, лишние 3"); + expect(countPhrase(line({ qty_counted: 0, resolution: "shortage", delta: -5 }))) + .toBe("числилось 5 → не нашли"); + }); + + it("причина `resort` подписывается по-разному у излишка и недостачи", () => { + expect(reasonLabel("resort", true)).toBe("Лежала не там / из другой зоны"); + expect(reasonLabel("resort", false)).toBe("Учтено в другой зоне / пересорт"); + expect(reasonLabel(null, false)).toBeNull(); + expect(reasonLabel("unknown_code", false)).toBe("unknown_code"); + }); +}); + +// Смесь, покрывающая все исходы разом: по ней удобно сверять шапку с группами. +const MIXED: StocktakeLine[] = [ + // не нашли, цена есть + line({ id: 1, qty_expected: 5, qty_counted: 0, resolution: "shortage", delta: -5, delta_amount: "-500.00" }), + // не хватает + line({ id: 2, qty_expected: 5, qty_counted: 3, resolution: "shortage", delta: -2, delta_amount: "-200.00" }), + // не нашли, но у партии нет цены — расхождение есть, денег нет + line({ id: 3, qty_expected: 4, qty_counted: 0, resolution: "shortage", delta: -4, unit_price: "0.00", delta_amount: "0.00" }), + // излишек + line({ id: 4, qty_expected: 2, qty_counted: 5, resolution: "surplus", delta: 3, delta_amount: "300.00" }), + // находка + line({ id: 5, qty_expected: 0, qty_counted: 7, resolution: "surplus", delta: 7, delta_amount: "700.00" }), + line({ id: 6, resolution: "match" }), + line({ id: 7, qty_counted: null, resolution: "not_counted" }), +]; + +describe("шапка итогов", () => { + it("«всё сошлось» не появляется, пока обойдено не всё", () => { + const s = buildSummary([ + line({ id: 1, resolution: "match" }), + line({ id: 2, qty_counted: null, resolution: "not_counted" }), + ]); + expect(s.hasDiff).toBe(false); + expect(s.headline?.tone).toBe("warn"); + expect(s.headline?.text).toContain("1 позиция из 2"); + expect(s.headline?.text).not.toContain("Обошли все позиции"); + }); + + it("«всё сошлось» — только когда обойдено всё и расхождений нет", () => { + const s = buildSummary([line({ id: 1, resolution: "match" }), line({ id: 2, resolution: "match" })]); + expect(s.headline).toEqual({ tone: "ok", text: "Обошли все позиции — расхождений нет." }); + }); + + it("обошли всё, но есть расхождения — плашки нет, говорят группы", () => { + const s = buildSummary([ + line({ id: 1, resolution: "match" }), + line({ id: 2, qty_counted: 3, resolution: "shortage", delta: -2, delta_amount: "-200.00" }), + ]); + expect(s.headline).toBeNull(); + }); + + it("непосчитанные при расхождениях тоже читаются предупреждением", () => { + const s = buildSummary(MIXED); + expect(s.headline?.tone).toBe("warn"); + expect(s.headline?.text).toContain("не считали"); + }); + + it("итог шапки сходится с суммой групп", () => { + const s = buildSummary(MIXED); + const sum = (parts: { totals: { qty: number; amount: number } }[]) => + parts.reduce((a, p) => ({ qty: a.qty + p.totals.qty, amount: a.amount + p.totals.amount }), { qty: 0, amount: 0 }); + + expect(s.shortage.parts.map((p) => p.title)).toEqual(["Не нашли", "Не хватает"]); + expect(s.surplus.parts.map((p) => p.title)).toEqual(["Излишек", "Находки"]); + expect(sum(s.shortage.parts)).toEqual(s.shortage.total); + expect(sum(s.surplus.parts)).toEqual(s.surplus.total); + }); + + it("недостача и излишек считаются по группам с экрана, а не одной кучей", () => { + const s = buildSummary(MIXED); + // «не нашли» (5 + 4 шт) и «не хватает» (2 шт) разведены, как секции ниже + expect(s.shortage.parts[0].totals).toEqual({ qty: 9, amount: 500 }); + expect(s.shortage.parts[1].totals).toEqual({ qty: 2, amount: 200 }); + expect(s.shortage.total).toEqual({ qty: 11, amount: 700 }); + // находки видны отдельным слагаемым, а не растворены в излишке + expect(s.surplus.parts[0].totals).toEqual({ qty: 3, amount: 300 }); + expect(s.surplus.parts[1].totals).toEqual({ qty: 7, amount: 700 }); + expect(s.surplus.total).toEqual({ qty: 10, amount: 1000 }); + }); + + it("находки не раздувают «обошли X из Y»", () => { + const s = buildSummary(MIXED); + // 7 строк, из них одна находка: обходили 6 позиций, до одной не дошли + expect(s.finds).toBe(1); + expect(s.walked).toEqual({ done: 5, total: 6, left: 1 }); + // «не дошли» из чипов и остаток обхода — одно и то же число + expect(s.groups.skipped).toHaveLength(s.walked.left); + }); + + it("строки без цены партии видны отдельно и не теряются", () => { + const s = buildSummary(MIXED); + expect(s.unpriced).toBe(1); + // штуки в шапку попали, деньги — нет: 9 шт «не нашли» стоят 500 ₽ + expect(s.shortage.parts[0].totals).toEqual({ qty: 9, amount: 500 }); + }); + + it("«без цены» — про расхождение, а не про сошедшуюся строку", () => { + expect(isUnpriced(line({ qty_counted: 0, resolution: "shortage", delta: -5, delta_amount: "0.00" }))).toBe(true); + expect(isUnpriced(line({ resolution: "match", delta: 0, delta_amount: "0.00" }))).toBe(false); + expect(isUnpriced(line({ qty_counted: 3, resolution: "shortage", delta: -2, delta_amount: "-200.00" }))).toBe(false); + }); + + it("копейки округляются один раз — слагаемые дают итог", () => { + const s = buildSummary([ + line({ id: 1, qty_counted: 0, resolution: "shortage", delta: -5, delta_amount: "-100.50" }), + line({ id: 2, qty_counted: 3, resolution: "shortage", delta: -2, delta_amount: "-100.50" }), + ]); + expect(s.shortage.parts[0].totals.amount + s.shortage.parts[1].totals.amount) + .toBe(s.shortage.total.amount); + }); +}); diff --git a/mechanic-pwa/frontend/src/pages/warehouse/stocktakeSummary.ts b/mechanic-pwa/frontend/src/pages/warehouse/stocktakeSummary.ts new file mode 100644 index 0000000..edb52fc --- /dev/null +++ b/mechanic-pwa/frontend/src/pages/warehouse/stocktakeSummary.ts @@ -0,0 +1,245 @@ +/** + * Разбор проведённой инвентаризации в человеческие итоги. + * + * Вынесено из экрана отдельным модулем ради двух вещей: логику «во что попала + * строка» можно проверить тестом без рендера, а формулировки лежат в одном + * месте — их правят чаще, чем вёрстку. + * + * Бэкенд отдаёт только коды (resolution, reason_code) и голые числа; подписи + * для человека живут на клиенте. Словарь причин зеркалит backend + * stock_reasons.py — общего пакета между репозиториями нет, поэтому он + * продублирован; коды менять синхронно с бэкендом. + */ +import type { StocktakeLine } from "@/api/warehouse"; + +export type ResultGroup = + | "missing" // числилось, на полке пусто + | "shortage" // нашли меньше, чем числилось + | "surplus" // нашли больше, чем числилось + | "find" // нашли то, чего в учёте не было + | "match" // сошлось + | "skipped"; // до позиции не дошли + +/** + * Находка на ПРОВЕДЁННОЙ сессии. Родной признак batch_id === null тут не + * работает: при проведении находке создаётся партия и её id прописывается в + * строку, после чего находка неотличима от обычного излишка. Единственное, что + * остаётся в ответе, — qty_expected: находке он ставится в 0 и больше не + * меняется. + */ +export function isFind(l: StocktakeLine): boolean { + return l.resolution === "surplus" && l.qty_expected === 0; +} + +export function groupOf(l: StocktakeLine): ResultGroup { + if (l.resolution === "not_counted") return "skipped"; + if (l.resolution === "match") return "match"; + if (l.resolution === "surplus") return isFind(l) ? "find" : "surplus"; + // shortage: «дошли и не нашли ни одной» читается иначе, чем «нашли меньше», + // поэтому недостача разводится на две группы. + return l.qty_counted === 0 ? "missing" : "shortage"; +} + +export interface GroupMeta { + key: ResultGroup; + title: string; + /** Пояснение под заголовком — что вообще значит эта группа. */ + note?: string; + /** Свёрнуто по умолчанию: на большом пересчёте это сотни строк. */ + collapsed?: boolean; +} + +/** Порядок групп на экране: сначала то, из-за чего пересчёт и затевали. */ +export const GROUPS: GroupMeta[] = [ + { key: "missing", title: "Не нашли", note: "числились на складе, на полке пусто" }, + { key: "shortage", title: "Не хватает", note: "нашли меньше, чем числилось — разница списана" }, + { key: "surplus", title: "Излишек", note: "нашли больше, чем числилось" }, + { key: "find", title: "Находки", note: "лежали на полке, в учёте их не было" }, + { key: "match", title: "Сошлось", collapsed: true }, + { key: "skipped", title: "Не дошли", note: "эти позиции не считали — остались как были", collapsed: true }, +]; + +export type Groups = Record; + +export function splitByGroup(lines: StocktakeLine[]): Groups { + const out: Groups = { missing: [], shortage: [], surplus: [], find: [], match: [], skipped: [] }; + for (const l of lines) out[groupOf(l)].push(l); + return out; +} + +/** Что стало с позицией — одной строкой, без слов «дельта» и «resolution». */ +export function countPhrase(l: StocktakeLine): string { + const expected = l.qty_expected; + const counted = l.qty_counted; + switch (groupOf(l)) { + case "missing": + return `числилось ${expected} → не нашли`; + case "shortage": + return `числилось ${expected} → нашли ${counted}, не хватает ${-l.delta}`; + case "surplus": + return `числилось ${expected} → нашли ${counted}, лишние ${l.delta}`; + case "find": + return `нашли ${counted} — в учёте не числилось`; + case "match": + return `числилось ${expected} — столько и нашли`; + default: + return `числилось ${expected} — до этой позиции не дошли`; + } +} + +// ── Причины расхождения ──────────────────────────────────────────────────── +// Один и тот же код `resort` для излишка и недостачи означает разное, поэтому +// подписи разведены по знаку расхождения — как в CRM. + +const SURPLUS_REASONS: Record = { + resort: "Лежала не там / из другой зоны", + back_from_car: "Сняли с машины / из ремонта, не оприходовали", + unrecorded_receipt: "Неучтённый приход", + count_fix: "Правка прошлого просчёта", + other: "Другое", +}; + +const SHORTAGE_REASONS: Record = { + issued_unposted: "Поставили на машину, не списали", + resort: "Учтено в другой зоне / пересорт", + lost: "Утеря / не найдено", + damaged_unposted: "Выкинули по браку, не провели", + other: "Другое", +}; + +/** Подпись причины; незнакомый код показываем как есть, чтобы не терять данные. */ +export function reasonLabel(code: string | null | undefined, positive: boolean): string | null { + if (!code) return null; + const dict = positive ? SURPLUS_REASONS : SHORTAGE_REASONS; + return dict[code] ?? SURPLUS_REASONS[code] ?? SHORTAGE_REASONS[code] ?? code; +} + +/** Сколько денег стоит расхождение по строке — всегда положительное число. */ +export function lineAmount(l: StocktakeLine): number { + return Math.abs(Number(l.delta_amount || 0)); +} + +/** + * Расхождение есть, а денег нет: у партии не проставлена цена (фантомные + * партии, прошлые находки без закупки). Такую строку нельзя молча ронять из + * сумм — иначе шапку принимают за полную картину недостачи. + */ +export function isUnpriced(l: StocktakeLine): boolean { + return l.delta !== 0 && lineAmount(l) === 0; +} + +/** 1 → «позиция», 2..4 → «позиции», 5+/11..14 → «позиций». */ +export function pluralPositions(n: number): string { + const mod10 = n % 10; + const mod100 = n % 100; + if (mod100 >= 11 && mod100 <= 14) return "позиций"; + if (mod10 === 1) return "позиция"; + if (mod10 >= 2 && mod10 <= 4) return "позиции"; + return "позиций"; +} + +// ── Итоги для шапки документа ────────────────────────────────────────────── +// +// Всё считаем по тем же строкам, что показаны группами ниже, а НЕ берём из +// шапки ответа бэкенда: там shortage_amount = «не нашли» + «не хватает», а +// surplus_amount включает находки, вынесенные на экране в отдельную секцию. +// Сверить такую шапку с группами невозможно — цифры расходятся в разы. Считая +// по группам, получаем сходимость по определению, а не по совпадению. + +/** Итог по группе: сколько штук и на сколько денег. */ +export interface GroupTotals { + qty: number; + amount: number; +} + +/** Блок денег в шапке: итог и его слагаемые — ровно группы с экрана. */ +export interface MoneyBlock { + total: GroupTotals; + parts: { key: ResultGroup; title: string; totals: GroupTotals }[]; +} + +export interface Headline { + /** warn — обойдено не всё; такой документ нельзя читать как «зона чистая». */ + tone: "ok" | "warn"; + text: string; +} + +export interface StocktakeSummary { + groups: Groups; + /** + * Обход списка. Находки не в счёт: они появились сверх списка и завышали бы + * долю обойдённого (бэкендовые lines_total/lines_counted их включают). + */ + walked: { done: number; total: number; left: number }; + finds: number; + shortage: MoneyBlock; + surplus: MoneyBlock; + /** Строк с расхождением, которые не дали денег из-за отсутствия цены. */ + unpriced: number; + hasDiff: boolean; + /** null — обошли всё и расхождения есть: группы ниже говорят сами за себя. */ + headline: Headline | null; +} + +function titleOf(key: ResultGroup): string { + return GROUPS.find((g) => g.key === key)?.title ?? key; +} + +function totalsOf(lines: StocktakeLine[]): GroupTotals { + let qty = 0; + let amount = 0; + for (const l of lines) { + qty += Math.abs(l.delta); + amount += lineAmount(l); + } + // Округляем на уровне группы: иначе слагаемые в шапке округляются каждое + // само по себе и визуально не дают итог — та самая «не сходится». + return { qty, amount: Math.round(amount) }; +} + +function blockOf(groups: Groups, keys: ResultGroup[]): MoneyBlock { + const parts = keys.map((key) => ({ key, title: titleOf(key), totals: totalsOf(groups[key]) })); + const total = parts.reduce( + (acc, p) => ({ qty: acc.qty + p.totals.qty, amount: acc.amount + p.totals.amount }), + { qty: 0, amount: 0 }, + ); + return { total, parts }; +} + +export function buildSummary(lines: StocktakeLine[]): StocktakeSummary { + const groups = splitByGroup(lines); + const finds = groups.find.length; + const total = lines.length - finds; + const left = groups.skipped.length; + + const shortage = blockOf(groups, ["missing", "shortage"]); + const surplus = blockOf(groups, ["surplus", "find"]); + const hasDiff = + groups.missing.length + groups.shortage.length + groups.surplus.length + finds > 0; + + // «Всё сошлось» имеет право появиться, только когда обойдено ВСЁ и + // расхождений нет. Непосчитанная позиция — это не «сошлось», а неизвестность, + // и на самом заметном месте документа она должна читаться предупреждением. + const headline: Headline | null = + left === 0 + ? hasDiff + ? null + : { tone: "ok", text: "Обошли все позиции — расхождений нет." } + : { + tone: "warn", + text: hasDiff + ? `Обойдено не всё: ${left} ${pluralPositions(left)} из ${total} не считали. Расхождения ниже — только по тому, что успели посчитать.` + : `Среди посчитанного расхождений нет, но обойдено не всё: ${left} ${pluralPositions(left)} из ${total} не считали — что там на полке, неизвестно.`, + }; + + return { + groups, + walked: { done: total - left, total, left }, + finds, + shortage, + surplus, + unpriced: lines.filter(isUnpriced).length, + hasDiff, + headline, + }; +}