fix(pwa): в штрафах только факт и сумма, без кухни расчётов

С карточки убраны строки про номинал постановления, скидку и «в ГИБДД
постановление закрыто», из списка — абзац о том, кто и как платит ГИБДД.
Остаётся то, ради чего экран делался: фотография нарушения, статья, место,
скорость, начисленная сумма и погашен ли долг.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 17:42:59 +10:00
co-authored by Claude Opus 5
parent eac59b823c
commit 3bf26d3650
4 changed files with 22 additions and 41 deletions
+4 -9
View File
@@ -147,17 +147,12 @@ const FineBriefSchema = z.object({
article: z.string(), article: z.string(),
address: z.string().default(""), address: z.string().default(""),
car_number: z.string().default(""), car_number: z.string().default(""),
// Сумма, начисленная ВОДИТЕЛЮ (парк платит ГИБДД со скидкой и выставляет то, // Сумма, начисленная ВОДИТЕЛЮ. Номинала постановления, скидки и расчётов парка
// что заплатил). fine_amount — номинал постановления, он бывает другим. // с ГИБДД в приложении нет намеренно — водителю нужен факт штрафа и сумма.
amount: z.number(), amount: z.number(),
billed: z.boolean().default(false), // Погашен ли штраф водителем перед парком. Постановление парк гасит авансом,
fine_amount: z.number().default(0), // и на этот признак его расчёты не влияют.
discount_amount: z.number().default(0),
discount_until: z.string().nullable().default(null),
// Погашен ли штраф ВОДИТЕЛЕМ перед парком. Не путать с closed_with_gibdd:
// постановление парк гасит авансом, долг водителя от этого не исчезает.
settled: z.enum(["owed", "paid", "not_charged", "cancelled", "unknown"]), settled: z.enum(["owed", "paid", "not_charged", "cancelled", "unknown"]),
closed_with_gibdd: z.boolean().default(false),
cancelled: z.boolean().default(false), cancelled: z.boolean().default(false),
has_photo: z.boolean().default(false), has_photo: z.boolean().default(false),
}); });
@@ -19,6 +19,10 @@ function Row({ k, v }: { k: string; v: string }) {
* Карточка штрафа. Главное здесь — фотография с камеры: по ней водитель узнаёт * Карточка штрафа. Главное здесь — фотография с камеры: по ней водитель узнаёт
* свою машину и свою поездку либо понимает, что штраф не его. Поэтому она стоит * свою машину и свою поездку либо понимает, что штраф не его. Поэтому она стоит
* первой, до реквизитов, и открывается в полный размер по нажатию. * первой, до реквизитов, и открывается в полный размер по нажатию.
*
* Что парк заплатил ГИБДД, со скидкой ли и какой номинал у постановления — здесь
* не показывается: водителю нужен факт штрафа и сумма к возврату, а разбор
* расчётов парка уводит разговор от долга к внутренней кухне.
*/ */
export function FineDetailPage() { export function FineDetailPage() {
const { fineId = "" } = useParams(); const { fineId = "" } = useParams();
@@ -39,10 +43,6 @@ export function FineDetailPage() {
); );
} }
const discountLive =
f.discount_amount > 0 && !!f.discount_until && !f.closed_with_gibdd &&
new Date(f.discount_until) >= new Date(new Date().toDateString());
return ( return (
<div className="pt-2 pb-4"> <div className="pt-2 pb-4">
<button className="text-muted text-xs mb-3" onClick={() => nav("/fines")}> Штрафы</button> <button className="text-muted text-xs mb-3" onClick={() => nav("/fines")}> Штрафы</button>
@@ -78,23 +78,6 @@ export function FineDetailPage() {
{formatMoney(f.amount)} {formatMoney(f.amount)}
</p> </p>
<p className="text-muted text-xs mt-1">{fineStatusText(f)}</p> <p className="text-muted text-xs mt-1">{fineStatusText(f)}</p>
{f.billed && f.fine_amount > 0 && f.fine_amount !== f.amount && (
// Номинал постановления и начисление расходятся почти всегда: парк
// платит со скидкой. Не показать этого — значит получить вопрос «почему
// с меня 618, если в постановлении 750».
<p className="text-muted text-xs mt-2">
Постановление {formatMoney(f.fine_amount)}, парк оплатил его
{f.fine_amount > f.amount ? " со скидкой" : ""} и выставил вам фактическую сумму
</p>
)}
{f.closed_with_gibdd && (
<p className="text-muted text-xs mt-2">В ГИБДД постановление закрыто</p>
)}
{discountLive && !f.billed && (
<p className="text-pos text-xs mt-2">
До {formatDate(f.discount_until)} {formatMoney(f.discount_amount)} со скидкой
</p>
)}
</div> </div>
{f.speed != null && f.speed_limit != null && ( {f.speed != null && f.speed_limit != null && (
@@ -9,9 +9,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const FINE = { const FINE = {
id: 12, number: "18810525260813035846", date: "2026-08-12", id: 12, number: "18810525260813035846", date: "2026-08-12",
article: "12.09.2 - Превышение скорости", address: "Владивосток, Некрасовская, 90", article: "12.09.2 - Превышение скорости", address: "Владивосток, Некрасовская, 90",
car_number: "А947УТ125", amount: 618.75, billed: true, fine_amount: 750, car_number: "А947УТ125", amount: 618.75,
discount_amount: 375, discount_until: "2026-09-01", settled: "owed" as const, cancelled: false, has_photo: true,
settled: "owed" as const, closed_with_gibdd: true, cancelled: false, has_photo: true,
}; };
vi.mock("@/api/driver", () => ({ vi.mock("@/api/driver", () => ({
@@ -89,15 +88,24 @@ describe("карточка штрафа", () => {
expect(img.closest("a")).toHaveAttribute("target", "_blank"); expect(img.closest("a")).toHaveAttribute("target", "_blank");
}); });
it("объясняет, почему начислено меньше номинала постановления", async () => { it("показывает начисленную сумму, скорость и постановление", async () => {
wrap("/fines/12"); wrap("/fines/12");
expect(await screen.findByText("618,75 ₽")).toBeInTheDocument(); expect(await screen.findByText("618,75 ₽")).toBeInTheDocument();
expect(screen.getByText(/Постановление — 750 ₽/)).toBeInTheDocument();
expect(screen.getByText(/В ГИБДД постановление закрыто/)).toBeInTheDocument();
expect(screen.getByText(/63 км\/ч при разрешённых 40/)).toBeInTheDocument(); expect(screen.getByText(/63 км\/ч при разрешённых 40/)).toBeInTheDocument();
expect(screen.getByText("18810525260813035846")).toBeInTheDocument(); expect(screen.getByText("18810525260813035846")).toBeInTheDocument();
}); });
it("расчёты парка с ГИБДД водителю не показываются", async () => {
// Решение владельца: без номинала, скидки и «парк уже оплатил». В начисление
// заложена комиссия парка, и любая из этих строк рядом требует объяснений.
wrap("/fines/12");
await screen.findByText("618,75 ₽");
expect(screen.queryByText(/750/)).not.toBeInTheDocument();
expect(screen.queryByText(/скидк/i)).not.toBeInTheDocument();
expect(screen.queryByText(/ГИБДД/)).not.toBeInTheDocument();
expect(screen.queryByText(/парк/i)).not.toBeInTheDocument();
});
it("неоплаченный штраф ведёт к оплате штрафов", async () => { it("неоплаченный штраф ведёт к оплате штрафов", async () => {
wrap("/fines/12"); wrap("/fines/12");
await userEvent.click(await screen.findByRole("button", { name: /Оплатить штрафы/ })); await userEvent.click(await screen.findByRole("button", { name: /Оплатить штрафы/ }));
@@ -41,11 +41,6 @@ export function FinesPage() {
: " "} : " "}
</p> </p>
<p className="text-muted text-[11px] mb-3 leading-4">
Постановление в ГИБДД оплачивает парк как правило, сразу и со скидкой.
Вы возвращаете парку начисленную сумму: она и складывается в долг по штрафам.
</p>
<div className="flex gap-1.5 mb-3"> <div className="flex gap-1.5 mb-3">
{([["open", "Не погашены"], ["all", "Все"]] as const).map(([key, label]) => ( {([["open", "Не погашены"], ["all", "Все"]] as const).map(([key, label]) => (
<button <button