fix(pwa): в штрафах «погашен», а не «оплачен»
Слово «оплачен» водитель читает как «я ничего не должен», а постановление к тому моменту оплачено парком — не им. Поэтому в списке и карточке состояние теперь «не погашен / погашен / не выставлялся / уточняется», долг в шапке берётся из баланса 1С, а карточка объясняет расхождение сумм: «постановление — 750 ₽, парк оплатил его со скидкой и выставил вам фактическую сумму». Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<typeof FineBriefSchema>;
|
||||
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({
|
||||
|
||||
@@ -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 { 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() {
|
||||
</p>
|
||||
|
||||
<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)}
|
||||
</p>
|
||||
<p className="text-muted text-xs mt-1">
|
||||
{f.cancelled ? "Постановление отменено" : f.paid ? "Оплачен" : "Не оплачен"}
|
||||
</p>
|
||||
{discountLive && (
|
||||
// Половина суммы в первые 20 дней — то, ради чего в карточку заходят
|
||||
// вовремя. Дату показываем прямо здесь, а не в реквизитах ниже.
|
||||
<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>
|
||||
@@ -111,7 +121,7 @@ export function FineDetailPage() {
|
||||
</a>
|
||||
)}
|
||||
|
||||
{!f.paid && !f.cancelled && (
|
||||
{isOwed(f) && (
|
||||
<button className="btn-primary"
|
||||
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 { 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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<h1 className="text-lg font-extrabold mb-1">Штрафы</h1>
|
||||
<p className="text-muted text-xs mb-4">
|
||||
{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}`
|
||||
: "Долгов по штрафам нет"
|
||||
: " "}
|
||||
</p>
|
||||
|
||||
<p className="text-muted text-[11px] mb-3 leading-4">
|
||||
Постановление в ГИБДД оплачивает парк — как правило, сразу и со скидкой.
|
||||
Вы возвращаете парку начисленную сумму: она и складывается в долг по штрафам.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-1.5 mb-3">
|
||||
{([["open", "Неоплаченные"], ["all", "Все"]] as const).map(([key, label]) => (
|
||||
{([["open", "Не погашены"], ["all", "Все"]] as const).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTab(key)}
|
||||
@@ -58,7 +69,7 @@ export function FinesPage() {
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-muted text-sm text-center py-10">
|
||||
{tab === "open" ? "Неоплаченных штрафов нет" : "Штрафов не найдено"}
|
||||
{tab === "open" ? "Непогашенных штрафов нет" : "Штрафов не найдено"}
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
@@ -78,10 +89,8 @@ export function FinesPage() {
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-right">
|
||||
<b className={f.paid ? "text-muted" : "text-neg"}>{formatMoney(f.amount)}</b>
|
||||
<span className="block text-muted text-[11px]">
|
||||
{f.cancelled ? "отменён" : f.paid ? "оплачен" : "не оплачен"}
|
||||
</span>
|
||||
<b className={isOwed(f) ? "text-neg" : "text-muted"}>{formatMoney(f.amount)}</b>
|
||||
<span className="block text-muted text-[11px]">{fineStatusText(f)}</span>
|
||||
</span>
|
||||
<ChevronRight size={16} className="text-muted shrink-0" />
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user