Files
portal/app/app/Services/Project/ProjectService.php
T
Дмитрий f248e27702 feat(projects/drawer): редактирование «Источника» (site/call/sms) в карточке проекта
UX-request 18.05.2026 (п.9):
- ProjectDetailsDrawer (правая панель на /projects) теперь редактирует
  signal_identifier для site (домен) и call (телефон 7\d{10}); для sms —
  sms_senders+sms_keyword (как раньше).
- Поле «Источник» отображается **только** в карточке проекта (read-only
  в drawer сделки на /deals — Task 2 закрыл).

Backend:
- UpdateProjectRequest: condition-based валидация по signal_type из БД
  (site domain regex, call 11-digit 7\d{10}; sms — без новых правил)
- ProjectService::update: убран signal_identifier из silent-drop;
  $needsResync расширен на signal_identifier → SyncSupplierProjectJob

signal_type остаётся immutable (менять тип проекта — отдельная задача).

Larastan baseline bumped (ProjectsUpdateTest: actingAs 8→12 для 4 новых тестов).
Pest tests/Feature/Plan5/Projects/ProjectsUpdateTest 12/12.
Vitest 33 passes на Project-spec'ах. Build 2.03s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:44:03 +03:00

236 lines
9.0 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\Services\Project;
use App\Jobs\SyncSupplierProjectJob;
use App\Models\Project;
use App\Models\Tenant;
use Illuminate\Http\Exceptions\HttpResponseException;
class ProjectService
{
public function update(Project $project, array $data): Project
{
// Immutable fields — silently drop (don't 422)
// signal_identifier — теперь editable (18.05.2026 ux), валидируется в UpdateProjectRequest.
unset(
$data['tenant_id'], $data['signal_type'],
$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));
}
// Resync на смену любого источник-несущего поля — поставщику нужно знать актуальный домен/телефон/sms.
$needsResync = array_key_exists('sms_senders', $data)
|| array_key_exists('sms_keyword', $data)
|| array_key_exists('signal_identifier', $data);
$project->update($data);
if ($needsResync) {
SyncSupplierProjectJob::dispatch($project->id);
}
return $project->fresh();
}
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);
}
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();
}
public function bulkAction(int $tenantId, string $action, array $payload): array
{
$ids = $payload['ids'] ?? [];
if (empty($ids)) {
return ['updated' => 0, 'skipped' => [], 'warnings' => []];
}
$query = Project::where('tenant_id', $tenantId)->whereIn('id', $ids);
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),
};
}
private function bulkSimpleUpdate($query, array $update): array
{
$updated = $query->update($update);
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
}
/**
* Plan 6.5: субъект-уровневый bulk-edit `regions` INT[].
*
* Для каждого проекта: regions := unique(regions add_regions) \ remove_regions,
* отсортировано по возрастанию. `regions[]` — источник истины региональной
* фильтрации с Plan 6 (outbound SyncSupplierProjectsJob читает именно его).
* Legacy `region_mask` здесь не трогается — как и в одиночном PATCH
* /api/projects/{id}; его удаление — Plan 6.5 cleanup.
*
* NB: проект с regions=[] («вся РФ») при add_regions сужается до выбранных
* субъектов — это осознанное действие оператора bulk-диалога.
*
* Обновление идёт через model-инстанс (не query-builder mass update): каст
* PostgresIntArray::set() сериализует PHP-массив в PG-литерал `{1,2,3}`, а
* mass update каст не применяет. count ≤ BULK_MAX (500) — допустимо.
*/
private function bulkUpdateRegions($query, array $payload): array
{
$add = array_map('intval', $payload['add_regions'] ?? []);
$remove = array_map('intval', $payload['remove_regions'] ?? []);
if ($add === [] && $remove === []) {
return ['updated' => 0, 'skipped' => [], 'warnings' => []];
}
$projects = (clone $query)->get(['id', 'regions']);
$updated = 0;
foreach ($projects as $project) {
$next = array_values(array_unique([...($project->regions ?? []), ...$add]));
$next = array_values(array_diff($next, $remove));
sort($next);
$project->update(['regions' => $next]);
$updated++;
}
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
}
private function bulkUpdateDays($query, array $payload): array
{
$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' => []];
}
private function bulkUpdateLimit($query, array $payload): array
{
$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' => []];
}
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;
$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';
$project = Project::create($data);
SyncSupplierProjectJob::dispatch($project->id);
return $project->fresh();
}
}