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>
57 lines
2.3 KiB
PHP
57 lines
2.3 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Models\Project;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
/**
|
||
* Резолвер активного поставщика лидов для проекта (Ю-2 / §20.12.3).
|
||
*
|
||
* Используется и в webhook-flow (`ProcessWebhookJob`), и в manual-create
|
||
* (`DealController::store`) — единая логика выбора первого активного
|
||
* supplier'а по `project_suppliers` m2m, чтобы snapshot цены `cost_rub`
|
||
* фиксировался идентично в `supplier_lead_costs` независимо от источника.
|
||
*
|
||
* Алгоритм MVP: первый активный supplier по `(project_suppliers.is_active=true,
|
||
* suppliers.is_active=true)`, ORDER BY `suppliers.sort_order, suppliers.id`.
|
||
*
|
||
* Production roadmap: при отсутствии активного supplier'а у проекта —
|
||
* fallback на `system_settings.default_supplier_id` (отдельный пункт TODO).
|
||
*/
|
||
class SupplierResolver
|
||
{
|
||
/**
|
||
* Возвращает supplier_id первого активного поставщика для project'а
|
||
* либо null, если у проекта нет активных m2m-связей.
|
||
*/
|
||
public function resolveForProject(Project $project): ?int
|
||
{
|
||
$row = DB::table('project_suppliers')
|
||
->join('suppliers', 'suppliers.id', '=', 'project_suppliers.supplier_id')
|
||
->where('project_suppliers.project_id', $project->id)
|
||
->where('project_suppliers.is_active', true)
|
||
->where('suppliers.is_active', true)
|
||
->orderBy('suppliers.sort_order')
|
||
->orderBy('suppliers.id')
|
||
->select('suppliers.id')
|
||
->first();
|
||
|
||
return $row !== null ? (int) $row->id : null;
|
||
}
|
||
|
||
/**
|
||
* Возвращает snapshot цены `cost_rub` поставщика на момент вызова.
|
||
* Используется для записи в `supplier_lead_costs.cost_rub` (история
|
||
* закупок не должна меняться при последующих правках цены поставщика).
|
||
*/
|
||
public function costRubSnapshot(int $supplierId): string
|
||
{
|
||
return (string) DB::table('suppliers')
|
||
->where('id', $supplierId)
|
||
->value('cost_rub');
|
||
}
|
||
}
|