447ef593fa
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
254 lines
9.5 KiB
PHP
254 lines
9.5 KiB
PHP
<?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']),
|
||
]);
|
||
}
|
||
}
|