eb8ca65c5d
Закрыт TODO (a) из v1.49: общая логика выбора активного supplier'а через
project_suppliers m2m была дублирована между ProcessWebhookJob (webhook-flow)
и DealController (manual-create) — 11 одинаковых строк query-builder'а на
2 файла. Теперь — единственный источник истины + DI через app() (тот же
паттерн, что у DuplicateDetector в v1.23).
App\Services\SupplierResolver:
- resolveForProject(Project): ?int — точная копия прежней query
(project_suppliers JOIN suppliers, is_active+is_active, ORDER BY
sort_order, id).
- costRubSnapshot(int $supplierId): string — вынесенный snapshot цены
для записи в supplier_lead_costs.
ProcessWebhookJob и DealController:
- Удалены private resolveSupplierId() (по 14 строк).
- Удалены локальные DB::table('suppliers')->value('cost_rub').
- Используют app(SupplierResolver::class) внутри handle()/store().
Pest +8 в tests/Feature/Services/SupplierResolverTest.php:
- null без связей / единственный активный / пропуск inactive supplier /
пропуск inactive m2m / ORDER BY sort_order / null если все inactive /
изоляция по project_id / costRubSnapshot формат '137.50'.
PHPStan baseline регенерирован.
Регресс:
- Pint + PHPStan passed (baseline регенерирован).
- Pest 174/174 за 21.46 сек (+8 от 166, 708 assertions).
- Vitest 247/247 за 17.53 сек (нетронут — backend-only refactor).
Реестр v1.58→v1.59 / CLAUDE.md v1.49→v1.50.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
122 lines
4.7 KiB
PHP
122 lines
4.7 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Models\Project;
|
||
use App\Models\Tenant;
|
||
use App\Services\SupplierResolver;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Str;
|
||
|
||
/**
|
||
* Unit-тесты SupplierResolver — вынесенный сервис из ProcessWebhookJob /
|
||
* DealController (Ю-2 / §20.12.3).
|
||
*
|
||
* Контракт:
|
||
* - resolveForProject() возвращает первого активного supplier'а
|
||
* по (project_suppliers.is_active=true, suppliers.is_active=true)
|
||
* ORDER BY suppliers.sort_order, suppliers.id; иначе null.
|
||
* - costRubSnapshot() возвращает текущий cost_rub поставщика как string
|
||
* (Decimal cast в Eloquent — но мы ходим через DB::table).
|
||
*/
|
||
uses(DatabaseTransactions::class);
|
||
|
||
function seedSupplier(string $code, float $costRub, bool $isActive, int $sortOrder = 0): int
|
||
{
|
||
return (int) DB::table('suppliers')->insertGetId([
|
||
'code' => $code.'-'.Str::lower(Str::random(4)),
|
||
'name' => 'Test '.$code,
|
||
'accepts_types' => '{websites,calls}',
|
||
'cost_rub' => $costRub,
|
||
'channel' => 'sites',
|
||
'quality_score' => 1.00,
|
||
'is_active' => $isActive,
|
||
'sort_order' => $sortOrder,
|
||
]);
|
||
}
|
||
|
||
function attachSupplier(Project $project, int $supplierId, bool $isActive = true): void
|
||
{
|
||
DB::table('project_suppliers')->insert([
|
||
'project_id' => $project->id,
|
||
'supplier_id' => $supplierId,
|
||
'is_active' => $isActive,
|
||
'created_at' => now(),
|
||
]);
|
||
}
|
||
|
||
beforeEach(function () {
|
||
$this->tenant = Tenant::factory()->create();
|
||
$this->project = Project::factory()->for($this->tenant)->create([
|
||
'name' => 'Test Project',
|
||
'type' => 'webhook',
|
||
]);
|
||
$this->resolver = new SupplierResolver;
|
||
});
|
||
|
||
test('resolveForProject возвращает null когда у проекта нет связей', function () {
|
||
expect($this->resolver->resolveForProject($this->project))->toBeNull();
|
||
});
|
||
|
||
test('resolveForProject возвращает supplier_id единственного активного поставщика', function () {
|
||
$supplierId = seedSupplier('one', 50.00, isActive: true);
|
||
attachSupplier($this->project, $supplierId);
|
||
|
||
expect($this->resolver->resolveForProject($this->project))->toBe($supplierId);
|
||
});
|
||
|
||
test('resolveForProject пропускает inactive supplier', function () {
|
||
$inactive = seedSupplier('inactive', 50.00, isActive: false);
|
||
$active = seedSupplier('active', 75.00, isActive: true);
|
||
attachSupplier($this->project, $inactive);
|
||
attachSupplier($this->project, $active);
|
||
|
||
expect($this->resolver->resolveForProject($this->project))->toBe($active);
|
||
});
|
||
|
||
test('resolveForProject пропускает inactive m2m-связь', function () {
|
||
$a = seedSupplier('a', 50.00, isActive: true);
|
||
$b = seedSupplier('b', 75.00, isActive: true);
|
||
attachSupplier($this->project, $a, isActive: false);
|
||
attachSupplier($this->project, $b, isActive: true);
|
||
|
||
expect($this->resolver->resolveForProject($this->project))->toBe($b);
|
||
});
|
||
|
||
test('resolveForProject соблюдает ORDER BY sort_order, id', function () {
|
||
$second = seedSupplier('second', 100.00, isActive: true, sortOrder: 10);
|
||
$first = seedSupplier('first', 200.00, isActive: true, sortOrder: 1);
|
||
$third = seedSupplier('third', 50.00, isActive: true, sortOrder: 100);
|
||
attachSupplier($this->project, $second);
|
||
attachSupplier($this->project, $first);
|
||
attachSupplier($this->project, $third);
|
||
|
||
expect($this->resolver->resolveForProject($this->project))->toBe($first);
|
||
});
|
||
|
||
test('resolveForProject возвращает null если все связи inactive', function () {
|
||
$a = seedSupplier('a', 50.00, isActive: true);
|
||
$b = seedSupplier('b', 75.00, isActive: true);
|
||
attachSupplier($this->project, $a, isActive: false);
|
||
attachSupplier($this->project, $b, isActive: false);
|
||
|
||
expect($this->resolver->resolveForProject($this->project))->toBeNull();
|
||
});
|
||
|
||
test('resolveForProject изолирован по project_id', function () {
|
||
$supplierId = seedSupplier('shared', 50.00, isActive: true);
|
||
attachSupplier($this->project, $supplierId);
|
||
|
||
$otherProject = Project::factory()->for($this->tenant)->create();
|
||
|
||
expect($this->resolver->resolveForProject($this->project))->toBe($supplierId);
|
||
expect($this->resolver->resolveForProject($otherProject))->toBeNull();
|
||
});
|
||
|
||
test('costRubSnapshot возвращает текущую цену поставщика', function () {
|
||
$supplierId = seedSupplier('priced', 137.50, isActive: true);
|
||
|
||
expect($this->resolver->costRubSnapshot($supplierId))->toBe('137.50');
|
||
});
|