Files
portal/app/tests/Feature/Bot/ClientFactsTest.php
T
Дмитрий 1ed82b6b36 fix: сутки по всему порталу считаются с московской полуночи, а не с трёх ночи
Продолжение правки отдела продаж от 02.08: тот же класс ошибки найден ещё
в тринадцати местах. Граница периода, посчитанная по Москве, уходит в базу
надписью без смещения и читается как гринвичская — сутки съезжают на три часа.

У ошибки оказалось ДВА вкуса, и поиск из промта находил только первый:
  1. граница по Москве, отданная как есть (дашборд клиента, списания,
     расход на рекламу, карточка клиента у отдела продаж, сводка админа);
  2. граница ВООБЩЕ без Москвы - now()->startOfDay() и Carbon::today();
     пояс приложения гринвичский, значит день начинался в 03:00 МСК
     (список лидов и выгрузка, биллинг, карточка клиента у админа,
     посетители, ответ бота клиенту, сверка CSV, снимок рекламной кампании).

Правило названо в одном месте - App\Support\MskBoundary: instant() отдаёт
границу мгновением, dayAfter() - первое мгновение после дня (полуинтервал,
чтобы не терять последнюю секунду). Календарные даты и счёт дней НЕ трогали:
там нужен именно московский календарь.

Заодно в списаниях клиента убрана вторая копия условий периода - выгрузка
CSV фильтровала по своей копии, и та уже разъехалась с общей.

Проверка: tests/Feature/NightBoundaryMskTest.php - девять проверок с часами,
замороженными на 00:30 МСК. До правки девять из девяти красные, после - зелёные.
Две из них поначалу проходили и на сломанном коде: в 00:30 МСК гринвичский день
это ещё вчерашний, и ночное событие случайно попадало в окно; добавлено второе
событие "вчера днём", которое обязано остаться за бортом.

Три чужие проверки закрепляли старую ошибку и поправлены:
  - DealIndexTest "конец дня" клал заявку в 23:30 по Гринвичу - по московскому
    календарю это уже 02:30 следующего дня;
  - ClientFactsTest и GuardCardContractTest строят заявки от now() и ночью
    краснеют сами - часы заморожены на 12:00 МСК с возвратом в afterEach.

Статанализ: 0. Форматтер: чисто.
2026-08-02 10:18:45 +03:00

218 lines
9.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\BalanceTransaction;
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\PricingTier;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Bot\ClientFacts;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// Часы держим на полудне по Москве. Заявки бот считает по МОСКОВСКИМ суткам, а
// фикстуры ниже строятся от `now()`: между 00:00 и 03:00 МСК московский и
// гринвичский день — разные даты, и проверки краснели бы только по ночам.
beforeEach(function (): void {
$polden = CarbonImmutable::now('Europe/Moscow')->startOfDay()->addHours(12);
Carbon::setTestNow($polden);
CarbonImmutable::setTestNow($polden);
});
afterEach(function (): void {
Carbon::setTestNow();
CarbonImmutable::setTestNow();
});
/** Две ступени: 1-я — 300 заявок по 50 ₽, 2-я — «всё свыше» по 40 ₽. */
function tiers(): void
{
PricingTier::factory()->create(['tier_no' => 1, 'leads_in_tier' => 300, 'price_per_lead_kopecks' => 5000]);
PricingTier::factory()->create(['tier_no' => 2, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 4000]);
}
it('называет баланс, сколько это заявок и на сколько дней хватит', function () {
tiers();
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00', 'delivered_in_month' => 0]);
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true, 'daily_limit_target' => 10]);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$card = app(ClientFacts::class)->card((int) $user->id);
// 1000 ₽ по 50 ₽ = 20 заявок; заказ 10/день → на 2 дня.
expect($card)->toContain('1 000 ₽')
->and($card)->toContain('20 заявок')
->and($card)->toContain('на 2 дня');
});
it('честно говорит, что тратить нечего, когда активных проектов нет', function () {
tiers();
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00', 'delivered_in_month' => 0]);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
expect(app(ClientFacts::class)->card((int) $user->id))
->toContain('активных проектов нет');
});
it('предупреждает о заморозке заказа из-за нулевого баланса', function () {
tiers();
$tenant = Tenant::factory()->create([
'balance_rub' => '0.00',
'delivered_in_month' => 0,
'frozen_by_balance_at' => now(),
]);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
expect(app(ClientFacts::class)->card((int) $user->id))
->toContain('Заказ заморожен');
});
it('отдаёт null, когда такого пользователя нет', function () {
expect(app(ClientFacts::class)->card(999999))->toBeNull();
});
it('называет ступень, цену заявки и сколько заявок до следующей ступени', function () {
tiers();
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00', 'delivered_in_month' => 180]);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$card = app(ClientFacts::class)->card((int) $user->id);
expect($card)->toContain('Ступень: 1-я, 50 ₽ за заявку')
->and($card)->toContain('получено в этом месяце 180')
->and($card)->toContain('до следующей ступени — 120');
});
it('на границе ступени называет уже СЛЕДУЮЩУЮ цену: 300 заявок пройдено — цена новая', function () {
tiers();
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00', 'delivered_in_month' => 300]);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
// 301-й лид идёт уже по цене второй ступени — так считает биллинг, так должен говорить бот.
expect(app(ClientFacts::class)->card((int) $user->id))
->toContain('Ступень: 2-я, 40 ₽ за заявку');
});
it('при пустой сетке цен не сочиняет арифметику, а честно говорит, что считать нечем', function () {
// ступени НЕ создаём
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00', 'delivered_in_month' => 0]);
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true, 'daily_limit_target' => 10]);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$card = app(ClientFacts::class)->card((int) $user->id);
expect($card)->toContain('1 000 ₽')
->and($card)->toContain('Сетка цен не настроена')
->and($card)->not->toContain('на 0 дней')
->and($card)->not->toContain('≈ 0 заявок');
});
it('на последней ступени говорит, что выше ступеней нет', function () {
tiers();
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00', 'delivered_in_month' => 500]);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$card = app(ClientFacts::class)->card((int) $user->id);
expect($card)->toContain('Ступень: 2-я, 40 ₽ за заявку')
->and($card)->toContain('это последняя ступень');
});
it('склоняет слова по-русски: одна заявка, один день', function () {
tiers();
// 50 ₽ по цене 50 ₽ = ровно 1 заявка; заказ 1/день → 1 день.
$tenant = Tenant::factory()->create(['balance_rub' => '50.00', 'delivered_in_month' => 0]);
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true, 'daily_limit_target' => 1]);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$card = app(ClientFacts::class)->card((int) $user->id);
expect($card)->toContain('1 заявка')
->and($card)->toContain('на 1 день')
->and($card)->not->toContain('дня(дней)');
});
it('перечисляет проекты и их состояние', function () {
tiers();
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00', 'delivered_in_month' => 0]);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
Project::factory()->create([
'tenant_id' => $tenant->id, 'name' => 'Стоматология МСК',
'is_active' => true, 'daily_limit_target' => 20, 'delivered_today' => 12,
]);
Project::factory()->create([
'tenant_id' => $tenant->id, 'name' => 'Клиника СПб',
'is_active' => true, 'paused_at' => now(),
]);
Project::factory()->create([
'tenant_id' => $tenant->id, 'name' => 'Без денег',
'is_active' => true, 'preflight_blocked_at' => now(),
]);
Project::factory()->create([
'tenant_id' => $tenant->id, 'name' => 'Выключенный', 'is_active' => false,
]);
$card = app(ClientFacts::class)->card((int) $user->id);
expect($card)->toContain('Проекты (4)')
->and($card)->toContain('«Стоматология МСК» — работает, лимит 20 в день, сегодня 12')
->and($card)->toContain('«Клиника СПб» — на паузе')
->and($card)->toContain('«Без денег» — остановлен из-за нехватки денег')
->and($card)->toContain('«Выключенный» — выключен');
});
it('считает заявки за периоды, списания за месяц и последнее пополнение', function () {
tiers();
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00', 'delivered_in_month' => 0]);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
// Сегодня 2 заявки, вчера 1. Даты держим ВНУТРИ текущего месяца — партиции deals помесячные.
Deal::factory()->count(2)->create([
'tenant_id' => $tenant->id, 'project_id' => $project->id,
'received_at' => now()->startOfDay()->addHours(9),
]);
Deal::factory()->create([
'tenant_id' => $tenant->id, 'project_id' => $project->id,
'received_at' => now()->subDay()->startOfDay()->addHours(9),
]);
LeadCharge::factory()->count(2)->create([
'tenant_id' => $tenant->id,
'price_per_lead_kopecks' => 5000, // 50 ₽ × 2 = 100 ₽
'charged_at' => now(),
]);
BalanceTransaction::factory()->create([
'tenant_id' => $tenant->id,
'type' => BalanceTransaction::TYPE_TOPUP,
'amount_rub' => '5000.00',
'created_at' => now()->subDays(2),
]);
$card = app(ClientFacts::class)->card((int) $user->id);
expect($card)->toContain('Заявки: сегодня 2, вчера 1')
->and($card)->toContain('Списано за месяц: 100 ₽')
->and($card)->toContain('Последнее пополнение: 5 000 ₽');
});
it('не падает на пустом клиенте', function () {
tiers();
$tenant = Tenant::factory()->create(['balance_rub' => '0.00', 'delivered_in_month' => 0]);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$card = app(ClientFacts::class)->card((int) $user->id);
expect($card)->toContain('Проектов нет')
->and($card)->toContain('Заявки: сегодня 0, вчера 0')
->and($card)->toContain('Пополнений ещё не было');
});