fix(pwa): экран баланса повторяет карточку CRM
Итог берётся с сервера, а не складывается из строк (1С округляет статьи до рублей, итог держит с копейками: −8 976 против −8 976,25). Статья, в которую платёж провести нечем, показывается, но не притворяется кнопкой — «Страховка». Пустой снимок 1С даёт «Уточняется» вместо нулей. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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"));
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user