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

221 lines
9.2 KiB
PHP
Raw Normal View History

<?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
{
/** 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;
}
}