2026-05-09 06:43:21 +03:00
|
|
|
|
<?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;
|
2026-05-09 06:58:49 +03:00
|
|
|
|
use App\Models\SupplierLeadCost;
|
2026-05-09 06:43:21 +03:00
|
|
|
|
use App\Models\Tenant;
|
2026-05-09 06:58:49 +03:00
|
|
|
|
use App\Models\User;
|
2026-05-09 07:11:44 +03:00
|
|
|
|
use App\Services\SupplierResolver;
|
2026-05-09 06:43:21 +03:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 06:58:49 +03:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 06:43:21 +03:00
|
|
|
|
$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(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-05-09 06:58:49 +03:00
|
|
|
|
// SupplierLeadCost для manual-leads — если у проекта есть активный
|
|
|
|
|
|
// supplier через project_suppliers (m2m). Manual НЕ списывает
|
|
|
|
|
|
// баланс (Ю-2: реселлерская модель работает только при закупке у
|
|
|
|
|
|
// supplier'а), но cost-аналитика всё равно нужна — owner проекта
|
|
|
|
|
|
// мог самостоятельно купить лид и ввести руками.
|
2026-05-09 07:11:44 +03:00
|
|
|
|
$resolver = app(SupplierResolver::class);
|
|
|
|
|
|
$supplierId = $resolver->resolveForProject($project);
|
2026-05-09 06:58:49 +03:00
|
|
|
|
if ($supplierId !== null) {
|
|
|
|
|
|
SupplierLeadCost::create([
|
|
|
|
|
|
'deal_id' => $deal->id,
|
|
|
|
|
|
'received_at' => $deal->received_at,
|
|
|
|
|
|
'supplier_id' => $supplierId,
|
2026-05-09 07:11:44 +03:00
|
|
|
|
'cost_rub' => $resolver->costRubSnapshot($supplierId),
|
2026-05-09 06:58:49 +03:00
|
|
|
|
'supplier_lead_id' => null, // manual: нет внешнего id
|
|
|
|
|
|
'created_at' => now(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 06:43:21 +03:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|