diff --git a/driver-pwa/frontend/src/api/driver.ts b/driver-pwa/frontend/src/api/driver.ts
index c46669a..b08a079 100644
--- a/driver-pwa/frontend/src/api/driver.ts
+++ b/driver-pwa/frontend/src/api/driver.ts
@@ -147,10 +147,17 @@ const FineBriefSchema = z.object({
article: z.string(),
address: z.string().default(""),
car_number: z.string().default(""),
+ // Сумма, начисленная ВОДИТЕЛЮ (парк платит ГИБДД со скидкой и выставляет то,
+ // что заплатил). fine_amount — номинал постановления, он бывает другим.
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),
- 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),
has_photo: z.boolean().default(false),
});
@@ -159,7 +166,8 @@ export type FineBrief = z.infer;
export const FinesSchema = z.object({
items: z.array(FineBriefSchema),
open_count: z.number(),
- open_sum: z.number(),
+ // Долг по штрафам из 1С — тот же, что на главном экране (отрицательный).
+ debt: z.number(),
});
export const FineDetailSchema = FineBriefSchema.extend({
diff --git a/driver-pwa/frontend/src/lib/fineStatus.ts b/driver-pwa/frontend/src/lib/fineStatus.ts
new file mode 100644
index 0000000..70f2773
--- /dev/null
+++ b/driver-pwa/frontend/src/lib/fineStatus.ts
@@ -0,0 +1,30 @@
+import type { FineBrief } from "@/api/driver";
+
+/**
+ * Что написать водителю о штрафе.
+ *
+ * Ключевое различие, которое интерфейс обязан удерживать: постановление в ГИБДД
+ * гасит ПАРК, обычно авансом и со скидкой, — и с этого момента штраф закрыт для
+ * государства, но не для водителя. Водитель возвращает деньги парку, и это видно
+ * только по балансу «Долг по штрафам». Поэтому слово «оплачен» здесь не
+ * используется вовсе: оно означало бы для человека «я ничего не должен».
+ */
+export function fineStatusText(f: Pick): string {
+ switch (f.settled) {
+ case "owed":
+ return "не погашен";
+ case "paid":
+ return "погашен";
+ case "not_charged":
+ return "не выставлялся";
+ case "cancelled":
+ return "отменён";
+ default:
+ return "уточняется";
+ }
+}
+
+/** Долг именно этого штрафа перед парком — по нему красим сумму. */
+export function isOwed(f: Pick): boolean {
+ return f.settled === "owed";
+}
diff --git a/driver-pwa/frontend/src/pages/FineDetailPage.tsx b/driver-pwa/frontend/src/pages/FineDetailPage.tsx
index ab82b6d..3e4b686 100644
--- a/driver-pwa/frontend/src/pages/FineDetailPage.tsx
+++ b/driver-pwa/frontend/src/pages/FineDetailPage.tsx
@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router-dom";
import { FileText, Gauge } from "lucide-react";
import { getFine } from "@/api/driver";
+import { fineStatusText, isOwed } from "@/lib/fineStatus";
import { formatDate, formatMoney } from "@/lib/format";
import { Spinner } from "@/components/Spinner";
@@ -39,7 +40,7 @@ export function FineDetailPage() {
}
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());
return (
@@ -72,15 +73,24 @@ export function FineDetailPage() {
-
+
Начислено вам
+
{formatMoney(f.amount)}
-
- {f.cancelled ? "Постановление отменено" : f.paid ? "Оплачен" : "Не оплачен"}
-
- {discountLive && (
- // Половина суммы в первые 20 дней — то, ради чего в карточку заходят
- // вовремя. Дату показываем прямо здесь, а не в реквизитах ниже.
+
{fineStatusText(f)}
+ {f.billed && f.fine_amount > 0 && f.fine_amount !== f.amount && (
+ // Номинал постановления и начисление расходятся почти всегда: парк
+ // платит со скидкой. Не показать этого — значит получить вопрос «почему
+ // с меня 618, если в постановлении 750».
+
+ Постановление — {formatMoney(f.fine_amount)}, парк оплатил его
+ {f.fine_amount > f.amount ? " со скидкой" : ""} и выставил вам фактическую сумму
+
+ )}
+ {f.closed_with_gibdd && (
+
В ГИБДД постановление закрыто
+ )}
+ {discountLive && !f.billed && (
До {formatDate(f.discount_until)} — {formatMoney(f.discount_amount)} со скидкой
@@ -111,7 +121,7 @@ export function FineDetailPage() {
)}
- {!f.paid && !f.cancelled && (
+ {isOwed(f) && (
nav("/topup", { state: { bucket: "Долг по штрафам" } })}>
Оплатить штрафы
diff --git a/driver-pwa/frontend/src/pages/FinesPage.test.tsx b/driver-pwa/frontend/src/pages/FinesPage.test.tsx
index 8ad287c..4e1b523 100644
--- a/driver-pwa/frontend/src/pages/FinesPage.test.tsx
+++ b/driver-pwa/frontend/src/pages/FinesPage.test.tsx
@@ -4,17 +4,22 @@ import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+// Реальный случай: парк уже погасил постановление в ГИБДД со скидкой, а водитель
+// парку ещё должен. Именно на нём приложение показывало «неоплаченных нет».
const FINE = {
id: 12, number: "18810525260813035846", date: "2026-08-12",
article: "12.09.2 - Превышение скорости", address: "Владивосток, Некрасовская, 90",
- car_number: "А947УТ125", amount: 562.5, discount_amount: 281.25,
- discount_until: "2026-09-01", paid: false, cancelled: false, has_photo: true,
+ car_number: "А947УТ125", amount: 618.75, billed: true, fine_amount: 750,
+ 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", () => ({
getFines: vi.fn(async (status: string) => ({
- items: status === "open" ? [FINE] : [FINE, { ...FINE, id: 13, paid: true, amount: 500 }],
- open_count: 1, open_sum: 562.5,
+ items: status === "open"
+ ? [FINE]
+ : [FINE, { ...FINE, id: 13, settled: "paid" as const, amount: 500 }],
+ open_count: 1, debt: -5156,
})),
getFine: vi.fn(async () => ({
...FINE, post_date: "2026-08-13", speed: 63, speed_limit: 40, speed_excess: 23,
@@ -46,17 +51,25 @@ function wrap(path = "/fines") {
describe("список штрафов", () => {
beforeEach(() => vi.clearAllMocks());
- it("открывается на неоплаченных и показывает сумму долга", async () => {
+ it("долг берётся из баланса 1С, а не из сумм постановлений", async () => {
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");
});
- it("вкладка «Все» показывает и оплаченные", async () => {
+ it("закрытый в ГИБДД штраф остаётся непогашенным для водителя", async () => {
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: "Все" }));
- expect(await screen.findByText("оплачен")).toBeInTheDocument();
+ expect(await screen.findByText("погашен")).toBeInTheDocument();
});
it("штраф открывается в карточку", async () => {
@@ -76,10 +89,12 @@ describe("карточка штрафа", () => {
expect(img.closest("a")).toHaveAttribute("target", "_blank");
});
- it("скорость и скидка объясняют штраф без звонка в парк", async () => {
+ it("объясняет, почему начислено меньше номинала постановления", async () => {
wrap("/fines/12");
- expect(await screen.findByText(/63 км\/ч при разрешённых 40/)).toBeInTheDocument();
- expect(screen.getByText(/281,25 ₽ со скидкой/)).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("18810525260813035846")).toBeInTheDocument();
});
diff --git a/driver-pwa/frontend/src/pages/FinesPage.tsx b/driver-pwa/frontend/src/pages/FinesPage.tsx
index be6330d..3198ab0 100644
--- a/driver-pwa/frontend/src/pages/FinesPage.tsx
+++ b/driver-pwa/frontend/src/pages/FinesPage.tsx
@@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { ChevronRight, Image as ImageIcon } from "lucide-react";
import { getFines } from "@/api/driver";
+import { fineStatusText, isOwed } from "@/lib/fineStatus";
import { formatDate, formatMoney } from "@/lib/format";
import { OperationIcon } from "@/components/operationIcon";
import { Spinner } from "@/components/Spinner";
@@ -13,6 +14,11 @@ import { Spinner } from "@/components/Spinner";
* Строка «Долг по штрафам −5 156 ₽» на балансе не отвечает на вопрос «за что»,
* и водитель идёт выяснять звонком. Здесь он видит каждое постановление, а в
* карточке — фотографию с камеры.
+ *
+ * Долг в шапке берём из баланса 1С, а не суммой строк: постановление парк гасит
+ * авансом, и по бумагам ГИБДД оно закрыто в тот же день — долг перед парком от
+ * этого никуда не девается. Список без этой оговорки показывал бы «неоплаченных
+ * нет» при −5 156 ₽ на главном экране.
*/
export function FinesPage() {
const nav = useNavigate();
@@ -29,14 +35,19 @@ export function FinesPage() {
Штрафы
{data
- ? data.open_count > 0
- ? `Не оплачено: ${data.open_count} на ${formatMoney(data.open_sum)}`
- : "Неоплаченных нет"
+ ? data.debt !== 0 || data.open_count > 0
+ ? `Долг по штрафам ${formatMoney(data.debt)} · не погашено: ${data.open_count}`
+ : "Долгов по штрафам нет"
: " "}
+
+ Постановление в ГИБДД оплачивает парк — как правило, сразу и со скидкой.
+ Вы возвращаете парку начисленную сумму: она и складывается в долг по штрафам.
+
+
- {([["open", "Неоплаченные"], ["all", "Все"]] as const).map(([key, label]) => (
+ {([["open", "Не погашены"], ["all", "Все"]] as const).map(([key, label]) => (
setTab(key)}
@@ -58,7 +69,7 @@ export function FinesPage() {
) : items.length === 0 ? (
- {tab === "open" ? "Неоплаченных штрафов нет" : "Штрафов не найдено"}
+ {tab === "open" ? "Непогашенных штрафов нет" : "Штрафов не найдено"}
) : (
@@ -78,10 +89,8 @@ export function FinesPage() {
- {formatMoney(f.amount)}
-
- {f.cancelled ? "отменён" : f.paid ? "оплачен" : "не оплачен"}
-
+ {formatMoney(f.amount)}
+ {fineStatusText(f)}