Files
portal/app/app/Http/Controllers/Api/ProjectController.php
T

206 lines
8.8 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\Project;
use App\Services\Project\ProjectService;
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) {}
/** 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
{
$project = $this->projects->create($request->user()->tenant, $request->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);
$updated = $this->projects->update($project, $request->validated());
return response()->json(['data' => new ProjectResource($updated)]);
}
/** 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);
}
}