balance: экран опрашивает себя после оплаты — итог обновляется на глазах
Первая обратная связь: «очень долго обновляется баланс». Экран показывал старый итог, пока приложение не перезапустят: перезапроса по расписанию не было вовсе. - баланс опрашивается каждые 4 сек, пока висит «Зачисляется» ЛИБО в последние 5 минут была платёжная активность (lib/paymentWatch, отметка в sessionStorage — переживает перезагрузку страницы при возврате из банка) - отметка ставится при создании платежа (TopupPage) и при факте оплаты (PaymentPage): опрос успевает застать появление «Зачисляется» при запаздывающем вебхуке и не гаснет раньше, чем снимок баланса догонит платёж при сбое точечного обновления на бэке Парная правка бэкенда — crm2 b64ea35 (запись в 1С каждые 15 сек + точечный снимок баланса). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
import * as driver from "@/api/driver";
|
||||||
|
|
||||||
function wrap() {
|
function wrap() {
|
||||||
@@ -56,6 +57,21 @@ function wrap() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("главный экран", () => {
|
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 () => {
|
it("сверху деньги: итог с сервера и строка «зачисляется»", async () => {
|
||||||
wrap();
|
wrap();
|
||||||
// Сумма статей дала бы −8 006, а в CRM у водителя −8 976,25. Показываем её.
|
// Сумма статей дала бы −8 006, а в CRM у водителя −8 976,25. Показываем её.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { ArrowUp, ChevronRight, LogOut } from "lucide-react";
|
import { ArrowUp, ChevronRight, LogOut } from "lucide-react";
|
||||||
import { getBalance, type StatementKind } from "@/api/driver";
|
import { getBalance, type StatementKind } from "@/api/driver";
|
||||||
import { formatMoney, isoDaysAgo, isoToday } from "@/lib/format";
|
import { formatMoney, isoDaysAgo, isoToday } from "@/lib/format";
|
||||||
|
import { paymentActivityWithin } from "@/lib/paymentWatch";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
import { AppHeader } from "@/components/AppHeader";
|
import { AppHeader } from "@/components/AppHeader";
|
||||||
import { NotificationsPrompt } from "@/components/NotificationsPrompt";
|
import { NotificationsPrompt } from "@/components/NotificationsPrompt";
|
||||||
@@ -17,6 +18,17 @@ import { Statement } from "@/components/Statement";
|
|||||||
// шторки «Пополнить», иначе экран снова превращается в столбик из шести строк.
|
// шторки «Пополнить», иначе экран снова превращается в столбик из шести строк.
|
||||||
const MAIN_BUCKETS = ["Долг аренда", "Долг по штрафам"];
|
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() {
|
export function BalancePage() {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const clear = useAuth((s) => s.clear);
|
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 [sheet, setSheet] = useState(false);
|
||||||
// Неделя — тот же период, что у вендора: столько человек помнит свои поездки.
|
// Неделя — тот же период, что у вендора: столько человек помнит свои поездки.
|
||||||
const [from, setFrom] = useState(isoDaysAgo(7));
|
const [from, setFrom] = useState(isoDaysAgo(7));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useLocation, useNavigate, useParams } from "react-router-dom";
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Check, Clock, Undo2, WifiOff, X } from "lucide-react";
|
import { Check, Clock, Undo2, WifiOff, X } from "lucide-react";
|
||||||
import { formatMoney } from "@/lib/format";
|
import { formatMoney } from "@/lib/format";
|
||||||
|
import { markPaymentActivity } from "@/lib/paymentWatch";
|
||||||
import { Spinner } from "@/components/Spinner";
|
import { Spinner } from "@/components/Spinner";
|
||||||
import { usePaymentStatus } from "@/hooks/usePaymentStatus";
|
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");
|
const card = st.method ? st.method === "card" : !!st.payUrl && !st.payUrl.includes("qr.nspk.ru");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (paid) qc.invalidateQueries({ queryKey: ["balance"] });
|
if (paid) {
|
||||||
|
// Отметка продлевает окно опроса баланса от ФАКТА оплаты: путь до нового
|
||||||
|
// итога (запись в 1С + снимок) начинается только теперь.
|
||||||
|
markPaymentActivity();
|
||||||
|
qc.invalidateQueries({ queryKey: ["balance"] });
|
||||||
|
}
|
||||||
}, [paid, qc]);
|
}, [paid, qc]);
|
||||||
|
|
||||||
const retry = (
|
const retry = (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { toast } from "sonner";
|
|||||||
import { apiErrorText, topup, getCommission, getBuckets } from "@/api/driver";
|
import { apiErrorText, topup, getCommission, getBuckets } from "@/api/driver";
|
||||||
import { keypadReduce, parseAmount, withCommission } from "@/lib/amount";
|
import { keypadReduce, parseAmount, withCommission } from "@/lib/amount";
|
||||||
import { formatMoney } from "@/lib/format";
|
import { formatMoney } from "@/lib/format";
|
||||||
|
import { markPaymentActivity } from "@/lib/paymentWatch";
|
||||||
import { useStaff } from "@/store/staff";
|
import { useStaff } from "@/store/staff";
|
||||||
import { Segment } from "@/components/Segment";
|
import { Segment } from "@/components/Segment";
|
||||||
import { Spinner } from "@/components/Spinner";
|
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 } });
|
nav(`/mock-pay/${r.order_id}`, { state: { total: r.total, bucket, base, method } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Отметка ДО ухода на оплату: вернись водитель на баланс жестом «назад»
|
||||||
|
// раньше вебхука банка, экран обязан опрашивать себя и подхватить платёж.
|
||||||
|
markPaymentActivity();
|
||||||
nav(`/pay/${r.order_id}`, { state: { payUrl: r.pay_url, total: r.total, bucket, base, method } });
|
nav(`/pay/${r.order_id}`, { state: { payUrl: r.pay_url, total: r.total, bucket, base, method } });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// Текст с сервера, а не глухое «не удалось»: минимум СБП, потолок суммы и
|
// Текст с сервера, а не глухое «не удалось»: минимум СБП, потолок суммы и
|
||||||
|
|||||||
Reference in New Issue
Block a user