feat(warehouse): итоги проведённой инвентаризации вместо тупика

Владелец: «чтобы по клику по инвентаризации я видел не просто информацию о том,
что я не могу её поменять, а итоги. Какие позиции найдены, какие списаны».

Клик по проведённой сессии открывал тот же экран обхода со списком,
отфильтрованным на «только непосчитанные». У проведённой посчитано всё, поэтому
список оказывался пуст, а сверху висел баннер «правки закрыты»: экран показывал
ровно то, чего не просили, и прятал всё остальное.

Теперь отдельный экран итогов: шапка (зона, кто и когда провёл, счётчики,
недостача и излишек в штуках и рублях) и список группами — не нашли, не хватает,
излишек, находки; «сошлось» и «не дошли» свёрнуты числом, чтобы не листать мимо
совпавших строк. Только просмотр.

Числа на этом экране решают, поэтому отдельно закрыто:

- «Всё сошлось — расхождений не было» показывалось при любом числе
  НЕПОСЧИТАННЫХ позиций. Механик обошёл одну из ста, провёл — и сверху документа
  зелёным «зона чистая». Теперь это отдельное предупреждение: расхождений среди
  посчитанного нет, но обойдено не всё.
- Шапка считается по тем же строкам, из которых собираются секции, а не берётся
  готовым агрегатом с бэкенда — сходимость по построению, а не по совпадению
  определений. «Недостача» и «Излишек и находки» названы так, чтобы не совпадать
  с именем секции ниже: одноимённость заставляла сверять и не сходиться в разы.
- Позиции без цены партии больше не выпадают из денег молча — помечены в строке,
  и в шапке сказано, что реальная сумма больше.
- «Обошли X из Y» больше не включает находки: раньше они завышали долю
  обойдённого и противоречили чипу «не дошли» в той же шапке.

Идущая инвентаризация не тронута: ввод количества, «Сходится», «Нет», находки и
проведение работают как раньше.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 23:13:14 +10:00
co-authored by Claude Opus 5
parent 5a65f8f1ca
commit 3cc9ffb3e6
5 changed files with 790 additions and 6 deletions
+13 -2
View File
@@ -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,
};
}