fix(pwa): экран баланса повторяет карточку CRM

Итог берётся с сервера, а не складывается из строк (1С округляет статьи до
рублей, итог держит с копейками: −8 976 против −8 976,25). Статья, в которую
платёж провести нечем, показывается, но не притворяется кнопкой — «Страховка».
Пустой снимок 1С даёт «Уточняется» вместо нулей.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 15:46:35 +10:00
co-authored by Claude Opus 5
parent 39ee2b7a49
commit 8b0ce4cf45
3 changed files with 65 additions and 19 deletions
+11 -1
View File
@@ -25,7 +25,17 @@ export type AuthRequest = z.infer<typeof AuthRequestSchema>;
export const VerifySchema = z.object({ token: z.string(), driver_id: z.number() }); export const VerifySchema = z.object({ token: z.string(), driver_id: z.number() });
export const BalanceSchema = z.object({ export const BalanceSchema = z.object({
accounts: z.array(z.object({ bucket: z.string(), balance: z.number() })), accounts: z.array(z.object({
bucket: z.string(),
balance: z.number(),
// Можно ли платить в эту статью. «Страховка» приходит из 1С, но платежом не
// закрывается — строку показываем, нажатие не предлагаем.
payable: z.boolean().default(true),
})),
// Итог берём с сервера, а не суммой строк: 1С округляет статьи до рублей, а итог
// держит с копейками, и сумма строк с ним не сходится (−8 976 против 8 976,25).
total: z.number().nullable().default(null),
synced_at: z.string().nullable().default(null),
// Принято нами, но ещё не проведено в 1С. В сумму баланса не входит. // Принято нами, но ещё не проведено в 1С. В сумму баланса не входит.
pending_topup: z.number().default(0), pending_topup: z.number().default(0),
}); });
@@ -3,12 +3,18 @@ import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// Ответ как у настоящего водителя: шесть статей 1С, итог отдельным числом.
vi.mock("@/api/driver", () => ({ vi.mock("@/api/driver", () => ({
getBalance: vi.fn(async () => ({ getBalance: vi.fn(async () => ({
accounts: [ accounts: [
{ bucket: "Долг аренда", balance: -3200 }, { bucket: "Долг аренда", balance: -3050, payable: true },
{ bucket: епозит", balance: 5000 }, { bucket: олг по штрафам", balance: -5156, payable: true },
{ bucket: "Прочее", balance: -770, payable: true },
{ bucket: "Страховка", balance: 0, payable: false },
], ],
total: -8976.25,
synced_at: null,
pending_topup: 0,
})), })),
})); }));
@@ -24,15 +30,22 @@ function wrap() {
} }
describe("BalancePage", () => { describe("BalancePage", () => {
it("shows hero total (sum of negatives) and account rows", async () => { it("итог берётся с сервера, а не складывается из строк", async () => {
wrap(); wrap();
expect(await screen.findByText("Долг аренда")).toBeInTheDocument(); expect(await screen.findByText("Долг аренда")).toBeInTheDocument();
expect(screen.getByText("Депозит")).toBeInTheDocument(); // Сумма статей дала бы −8 976, а в CRM у водителя −8 976,25. Показываем её.
// Appears in both the hero total and the single debt account row. expect(screen.getByText("8 976,25 ₽")).toBeInTheDocument();
expect(screen.getAllByText("3 200 ₽").length).toBeGreaterThan(0);
expect(screen.getByRole("button", { name: /Пополнить/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Пополнить/i })).toBeInTheDocument();
}); });
it("статья, в которую платить нечем, не притворяется кнопкой", async () => {
wrap();
await screen.findByText("Страховка");
const buckets = screen.getAllByRole("button").map((b) => b.textContent ?? "");
expect(buckets.some((t) => t.includes("Долг аренда"))).toBe(true);
expect(buckets.some((t) => t.includes("Страховка"))).toBe(false);
});
it("shows an error state when balance fails to load", async () => { it("shows an error state when balance fails to load", async () => {
const driver = await import("@/api/driver"); const driver = await import("@/api/driver");
(driver.getBalance as any).mockRejectedValueOnce(new Error("boom")); (driver.getBalance as any).mockRejectedValueOnce(new Error("boom"));
+32 -9
View File
@@ -13,15 +13,24 @@ export function BalancePage() {
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["balance"], queryFn: getBalance }); const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["balance"], queryFn: getBalance });
const accounts = data?.accounts ?? []; const accounts = data?.accounts ?? [];
const total = accounts.reduce((s, a) => s + a.balance, 0); // Итог — с сервера (это ровно то число, что видит сотрудник в CRM). Сумма строк
// тут не годится: 1С округляет статьи до рублей, а итог держит с копейками.
const total = data?.total ?? accounts.reduce((s, a) => s + a.balance, 0);
const pending = data?.pending_topup ?? 0; const pending = data?.pending_topup ?? 0;
const noData = !isLoading && !isError && accounts.length === 0;
return ( return (
<div> <div>
<AppHeader /> <AppHeader />
<div className="card p-4 mb-4 text-center"> <div className="card p-4 mb-4 text-center">
<p className="text-muted text-xs mb-1">Баланс</p> <p className="text-muted text-xs mb-1">Баланс</p>
<p className={`text-2xl font-extrabold ${total < 0 ? "text-neg" : "text-pos"}`}>{formatMoney(total)}</p> {noData ? (
<p className="text-sm font-semibold">Уточняется</p>
) : (
<p className={`text-2xl font-extrabold ${total < 0 ? "text-neg" : "text-pos"}`}>
{formatMoney(total)}
</p>
)}
{pending > 0 && ( {pending > 0 && (
// Платёж принят нами, но 1С его ещё не провела. В сумму баланса он не // Платёж принят нами, но 1С его ещё не провела. В сумму баланса он не
// входит намеренно: сложить оба источника значило бы показать // входит намеренно: сложить оба источника значило бы показать
@@ -41,16 +50,30 @@ export function BalancePage() {
</div> </div>
) : ( ) : (
<div className="mb-4"> <div className="mb-4">
{accounts.map((a) => ( {noData && (
<button <p className="text-muted text-sm text-center py-6">
key={a.bucket} Баланс ещё не пришёл из бухгалтерии. Если он нужен прямо сейчас спросите в парке.
onClick={() => nav("/topup", { state: { bucket: a.bucket } })} </p>
className="w-full flex justify-between items-center py-3 border-b border-line last:border-0 text-left" )}
> {accounts.map((a) => {
const row = (
<>
<span className="text-sm">{a.bucket}</span> <span className="text-sm">{a.bucket}</span>
<b className={a.balance < 0 ? "text-neg" : "text-pos"}>{formatMoney(a.balance)}</b> <b className={a.balance < 0 ? "text-neg" : "text-pos"}>{formatMoney(a.balance)}</b>
</>
);
const cls = "w-full flex justify-between items-center py-3 border-b border-line last:border-0 text-left";
// В «Страховку» платёж провести нечем — строку показываем, нажатие не
// предлагаем: кнопка, ведущая в отказ, хуже отсутствующей кнопки.
return a.payable ? (
<button key={a.bucket} className={cls}
onClick={() => nav("/topup", { state: { bucket: a.bucket } })}>
{row}
</button> </button>
))} ) : (
<div key={a.bucket} className={cls}>{row}</div>
);
})}
</div> </div>
)} )}