2026-05-11 18:29:54 +03:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
namespace App\Services\Project;
|
|
|
|
|
|
|
|
|
|
|
|
use App\Jobs\SyncSupplierProjectJob;
|
|
|
|
|
|
use App\Models\Project;
|
|
|
|
|
|
use App\Models\Tenant;
|
|
|
|
|
|
use Illuminate\Http\Exceptions\HttpResponseException;
|
|
|
|
|
|
|
|
|
|
|
|
class ProjectService
|
|
|
|
|
|
{
|
2026-05-11 19:00:39 +03:00
|
|
|
|
public function update(Project $project, array $data): Project
|
|
|
|
|
|
{
|
|
|
|
|
|
// Immutable fields — silently drop (don't 422)
|
|
|
|
|
|
unset(
|
|
|
|
|
|
$data['tenant_id'], $data['signal_type'], $data['signal_identifier'],
|
|
|
|
|
|
$data['delivered_today'], $data['delivered_in_month'],
|
|
|
|
|
|
$data['supplier_b1_project_id'], $data['supplier_b2_project_id'], $data['supplier_b3_project_id'],
|
|
|
|
|
|
$data['archived_at'],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (isset($data['daily_limit_target']) && $data['daily_limit_target'] < $project->delivered_today) {
|
|
|
|
|
|
throw new HttpResponseException(response()->json([
|
|
|
|
|
|
'errors' => [
|
|
|
|
|
|
'daily_limit_target' => [
|
|
|
|
|
|
"Лимит не может быть меньше уже доставленных лидов сегодня ({$project->delivered_today}).",
|
|
|
|
|
|
],
|
|
|
|
|
|
],
|
|
|
|
|
|
], 422));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$needsResync = array_key_exists('sms_senders', $data) || array_key_exists('sms_keyword', $data);
|
|
|
|
|
|
|
|
|
|
|
|
$project->update($data);
|
|
|
|
|
|
|
|
|
|
|
|
if ($needsResync) {
|
|
|
|
|
|
SyncSupplierProjectJob::dispatch($project->id);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return $project->fresh();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-11 19:06:07 +03:00
|
|
|
|
public function archive(Project $project): void
|
|
|
|
|
|
{
|
|
|
|
|
|
if ($project->archived_at !== null) {
|
|
|
|
|
|
throw new HttpResponseException(response()->json([
|
|
|
|
|
|
'message' => 'Project уже архивирован.',
|
|
|
|
|
|
], 409));
|
|
|
|
|
|
}
|
|
|
|
|
|
$project->update([
|
|
|
|
|
|
'is_active' => false,
|
|
|
|
|
|
'archived_at' => now(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public function triggerSync(Project $project): void
|
|
|
|
|
|
{
|
|
|
|
|
|
SyncSupplierProjectJob::dispatch($project->id);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 14:59:59 +03:00
|
|
|
|
public const BULK_MAX = 500;
|
|
|
|
|
|
|
|
|
|
|
|
public function resolveBulkScope(int $tenantId, ?array $ids, ?array $filter): array
|
|
|
|
|
|
{
|
|
|
|
|
|
if (! empty($ids)) {
|
|
|
|
|
|
return array_values(array_unique($ids));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$query = Project::where('tenant_id', $tenantId);
|
|
|
|
|
|
|
|
|
|
|
|
if (! empty($filter['signal_type'])) {
|
|
|
|
|
|
$query->where('signal_type', $filter['signal_type']);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (! empty($filter['status'])) {
|
|
|
|
|
|
match ($filter['status']) {
|
|
|
|
|
|
'active' => $query->where('is_active', true)->whereNull('archived_at'),
|
|
|
|
|
|
'paused' => $query->where('is_active', false)->whereNull('archived_at'),
|
|
|
|
|
|
'archived' => $query->whereNotNull('archived_at'),
|
|
|
|
|
|
default => null,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if (! empty($filter['search'])) {
|
|
|
|
|
|
$query->where('name', 'ilike', '%'.$filter['search'].'%');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return $query->pluck('id')->all();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 14:48:10 +03:00
|
|
|
|
public function bulkAction(int $tenantId, string $action, array $payload): array
|
2026-05-11 19:06:07 +03:00
|
|
|
|
{
|
2026-05-12 14:48:10 +03:00
|
|
|
|
$ids = $payload['ids'] ?? [];
|
|
|
|
|
|
if (empty($ids)) {
|
|
|
|
|
|
return ['updated' => 0, 'skipped' => [], 'warnings' => []];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-11 19:06:07 +03:00
|
|
|
|
$query = Project::where('tenant_id', $tenantId)->whereIn('id', $ids);
|
|
|
|
|
|
|
2026-05-12 14:48:10 +03:00
|
|
|
|
return match ($action) {
|
|
|
|
|
|
'pause' => $this->bulkSimpleUpdate($query, ['is_active' => false]),
|
|
|
|
|
|
'resume' => $this->bulkSimpleUpdate($query, ['is_active' => true]),
|
|
|
|
|
|
'archive' => $this->bulkSimpleUpdate($query, ['is_active' => false, 'archived_at' => now()]),
|
|
|
|
|
|
'update_regions' => $this->bulkUpdateRegions($query, $payload),
|
|
|
|
|
|
'update_days' => $this->bulkUpdateDays($query, $payload),
|
|
|
|
|
|
'update_limit' => $this->bulkUpdateLimit($query, $payload),
|
2026-05-11 19:06:07 +03:00
|
|
|
|
};
|
2026-05-12 14:48:10 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private function bulkSimpleUpdate($query, array $update): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$updated = $query->update($update);
|
|
|
|
|
|
|
|
|
|
|
|
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private function bulkUpdateRegions($query, array $payload): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$add = (int) ($payload['add'] ?? 0);
|
|
|
|
|
|
$remove = (int) ($payload['remove'] ?? 0);
|
2026-05-11 19:06:07 +03:00
|
|
|
|
|
2026-05-12 14:48:10 +03:00
|
|
|
|
// region_mask = (region_mask | add) & ~remove, clamped to 8 bits (0–255)
|
|
|
|
|
|
$updated = $query->update([
|
|
|
|
|
|
'region_mask' => \DB::raw("(region_mask | {$add}) & ~{$remove} & 255"),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private function bulkUpdateDays($query, array $payload): array
|
|
|
|
|
|
{
|
2026-05-12 14:52:23 +03:00
|
|
|
|
$add = (int) ($payload['add'] ?? 0);
|
|
|
|
|
|
$remove = (int) ($payload['remove'] ?? 0);
|
|
|
|
|
|
|
|
|
|
|
|
$updated = $query->update([
|
|
|
|
|
|
'delivery_days_mask' => \DB::raw("(delivery_days_mask | {$add}) & ~{$remove} & 127"),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
|
2026-05-12 14:48:10 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private function bulkUpdateLimit($query, array $payload): array
|
|
|
|
|
|
{
|
2026-05-12 14:55:45 +03:00
|
|
|
|
$delta = $payload['delta'] ?? null;
|
|
|
|
|
|
$replace = $payload['replace'] ?? null;
|
|
|
|
|
|
|
|
|
|
|
|
$projects = (clone $query)->select(['id', 'daily_limit_target', 'delivered_today'])->get();
|
|
|
|
|
|
|
|
|
|
|
|
$updatableIds = [];
|
|
|
|
|
|
$skipped = [];
|
|
|
|
|
|
|
|
|
|
|
|
foreach ($projects as $p) {
|
|
|
|
|
|
$newValue = $replace !== null
|
|
|
|
|
|
? (int) $replace
|
|
|
|
|
|
: (int) $p->daily_limit_target + (int) $delta;
|
|
|
|
|
|
|
|
|
|
|
|
if ($newValue < (int) $p->delivered_today) {
|
|
|
|
|
|
$skipped[] = ['id' => $p->id, 'reason' => 'below_delivered_today'];
|
|
|
|
|
|
} else {
|
|
|
|
|
|
$updatableIds[$p->id] = $newValue;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$updated = 0;
|
|
|
|
|
|
|
|
|
|
|
|
if (! empty($updatableIds)) {
|
|
|
|
|
|
if ($replace !== null) {
|
|
|
|
|
|
$updated = Project::whereIn('id', array_keys($updatableIds))
|
|
|
|
|
|
->update(['daily_limit_target' => (int) $replace]);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// delta — обновляем по одному (count bounded by MAX 500).
|
|
|
|
|
|
foreach ($updatableIds as $id => $newValue) {
|
|
|
|
|
|
Project::where('id', $id)->update(['daily_limit_target' => $newValue]);
|
|
|
|
|
|
$updated++;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return ['updated' => $updated, 'skipped' => $skipped, 'warnings' => []];
|
2026-05-11 19:06:07 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-11 18:29:54 +03:00
|
|
|
|
public function create(Tenant $tenant, array $data): Project
|
|
|
|
|
|
{
|
|
|
|
|
|
$limit = (int) ($tenant->limits['max_projects'] ?? 10);
|
|
|
|
|
|
$current = Project::where('tenant_id', $tenant->id)->active()->count();
|
|
|
|
|
|
if ($current >= $limit) {
|
|
|
|
|
|
throw new HttpResponseException(response()->json([
|
|
|
|
|
|
'message' => "Достигнут лимит проектов ({$limit}). Смените тариф.",
|
|
|
|
|
|
], 403));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$data['tenant_id'] = $tenant->id;
|
|
|
|
|
|
$data['is_active'] = true;
|
2026-05-15 05:39:43 +03:00
|
|
|
|
$data['regions'] = $data['regions'] ?? [];
|
|
|
|
|
|
// Plan 6 dual-write: regions[] источник истины; region_mask/mode — legacy для
|
|
|
|
|
|
// PhonePrefixService / LeadRouter, удаляются в Plan 6.5 после переключения читателей.
|
|
|
|
|
|
$data['region_mask'] = 255;
|
|
|
|
|
|
$data['region_mode'] = 'include';
|
2026-05-11 18:29:54 +03:00
|
|
|
|
$project = Project::create($data);
|
|
|
|
|
|
|
|
|
|
|
|
SyncSupplierProjectJob::dispatch($project->id);
|
|
|
|
|
|
|
|
|
|
|
|
return $project->fresh();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|