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 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С. В сумму баланса не входит.
pending_topup: z.number().default(0),
});
@@ -3,12 +3,18 @@ import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// Ответ как у настоящего водителя: шесть статей 1С, итог отдельным числом.
vi.mock("@/api/driver", () => ({
getBalance: vi.fn(async () => ({
accounts: [
{ bucket: "Долг аренда", balance: -3200 },
{ bucket: епозит", balance: 5000 },
{ bucket: "Долг аренда", balance: -3050, payable: true },
{ 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", () => {
it("shows hero total (sum of negatives) and account rows", async () => {
it("итог берётся с сервера, а не складывается из строк", async () => {
wrap();
expect(await screen.findByText("Долг аренда")).toBeInTheDocument();
expect(screen.getByText("Депозит")).toBeInTheDocument();
// Appears in both the hero total and the single debt account row.
expect(screen.getAllByText("3 200 ₽").length).toBeGreaterThan(0);
// Сумма статей дала бы −8 976, а в CRM у водителя −8 976,25. Показываем её.
expect(screen.getByText("8 976,25 ₽")).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 () => {
const driver = await import("@/api/driver");
(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 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 noData = !isLoading && !isError && accounts.length === 0;
return (
<div>
<AppHeader />
<div className="card p-4 mb-4 text-center">
<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 && (
// Платёж принят нами, но 1С его ещё не провела. В сумму баланса он не
// входит намеренно: сложить оба источника значило бы показать
@@ -41,16 +50,30 @@ export function BalancePage() {
</div>
) : (
<div className="mb-4">
{accounts.map((a) => (
<button
key={a.bucket}
onClick={() => nav("/topup", { state: { bucket: a.bucket } })}
className="w-full flex justify-between items-center py-3 border-b border-line last:border-0 text-left"
>
{noData && (
<p className="text-muted text-sm text-center py-6">
Баланс ещё не пришёл из бухгалтерии. Если он нужен прямо сейчас спросите в парке.
</p>
)}
{accounts.map((a) => {
const row = (
<>
<span className="text-sm">{a.bucket}</span>
<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>
))}
) : (
<div key={a.bucket} className={cls}>{row}</div>
);
})}
</div>
)}