1ed82b6b36
Продолжение правки отдела продаж от 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. Форматтер: чисто.
218 lines
9.9 KiB
PHP
218 lines
9.9 KiB
PHP
<?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('Пополнений ещё не было');
|
||
});
|