From 0c433280fe98fd74732108baac00caa449b7e423 Mon Sep 17 00:00:00 2001 From: vladtechno Date: Mon, 24 Aug 2026 20:38:49 +1000 Subject: [PATCH] =?UTF-8?q?balance:=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20?= =?UTF-8?q?=D0=BE=D0=BF=D1=80=D0=B0=D1=88=D0=B8=D0=B2=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=D1=81=D0=B5=D0=B1=D1=8F=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20?= =?UTF-8?q?=D0=BE=D0=BF=D0=BB=D0=B0=D1=82=D1=8B=20=E2=80=94=20=D0=B8=D1=82?= =?UTF-8?q?=D0=BE=D0=B3=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D1=8F=D0=B5?= =?UTF-8?q?=D1=82=D1=81=D1=8F=20=D0=BD=D0=B0=20=D0=B3=D0=BB=D0=B0=D0=B7?= =?UTF-8?q?=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Первая обратная связь: «очень долго обновляется баланс». Экран показывал старый итог, пока приложение не перезапустят: перезапроса по расписанию не было вовсе. - баланс опрашивается каждые 4 сек, пока висит «Зачисляется» ЛИБО в последние 5 минут была платёжная активность (lib/paymentWatch, отметка в sessionStorage — переживает перезагрузку страницы при возврате из банка) - отметка ставится при создании платежа (TopupPage) и при факте оплаты (PaymentPage): опрос успевает застать появление «Зачисляется» при запаздывающем вебхуке и не гаснет раньше, чем снимок баланса догонит платёж при сбое точечного обновления на бэке Парная правка бэкенда — crm2 b64ea35 (запись в 1С каждые 15 сек + точечный снимок баланса). Co-Authored-By: Claude Fable 5 --- driver-pwa/frontend/src/lib/paymentWatch.ts | 51 +++++++++++++++++++ .../frontend/src/pages/BalancePage.test.tsx | 18 ++++++- driver-pwa/frontend/src/pages/BalancePage.tsx | 18 ++++++- driver-pwa/frontend/src/pages/PaymentPage.tsx | 8 ++- driver-pwa/frontend/src/pages/TopupPage.tsx | 4 ++ 5 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 driver-pwa/frontend/src/lib/paymentWatch.ts diff --git a/driver-pwa/frontend/src/lib/paymentWatch.ts b/driver-pwa/frontend/src/lib/paymentWatch.ts new file mode 100644 index 0000000..15ee656 --- /dev/null +++ b/driver-pwa/frontend/src/lib/paymentWatch.ts @@ -0,0 +1,51 @@ +/** + * Отметка «водитель только что платил» — общая для экранов пополнения и баланса. + * + * Зачем она нужна, если экран баланса и так опрашивает себя, пока висит + * «Зачисляется» (pending_topup > 0). У опроса по одному этому признаку два + * слепых пятна, и оба приходятся ровно на сбойные сценарии: + * + * 1. Водитель ушёл с экрана оплаты жестом «назад» ДО прихода вебхука банка + * (СБП подтверждается и через 10–60 секунд). Баланс на тот момент отвечает + * pending_topup=0 — опрос не взводится, и когда вебхук наконец ставит PAID, + * перезапросить баланс уже некому: экран оплаты размонтирован вместе со + * своим опросом. Старый итог висит без строки «Зачисляется» бессрочно. + * + * 2. «Зачисляется» гаснет в момент записи платежа в 1С, и в норме итог в том же + * ответе уже новый. Но точечное обновление снимка — не гарантия (1С может + * не ответить, тогда снимок догонит общий синк за ≤2 минуты): опрос, + * выключившийся по pending_topup=0, этот догон уже не увидит — деньги + * «пропадают» с экрана до смены фокуса. + * + * Поэтому платёжная активность (создание платежа, факт оплаты) оставляет + * отметку, и баланс продолжает опрашиваться ещё WATCH_MS после неё — окно с + * запасом накрывает и лаг вебхука, и полный цикл общего синка. + * + * sessionStorage, а не переменная модуля: PWA на телефоне перезагружает + * страницу охотно (нехватка памяти, возврат из банковского приложения), и + * отметка в памяти не пережила бы ровно тот переход, ради которого заведена. + */ + +const KEY = "pp-payment-activity-at"; + +/** Окно опроса после платёжной активности: лаг вебхука (до минуты) + запись в + * 1С (≤15 с) + общий синк снимка (≤2 мин) — с запасом. */ +export const WATCH_MS = 5 * 60_000; + +export function markPaymentActivity(): void { + try { + sessionStorage.setItem(KEY, String(Date.now())); + } catch { + // Приватный режим или отключённое хранилище: без отметки остаётся опрос по + // «Зачисляется» — деградация до прежнего поведения, а не поломка. + } +} + +export function paymentActivityWithin(ms: number = WATCH_MS): boolean { + try { + const t = Number(sessionStorage.getItem(KEY) || 0); + return t > 0 && Date.now() - t < ms; + } catch { + return false; + } +} diff --git a/driver-pwa/frontend/src/pages/BalancePage.test.tsx b/driver-pwa/frontend/src/pages/BalancePage.test.tsx index b926635..37cfeb6 100644 --- a/driver-pwa/frontend/src/pages/BalancePage.test.tsx +++ b/driver-pwa/frontend/src/pages/BalancePage.test.tsx @@ -38,7 +38,8 @@ vi.mock("@/api/driver", () => ({ })), })); -import { BalancePage } from "./BalancePage"; +import { BalancePage, balancePollMs } from "./BalancePage"; +import { markPaymentActivity } from "@/lib/paymentWatch"; import * as driver from "@/api/driver"; function wrap() { @@ -56,6 +57,21 @@ function wrap() { } describe("главный экран", () => { + it("опрос баланса: пока висит «зачисляется» или водитель недавно платил", () => { + // Водитель после оплаты смотрит на экран и ждёт новый итог — экран обязан + // обновиться сам. Вне платёжных окон опроса быть не должно. + sessionStorage.clear(); + expect(balancePollMs({ pending_topup: 1500 })).toBe(4000); + expect(balancePollMs({ pending_topup: 0 })).toBe(false); + expect(balancePollMs(undefined)).toBe(false); + // Свежая платёжная активность держит опрос и без «зачисляется»: вебхук + // банка может запаздывать (опрос обязан застать появление строки), а снимок + // баланса — догонять платёж общим синком уже после её погашения. + markPaymentActivity(); + expect(balancePollMs({ pending_topup: 0 })).toBe(4000); + sessionStorage.clear(); + }); + it("сверху деньги: итог с сервера и строка «зачисляется»", async () => { wrap(); // Сумма статей дала бы −8 006, а в CRM у водителя −8 976,25. Показываем её. diff --git a/driver-pwa/frontend/src/pages/BalancePage.tsx b/driver-pwa/frontend/src/pages/BalancePage.tsx index c9e8cc6..1893cdb 100644 --- a/driver-pwa/frontend/src/pages/BalancePage.tsx +++ b/driver-pwa/frontend/src/pages/BalancePage.tsx @@ -4,6 +4,7 @@ import { useNavigate } from "react-router-dom"; import { ArrowUp, ChevronRight, LogOut } from "lucide-react"; import { getBalance, type StatementKind } from "@/api/driver"; import { formatMoney, isoDaysAgo, isoToday } from "@/lib/format"; +import { paymentActivityWithin } from "@/lib/paymentWatch"; import { useAuth } from "@/store/auth"; import { AppHeader } from "@/components/AppHeader"; import { NotificationsPrompt } from "@/components/NotificationsPrompt"; @@ -17,6 +18,17 @@ import { Statement } from "@/components/Statement"; // шторки «Пополнить», иначе экран снова превращается в столбик из шести строк. const MAIN_BUCKETS = ["Долг аренда", "Долг по штрафам"]; +// Пока висит «Зачисляется» ЛИБО водитель недавно платил, экран опрашивает +// баланс сам: платёж доезжает до 1С за секунды, и новый итог должен появиться +// на глазах — водитель после оплаты смотрит именно сюда, а перезапускать +// приложение он не обязан. Одного «зачисляется» мало: опрос по нему не +// взводится, пока вебхук банка запаздывает, и гаснет раньше, чем снимок +// баланса догонит платёж при сбое точечного обновления (см. lib/paymentWatch). +// Вне этих окон опрос не нужен: остальные движения баланса водитель не +// караулит у экрана. +export const balancePollMs = (d?: { pending_topup?: number }): number | false => + (d?.pending_topup ?? 0) > 0 || paymentActivityWithin() ? 4000 : false; + /** * Главный экран из двух половин: сверху деньги, снизу — за что они списаны. * @@ -27,7 +39,11 @@ const MAIN_BUCKETS = ["Долг аренда", "Долг по штрафам"]; export function BalancePage() { const nav = useNavigate(); const clear = useAuth((s) => s.clear); - const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["balance"], queryFn: getBalance }); + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ["balance"], + queryFn: getBalance, + refetchInterval: (q) => balancePollMs(q.state.data), + }); const [sheet, setSheet] = useState(false); // Неделя — тот же период, что у вендора: столько человек помнит свои поездки. const [from, setFrom] = useState(isoDaysAgo(7)); diff --git a/driver-pwa/frontend/src/pages/PaymentPage.tsx b/driver-pwa/frontend/src/pages/PaymentPage.tsx index 577ef69..a6607bd 100644 --- a/driver-pwa/frontend/src/pages/PaymentPage.tsx +++ b/driver-pwa/frontend/src/pages/PaymentPage.tsx @@ -3,6 +3,7 @@ import { useLocation, useNavigate, useParams } from "react-router-dom"; import { useQueryClient } from "@tanstack/react-query"; import { Check, Clock, Undo2, WifiOff, X } from "lucide-react"; import { formatMoney } from "@/lib/format"; +import { markPaymentActivity } from "@/lib/paymentWatch"; import { Spinner } from "@/components/Spinner"; import { usePaymentStatus } from "@/hooks/usePaymentStatus"; @@ -31,7 +32,12 @@ export function PaymentPage() { const card = st.method ? st.method === "card" : !!st.payUrl && !st.payUrl.includes("qr.nspk.ru"); useEffect(() => { - if (paid) qc.invalidateQueries({ queryKey: ["balance"] }); + if (paid) { + // Отметка продлевает окно опроса баланса от ФАКТА оплаты: путь до нового + // итога (запись в 1С + снимок) начинается только теперь. + markPaymentActivity(); + qc.invalidateQueries({ queryKey: ["balance"] }); + } }, [paid, qc]); const retry = ( diff --git a/driver-pwa/frontend/src/pages/TopupPage.tsx b/driver-pwa/frontend/src/pages/TopupPage.tsx index b741099..556da30 100644 --- a/driver-pwa/frontend/src/pages/TopupPage.tsx +++ b/driver-pwa/frontend/src/pages/TopupPage.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner"; import { apiErrorText, topup, getCommission, getBuckets } from "@/api/driver"; import { keypadReduce, parseAmount, withCommission } from "@/lib/amount"; import { formatMoney } from "@/lib/format"; +import { markPaymentActivity } from "@/lib/paymentWatch"; import { useStaff } from "@/store/staff"; import { Segment } from "@/components/Segment"; import { Spinner } from "@/components/Spinner"; @@ -49,6 +50,9 @@ export function TopupPage() { nav(`/mock-pay/${r.order_id}`, { state: { total: r.total, bucket, base, method } }); return; } + // Отметка ДО ухода на оплату: вернись водитель на баланс жестом «назад» + // раньше вебхука банка, экран обязан опрашивать себя и подхватить платёж. + markPaymentActivity(); nav(`/pay/${r.order_id}`, { state: { payUrl: r.pay_url, total: r.total, bucket, base, method } }); } catch (err: unknown) { // Текст с сервера, а не глухое «не удалось»: минимум СБП, потолок суммы и