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(),
|
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({
|
||||||
|
|||||||
@@ -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 && (
|
||||||
</p>
|
// Номинал постановления и начисление расходятся почти всегда: парк
|
||||||
{discountLive && (
|
// платит со скидкой. Не показать этого — значит получить вопрос «почему
|
||||||
// Половина суммы в первые 20 дней — то, ради чего в карточку заходят
|
// с меня 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">
|
<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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user