4387333118
Фича «Конкурентное поле» на dev до уровня прототипа 2026-06-29-konkurentnoe-pole-proto.html.
Данные: box (proposal|field) на competitors+sources; phone_type city/mobile/tollfree рядом
с phone_kind (вариант C). 3 миграции, дефолты тарифов 300/50.
API (AutopodborController): GET /field (+счётчики), GET /proposals, PATCH/DELETE competitors
и sources с гвардами активного проекта, переключение box, POST /competitors/manual (+directory_urls),
competitor(id) обогащён box+project-статусом; projectStatus отдаёт limit/delivered/days/regions.
Смена источника проекта = PATCH /api/projects/{id} (реальный гвард слепка §14.10).
Фронт: FieldWorkspaceScreen/FieldCompetitorScreen/FieldProposalsScreen/FieldManualCompetitorScreen
+ field-shared.css (Forest) + AutopodborServicesPanel в Биллинге. Дословно по прототипу: подзаголовки,
баннер предложений, баннер правил времени 18:00 МСК, Справочник 2ГИС·Яндекс, статус проекта
5/день·заявки, окна сбора с ценами 300/50 + «что известно», полные формы. Пункт меню «Конкурентное поле».
Тесты: backend автоподбор 80/80, фронт автоподбор 49/49. Движок шага 2 = заглушка FakeCompetitorAgent.
OmegaDemoFieldSeeder — только для визуальной проверки (НЕ на прод).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
582 lines
23 KiB
PHP
582 lines
23 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Http\Controllers\Api;
|
||
|
||
use App\Exceptions\Autopodbor\RunInFlightException;
|
||
use App\Exceptions\Billing\InsufficientBalanceException;
|
||
use App\Http\Controllers\Controller;
|
||
use App\Http\Resources\Autopodbor\CompetitorResource;
|
||
use App\Http\Resources\Autopodbor\RunResource;
|
||
use App\Http\Resources\Autopodbor\SourceResource;
|
||
use App\Models\AutopodborCompetitor;
|
||
use App\Models\AutopodborRun;
|
||
use App\Models\AutopodborSource;
|
||
use App\Models\Project;
|
||
use App\Models\Tenant;
|
||
use App\Repositories\PricingTierRepository;
|
||
use App\Services\Autopodbor\AutopodborDedup;
|
||
use App\Services\Autopodbor\AutopodborNormalizer;
|
||
use App\Services\Autopodbor\AutopodborProjectCreator;
|
||
use App\Services\Autopodbor\AutopodborRunService;
|
||
use App\Services\Billing\BalancePreflightService;
|
||
use App\Support\SystemSettings;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Http\Request;
|
||
|
||
/**
|
||
* Клиентский API автоподбора конкурентов.
|
||
*
|
||
* Auth: auth:sanctum + tenant middleware (устанавливает app.current_tenant_id для RLS).
|
||
* Все выборки дополнительно скоупятся по tenant_id (пояс+подтяжки к RLS).
|
||
*/
|
||
class AutopodborController extends Controller
|
||
{
|
||
/** GET /api/autopodbor/state */
|
||
public function state(Request $request): JsonResponse
|
||
{
|
||
$tenantId = $request->user()->tenant_id;
|
||
|
||
$runs = AutopodborRun::where('tenant_id', $tenantId)
|
||
->orderByDesc('id')
|
||
->limit(20)
|
||
->get();
|
||
|
||
return response()->json([
|
||
'enabled' => SystemSettings::bool('autopodbor_enabled'),
|
||
'runs' => RunResource::collection($runs),
|
||
'prices' => [
|
||
'search' => (string) (SystemSettings::get('autopodbor_price_search_rub') ?? '0'),
|
||
'study' => (string) (SystemSettings::get('autopodbor_price_study_rub') ?? '0'),
|
||
],
|
||
]);
|
||
}
|
||
|
||
/** GET /api/autopodbor/runs/{run} */
|
||
public function run(Request $request, int $run): JsonResponse
|
||
{
|
||
$r = AutopodborRun::where('tenant_id', $request->user()->tenant_id)
|
||
->findOrFail($run);
|
||
|
||
return response()->json(['data' => new RunResource($r)]);
|
||
}
|
||
|
||
/** GET /api/autopodbor/competitors/{competitor} */
|
||
public function competitor(Request $request, int $competitor, AutopodborDedup $dedup): JsonResponse
|
||
{
|
||
$tenantId = $request->user()->tenant_id;
|
||
|
||
$comp = AutopodborCompetitor::where('tenant_id', $tenantId)
|
||
->with('sources.project')
|
||
->findOrFail($competitor);
|
||
|
||
$sources = $comp->sources->map(function (AutopodborSource $s) use ($dedup) {
|
||
$existingProjectId = $s->created_project_id
|
||
?? $dedup->existingProjectId($s->tenant_id, $s->signal_type, $s->identifier);
|
||
|
||
return array_merge(
|
||
(new SourceResource($s))->resolve(),
|
||
[
|
||
'existing_project_id' => $existingProjectId,
|
||
'project' => $this->projectStatus($s->project),
|
||
]
|
||
);
|
||
});
|
||
|
||
return response()->json([
|
||
'data' => new CompetitorResource($comp),
|
||
'sources' => $sources,
|
||
]);
|
||
}
|
||
|
||
/** GET /api/autopodbor/runs/{run}/competitors */
|
||
public function runCompetitors(Request $request, int $run): JsonResponse
|
||
{
|
||
$tenantId = $request->user()->tenant_id;
|
||
// убедимся, что прогон принадлежит tenant (404 если чужой)
|
||
AutopodborRun::where('tenant_id', $tenantId)->findOrFail($run);
|
||
|
||
$competitors = AutopodborCompetitor::where('tenant_id', $tenantId)
|
||
->where('search_run_id', $run)
|
||
->orderByDesc('relevance_pct')
|
||
->orderBy('id')
|
||
->get();
|
||
|
||
return response()->json(['data' => CompetitorResource::collection($competitors)]);
|
||
}
|
||
|
||
/** POST /api/autopodbor/search */
|
||
public function search(Request $request, AutopodborRunService $svc): JsonResponse
|
||
{
|
||
$v = $request->validate([
|
||
'region_code' => 'required|integer',
|
||
'examples' => 'array',
|
||
'about_self' => 'array',
|
||
'include_federal' => 'boolean',
|
||
]);
|
||
|
||
try {
|
||
$run = $svc->startSearch(
|
||
$request->user()->tenant_id,
|
||
(int) $v['region_code'],
|
||
$v['examples'] ?? [],
|
||
$v['about_self'] ?? [],
|
||
(bool) ($v['include_federal'] ?? false),
|
||
);
|
||
|
||
return response()->json(['data' => new RunResource($run)], 201);
|
||
} catch (RunInFlightException) {
|
||
return response()->json(['error' => 'run_in_flight'], 409);
|
||
} catch (InsufficientBalanceException) {
|
||
return response()->json(['error' => 'balance_insufficient'], 409);
|
||
}
|
||
}
|
||
|
||
/** POST /api/autopodbor/study */
|
||
public function study(Request $request, AutopodborRunService $svc): JsonResponse
|
||
{
|
||
$v = $request->validate([
|
||
'competitor_id' => 'required|integer',
|
||
]);
|
||
|
||
try {
|
||
$run = $svc->startStudy(
|
||
$request->user()->tenant_id,
|
||
(int) $v['competitor_id'],
|
||
);
|
||
|
||
return response()->json(['data' => new RunResource($run)], 201);
|
||
} catch (RunInFlightException) {
|
||
return response()->json(['error' => 'run_in_flight'], 409);
|
||
} catch (InsufficientBalanceException) {
|
||
return response()->json(['error' => 'balance_insufficient'], 409);
|
||
}
|
||
}
|
||
|
||
/** POST /api/autopodbor/resolve */
|
||
public function resolve(Request $request, AutopodborRunService $svc): JsonResponse
|
||
{
|
||
$v = $request->validate([
|
||
'name' => 'required|string',
|
||
'region_code' => 'required|integer',
|
||
]);
|
||
|
||
try {
|
||
$run = $svc->startResolve(
|
||
$request->user()->tenant_id,
|
||
$v['name'],
|
||
(int) $v['region_code'],
|
||
);
|
||
|
||
return response()->json(['data' => new RunResource($run)], 201);
|
||
} catch (RunInFlightException) {
|
||
return response()->json(['error' => 'run_in_flight'], 409);
|
||
}
|
||
}
|
||
|
||
/** POST /api/autopodbor/manual-study */
|
||
public function manualStudy(Request $request, AutopodborRunService $svc, AutopodborNormalizer $norm): JsonResponse
|
||
{
|
||
$v = $request->validate([
|
||
'competitor_id' => ['nullable', 'integer'],
|
||
'name' => ['nullable', 'string', 'max:255'],
|
||
'site_url' => ['nullable', 'string', 'max:500'],
|
||
'directory' => ['nullable', 'string', 'max:500'],
|
||
'region_code' => ['required', 'integer'],
|
||
]);
|
||
$uid = $request->user()->tenant_id;
|
||
|
||
try {
|
||
if (! empty($v['competitor_id'])) {
|
||
$run = $svc->startStudy($uid, (int) $v['competitor_id']);
|
||
} else {
|
||
$site = ! empty($v['site_url']) ? $norm->domainHead($v['site_url']) : null;
|
||
$name = ! empty($v['name']) ? $v['name'] : ($site ?? 'Конкурент');
|
||
if (empty($v['name']) && $site === null) {
|
||
return response()->json(['error' => 'name_or_site_required'], 422);
|
||
}
|
||
$run = $svc->startManualStudy($uid, [
|
||
'name' => $name,
|
||
'site_url' => $site,
|
||
'directory_urls' => ! empty($v['directory']) ? [$v['directory']] : [],
|
||
], (int) $v['region_code']);
|
||
}
|
||
} catch (RunInFlightException) {
|
||
return response()->json(['error' => 'run_in_flight'], 409);
|
||
} catch (InsufficientBalanceException) {
|
||
return response()->json(['error' => 'balance_insufficient'], 409);
|
||
}
|
||
|
||
return response()->json(['data' => new RunResource($run)], 201);
|
||
}
|
||
|
||
/**
|
||
* GET /api/autopodbor/field — рабочее место «Конкурентное поле».
|
||
* Конкуренты в ящике «поле» с их источниками в поле, статусом проекта по каждому
|
||
* источнику и счётчиками (источников / создано проектов / в работе).
|
||
*/
|
||
public function field(Request $request): JsonResponse
|
||
{
|
||
$tenantId = $request->user()->tenant_id;
|
||
|
||
$competitors = AutopodborCompetitor::where('tenant_id', $tenantId)
|
||
->where('box', 'field')
|
||
->with(['sources' => function ($q) {
|
||
$q->where('box', 'field')->with('project');
|
||
}])
|
||
->orderByDesc('relevance_pct')
|
||
->orderBy('id')
|
||
->get();
|
||
|
||
$payload = $competitors->map(function (AutopodborCompetitor $comp) {
|
||
$sources = $comp->sources->map(fn (AutopodborSource $s) => array_merge(
|
||
(new SourceResource($s))->resolve(),
|
||
['project' => $this->projectStatus($s->project)],
|
||
));
|
||
|
||
$created = $comp->sources->filter(fn ($s) => $s->project !== null);
|
||
$inWork = $created->filter(
|
||
fn ($s) => $s->project->is_active && $s->project->preflight_blocked_at === null
|
||
);
|
||
|
||
return array_merge(
|
||
(new CompetitorResource($comp))->resolve(),
|
||
[
|
||
'counters' => [
|
||
'sources' => $comp->sources->count(),
|
||
'projects_created' => $created->count(),
|
||
'projects_in_work' => $inWork->count(),
|
||
],
|
||
'sources' => $sources,
|
||
],
|
||
);
|
||
});
|
||
|
||
return response()->json(['competitors' => $payload]);
|
||
}
|
||
|
||
/**
|
||
* POST /api/autopodbor/competitors/manual — завести конкурента вручную сразу В ПОЛЕ,
|
||
* без запуска изучения (§14.2 «+ Добавить вручную»). Изучение источников — отдельно, по кнопке.
|
||
*/
|
||
public function manualCompetitor(Request $request, AutopodborNormalizer $norm): JsonResponse
|
||
{
|
||
$v = $request->validate([
|
||
'name' => ['required', 'string', 'max:255'],
|
||
'description' => ['nullable', 'string', 'max:2000'],
|
||
'is_federal' => ['boolean'],
|
||
'relevance_pct' => ['nullable', 'integer', 'min:0', 'max:100'],
|
||
'site_url' => ['nullable', 'string', 'max:500'],
|
||
'directory' => ['nullable', 'string', 'max:500'],
|
||
'directory_urls' => ['nullable', 'array'],
|
||
'directory_urls.*' => ['string', 'max:500'],
|
||
]);
|
||
$uid = $request->user()->tenant_id;
|
||
|
||
$site = ! empty($v['site_url']) ? $norm->domainHead($v['site_url']) : null;
|
||
|
||
$dirs = $v['directory_urls'] ?? (! empty($v['directory']) ? [$v['directory']] : []);
|
||
$dirs = array_values(array_filter(array_map('trim', $dirs)));
|
||
|
||
$comp = AutopodborCompetitor::create([
|
||
'tenant_id' => $uid,
|
||
'search_run_id' => null,
|
||
'name' => $v['name'],
|
||
'description' => $v['description'] ?? null,
|
||
'is_federal' => (bool) ($v['is_federal'] ?? false),
|
||
'relevance_pct' => $v['relevance_pct'] ?? null,
|
||
'origin' => 'manual',
|
||
'box' => 'field',
|
||
'site_url' => $site,
|
||
'directory_urls' => $dirs,
|
||
'dedup_key' => $norm->competitorKey($v['name'], $site),
|
||
]);
|
||
|
||
return response()->json(['data' => new CompetitorResource($comp)], 201);
|
||
}
|
||
|
||
/** PATCH /api/autopodbor/competitors/{id} — правка полей карточки конкурента */
|
||
public function updateCompetitor(Request $request, int $competitor): JsonResponse
|
||
{
|
||
$v = $request->validate([
|
||
'name' => ['sometimes', 'string', 'max:255'],
|
||
'description' => ['sometimes', 'nullable', 'string', 'max:2000'],
|
||
'is_federal' => ['sometimes', 'boolean'],
|
||
'relevance_pct' => ['sometimes', 'nullable', 'integer', 'min:0', 'max:100'],
|
||
'site_url' => ['sometimes', 'nullable', 'string', 'max:500'],
|
||
'directory_urls' => ['sometimes', 'array'],
|
||
'directory_urls.*' => ['string', 'max:500'],
|
||
'box' => ['sometimes', 'string', 'in:proposal,field'],
|
||
]);
|
||
|
||
$comp = AutopodborCompetitor::where('tenant_id', $request->user()->tenant_id)
|
||
->findOrFail($competitor);
|
||
|
||
$comp->update($v);
|
||
|
||
return response()->json(['data' => new CompetitorResource($comp)]);
|
||
}
|
||
|
||
/**
|
||
* DELETE /api/autopodbor/competitors/{id} — удаление конкурента и его источников.
|
||
* Блокируется, если у любого источника есть активный созданный проект
|
||
* (управлять проектом нужно через раздел проектов — §14.10).
|
||
*/
|
||
public function destroyCompetitor(Request $request, int $competitor): JsonResponse
|
||
{
|
||
$comp = AutopodborCompetitor::where('tenant_id', $request->user()->tenant_id)
|
||
->with('sources.project')
|
||
->findOrFail($competitor);
|
||
|
||
$hasActive = $comp->sources->contains(
|
||
fn (AutopodborSource $s) => $s->project && $s->project->is_active
|
||
);
|
||
|
||
if ($hasActive) {
|
||
return response()->json(['error' => 'has_active_projects'], 409);
|
||
}
|
||
|
||
$comp->sources()->delete();
|
||
$comp->delete();
|
||
|
||
return response()->json(null, 204);
|
||
}
|
||
|
||
/** GET /api/autopodbor/proposals — конкуренты в ящике «предложения», сорт по похожести. */
|
||
public function proposals(Request $request): JsonResponse
|
||
{
|
||
$competitors = AutopodborCompetitor::where('tenant_id', $request->user()->tenant_id)
|
||
->where('box', 'proposal')
|
||
->orderByDesc('relevance_pct')
|
||
->orderBy('id')
|
||
->get();
|
||
|
||
return response()->json(['data' => CompetitorResource::collection($competitors)]);
|
||
}
|
||
|
||
/** PATCH /api/autopodbor/competitors/{id}/box — перенос конкурента предложение↔поле */
|
||
public function competitorBox(Request $request, int $competitor): JsonResponse
|
||
{
|
||
$v = $request->validate([
|
||
'box' => ['required', 'string', 'in:proposal,field'],
|
||
]);
|
||
|
||
$comp = AutopodborCompetitor::where('tenant_id', $request->user()->tenant_id)
|
||
->findOrFail($competitor);
|
||
|
||
$comp->update(['box' => $v['box']]);
|
||
|
||
return response()->json(['data' => new CompetitorResource($comp)]);
|
||
}
|
||
|
||
/**
|
||
* PATCH /api/autopodbor/sources/{id} — правка значения/провенанса/ящика источника.
|
||
* Тип источника (signal_type) НЕИЗМЕНЯЕМ (как в ProjectService — молча игнорируем).
|
||
* Смена самого значения (identifier) у источника с активным проектом запрещена —
|
||
* это смена источника проекта, делается через раздел проектов (§14.10).
|
||
*/
|
||
public function updateSource(Request $request, int $source): JsonResponse
|
||
{
|
||
$v = $request->validate([
|
||
'identifier' => ['sometimes', 'string', 'max:500'],
|
||
'phone_kind' => ['sometimes', 'nullable', 'string', 'in:real,substitute'],
|
||
'phone_type' => ['sometimes', 'nullable', 'string', 'in:city,mobile,tollfree'],
|
||
'provenance_url' => ['sometimes', 'nullable', 'string', 'max:500'],
|
||
'provenance_label' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||
'box' => ['sometimes', 'string', 'in:proposal,field'],
|
||
]);
|
||
|
||
$src = AutopodborSource::where('tenant_id', $request->user()->tenant_id)
|
||
->with('project')
|
||
->findOrFail($source);
|
||
|
||
$changesIdentifier = array_key_exists('identifier', $v) && $v['identifier'] !== $src->identifier;
|
||
if ($changesIdentifier && $src->project && $src->project->is_active) {
|
||
return response()->json(['error' => 'manage_via_project'], 409);
|
||
}
|
||
|
||
$src->update($v);
|
||
|
||
return response()->json(['data' => new SourceResource($src)]);
|
||
}
|
||
|
||
/**
|
||
* DELETE /api/autopodbor/sources/{id} — удаление источника.
|
||
* Блокируется, если у источника есть активный созданный проект (§14.10).
|
||
*/
|
||
public function destroySource(Request $request, int $source): JsonResponse
|
||
{
|
||
$src = AutopodborSource::where('tenant_id', $request->user()->tenant_id)
|
||
->with('project')
|
||
->findOrFail($source);
|
||
|
||
if ($src->project && $src->project->is_active) {
|
||
return response()->json(['error' => 'has_active_project'], 409);
|
||
}
|
||
|
||
$src->delete();
|
||
|
||
return response()->json(null, 204);
|
||
}
|
||
|
||
/** PATCH /api/autopodbor/sources/{id}/box — перенос источника предложение↔в работу */
|
||
public function sourceBox(Request $request, int $source): JsonResponse
|
||
{
|
||
$v = $request->validate([
|
||
'box' => ['required', 'string', 'in:proposal,field'],
|
||
]);
|
||
|
||
$src = AutopodborSource::where('tenant_id', $request->user()->tenant_id)
|
||
->findOrFail($source);
|
||
|
||
$src->update(['box' => $v['box']]);
|
||
|
||
return response()->json(['data' => new SourceResource($src)]);
|
||
}
|
||
|
||
/** POST /api/autopodbor/sources/manual */
|
||
public function addManualSource(Request $request, AutopodborNormalizer $norm): JsonResponse
|
||
{
|
||
$v = $request->validate([
|
||
'competitor_id' => ['required', 'integer'],
|
||
'raw' => ['required', 'string', 'max:500'],
|
||
]);
|
||
$uid = $request->user()->tenant_id;
|
||
|
||
$comp = AutopodborCompetitor::where('tenant_id', $uid)->findOrFail((int) $v['competitor_id']);
|
||
if ($comp->study_run_id === null) {
|
||
return response()->json(['error' => 'not_studied'], 422);
|
||
}
|
||
|
||
$raw = trim($v['raw']);
|
||
$digits = preg_replace('/\D+/', '', $raw) ?? '';
|
||
$isCall = strlen($digits) >= 10;
|
||
$signalType = $isCall ? 'call' : 'site';
|
||
$identifier = $isCall ? $norm->phone($raw) : $norm->domainHead($raw);
|
||
|
||
$source = AutopodborSource::updateOrCreate(
|
||
['competitor_id' => $comp->id, 'dedup_key' => $norm->sourceKey($signalType, $raw)],
|
||
[
|
||
'tenant_id' => $uid,
|
||
'study_run_id' => $comp->study_run_id,
|
||
'signal_type' => $signalType,
|
||
'identifier' => $identifier,
|
||
'phone_kind' => $isCall ? 'real' : null,
|
||
'provenance_url' => null,
|
||
'provenance_label' => 'Добавлено вручную',
|
||
],
|
||
);
|
||
|
||
return response()->json(['data' => new SourceResource($source)], 201);
|
||
}
|
||
|
||
/**
|
||
* Статус проекта источника для UI (пауза/работа/блок). null — проекта нет.
|
||
*
|
||
* @return array{id: int, name: string, is_active: bool, paused_at: ?string, preflight_blocked_at: ?string}|null
|
||
*/
|
||
private function projectStatus(?Project $project): ?array
|
||
{
|
||
if ($project === null) {
|
||
return null;
|
||
}
|
||
|
||
return [
|
||
'id' => $project->id,
|
||
'name' => $project->name,
|
||
'signal_identifier' => $project->signal_identifier,
|
||
'is_active' => (bool) $project->is_active,
|
||
'paused_at' => $project->paused_at?->toIso8601String(),
|
||
'preflight_blocked_at' => $project->preflight_blocked_at?->toIso8601String(),
|
||
'daily_limit_target' => (int) $project->daily_limit_target,
|
||
'delivered_in_month' => (int) $project->delivered_in_month,
|
||
'delivery_days_mask' => (int) $project->delivery_days_mask,
|
||
'regions' => $project->regions ?? [],
|
||
];
|
||
}
|
||
|
||
/** POST /api/autopodbor/projects */
|
||
public function createProjects(Request $request, AutopodborProjectCreator $creator): JsonResponse
|
||
{
|
||
$v = $request->validate([
|
||
'source_ids' => 'required|array',
|
||
'source_ids.*' => 'integer',
|
||
'regions' => 'array',
|
||
'regions.*' => 'integer',
|
||
'daily_limit_target' => 'required|integer',
|
||
'delivery_days_mask' => 'required|integer',
|
||
'launch' => 'boolean',
|
||
]);
|
||
|
||
$tenant = $request->user()->tenant;
|
||
$launch = (bool) ($v['launch'] ?? false);
|
||
|
||
// Балансовый preflight при launch=true
|
||
if ($launch) {
|
||
$existingLimit = (int) Project::where('tenant_id', $tenant->id)
|
||
->where('is_active', true)
|
||
->whereNull('preflight_blocked_at')
|
||
->sum('daily_limit_target');
|
||
|
||
$wouldBe = $existingLimit + count($v['source_ids']) * (int) $v['daily_limit_target'];
|
||
|
||
$preflight = $this->runPreflight($tenant, $wouldBe);
|
||
|
||
if (! $preflight['passes']) {
|
||
return response()->json([
|
||
'error' => 'balance_insufficient',
|
||
'current_balance_rub' => (string) $tenant->balance_rub,
|
||
'current_capacity_leads' => $preflight['capacity_leads'],
|
||
'would_be_required_leads' => $wouldBe,
|
||
'deficit_leads' => $preflight['deficit_leads'],
|
||
], 409);
|
||
}
|
||
}
|
||
|
||
$projects = $creator->createFromSources(
|
||
$tenant->id,
|
||
$v['source_ids'],
|
||
[
|
||
'regions' => $v['regions'] ?? [],
|
||
'daily_limit_target' => (int) $v['daily_limit_target'],
|
||
'delivery_days_mask' => (int) $v['delivery_days_mask'],
|
||
],
|
||
$launch,
|
||
);
|
||
|
||
return response()->json([
|
||
'data' => collect($projects)->map(fn ($p) => ['id' => $p->id, 'name' => $p->name])->all(),
|
||
], 201);
|
||
}
|
||
|
||
/**
|
||
* Копия helper'а из ProjectController — балансовый preflight.
|
||
*
|
||
* @return array{passes: bool, capacity_leads: int, deficit_leads: int}
|
||
*/
|
||
private function runPreflight(Tenant $tenant, int $requiredLeads): array
|
||
{
|
||
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
|
||
|
||
// Safe fallback: без активных pricing_tiers биллинг не настроен —
|
||
// preflight пропускаем (legacy-окружения / тесты).
|
||
if ($tiers->isEmpty()) {
|
||
return ['passes' => true, 'capacity_leads' => PHP_INT_MAX, 'deficit_leads' => 0];
|
||
}
|
||
|
||
$result = (new BalancePreflightService)->evaluate(
|
||
balanceRub: (string) $tenant->balance_rub,
|
||
deliveredInMonth: (int) $tenant->delivered_in_month,
|
||
requiredLeads: $requiredLeads,
|
||
tiers: $tiers,
|
||
);
|
||
|
||
return [
|
||
'passes' => $result->passes,
|
||
'capacity_leads' => $result->capacityLeads,
|
||
'deficit_leads' => $result->deficitLeads,
|
||
];
|
||
}
|
||
}
|