fix(pwa): в штрафах «погашен», а не «оплачен»

Слово «оплачен» водитель читает как «я ничего не должен», а постановление к тому
моменту оплачено парком — не им. Поэтому в списке и карточке состояние теперь
«не погашен / погашен / не выставлялся / уточняется», долг в шапке берётся из
баланса 1С, а карточка объясняет расхождение сумм: «постановление — 750 ₽, парк
оплатил его со скидкой и выставил вам фактическую сумму».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 17:36:01 +10:00
co-authored by Claude Opus 5
parent f4a9da25cf
commit eac59b823c
5 changed files with 104 additions and 32 deletions
+10 -2
View File
@@ -147,10 +147,17 @@ 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_amount: z.number().default(0),
discount_until: z.string().nullable().default(null), discount_until: z.string().nullable().default(null),
paid: z.boolean(), // Погашен ли штраф ВОДИТЕЛЕМ перед парком. Не путать с closed_with_gibdd:
// постановление парк гасит авансом, долг водителя от этого не исчезает.
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),
}); });
@@ -159,7 +166,8 @@ export type FineBrief = z.infer<typeof FineBriefSchema>;
export const FinesSchema = z.object({ export const FinesSchema = z.object({
items: z.array(FineBriefSchema), items: z.array(FineBriefSchema),
open_count: z.number(), open_count: z.number(),
open_sum: z.number(), // Долг по штрафам из 1С — тот же, что на главном экране (отрицательный).
debt: z.number(),
}); });
export const FineDetailSchema = FineBriefSchema.extend({ export const FineDetailSchema = FineBriefSchema.extend({
+30
View File
@@ -0,0 +1,30 @@
import type { FineBrief } from "@/api/driver";
/**
* Что написать водителю о штрафе.
*
* Ключевое различие, которое интерфейс обязан удерживать: постановление в ГИБДД
* гасит ПАРК, обычно авансом и со скидкой, — и с этого момента штраф закрыт для
* государства, но не для водителя. Водитель возвращает деньги парку, и это видно
* только по балансу «Долг по штрафам». Поэтому слово «оплачен» здесь не
* используется вовсе: оно означало бы для человека «я ничего не должен».
*/
export function fineStatusText(f: Pick<FineBrief, "settled">): string {
switch (f.settled) {
case "owed":
return "не погашен";
case "paid":
return "погашен";
case "not_charged":
return "не выставлялся";
case "cancelled":
return "отменён";
default:
return "уточняется";
}
}
/** Долг именно этого штрафа перед парком — по нему красим сумму. */
export function isOwed(f: Pick<FineBrief, "settled">): boolean {
return f.settled === "owed";
}
@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { FileText, Gauge } from "lucide-react"; import { FileText, Gauge } from "lucide-react";
import { getFine } from "@/api/driver"; import { getFine } from "@/api/driver";
import { fineStatusText, isOwed } from "@/lib/fineStatus";
import { formatDate, formatMoney } from "@/lib/format"; import { formatDate, formatMoney } from "@/lib/format";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
@@ -39,7 +40,7 @@ export function FineDetailPage() {
} }
const discountLive = const discountLive =
f.discount_amount > 0 && !!f.discount_until && !f.paid && f.discount_amount > 0 && !!f.discount_until && !f.closed_with_gibdd &&
new Date(f.discount_until) >= new Date(new Date().toDateString()); new Date(f.discount_until) >= new Date(new Date().toDateString());
return ( return (
@@ -72,15 +73,24 @@ export function FineDetailPage() {
</p> </p>
<div className="card p-4 mb-4 text-center"> <div className="card p-4 mb-4 text-center">
<p className={`text-2xl font-extrabold ${f.paid ? "" : "text-neg"}`}> <p className="text-muted text-xs mb-1">Начислено вам</p>
<p className={`text-2xl font-extrabold ${isOwed(f) ? "text-neg" : ""}`}>
{formatMoney(f.amount)} {formatMoney(f.amount)}
</p> </p>
<p className="text-muted text-xs mt-1"> <p className="text-muted text-xs mt-1">{fineStatusText(f)}</p>
{f.cancelled ? "Постановление отменено" : f.paid ? "Оплачен" : "Не оплачен"} {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> </p>
{discountLive && ( )}
// Половина суммы в первые 20 дней — то, ради чего в карточку заходят {f.closed_with_gibdd && (
// вовремя. Дату показываем прямо здесь, а не в реквизитах ниже. <p className="text-muted text-xs mt-2">В ГИБДД постановление закрыто</p>
)}
{discountLive && !f.billed && (
<p className="text-pos text-xs mt-2"> <p className="text-pos text-xs mt-2">
До {formatDate(f.discount_until)} {formatMoney(f.discount_amount)} со скидкой До {formatDate(f.discount_until)} {formatMoney(f.discount_amount)} со скидкой
</p> </p>
@@ -111,7 +121,7 @@ export function FineDetailPage() {
</a> </a>
)} )}
{!f.paid && !f.cancelled && ( {isOwed(f) && (
<button className="btn-primary" <button className="btn-primary"
onClick={() => nav("/topup", { state: { bucket: "Долг по штрафам" } })}> onClick={() => nav("/topup", { state: { bucket: "Долг по штрафам" } })}>
Оплатить штрафы Оплатить штрафы
@@ -4,17 +4,22 @@ import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom"; import { MemoryRouter, Route, Routes } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 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: 562.5, discount_amount: 281.25, car_number: "А947УТ125", amount: 618.75, billed: true, fine_amount: 750,
discount_until: "2026-09-01", paid: false, cancelled: false, has_photo: true, discount_amount: 375, discount_until: "2026-09-01",
settled: "owed" as const, closed_with_gibdd: true, cancelled: false, has_photo: true,
}; };
vi.mock("@/api/driver", () => ({ vi.mock("@/api/driver", () => ({
getFines: vi.fn(async (status: string) => ({ getFines: vi.fn(async (status: string) => ({
items: status === "open" ? [FINE] : [FINE, { ...FINE, id: 13, paid: true, amount: 500 }], items: status === "open"
open_count: 1, open_sum: 562.5, ? [FINE]
: [FINE, { ...FINE, id: 13, settled: "paid" as const, amount: 500 }],
open_count: 1, debt: -5156,
})), })),
getFine: vi.fn(async () => ({ getFine: vi.fn(async () => ({
...FINE, post_date: "2026-08-13", speed: 63, speed_limit: 40, speed_excess: 23, ...FINE, post_date: "2026-08-13", speed: 63, speed_limit: 40, speed_excess: 23,
@@ -46,17 +51,25 @@ function wrap(path = "/fines") {
describe("список штрафов", () => { describe("список штрафов", () => {
beforeEach(() => vi.clearAllMocks()); beforeEach(() => vi.clearAllMocks());
it("открывается на неоплаченных и показывает сумму долга", async () => { it("долг берётся из баланса 1С, а не из сумм постановлений", async () => {
wrap(); wrap();
expect(await screen.findByText(/Не оплачено: 1 на 562,50 ₽/)).toBeInTheDocument(); // Парк гасит ГИБДД авансом: если считать по «оплачен/не оплачен» из
// постановлений, экран покажет «долгов нет» при −5 156 ₽ на главном.
expect(await screen.findByText(/Долг по штрафам −5 156 ₽ · не погашено: 1/)).toBeInTheDocument();
expect(vi.mocked(driver.getFines).mock.calls[0][0]).toBe("open"); expect(vi.mocked(driver.getFines).mock.calls[0][0]).toBe("open");
}); });
it("вкладка «Все» показывает и оплаченные", async () => { it("закрытый в ГИБДД штраф остаётся непогашенным для водителя", async () => {
wrap(); wrap();
await screen.findByText(/Не оплачено/); expect(await screen.findByText("не погашен")).toBeInTheDocument();
expect(screen.queryByText("оплачен")).not.toBeInTheDocument();
});
it("вкладка «Все» показывает и погашенные", async () => {
wrap();
await screen.findByText(/Долг по штрафам/);
await userEvent.click(screen.getByRole("button", { name: "Все" })); await userEvent.click(screen.getByRole("button", { name: "Все" }));
expect(await screen.findByText("оплачен")).toBeInTheDocument(); expect(await screen.findByText("погашен")).toBeInTheDocument();
}); });
it("штраф открывается в карточку", async () => { it("штраф открывается в карточку", async () => {
@@ -76,10 +89,12 @@ 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(/63 км\/ч при разрешённых 40/)).toBeInTheDocument(); expect(await screen.findByText("618,75 ₽")).toBeInTheDocument();
expect(screen.getByText(/281,25 ₽ со скидкой/)).toBeInTheDocument(); expect(screen.getByText(/Постановление — 750 ₽/)).toBeInTheDocument();
expect(screen.getByText(/В ГИБДД постановление закрыто/)).toBeInTheDocument();
expect(screen.getByText(/63 км\/ч при разрешённых 40/)).toBeInTheDocument();
expect(screen.getByText("18810525260813035846")).toBeInTheDocument(); expect(screen.getByText("18810525260813035846")).toBeInTheDocument();
}); });
+18 -9
View File
@@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ChevronRight, Image as ImageIcon } from "lucide-react"; import { ChevronRight, Image as ImageIcon } from "lucide-react";
import { getFines } from "@/api/driver"; import { getFines } from "@/api/driver";
import { fineStatusText, isOwed } from "@/lib/fineStatus";
import { formatDate, formatMoney } from "@/lib/format"; import { formatDate, formatMoney } from "@/lib/format";
import { OperationIcon } from "@/components/operationIcon"; import { OperationIcon } from "@/components/operationIcon";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
@@ -13,6 +14,11 @@ import { Spinner } from "@/components/Spinner";
* Строка «Долг по штрафам −5 156 ₽» на балансе не отвечает на вопрос «за что», * Строка «Долг по штрафам −5 156 ₽» на балансе не отвечает на вопрос «за что»,
* и водитель идёт выяснять звонком. Здесь он видит каждое постановление, а в * и водитель идёт выяснять звонком. Здесь он видит каждое постановление, а в
* карточке — фотографию с камеры. * карточке — фотографию с камеры.
*
* Долг в шапке берём из баланса 1С, а не суммой строк: постановление парк гасит
* авансом, и по бумагам ГИБДД оно закрыто в тот же день — долг перед парком от
* этого никуда не девается. Список без этой оговорки показывал бы «неоплаченных
* нет» при −5 156 ₽ на главном экране.
*/ */
export function FinesPage() { export function FinesPage() {
const nav = useNavigate(); const nav = useNavigate();
@@ -29,14 +35,19 @@ export function FinesPage() {
<h1 className="text-lg font-extrabold mb-1">Штрафы</h1> <h1 className="text-lg font-extrabold mb-1">Штрафы</h1>
<p className="text-muted text-xs mb-4"> <p className="text-muted text-xs mb-4">
{data {data
? data.open_count > 0 ? data.debt !== 0 || data.open_count > 0
? `Не оплачено: ${data.open_count} на ${formatMoney(data.open_sum)}` ? `Долг по штрафам ${formatMoney(data.debt)} · не погашено: ${data.open_count}`
: "Неоплаченных нет" : "Долгов по штрафам нет"
: " "} : " "}
</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
key={key} key={key}
onClick={() => setTab(key)} onClick={() => setTab(key)}
@@ -58,7 +69,7 @@ export function FinesPage() {
</div> </div>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<p className="text-muted text-sm text-center py-10"> <p className="text-muted text-sm text-center py-10">
{tab === "open" ? "Неоплаченных штрафов нет" : "Штрафов не найдено"} {tab === "open" ? "Непогашенных штрафов нет" : "Штрафов не найдено"}
</p> </p>
) : ( ) : (
<div> <div>
@@ -78,10 +89,8 @@ export function FinesPage() {
</span> </span>
</span> </span>
<span className="shrink-0 text-right"> <span className="shrink-0 text-right">
<b className={f.paid ? "text-muted" : "text-neg"}>{formatMoney(f.amount)}</b> <b className={isOwed(f) ? "text-neg" : "text-muted"}>{formatMoney(f.amount)}</b>
<span className="block text-muted text-[11px]"> <span className="block text-muted text-[11px]">{fineStatusText(f)}</span>
{f.cancelled ? "отменён" : f.paid ? "оплачен" : "не оплачен"}
</span>
</span> </span>
<ChevronRight size={16} className="text-muted shrink-0" /> <ChevronRight size={16} className="text-muted shrink-0" />
</button> </button>