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 {
|
export interface Stocktake {
|
||||||
id: number;
|
id: number;
|
||||||
status: string; // draft | done
|
/** Статусов ровно два — сужаем до union, чтобы «проведена» проверялась типом. */
|
||||||
|
status: "draft" | "done";
|
||||||
place_id: number | null;
|
place_id: number | null;
|
||||||
place_code: string | null;
|
place_code: string | null;
|
||||||
author_name: string | null;
|
author_name: string | null;
|
||||||
@@ -212,18 +213,28 @@ export interface Stocktake {
|
|||||||
|
|
||||||
export interface StocktakeLine {
|
export interface StocktakeLine {
|
||||||
id: number;
|
id: number;
|
||||||
batch_id: number | null; // null = находка
|
/**
|
||||||
|
* null = находка, но ТОЛЬКО до проведения: apply создаёт находке партию и
|
||||||
|
* прописывает её id сюда. На проведённой сессии находку узнаём иначе —
|
||||||
|
* см. isFind() в pages/warehouse/stocktakeSummary.ts.
|
||||||
|
*/
|
||||||
|
batch_id: number | null;
|
||||||
part_id: number;
|
part_id: number;
|
||||||
part_name: string;
|
part_name: string;
|
||||||
article: string | null;
|
article: string | null;
|
||||||
|
place_id: number | null;
|
||||||
place_code: string | null;
|
place_code: string | null;
|
||||||
qty_expected: number;
|
qty_expected: number;
|
||||||
qty_counted: number | null;
|
qty_counted: number | null;
|
||||||
unit_price: number | string | null;
|
unit_price: number | string | null;
|
||||||
|
condition: "new" | "used";
|
||||||
|
seasons_left: number | null;
|
||||||
resolution: "not_counted" | "match" | "shortage" | "surplus";
|
resolution: "not_counted" | "match" | "shortage" | "surplus";
|
||||||
delta: number;
|
delta: number;
|
||||||
delta_amount: number | string;
|
delta_amount: number | string;
|
||||||
comment: string | null;
|
comment: string | null;
|
||||||
|
/** Код причины расхождения (курируемый список, зеркалит stock_reasons.py). */
|
||||||
|
reason_code: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StocktakeDetail extends Stocktake {
|
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 { useMemo, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
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 { toast } from "sonner";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -28,6 +33,7 @@ import {
|
|||||||
type StocktakeLine,
|
type StocktakeLine,
|
||||||
} from "@/api/warehouse";
|
} from "@/api/warehouse";
|
||||||
import type { PartCatalogItem } from "@/api/repairsCreate";
|
import type { PartCatalogItem } from "@/api/repairsCreate";
|
||||||
|
import StocktakeResult from "./StocktakeResult";
|
||||||
|
|
||||||
export default function StocktakeSessionPage() {
|
export default function StocktakeSessionPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -67,6 +73,13 @@ export default function StocktakeSessionPage() {
|
|||||||
return { shortQty, shortAmt, surQty, surAmt };
|
return { shortQty, shortAmt, surQty, surAmt };
|
||||||
}, [st]);
|
}, [st]);
|
||||||
|
|
||||||
|
// Дальше — только черновик. Проведённую отдаём экрану итогов: править в ней
|
||||||
|
// нечего, а рабочий список с фильтром «только непосчитанные» показывал бы
|
||||||
|
// ровно то, чего человек НЕ делал.
|
||||||
|
if (st && !isDraft) {
|
||||||
|
return <StocktakeResult st={st} onBack={() => navigate("/wh/stocktake")} />;
|
||||||
|
}
|
||||||
|
|
||||||
const applyLine = (updated: StocktakeLine) =>
|
const applyLine = (updated: StocktakeLine) =>
|
||||||
setData((cur) => {
|
setData((cur) => {
|
||||||
const base = cur ?? query.data ?? null;
|
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">
|
<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" />}
|
{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>
|
</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