Files
portal/app/app/Http/Controllers/Api/DealBulkActionController.php
T
2026-05-16 15:18:13 +03:00

254 lines
9.5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\Deal;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Bulk-операции над сделками — transition / destroy / restore.
*
* Извлечено из DealController (Sprint 3 Phase A, audit O-refactor-01) — раньше
* один контроллер был 802 строки с 4 разнородными ответственностями (CRUD +
* bulk + export + helpers). Этот класс отвечает только за многоразовые
* массовые операции; single-resource действия остаются в DealController.
*
* J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user().
*
* O-perf-01: N+1 устранён.
*
* transition: сначала SELECT всех сделок tenant'а из ids, чтобы отфильтровать
* NO-OP (status уже совпадает) и собрать прежние значения для ActivityLog
* (event=deal.status_changed требует context.from). Затем bulk-UPDATE
* `whereIn(changed_ids)->update(status)` (1 запрос вместо N) +
* ActivityLog::insert(массив строк) (1 запрос вместо N). На 100 сделках это
* ≈ 200 запросов → 2 запроса.
*
* destroy / restore: WHERE гарантирует idempotency (deleted_at IS NULL для
* destroy; deleted_at IS NOT NULL для restore), поэтому контракт «requested
* vs deleted/restored» корректен без foreach. Bulk-UPDATE + bulk-INSERT.
*
* RLS + defense-in-depth `where(tenant_id)` сохраняется — поведение из
* DealTransitionTest «не апдейтит чужие сделки» обязательно.
*/
class DealBulkActionController extends Controller
{
/**
* POST /api/deals/transition — bulk status-update.
*
* Body: {ids: [int...], status: slug}.
* Response: {updated, requested, status} (updated = реально изменённых,
* без NO-OP).
*/
public function transition(Request $request): JsonResponse
{
$validated = $request->validate([
'ids' => 'required|array|min:1|max:1000',
'ids.*' => 'integer|min:1',
'status' => 'required|string|max:50',
]);
$tenantId = (int) $request->user()->tenant_id;
$statusExists = DB::table('lead_statuses')->where('slug', $validated['status'])->exists();
if (! $statusExists) {
return response()->json([
'message' => 'Неизвестный статус.',
'errors' => ['status' => ['Slug не найден в lead_statuses.']],
], 422);
}
$updated = DB::transaction(function () use ($validated, $tenantId) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// Фаза 1: SELECT — нужны id и предыдущий status для каждой строки,
// чтобы (а) отфильтровать NO-OP и (б) сохранить prev в context.from.
// Defense-in-depth where(tenant_id) — защита от кросс-tenant id.
$rows = Deal::query()
->where('tenant_id', $tenantId)
->whereIn('id', $validated['ids'])
->get(['id', 'status']);
$changed = $rows->filter(fn (Deal $d) => $d->status !== $validated['status']);
if ($changed->isEmpty()) {
return 0;
}
$changedIds = $changed->pluck('id')->all();
$now = now();
// Фаза 2: bulk-UPDATE 1 запросом вместо N.
Deal::query()
->where('tenant_id', $tenantId)
->whereIn('id', $changedIds)
->update([
'status' => $validated['status'],
'updated_at' => $now,
]);
// Фаза 3: bulk-INSERT в activity_log 1 запросом вместо N.
// ActivityLog::insert() обходит модельный cast `context => array` —
// массив сериализуем в JSON руками, остальные scalar-поля передаём
// напрямую. Триггер audit_chain_hash() заполнит log_hash на уровне БД.
$logRows = $changed->map(fn (Deal $d) => [
'tenant_id' => $tenantId,
'user_id' => null,
'deal_id' => $d->id,
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
'context' => json_encode([
'from' => $d->status,
'to' => $validated['status'],
'source' => 'bulk',
], JSON_UNESCAPED_UNICODE),
'created_at' => $now,
])->all();
ActivityLog::insert($logRows);
return count($changedIds);
});
return response()->json([
'updated' => $updated,
'requested' => count($validated['ids']),
'status' => $validated['status'],
]);
}
/**
* DELETE /api/deals — bulk soft-delete.
*
* Body: {ids: [int...]}.
* Response: {deleted, requested}.
*
* Soft-delete сохраняется (см. документацию в DealController.destroy на
* предыдущей версии): partition deals + CASCADE-FK от webhook_dedup_keys
* → hard-delete сломает идемпотентность webhook'ов.
*/
public function destroy(Request $request): JsonResponse
{
$validated = $request->validate([
'ids' => 'required|array|min:1|max:1000',
'ids.*' => 'integer|min:1',
]);
$tenantId = (int) $request->user()->tenant_id;
$deleted = DB::transaction(function () use ($validated, $tenantId) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// SELECT id'шников живых сделок tenant'а из ids — для bulk-INSERT
// в activity_log по списку реально удаляемых (NO-OP idempotency).
$targetIds = Deal::query()
->where('tenant_id', $tenantId)
->whereIn('id', $validated['ids'])
->whereNull('deleted_at')
->pluck('id')
->all();
if ($targetIds === []) {
return 0;
}
$now = now();
Deal::query()
->where('tenant_id', $tenantId)
->whereIn('id', $targetIds)
->whereNull('deleted_at')
->update([
'deleted_at' => $now,
'updated_at' => $now,
]);
$logRows = array_map(fn (int $id) => [
'tenant_id' => $tenantId,
'user_id' => null,
'deal_id' => $id,
'event' => ActivityLog::EVENT_DEAL_DELETED,
'context' => json_encode(['source' => 'bulk'], JSON_UNESCAPED_UNICODE),
'created_at' => $now,
], $targetIds);
ActivityLog::insert($logRows);
return count($targetIds);
});
return response()->json([
'deleted' => $deleted,
'requested' => count($validated['ids']),
]);
}
/**
* POST /api/deals/restore — bulk restore soft-deleted.
*
* Body: {ids: [int...]}.
* Response: {restored, requested}.
*/
public function restore(Request $request): JsonResponse
{
$validated = $request->validate([
'ids' => 'required|array|min:1|max:1000',
'ids.*' => 'integer|min:1',
]);
$tenantId = (int) $request->user()->tenant_id;
$restored = DB::transaction(function () use ($validated, $tenantId) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// withTrashed обходит SoftDeletes global scope; whereNotNull —
// NO-OP idempotency для уже живых.
$targetIds = Deal::query()
->withTrashed()
->where('tenant_id', $tenantId)
->whereIn('id', $validated['ids'])
->whereNotNull('deleted_at')
->pluck('id')
->all();
if ($targetIds === []) {
return 0;
}
$now = now();
Deal::query()
->withTrashed()
->where('tenant_id', $tenantId)
->whereIn('id', $targetIds)
->whereNotNull('deleted_at')
->update([
'deleted_at' => null,
'updated_at' => $now,
]);
$logRows = array_map(fn (int $id) => [
'tenant_id' => $tenantId,
'user_id' => null,
'deal_id' => $id,
'event' => ActivityLog::EVENT_DEAL_RESTORED,
'context' => json_encode(['source' => 'bulk'], JSON_UNESCAPED_UNICODE),
'created_at' => $now,
], $targetIds);
ActivityLog::insert($logRows);
return count($targetIds);
});
return response()->json([
'restored' => $restored,
'requested' => count($validated['ids']),
]);
}
}