phase2(lookups+integrity): GET /api/managers+projects + manager FK guard + SupplierLeadCost для manual
3 интеграционных доработки после backend-completion v1.57.
(1) GET /api/managers + /api/projects + manager FK guard:
- ManagerController::index — active users тенанта (is_active+deleted_at IS NULL).
Формат {id, email, first_name, last_name, name, initials} с
formatName/formatInitials helpers (fallback на email).
- ProjectController::index — active projects (is_active=true).
- Оба endpoint'а: tenant_id query-param, 422 без, 404 unknown, RLS-обёртка.
- DealController::store FK guard: manager_id должен принадлежать tenant'у +
is_active. Иначе 422 (закрывает security-gap чужого менеджера).
- Pest +8 в LookupsTest.
(2) Replace MOCK_MANAGERS / MOCK_PROJECTS на API в NewDealDialog:
- projectOptions/managerOptions ref'ы с MOCK fallback.
- loadLookups через Promise.all([listProjects, listManagers]) на open
диалога с tenantId.
- managerIdByName Map name→id для submit'а.
- Silent fallback на mock при network-error.
- Vitest +2.
(3) SupplierLeadCost для manual-leads:
- В DealController::store после Deal::create — resolveSupplierId (копия
логики ProcessWebhookJob: project_suppliers JOIN suppliers + ORDER BY
sort_order). Если supplier найден — SupplierLeadCost с snapshot cost_rub
+ supplier_lead_id=NULL (manual: нет внешнего id).
- Manual по-прежнему НЕ списывает баланс (Ю-2 reseller-модель — charge
только при webhook'е); cost-аналитика всё равно нужна.
- Pest +2.
- TODO: рефактор resolveSupplierId в App\Services\SupplierResolver чтобы
Job + Controller разделяли логику.
Старый тест manager_id=42 переписан под FK guard через User::factory.
PHPStan baseline регенерирован (+28 ignored Pest TestCall warnings).
Регресс: lint+type-check+format ✅; vitest 247/247 за 16.32 сек (+2);
vite build 951 ms; Pint+PHPStan passed; Pest 166/166 за 22.11 сек
(+10 от 156, 699 assertions). Реестр v1.57→v1.58, CLAUDE.md v1.48→v1.49.
Production TODO остаточные:
- resolveSupplierId → SupplierResolver service.
- XLSX-export через PhpSpreadsheet.
- GET /api/deals для replace MOCK_DEALS в DealsView/KanbanView.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,9 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierLeadCost;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
@@ -53,6 +55,23 @@ class DealController extends Controller
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
}
|
||||
|
||||
// Manager FK guard: если manager_id передан, он должен принадлежать
|
||||
// этому tenant'у. Иначе можно назначить чужого менеджера на свою сделку.
|
||||
if (isset($validated['manager_id'])) {
|
||||
$managerExists = User::query()
|
||||
->where('id', $validated['manager_id'])
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_active', true)
|
||||
->exists();
|
||||
if (! $managerExists) {
|
||||
return response()->json([
|
||||
'message' => 'Менеджер не найден в этом тенанте.',
|
||||
'errors' => ['manager_id' => ['Не принадлежит вашему тенанту или не активен.']],
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
$statusSlug = $validated['status'] ?? 'new';
|
||||
|
||||
// Транзакция + RLS: SET LOCAL внутри (PgBouncer-safe).
|
||||
@@ -77,6 +96,27 @@ class DealController extends Controller
|
||||
'received_at' => now(),
|
||||
]);
|
||||
|
||||
// SupplierLeadCost для manual-leads — если у проекта есть активный
|
||||
// supplier через project_suppliers (m2m). Manual НЕ списывает
|
||||
// баланс (Ю-2: реселлерская модель работает только при закупке у
|
||||
// supplier'а), но cost-аналитика всё равно нужна — owner проекта
|
||||
// мог самостоятельно купить лид и ввести руками.
|
||||
$supplierId = $this->resolveSupplierId($project);
|
||||
if ($supplierId !== null) {
|
||||
$costRub = (string) DB::table('suppliers')
|
||||
->where('id', $supplierId)
|
||||
->value('cost_rub');
|
||||
|
||||
SupplierLeadCost::create([
|
||||
'deal_id' => $deal->id,
|
||||
'received_at' => $deal->received_at,
|
||||
'supplier_id' => $supplierId,
|
||||
'cost_rub' => $costRub,
|
||||
'supplier_lead_id' => null, // manual: нет внешнего id
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => null, // на prod — request()->user()->id
|
||||
@@ -179,4 +219,25 @@ class DealController extends Controller
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск активного supplier_id для проекта через project_suppliers m2m.
|
||||
* Дублирует логику ProcessWebhookJob::resolveSupplierId — на MVP
|
||||
* приемлемо; рефактор в `App\Services\SupplierResolver` — отдельный коммит
|
||||
* (тогда Job и Controller будут шарить логику + system_settings fallback).
|
||||
*/
|
||||
private function resolveSupplierId(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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user