Files
portal/app/app/Http/Controllers/Api/DealController.php
T
Дмитрий ac186593f2 phase2(bulk-transition+reload): POST /api/deals/transition + reload-btn
Закрывает gap «UI меняет статус, но изменения не сохраняются на backend»
из v1.51. Reload-btn заменяет polling/SSE до прихода long-poll'а на prod.

Backend (DealController::transition):
- POST /api/deals/transition {tenant_id, ids: [1..1000 ints], status}.
- Валидация status — exists в lead_statuses (глобальная таблица).
- RLS-обёртка SET LOCAL + defense-in-depth where(tenant_id) для
  partial-update: чужие id остаются в исходном статусе.
- ActivityLog event=deal.status_changed с context={from, to, source: 'bulk'}
  для каждой ИЗМЕНЁННОЙ сделки. NO-OP (старый==новый) не пишется в audit.
- Ответ: {updated, requested, status}.

Pest +7 (DealTransitionTest):
- 422 missing fields / 404 unknown tenant / 422 неизвестный slug + не апдейт /
  batch update 3 сделок + 3 ActivityLog с правильным context /
  NO-OP не пишет ActivityLog / defense-in-depth (2 tenant'а — обновляется
  только свой) / 422 пустой массив ids.

Frontend:
- dealsApi.transitionDeals — типизированный helper с ensureCsrfCookie.
- applyBulkStatus в DealsView переписан async: optimistic local-update +
  backend-вызов если auth.user.tenant_id. На success — toast «Обновлено
  N из M.», на fail — warning toast + локальный update НЕ откатывается.
  Без auth.user — только optimistic (legacy local-mode сохранён).
- reload-btn в DealsView и KanbanView — outlined «Обновить» mdi-refresh,
  привязан к loadDeals. В DealsView :loading="loading" во время fetch'а.

Vitest +5:
- reload-btn (Deals + Kanban) — listDeals вызывается дважды.
- applyBulkStatus с tenant_id — transitionDeals + optimistic + toast.
- applyBulkStatus без tenant_id — НЕ вызывается transitionDeals.
- applyBulkStatus reject — toast warning + локальный update остаётся.

PHPStan baseline регенерирован. cspell-glossary +апдейт*.

Регресс:
- Lint+type-check+format passed.
- Vitest 266/266 за 18.16 сек (+5 от 261).
- Vite build 1.06 сек.
- Pint + PHPStan passed.
- Pest 193/193 за 23.27 сек (+7 от 186, 767 assertions).

Реестр v1.60→v1.61 / CLAUDE.md v1.51→v1.52.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 07:46:19 +03:00

394 lines
17 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 App\Models\Project;
use App\Models\SupplierLeadCost;
use App\Models\Tenant;
use App\Models\User;
use App\Services\SupplierResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
/**
* Сделки — manual create/list через REST API.
*
* NB: webhook-flow (автосоздание из crm.bp-gr.ru) — отдельный endpoint
* `WebhookReceiveController` + `ProcessWebhookJob` (асинхронно через очередь
* с advisory lock + dedup). Этот controller — для ручных action'ов из UI.
*
* На MVP без auth-middleware (multi-tenant контекст резолвится по
* `tenant_id` параметру). Production: middleware('auth:sanctum')+'tenant'
* → tenant_id из request()->user()->tenant_id; user ID для manager/audit.
*
* Manual-create отличается от webhook'а:
* - source_crm_id = NULL (не из webhook).
* - НЕ списывает баланс (manual leads — не закупка у supplier'а).
* - НЕ создаёт SupplierLeadCost / BalanceTransaction.
* - Антифрод-дедуп НЕ применяется (manual — admin знает что вводит).
* - Project резолвится / создаётся по name (как и в webhook).
* - Если manager_id передан — должен принадлежать tenant'у (FK guard).
*/
class DealController extends Controller
{
/**
* GET /api/deals?tenant_id={id}&status_in[]=...&project_id=...&manager_id=...&search=...&limit=...&offset=...
*
* Список сделок tenant'а с relations (project.name, manager.first/last/email).
* Используется в `DealsView`/`KanbanView` вместо MOCK_DEALS.
*
* Сортировка: ORDER BY received_at DESC (свежие сверху). На MVP без
* курсорной пагинации — limit/offset простой OFFSET-paging достаточен
* для UI-разводки (на prod при росте datasets — переход на keyset на
* (received_at, id)).
*
* RLS: SET LOCAL app.current_tenant_id внутри транзакции (PgBouncer-safe).
* Чужие сделки отфильтрует политика, даже если клиент подсунет чужой
* tenant_id (без auth — на MVP, на prod — middleware).
*/
public function index(Request $request): JsonResponse
{
$tenantId = (int) $request->query('tenant_id', '0');
if ($tenantId < 1) {
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
}
$tenant = Tenant::find($tenantId);
if ($tenant === null) {
return response()->json(['message' => 'Тенант не найден.'], 404);
}
$statuses = (array) $request->query('status_in', []);
$projectId = $request->query('project_id') !== null ? (int) $request->query('project_id') : null;
$managerId = $request->query('manager_id') !== null ? (int) $request->query('manager_id') : null;
$search = trim((string) $request->query('search', ''));
$limit = max(1, min(500, (int) $request->query('limit', '100')));
$offset = max(0, (int) $request->query('offset', '0'));
[$deals, $total] = DB::transaction(function () use ($tenantId, $statuses, $projectId, $managerId, $search, $limit, $offset) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// Defense-in-depth: явный where(tenant_id) поверх RLS — на тестах
// через `postgres` superuser RLS обходится BYPASSRLS, app-фильтр
// гарантирует изоляцию даже при misconfig RLS.
$query = Deal::query()
->where('tenant_id', $tenantId)
->with(['project:id,name', 'manager:id,email,first_name,last_name']);
if ($statuses !== []) {
$query->whereIn('status', array_filter($statuses, 'is_string'));
}
if ($projectId !== null) {
$query->where('project_id', $projectId);
}
if ($managerId !== null) {
$query->where('manager_id', $managerId);
}
if ($search !== '') {
$like = '%'.$search.'%';
$query->where(function ($q) use ($like) {
$q->where('phone', 'ilike', $like)
->orWhere('contact_name', 'ilike', $like);
});
}
$total = (clone $query)->count();
$rows = $query->orderByDesc('received_at')->orderByDesc('id')
->limit($limit)->offset($offset)->get();
return [$rows, $total];
});
return response()->json([
'deals' => $deals->map(fn (Deal $d) => [
'id' => $d->id,
'tenant_id' => $d->tenant_id,
'project_id' => $d->project_id,
'project_name' => $d->project?->name,
'phone' => $d->phone,
'contact_name' => $d->contact_name,
'status' => $d->status,
'manager_id' => $d->manager_id,
'manager_name' => $d->manager
? ManagerController::formatName($d->manager->first_name, $d->manager->last_name, $d->manager->email)
: null,
'manager_initials' => $d->manager
? ManagerController::formatInitials($d->manager->first_name, $d->manager->last_name, $d->manager->email)
: null,
'received_at' => $d->received_at?->toIso8601String(),
]),
'total' => $total,
'limit' => $limit,
'offset' => $offset,
]);
}
/**
* POST /api/deals/transition — bulk status-update для выбранных сделок.
*
* Body: {tenant_id, ids: [int...], status: slug}.
* Используется в DealsView bulk-actions (sticky-bar «Сменить статус»).
*
* Валидация:
* - status должен существовать в lead_statuses (slug).
* - ids проверяются на принадлежность tenant'у через `where(tenant_id)`.
*
* RLS + defense-in-depth: SET LOCAL + явный where(tenant_id) — в тестах
* через postgres superuser RLS обходится BYPASSRLS, app-фильтр гарантирует
* что чужие id'шники не апдейтятся.
*
* На MVP без проверки allowed-переходов между статусами (любой → любой);
* production: валидировать через lead_status_transitions (если введём).
* История смен пишется в ActivityLog как `deal.status_changed`.
*/
public function transition(Request $request): JsonResponse
{
$validated = $request->validate([
'tenant_id' => 'required|integer|min:1',
'ids' => 'required|array|min:1|max:1000',
'ids.*' => 'integer|min:1',
'status' => 'required|string|max:50',
]);
$tenant = Tenant::find($validated['tenant_id']);
if ($tenant === null) {
return response()->json(['message' => 'Тенант не найден.'], 404);
}
$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, $tenant) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$deals = Deal::query()
->where('tenant_id', $tenant->id)
->whereIn('id', $validated['ids'])
->get();
$count = 0;
foreach ($deals as $deal) {
if ($deal->status === $validated['status']) {
continue; // не пишем NO-OP в ActivityLog
}
$previous = $deal->status;
$deal->status = $validated['status'];
$deal->save();
ActivityLog::create([
'tenant_id' => $tenant->id,
'user_id' => null, // на prod — request()->user()->id
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
'context' => [
'from' => $previous,
'to' => $validated['status'],
'source' => 'bulk',
],
]);
$count++;
}
return $count;
});
return response()->json([
'updated' => $updated,
'requested' => count($validated['ids']),
'status' => $validated['status'],
]);
}
/** POST /api/deals — manual create */
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'tenant_id' => 'required|integer|min:1',
'project_name' => 'required|string|max:255',
'phone' => 'required|string|max:20',
'contact_name' => 'nullable|string|max:255',
'status' => 'nullable|string|max:50',
'manager_id' => 'nullable|integer|min:1',
'comment' => 'nullable|string|max:5000',
]);
$tenant = Tenant::find($validated['tenant_id']);
if ($tenant === null) {
return response()->json(['message' => 'Тенант не найден.'], 404);
}
// Manager FK guard: если manager_id передан, он должен принадлежать
// этому tenant'у. Иначе можно назначить чужого менеджера на свою сделку.
if (isset($validated['manager_id'])) {
$managerExists = User::query()
->where('id', $validated['manager_id'])
->where('tenant_id', $tenant->id)
->whereNull('deleted_at')
->where('is_active', true)
->exists();
if (! $managerExists) {
return response()->json([
'message' => 'Менеджер не найден в этом тенанте.',
'errors' => ['manager_id' => ['Не принадлежит вашему тенанту или не активен.']],
], 422);
}
}
$statusSlug = $validated['status'] ?? 'new';
// Транзакция + RLS: SET LOCAL внутри (PgBouncer-safe).
$deal = DB::transaction(function () use ($validated, $tenant, $statusSlug) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$project = Project::firstOrCreate(
['tenant_id' => $tenant->id, 'name' => $validated['project_name']],
['type' => 'manual'],
);
$deal = Deal::create([
'tenant_id' => $tenant->id,
'source_crm_id' => null, // manual
'project_id' => $project->id,
'phone' => $validated['phone'],
'status' => $statusSlug,
'contact_name' => $validated['contact_name'] ?? null,
'comment' => $validated['comment'] ?? null,
'manager_id' => $validated['manager_id'] ?? null,
'assigned_at' => isset($validated['manager_id']) ? now() : null,
'received_at' => now(),
]);
// SupplierLeadCost для manual-leads — если у проекта есть активный
// supplier через project_suppliers (m2m). Manual НЕ списывает
// баланс (Ю-2: реселлерская модель работает только при закупке у
// supplier'а), но cost-аналитика всё равно нужна — owner проекта
// мог самостоятельно купить лид и ввести руками.
$resolver = app(SupplierResolver::class);
$supplierId = $resolver->resolveForProject($project);
if ($supplierId !== null) {
SupplierLeadCost::create([
'deal_id' => $deal->id,
'received_at' => $deal->received_at,
'supplier_id' => $supplierId,
'cost_rub' => $resolver->costRubSnapshot($supplierId),
'supplier_lead_id' => null, // manual: нет внешнего id
'created_at' => now(),
]);
}
ActivityLog::create([
'tenant_id' => $tenant->id,
'user_id' => null, // на prod — request()->user()->id
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_CREATED,
'context' => ['source' => 'manual'],
]);
return $deal;
});
return response()->json([
'deal' => [
'id' => $deal->id,
'tenant_id' => $deal->tenant_id,
'project_id' => $deal->project_id,
'phone' => $deal->phone,
'status' => $deal->status,
'contact_name' => $deal->contact_name,
'manager_id' => $deal->manager_id,
'received_at' => $deal->received_at->toIso8601String(),
],
'message' => 'Сделка создана.',
], 201);
}
/**
* POST /api/deals/export — CSV-export выбранных сделок.
*
* Body: {tenant_id, ids: [int...]}.
* Возвращает text/csv attachment (UTF-8 + BOM, ; разделитель).
*
* На MVP без auth — id'шники и tenant_id переданы клиентом, RLS-обёртка
* на SELECT гарантирует что чужие сделки не попадут (даже если клиент
* подсунет чужие id, RLS их отфильтрует).
*/
public function export(Request $request): Response
{
$validated = $request->validate([
'tenant_id' => 'required|integer|min:1',
'ids' => 'required|array|min:1|max:10000',
'ids.*' => 'integer|min:1',
]);
$tenant = Tenant::find($validated['tenant_id']);
if ($tenant === null) {
abort(404, 'Тенант не найден.');
}
$rows = DB::transaction(function () use ($validated, $tenant) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
return Deal::query()
->whereIn('id', $validated['ids'])
->orderBy('id')
->get([
'id', 'phone', 'status', 'contact_name',
'project_id', 'manager_id', 'received_at',
]);
});
$csv = $this->buildCsv($rows->toArray());
$filename = 'deals_export_'.now()->format('Y-m-d').'.csv';
return response($csv, 200, [
'Content-Type' => 'text/csv; charset=utf-8',
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
]);
}
/**
* @param array<int, array<string, mixed>> $rows
*/
private function buildCsv(array $rows): string
{
$headers = ['ID', 'Имя', 'Телефон', 'Статус', 'Проект ID', 'Менеджер ID', 'Получено'];
$lines = [implode(';', $headers)];
foreach ($rows as $r) {
$lines[] = implode(';', [
$this->escape((string) $r['id']),
$this->escape((string) ($r['contact_name'] ?? '')),
$this->escape((string) $r['phone']),
$this->escape((string) $r['status']),
$this->escape((string) $r['project_id']),
$this->escape((string) ($r['manager_id'] ?? '')),
$this->escape((string) $r['received_at']),
]);
}
// BOM (U+FEFF) — Excel правильно распознаёт UTF-8 в CSV.
return "\u{FEFF}".implode("\r\n", $lines);
}
private function escape(string $value): string
{
if (str_contains($value, ';') || str_contains($value, '"') || str_contains($value, "\n")) {
return '"'.str_replace('"', '""', $value).'"';
}
return $value;
}
}