08d51eb6c8
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
307 lines
13 KiB
PHP
307 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Http\Requests\BulkProjectActionRequest;
|
|
use App\Http\Requests\StoreProjectRequest;
|
|
use App\Http\Requests\UpdateProjectRequest;
|
|
use App\Http\Resources\ProjectResource;
|
|
use App\Jobs\SyncSupplierProjectJob;
|
|
use App\Models\PricingTier;
|
|
use App\Models\Project;
|
|
use App\Models\Tenant;
|
|
use App\Services\Billing\BalancePreflightService;
|
|
use App\Services\Project\ProjectService;
|
|
use App\Services\Requisites\RequisitesService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
|
|
/**
|
|
* Проекты tenant'а — расширенный API для ProjectsView + NewDealDialog.
|
|
*
|
|
* index: фильтры по signal_type/status/search, пагинация, batch-fetch по ids.
|
|
* show: детальная карточка проекта с supplier_links.
|
|
*
|
|
* Auth: auth:sanctum + tenant middleware (устанавливает app.current_tenant_id для RLS).
|
|
* Task 2 Plan 5 заменяет MVP-версию (tenant_id параметром, без auth).
|
|
*/
|
|
class ProjectController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly ProjectService $projects,
|
|
private readonly RequisitesService $requisites,
|
|
) {}
|
|
|
|
/** GET /api/projects */
|
|
public function index(Request $request): JsonResponse
|
|
{
|
|
$query = Project::query()
|
|
->with(['supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid N+1 in aggregation helpers
|
|
->where('tenant_id', $request->user()->tenant_id);
|
|
|
|
// Batch-fetch по ids — возвращает без пагинации (для dropdown'ов и т.п.)
|
|
if ($ids = $request->query('ids')) {
|
|
// '?ids=' batch fetch. Non-numeric and zero values silently dropped via intval+filter
|
|
// (intval('abc')=0 → array_filter drops 0). Acceptable for a read-only dropdown:
|
|
// invalid input produces empty result, not 422.
|
|
$idArray = array_filter(array_map('intval', explode(',', (string) $ids)));
|
|
$items = $query->whereIn('id', $idArray)->get();
|
|
|
|
return response()->json(['data' => ProjectResource::collection($items)]);
|
|
}
|
|
|
|
// Фильтр по типу сигнала
|
|
if ($type = $request->query('signal_type')) {
|
|
$query->where('signal_type', $type);
|
|
}
|
|
|
|
// Фильтр по статусу жизненного цикла
|
|
$status = $request->query('status');
|
|
if ($status === 'active') {
|
|
$query->where('is_active', true);
|
|
} elseif ($status === 'paused') {
|
|
$query->where('is_active', false);
|
|
}
|
|
// default → no extra filter
|
|
|
|
// Поиск по name и signal_identifier
|
|
if ($search = $request->query('search')) {
|
|
$query->where(function ($q) use ($search) {
|
|
$q->where('name', 'ilike', "%{$search}%")
|
|
->orWhere('signal_identifier', 'ilike', "%{$search}%");
|
|
});
|
|
}
|
|
|
|
// #7: фильтр по региону (subject code 1..89). Проект под фильтр попадает, если
|
|
// его regions[] содержит код ИЛИ пуст (= вся РФ, имплицитно покрывает любой регион).
|
|
$region = (int) $request->query('region', '0');
|
|
if ($region >= 1 && $region <= 89) {
|
|
$query->where(function ($q) use ($region) {
|
|
$q->whereRaw('regions @> ARRAY[?]::int[]', [$region])
|
|
->orWhereRaw("regions = '{}'::int[]")
|
|
->orWhereNull('regions');
|
|
});
|
|
}
|
|
|
|
// #7: фильтр по дню недели приёма (0=Пн..6=Вс — same bit-index, как в UI dayLabels).
|
|
$day = $request->query('delivery_day');
|
|
if ($day !== null && $day !== '' && (int) $day >= 0 && (int) $day <= 6) {
|
|
$bit = 1 << (int) $day;
|
|
$query->whereRaw('(delivery_days_mask & ?) <> 0', [$bit]);
|
|
}
|
|
|
|
// #7: сортировка. Whitelist + опциональный '-' для desc. Default = '-delivered_today'
|
|
// (карточки с активной доставкой за сегодня видны сверху, как просил заказчик).
|
|
$sortRaw = (string) $request->query('sort', '-delivered_today');
|
|
$desc = str_starts_with($sortRaw, '-');
|
|
$sortField = ltrim($sortRaw, '-');
|
|
$sortable = ['delivered_today', 'delivered_in_month', 'daily_limit_target', 'name', 'created_at'];
|
|
if (! in_array($sortField, $sortable, true)) {
|
|
$sortField = 'delivered_today';
|
|
$desc = true;
|
|
}
|
|
|
|
// #6: per_page до 200 (было 100). UI-селектор: 20/50/100/200.
|
|
$perPage = min((int) $request->query('per_page', '20'), 200);
|
|
$projects = $query
|
|
->orderBy($sortField, $desc ? 'desc' : 'asc')
|
|
->orderBy('id', 'desc') // стабильный tie-breaker для пагинации
|
|
->paginate($perPage);
|
|
|
|
return response()->json([
|
|
'data' => ProjectResource::collection($projects->items()),
|
|
'meta' => [
|
|
'current_page' => $projects->currentPage(),
|
|
'per_page' => $projects->perPage(),
|
|
'total' => $projects->total(),
|
|
],
|
|
]);
|
|
}
|
|
|
|
/** POST /api/projects */
|
|
public function store(StoreProjectRequest $request): JsonResponse
|
|
{
|
|
$validated = $request->validated();
|
|
$tenant = $request->user()->tenant;
|
|
|
|
// G1/SP2: гейт первого проекта — нельзя создать первый проект без минимальных реквизитов.
|
|
if (Project::where('tenant_id', $tenant->id)->count() === 0
|
|
&& ! $this->requisites->isLightComplete($tenant)) {
|
|
return response()->json(['error' => 'requisites_required'], 422);
|
|
}
|
|
|
|
$forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false);
|
|
unset($validated['force_save_blocked']);
|
|
|
|
// Spec C §3.4: преfflight баланса при создании. existingLimit учитывает только активные.
|
|
$existingLimit = (int) Project::where('tenant_id', $tenant->id)
|
|
->where('is_active', true)
|
|
->whereNull('preflight_blocked_at')
|
|
->sum('daily_limit_target');
|
|
$wouldBeRequired = $existingLimit + (int) $validated['daily_limit_target'];
|
|
|
|
$preflight = $this->runPreflight($tenant, $wouldBeRequired);
|
|
|
|
if (! $preflight['passes'] && ! $forceSaveBlocked) {
|
|
return response()->json([
|
|
'error' => 'balance_insufficient',
|
|
'current_balance_rub' => (string) $tenant->balance_rub,
|
|
'current_capacity_leads' => $preflight['capacity_leads'],
|
|
'would_be_required_leads' => $wouldBeRequired,
|
|
'deficit_leads' => $preflight['deficit_leads'],
|
|
], 409);
|
|
}
|
|
|
|
if (! $preflight['passes'] && $forceSaveBlocked) {
|
|
$validated['preflight_blocked_at'] = now();
|
|
}
|
|
|
|
$project = $this->projects->create($tenant, $validated);
|
|
|
|
return response()->json(['data' => new ProjectResource($project)], 201);
|
|
}
|
|
|
|
/** PATCH /api/projects/{id} */
|
|
public function update(UpdateProjectRequest $request, int $id): JsonResponse
|
|
{
|
|
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
|
|
$validated = $request->validated();
|
|
$tenant = $request->user()->tenant;
|
|
$forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false);
|
|
unset($validated['force_save_blocked']);
|
|
|
|
// Spec C §3.4: преfflight при изменении лимита — учитываем новое значение для ЭТОГО
|
|
// проекта + лимиты остальных активных не-blocked.
|
|
if (array_key_exists('daily_limit_target', $validated)) {
|
|
$existingLimit = (int) Project::where('tenant_id', $tenant->id)
|
|
->where('id', '!=', $project->id)
|
|
->where('is_active', true)
|
|
->whereNull('preflight_blocked_at')
|
|
->sum('daily_limit_target');
|
|
$wouldBeRequired = $existingLimit + (int) $validated['daily_limit_target'];
|
|
|
|
$preflight = $this->runPreflight($tenant, $wouldBeRequired);
|
|
|
|
if (! $preflight['passes'] && ! $forceSaveBlocked) {
|
|
return response()->json([
|
|
'error' => 'balance_insufficient',
|
|
'current_balance_rub' => (string) $tenant->balance_rub,
|
|
'current_capacity_leads' => $preflight['capacity_leads'],
|
|
'would_be_required_leads' => $wouldBeRequired,
|
|
'deficit_leads' => $preflight['deficit_leads'],
|
|
], 409);
|
|
}
|
|
|
|
if (! $preflight['passes'] && $forceSaveBlocked) {
|
|
$validated['preflight_blocked_at'] = now();
|
|
}
|
|
}
|
|
|
|
$updated = $this->projects->update($project, $validated);
|
|
|
|
return response()->json(['data' => new ProjectResource($updated)]);
|
|
}
|
|
|
|
/**
|
|
* @return array{passes: bool, capacity_leads: int, deficit_leads: int}
|
|
*/
|
|
private function runPreflight(Tenant $tenant, int $requiredLeads): array
|
|
{
|
|
$tiers = PricingTier::query()->where('is_active', true)->get();
|
|
|
|
// Safe fallback: без активных pricing_tiers биллинг не настроен —
|
|
// преfflight не имеет смысла, пропускаем (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,
|
|
];
|
|
}
|
|
|
|
/** GET /api/projects/{id} */
|
|
public function show(Request $request, int $id): JsonResponse
|
|
{
|
|
$project = Project::with(['supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid N+1
|
|
->where('tenant_id', $request->user()->tenant_id)
|
|
->findOrFail($id);
|
|
|
|
return response()->json(['data' => new ProjectResource($project)]);
|
|
}
|
|
|
|
/** DELETE /api/projects/{id} — hard delete (guard по сделкам: 422 если есть сделки) */
|
|
public function destroy(Request $request, int $id): JsonResponse
|
|
{
|
|
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
|
|
$this->projects->delete($project);
|
|
|
|
return response()->json(null, 204);
|
|
}
|
|
|
|
/** POST /api/projects/{id}/sync — re-dispatch SyncSupplierProjectJob */
|
|
public function sync(Request $request, int $id): JsonResponse
|
|
{
|
|
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
|
|
$this->projects->triggerSync($project);
|
|
|
|
return response()->json(['queued' => true, 'sync_status' => 'pending'], 202);
|
|
}
|
|
|
|
/** PATCH /api/projects/{id}/toggle-active — flip is_active flag */
|
|
public function toggleActive(Request $request, int $id): JsonResponse
|
|
{
|
|
$request->validate(['is_active' => ['required', 'boolean']]);
|
|
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
|
|
|
|
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 11).
|
|
// paused_at — anchor для SupplierSnapshotGuard grace-расчёта.
|
|
$newActive = $request->boolean('is_active');
|
|
$project->update([
|
|
'is_active' => $newActive,
|
|
'paused_at' => $newActive ? null : now(),
|
|
]);
|
|
|
|
// #10: pause/resume must reach the supplier. The job's group recompute pushes
|
|
// status=paused when no active project of the group remains (resume → active).
|
|
SyncSupplierProjectJob::dispatch($project->id);
|
|
|
|
return response()->json(['data' => new ProjectResource($project->fresh())]);
|
|
}
|
|
|
|
/** POST /api/projects/bulk — batch pause/resume/delete/update_regions/update_days/update_limit */
|
|
public function bulk(BulkProjectActionRequest $request): JsonResponse
|
|
{
|
|
$tenantId = $request->user()->tenant_id;
|
|
$ids = $this->projects->resolveBulkScope(
|
|
$tenantId,
|
|
$request->validated('ids'),
|
|
$request->validated('scope.filter'),
|
|
);
|
|
|
|
if (count($ids) > ProjectService::BULK_MAX) {
|
|
return response()->json([
|
|
'errors' => ['scope' => ['Слишком много проектов под фильтр (>500). Уточните фильтры или выберите вручную.']],
|
|
], 422);
|
|
}
|
|
|
|
$payload = array_merge($request->validated(), ['ids' => $ids]);
|
|
|
|
$result = $this->projects->bulkAction($tenantId, $request->validated('action'), $payload);
|
|
|
|
return response()->json($result);
|
|
}
|
|
}
|