908386b0da
ExpireInvoicesCommand бил по SaaS-таблице saas_invoices через дефолтное
соединение (crm_app_user, RLS). Планировщик бежит без app.current_tenant_id
→ policy отдаёт 0 строк → команда НИКОГДА не помечала счёт overdue (тот же
класс бага, что SendNewLeadsDigestJob). На бою проверено: роль портала видит
0 счетов, реально 3 (кандидатов на просрочку сейчас 0 — живого вреда нет).
Лечение как у остальных cross-tenant обслуживающих команд (ScrubSoftDeletedDeals,
ReportsCleanupExpired): SaasInvoice::on('pgsql_supplier') (BYPASSRLS).
Тест: +регрессия «просрочивает счёт даже без контекста фирмы (tenant 0)»;
+SharesSupplierPdo. 2/2 зелёные, Larastan 0.
Найдено при аудите-хвосте после дайджест-фикса (01287d08).
Escape: владелец дал явное «коммить пуш и кати» + выбрал доделать в worktree.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
56 lines
3.0 KiB
PHP
56 lines
3.0 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Models\LegalEntity;
|
||
use App\Models\SaasInvoice;
|
||
use App\Models\Tenant;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Tests\Concerns\SharesSupplierPdo;
|
||
|
||
// Команда просрочивает счета через pgsql_supplier (BYPASSRLS — иначе под RLS-ролью
|
||
// планировщика saas_invoices не видны, просрочка = no-op). SharesSupplierPdo делает
|
||
// pgsql_supplier общим PDO с pgsql, иначе незакоммиченный тестовый счёт не виден.
|
||
uses(DatabaseTransactions::class);
|
||
uses(SharesSupplierPdo::class);
|
||
|
||
function expSeed(int $tenantId, int $leId, string $status, string $number, $expiresAt): SaasInvoice
|
||
{
|
||
return SaasInvoice::create([
|
||
'tenant_id' => $tenantId, 'legal_entity_id' => $leId, 'invoice_number' => $number,
|
||
'payer_type' => 'legal', 'amount_net' => '100.00', 'amount_total' => '100.00',
|
||
'status' => $status, 'issued_at' => now()->subDays(10), 'expires_at' => $expiresAt,
|
||
]);
|
||
}
|
||
|
||
it('помечает overdue только просроченные неоплаченные счета', function () {
|
||
$t = Tenant::factory()->create();
|
||
$le = LegalEntity::create(['code' => 'exp_'.uniqid(), 'name' => 'ИП', 'legal_form' => 'IP', 'inn' => '770000000020']);
|
||
$stale = expSeed($t->id, $le->id, 'issued', 'СЧ-2026-02001', now()->subDay());
|
||
$fresh = expSeed($t->id, $le->id, 'issued', 'СЧ-2026-02002', now()->addDay());
|
||
$paid = expSeed($t->id, $le->id, 'paid', 'СЧ-2026-02003', now()->subDay());
|
||
|
||
$this->artisan('invoices:expire')->assertExitCode(0);
|
||
|
||
expect(SaasInvoice::find($stale->id)->status)->toBe('overdue')
|
||
->and(SaasInvoice::find($fresh->id)->status)->toBe('issued')
|
||
->and(SaasInvoice::find($paid->id)->status)->toBe('paid');
|
||
});
|
||
|
||
it('просрочивает счёт даже без контекста фирмы (планировщик бежит под системным tenant 0)', function () {
|
||
// Регрессия на «тихий no-op»: saas_invoices под RLS, планировщик — без
|
||
// app.current_tenant_id. Команда обязана просрочить счёт через BYPASSRLS,
|
||
// а не остаться пустышкой. Раньше overdue не выставлялся никогда.
|
||
$t = Tenant::factory()->create();
|
||
$le = LegalEntity::create(['code' => 'exp_'.uniqid(), 'name' => 'ИП', 'legal_form' => 'IP', 'inn' => '770000000021']);
|
||
$stale = expSeed($t->id, $le->id, 'issued', 'СЧ-2026-02010', now()->subDay());
|
||
|
||
// Планировщик реально запускает команду без контекста своей фирмы.
|
||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||
|
||
$this->artisan('invoices:expire')->assertExitCode(0);
|
||
|
||
expect(SaasInvoice::on('pgsql_supplier')->find($stale->id)->status)->toBe('overdue');
|
||
});
|