feat(warehouse): итоги проведённой инвентаризации вместо тупика
Владелец: «чтобы по клику по инвентаризации я видел не просто информацию о том, что я не могу её поменять, а итоги. Какие позиции найдены, какие списаны». Клик по проведённой сессии открывал тот же экран обхода со списком, отфильтрованным на «только непосчитанные». У проведённой посчитано всё, поэтому список оказывался пуст, а сверху висел баннер «правки закрыты»: экран показывал ровно то, чего не просили, и прятал всё остальное. Теперь отдельный экран итогов: шапка (зона, кто и когда провёл, счётчики, недостача и излишек в штуках и рублях) и список группами — не нашли, не хватает, излишек, находки; «сошлось» и «не дошли» свёрнуты числом, чтобы не листать мимо совпавших строк. Только просмотр. Числа на этом экране решают, поэтому отдельно закрыто: - «Всё сошлось — расхождений не было» показывалось при любом числе НЕПОСЧИТАННЫХ позиций. Механик обошёл одну из ста, провёл — и сверху документа зелёным «зона чистая». Теперь это отдельное предупреждение: расхождений среди посчитанного нет, но обойдено не всё. - Шапка считается по тем же строкам, из которых собираются секции, а не берётся готовым агрегатом с бэкенда — сходимость по построению, а не по совпадению определений. «Недостача» и «Излишек и находки» названы так, чтобы не совпадать с именем секции ниже: одноимённость заставляла сверять и не сходиться в разы. - Позиции без цены партии больше не выпадают из денег молча — помечены в строке, и в шапке сказано, что реальная сумма больше. - «Обошли X из Y» больше не включает находки: раньше они завышали долю обойдённого и противоречили чипу «не дошли» в той же шапке. Идущая инвентаризация не тронута: ввод количества, «Сходится», «Нет», находки и проведение работают как раньше. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -195,7 +195,8 @@ export async function getReceipt(id: number): Promise<ReceiptDetail> {
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<Button variant="ghost" size="sm" onClick={onBack} className="-ml-2">
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
<span className="ml-1">Назад</span>
|
||||
</Button>
|
||||
<h1 className="text-base font-semibold">Итоги пересчёта</h1>
|
||||
<span className="w-16" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 container max-w-2xl py-4 space-y-4">
|
||||
<section className="rounded-lg border bg-card p-3 space-y-1">
|
||||
<div className="text-base font-semibold">
|
||||
{st.place_code ? `Зона ${st.place_code}` : "Весь склад"}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Завершён {fmtDateTime(st.finished_at ?? st.started_at)}
|
||||
</div>
|
||||
{/* author_name — кто ОТКРЫЛ сессию; отдельного «кто провёл» бэкенд не
|
||||
отдаёт, поэтому и подписываем честно. */}
|
||||
{st.author_name && (
|
||||
<div className="text-xs text-muted-foreground">Пересчёт вёл: {st.author_name}</div>
|
||||
)}
|
||||
{st.comment && <div className="text-xs text-muted-foreground italic">{st.comment}</div>}
|
||||
|
||||
<div className="pt-2 border-t mt-2 space-y-2">
|
||||
{/* Считаем по строкам, а не по lines_counted/lines_total: те включают
|
||||
находки и завышают долю обойдённого, противореча чипу «не дошли». */}
|
||||
<div className="text-sm">
|
||||
Обошли <span className="tabular-nums">{s.walked.done}</span> из{" "}
|
||||
<span className="tabular-nums">{s.walked.total}</span> {pluralPositions(s.walked.total)}
|
||||
{s.finds > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}· сверх списка нашли <span className="tabular-nums">{s.finds}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{chips.length > 0 && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{chips.map((c) => (
|
||||
<span key={c.label}>
|
||||
{c.label} <span className="font-semibold tabular-nums">{c.count}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MoneyRow label="Недостача" block={s.shortage} bad />
|
||||
{/* Не просто «Излишек»: ниже есть секция с таким названием, а сюда
|
||||
входят ещё и находки. Одноимённость заставляет сверять шапку
|
||||
с секцией и не сходиться — ровно то, что уже лечили слева. */}
|
||||
<MoneyRow label="Излишек и находки" block={s.surplus} />
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Суммы — по закупочной цене партии.
|
||||
</p>
|
||||
{s.unpriced > 0 && (
|
||||
<p className="text-xs text-destructive">
|
||||
У {s.unpriced} {pluralPositions(s.unpriced)} с расхождением нет цены партии —
|
||||
в суммы они не вошли, реальные деньги больше.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{st.lines.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||
В этой сессии не было ни одной позиции — считать было нечего.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{s.headline && (
|
||||
<div
|
||||
className={`rounded-md border p-3 text-sm flex items-start gap-2 ${
|
||||
s.headline.tone === "warn"
|
||||
? "border-destructive/40 bg-destructive/5"
|
||||
: "bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
{s.headline.tone === "warn" ? (
|
||||
<AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0 text-destructive" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 mt-0.5 flex-shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span>{s.headline.text}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{GROUPS.map((meta) =>
|
||||
groups[meta.key].length > 0 ? (
|
||||
<Group key={meta.key} meta={meta} lines={groups[meta.key]} />
|
||||
) : null,
|
||||
)}
|
||||
|
||||
{notCounted > 0 && (
|
||||
<p className="text-xs text-muted-foreground px-1">
|
||||
Непосчитанные позиции при проведении пропущены: остатки по ним
|
||||
остались прежними, ничего не списывалось.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground px-1">
|
||||
Инвентаризация проведена — расхождения уже стали движениями склада.
|
||||
Правка только в CRM, чтобы не разошлись остатки и журнал.
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Строка денег в шапке. Слагаемые подписаны названиями групп с этого же экрана
|
||||
* — человек должен уметь сверить шапку с секциями ниже и сойтись.
|
||||
*/
|
||||
function MoneyRow({ label, block, bad }: { label: string; block: MoneyBlock; bad?: boolean }) {
|
||||
const parts = block.parts.filter((p) => p.totals.qty > 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-baseline justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className={`font-semibold tabular-nums ${bad && block.total.qty ? "text-destructive" : ""}`}
|
||||
>
|
||||
{block.total.qty} шт · {fmtMoney(block.total.amount)} ₽
|
||||
</span>
|
||||
</div>
|
||||
{/* Расшифровку показываем не только когда слагаемых несколько, но и когда
|
||||
единственное называется иначе, чем строка шапки. Иначе самый частый
|
||||
случай — только находки, без обычного излишка — оставался без
|
||||
объяснения: в шапке сумма есть, а секции с таким названием ниже нет. */}
|
||||
{(parts.length > 1 ||
|
||||
(parts.length === 1 && parts[0].title.toLowerCase() !== label.toLowerCase())) && (
|
||||
<div className="text-xs text-muted-foreground text-right">
|
||||
{parts
|
||||
.map((p) => `${p.title.toLowerCase()} ${fmtMoney(p.totals.amount)} ₽`)
|
||||
.join(" + ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Сколько строк рисуем за раз — пересчёт всего склада это сотни позиций. */
|
||||
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 (
|
||||
<section className="rounded-lg border bg-card overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`w-full px-3 py-2 bg-muted/40 flex items-center gap-2 text-left ${open ? "border-b" : ""}`}
|
||||
>
|
||||
{open
|
||||
? <ChevronDown className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
: <ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />}
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-sm font-semibold">
|
||||
{meta.title} · <span className="tabular-nums">{lines.length}</span>
|
||||
</span>
|
||||
{meta.note && (
|
||||
<span className="block text-xs font-normal text-muted-foreground">{meta.note}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<ul className="divide-y">
|
||||
{shown.map((l) => <LineItem key={l.id} line={l} />)}
|
||||
</ul>
|
||||
{rest > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLimit((v) => v + PAGE)}
|
||||
className="w-full border-t px-3 py-2 text-sm font-medium text-primary hover:bg-accent/40"
|
||||
>
|
||||
Показать ещё {Math.min(rest, PAGE)} из {rest}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LineItem({ line }: { line: StocktakeLine }) {
|
||||
const reason = reasonLabel(line.reason_code, line.delta > 0);
|
||||
const amount = lineAmount(line);
|
||||
|
||||
return (
|
||||
<li className="p-3 flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm truncate">{line.part_name}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{line.article ? `арт. ${line.article}` : "без артикула"}
|
||||
{line.place_code ? ` · ${line.place_code}` : ""}
|
||||
{line.condition === "used"
|
||||
? ` · Б/У${line.seasons_left ? `, ${line.seasons_left} сез.` : ""}`
|
||||
: ""}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{countPhrase(line)}</div>
|
||||
{reason && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">Причина: {reason}</div>
|
||||
)}
|
||||
{line.comment && (
|
||||
<div className="text-xs text-muted-foreground italic mt-0.5">{line.comment}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{line.delta !== 0 && (
|
||||
<div className="text-right whitespace-nowrap">
|
||||
<div className={`text-sm font-semibold tabular-nums ${line.delta < 0 ? "text-destructive" : ""}`}>
|
||||
{line.delta > 0 ? `+${line.delta}` : line.delta}
|
||||
</div>
|
||||
{/* Цена партии не проставлена — расхождение настоящее, а денег ноль.
|
||||
Молча пропустить нельзя: шапку примут за полную картину. */}
|
||||
{amount > 0 ? (
|
||||
<div className="text-xs text-muted-foreground tabular-nums">{fmtMoney(amount)} ₽</div>
|
||||
) : (
|
||||
<div className="text-xs text-destructive">без цены</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -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 <StocktakeResult st={st} onBack={() => navigate("/wh/stocktake")} />;
|
||||
}
|
||||
|
||||
const applyLine = (updated: StocktakeLine) =>
|
||||
setData((cur) => {
|
||||
const base = cur ?? query.data ?? null;
|
||||
@@ -165,9 +178,24 @@ export default function StocktakeSessionPage() {
|
||||
<main className="flex-1 container max-w-2xl py-3 space-y-2">
|
||||
{query.isLoading && <div className="h-20 rounded-lg bg-muted animate-pulse" />}
|
||||
|
||||
{st && !isDraft && (
|
||||
<div className="rounded-md border bg-muted/40 p-3 text-sm">
|
||||
Инвентаризация проведена — расхождения уже в журнале склада, правки закрыты.
|
||||
{/* Обрыв связи — это не «пустая инвентаризация»: показываем отдельно,
|
||||
иначе человек решит, что данные пропали. Повтор кнопкой: жеста
|
||||
«потянуть экран» в приложении нет, без неё в дальнем углу склада
|
||||
остаётся только перезапускать PWA. */}
|
||||
{query.isError && !st && (
|
||||
<div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm space-y-2">
|
||||
<div>Не удалось загрузить инвентаризацию — проверьте связь.</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
{query.isFetching
|
||||
? <Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
: <RefreshCw className="h-4 w-4 mr-1" />}
|
||||
Повторить
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>): 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);
|
||||
});
|
||||
});
|
||||
@@ -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<ResultGroup, StocktakeLine[]>;
|
||||
|
||||
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<string, string> = {
|
||||
resort: "Лежала не там / из другой зоны",
|
||||
back_from_car: "Сняли с машины / из ремонта, не оприходовали",
|
||||
unrecorded_receipt: "Неучтённый приход",
|
||||
count_fix: "Правка прошлого просчёта",
|
||||
other: "Другое",
|
||||
};
|
||||
|
||||
const SHORTAGE_REASONS: Record<string, string> = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user