a7038367e4
Task 9 Sprint 4: ImportController с 5 методами (store/index/show/ unknownStatuses/resolveUnknownStatuses), 2 FormRequest (StoreImportRequest / ResolveUnknownStatusesRequest), 5 маршрутов в routes/web.php под auth:sanctum+tenant. Defense-in-depth: явный where(tenant_id) поверх RLS (postgres superuser обходит BYPASSRLS на dev — паттерн DealController). Тест 8/8, Larastan baseline regen (только TestCall false positives). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
159 lines
5.7 KiB
PHP
159 lines
5.7 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Http\Controllers\Api;
|
||
|
||
use App\Http\Controllers\Controller;
|
||
use App\Http\Requests\ResolveUnknownStatusesRequest;
|
||
use App\Http\Requests\StoreImportRequest;
|
||
use App\Jobs\ImportLeadsJob;
|
||
use App\Models\ImportLog;
|
||
use App\Models\ImportUnknownStatus;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Str;
|
||
|
||
/**
|
||
* CSV-импорт исторических лидов из crm.bp-gr.ru (ТЗ §6).
|
||
*
|
||
* Все маршруты — под auth:sanctum + tenant (RLS-контекст задан middleware).
|
||
* tenant_id берётся из авторизованного пользователя, не из запроса.
|
||
*/
|
||
class ImportController extends Controller
|
||
{
|
||
/**
|
||
* POST /api/imports — загрузка CSV, создание import_log, dispatch job'а.
|
||
*/
|
||
public function store(StoreImportRequest $request): JsonResponse
|
||
{
|
||
$tenantId = (int) $request->user()->tenant_id;
|
||
$file = $request->file('file');
|
||
$storedName = Str::uuid()->toString().'.csv';
|
||
$path = $file->storeAs("imports/{$tenantId}", $storedName, 'local');
|
||
|
||
$log = ImportLog::create([
|
||
'tenant_id' => $tenantId,
|
||
'user_id' => $request->user()->id,
|
||
'filename' => $file->getClientOriginalName(),
|
||
'file_path' => $path,
|
||
'status' => 'pending',
|
||
'entity_type' => 'leads',
|
||
'source_system' => 'crm.bp-gr.ru',
|
||
'dry_run' => $request->boolean('dry_run'),
|
||
]);
|
||
|
||
ImportLeadsJob::dispatch($log->id, $tenantId);
|
||
|
||
return response()->json(['data' => $this->toResource($log)], 201);
|
||
}
|
||
|
||
/**
|
||
* GET /api/imports — история импортов тенанта (RLS отфильтрует по tenant).
|
||
*
|
||
* Defense-in-depth: явный where(tenant_id) поверх RLS — на dev через
|
||
* `postgres` superuser RLS обходится BYPASSRLS, app-фильтр гарантирует
|
||
* изоляцию (паттерн из DealController).
|
||
*/
|
||
public function index(Request $request): JsonResponse
|
||
{
|
||
$tenantId = (int) $request->user()->tenant_id;
|
||
|
||
$logs = ImportLog::query()
|
||
->where('tenant_id', $tenantId)
|
||
->orderByDesc('id')
|
||
->limit(50)
|
||
->get()
|
||
->map(fn (ImportLog $log) => $this->toResource($log));
|
||
|
||
return response()->json(['data' => $logs]);
|
||
}
|
||
|
||
/**
|
||
* GET /api/imports/{importLog} — прогресс одного импорта (для polling'а).
|
||
*
|
||
* Defense-in-depth: явная проверка tenant_id на принадлежность поверх RLS.
|
||
*/
|
||
public function show(Request $request, ImportLog $importLog): JsonResponse
|
||
{
|
||
$tenantId = (int) $request->user()->tenant_id;
|
||
|
||
abort_if($importLog->tenant_id !== $tenantId, 403, 'Доступ к импорту другого тенанта запрещён.');
|
||
|
||
return response()->json(['data' => $this->toResource($importLog)]);
|
||
}
|
||
|
||
/**
|
||
* GET /api/imports/unknown-statuses — незамапленные статусы (вход wizard'а §6.6).
|
||
*
|
||
* Defense-in-depth: явный where(tenant_id) поверх RLS.
|
||
*/
|
||
public function unknownStatuses(Request $request): JsonResponse
|
||
{
|
||
$tenantId = (int) $request->user()->tenant_id;
|
||
|
||
$rows = ImportUnknownStatus::query()
|
||
->where('tenant_id', $tenantId)
|
||
->unresolved()
|
||
->orderByDesc('occurrences')
|
||
->get()
|
||
->map(fn (ImportUnknownStatus $s) => [
|
||
'id' => $s->id,
|
||
'status_ru' => $s->status_ru,
|
||
'occurrences' => $s->occurrences,
|
||
]);
|
||
|
||
return response()->json(['data' => $rows]);
|
||
}
|
||
|
||
/**
|
||
* POST /api/imports/unknown-statuses/resolve — ручной маппинг статусов.
|
||
*
|
||
* Defense-in-depth: явный where(tenant_id) поверх RLS.
|
||
*/
|
||
public function resolveUnknownStatuses(ResolveUnknownStatusesRequest $request): JsonResponse
|
||
{
|
||
$tenantId = (int) $request->user()->tenant_id;
|
||
$userId = (int) $request->user()->id;
|
||
|
||
DB::transaction(function () use ($request, $tenantId, $userId): void {
|
||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||
|
||
foreach ($request->validated()['mappings'] as $mapping) {
|
||
ImportUnknownStatus::query()
|
||
->where('tenant_id', $tenantId)
|
||
->where('status_ru', $mapping['status_ru'])
|
||
->update([
|
||
'mapped_to_slug' => $mapping['slug'],
|
||
'resolved_at' => now(),
|
||
'resolved_by' => $userId,
|
||
]);
|
||
}
|
||
});
|
||
|
||
return response()->json(['data' => ['resolved' => count($request->validated()['mappings'])]]);
|
||
}
|
||
|
||
/**
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function toResource(ImportLog $log): array
|
||
{
|
||
return [
|
||
'id' => $log->id,
|
||
'filename' => $log->filename,
|
||
'status' => $log->status,
|
||
'rows_total' => $log->rows_total,
|
||
'rows_added' => $log->rows_added,
|
||
'rows_updated' => $log->rows_updated,
|
||
'rows_skipped' => $log->rows_skipped,
|
||
'unknown_statuses_count' => $log->unknown_statuses_count,
|
||
'dry_run' => $log->dry_run,
|
||
'error_message' => $log->error_message,
|
||
'started_at' => $log->started_at?->toIso8601String(),
|
||
'finished_at' => $log->finished_at?->toIso8601String(),
|
||
];
|
||
}
|
||
}
|