Files
portal/app/app/Services/Project/ProjectService.php
T
2026-05-22 18:53:11 +03:00

483 lines
18 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\Supplier\DeleteSupplierProjectJob;
use App\Jobs\SyncSupplierProjectJob;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Audit\OperationsLogger;
use App\Services\Supplier\SupplierProjectGrouping;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Support\Facades\DB;
class ProjectService
{
public function __construct(
private readonly OperationsLogger $ops = new OperationsLogger,
) {}
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'],
);
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 на смену источник-несущих полей, регионов, лимита и дней недели —
// поставщик должен видеть актуальные параметры сразу, не дожидаясь ночного батча.
$needsResync = array_key_exists('sms_senders', $data)
|| array_key_exists('sms_keyword', $data)
|| array_key_exists('signal_identifier', $data)
|| array_key_exists('regions', $data)
|| array_key_exists('daily_limit_target', $data)
|| array_key_exists('delivery_days_mask', $data);
if (array_key_exists('signal_identifier', $data) || array_key_exists('sms_senders', $data) || array_key_exists('sms_keyword', $data)) {
$this->assertSourceUnique($project->tenant_id, array_merge([
'signal_type' => $project->signal_type,
'signal_identifier' => $project->signal_identifier,
'sms_senders' => $project->sms_senders,
'sms_keyword' => $project->sms_keyword,
], $data), exceptId: $project->id);
}
if (array_key_exists('name', $data)) {
$this->assertNameUnique($project->tenant_id, (string) $data['name'], exceptId: $project->id);
}
// #8/#9: capture the OLD source identifier BEFORE update so we can detach the
// project from supplier_projects keyed by the old source (otherwise they orphan).
$identifierFieldsTouched = array_key_exists('signal_identifier', $data)
|| array_key_exists('sms_senders', $data)
|| array_key_exists('sms_keyword', $data);
$oldAgnostic = $identifierFieldsTouched ? SupplierProjectGrouping::buildUniqueKeyAgnostic($project) : null;
// Snapshot before mutation for audit diff
$before = array_intersect_key($project->toArray(), $data);
$project->update($data);
if ($oldAgnostic !== null) {
$newAgnostic = SupplierProjectGrouping::buildUniqueKeyAgnostic($project->fresh());
if ($oldAgnostic !== $newAgnostic) {
$this->detachOldSourceSupplierProjects($project, $oldAgnostic);
}
}
$this->ops->record(
tenantId: $project->tenant_id,
userId: auth()->id(),
entityType: 'project',
entityId: $project->id,
event: 'project.updated',
payloadBefore: $before,
payloadAfter: $data,
ip: request()->ip(),
userAgent: request()->userAgent(),
);
if ($needsResync) {
SyncSupplierProjectJob::dispatch($project->id);
}
return $project->fresh();
}
/**
* #8/#9: при смене источника отвязать старые supplier_projects этого проекта (по
* старому ключу) и запустить их чистку. DeleteSupplierProjectJob удалит их у
* поставщика, если других потребителей не осталось; иначе — пересинк агрегата.
*/
private function detachOldSourceSupplierProjects(Project $project, string $oldAgnostic): void
{
$oldSpIds = SupplierProject::where('unique_key', $oldAgnostic)
->where('signal_type', $project->signal_type)
->pluck('id')
->all();
if ($oldSpIds === []) {
return;
}
// Linked to THIS project via pivot — those are the ones we're detaching.
$linkedIds = DB::table('project_supplier_links')
->where('project_id', $project->id)
->whereIn('supplier_project_id', $oldSpIds)
->pluck('supplier_project_id')
->map(fn ($v) => (int) $v)
->all();
if ($linkedIds === []) {
return;
}
DB::table('project_supplier_links')
->where('project_id', $project->id)
->whereIn('supplier_project_id', $linkedIds)
->delete();
// Clear legacy FKs that point at old sps (they no longer belong to this project).
$dirty = false;
foreach (['supplier_b1_project_id', 'supplier_b2_project_id', 'supplier_b3_project_id'] as $col) {
if (in_array((int) $project->{$col}, $linkedIds, true)) {
$project->{$col} = null;
$dirty = true;
}
}
if ($dirty) {
$project->save();
}
DeleteSupplierProjectJob::dispatch($linkedIds);
}
public function delete(Project $project): void
{
$hasDeals = DB::table('deals')->where('project_id', $project->id)->exists();
if ($hasDeals) {
throw new HttpResponseException(response()->json([
'errors' => ['project' => ['Нельзя удалить проект: по нему есть сделки. Поставьте приём на паузу, чтобы скрыть проект из работы.']],
], 422));
}
// Капчим доноров ДО удаления — pivot уйдёт каскадом.
$supplierProjectIds = DB::table('project_supplier_links')
->where('project_id', $project->id)
->pluck('supplier_project_id')
->all();
// Snapshot ДО удаления — после delete() модель недоступна.
$tenantId = $project->tenant_id;
$projectId = $project->id;
$snapshot = $project->toArray();
$project->delete(); // hard delete (Project без SoftDeletes); cascade чистит pivot + служебные.
$this->ops->record(
tenantId: $tenantId,
userId: auth()->id(),
entityType: 'project',
entityId: $projectId,
event: 'project.deleted',
payloadBefore: $snapshot,
payloadAfter: null,
ip: request()->ip(),
userAgent: request()->userAgent(),
);
if ($supplierProjectIds !== []) {
DeleteSupplierProjectJob::dispatch(array_map('intval', $supplierProjectIds));
}
}
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),
'paused' => $query->where('is_active', false),
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);
$result = match ($action) {
'pause' => $this->bulkPauseResume($query, false),
'resume' => $this->bulkPauseResume($query, true),
'delete' => $this->bulkDelete($query),
'update_regions' => $this->bulkUpdateRegions($query, $payload),
'update_days' => $this->bulkUpdateDays($query, $payload),
'update_limit' => $this->bulkUpdateLimit($query, $payload),
};
$this->ops->record(
tenantId: $tenantId,
userId: auth()->id(),
entityType: 'project',
entityId: null,
event: 'project.bulk_'.$action,
payloadBefore: null,
payloadAfter: array_merge(['ids' => $ids], $result),
ip: request()->ip(),
userAgent: request()->userAgent(),
);
return $result;
}
/**
* Pause/resume + supplier sync per affected project (#10).
*
* Without the dispatch, pause never reached the supplier (status stayed active).
* The job's group recompute then pushes status=paused when no active project of
* the group remains, or rebalances the order when some siblings are still active.
*/
private function bulkPauseResume($query, bool $isActive): array
{
$ids = (clone $query)->pluck('id')->all();
$updated = $query->update(['is_active' => $isActive]);
foreach ($ids as $id) {
SyncSupplierProjectJob::dispatch((int) $id);
}
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
}
private function bulkSimpleUpdate($query, array $update): array
{
$updated = $query->update($update);
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
}
private function bulkDelete($query): array
{
$projects = (clone $query)->get(['id']);
$deleted = 0;
$skipped = [];
foreach ($projects as $p) {
$model = Project::find($p->id);
if ($model === null) {
continue;
}
try {
$this->delete($model);
$deleted++;
} catch (HttpResponseException) {
$skipped[] = ['id' => $p->id, 'reason' => 'has_deals'];
}
}
return ['updated' => $deleted, 'skipped' => $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' => []];
}
private function assertNameUnique(int $tenantId, string $name, ?int $exceptId = null): void
{
$q = Project::where('tenant_id', $tenantId)->where('name', $name);
if ($exceptId !== null) {
$q->where('id', '!=', $exceptId);
}
if ($q->exists()) {
throw new HttpResponseException(response()->json([
'errors' => ['name' => ['Проект с таким названием у вас уже есть. Выберите другое название.']],
], 422));
}
}
/** @param array<string,mixed> $data */
private function assertSourceUnique(int $tenantId, array $data, ?int $exceptId = null): void
{
$signalType = $data['signal_type'] ?? null;
$q = Project::where('tenant_id', $tenantId)->where('signal_type', $signalType);
if ($exceptId !== null) {
$q->where('id', '!=', $exceptId);
}
if (in_array($signalType, ['call', 'site'], true)) {
$identifier = (string) ($data['signal_identifier'] ?? '');
if ($identifier === '') {
return;
}
$q->where('signal_identifier', $identifier);
} elseif ($signalType === 'sms') {
$senders = (array) ($data['sms_senders'] ?? []);
$norm = collect($senders)->map(fn ($s) => mb_strtolower(trim((string) $s)))->sort()->values()->all();
if ($norm === []) {
return;
}
$keyword = $data['sms_keyword'] ?? null;
$q->where('sms_keyword', $keyword)
->whereJsonContains('sms_senders', $norm)
->whereRaw('jsonb_array_length(sms_senders::jsonb) = ?', [count($norm)]);
} else {
return;
}
$existing = $q->first();
if ($existing !== null) {
throw new HttpResponseException(response()->json([
'errors' => ['signal_identifier' => ["У вас уже есть проект с этим источником: «{$existing->name}»."]],
], 422));
}
}
public function create(Tenant $tenant, array $data): Project
{
$limit = (int) ($tenant->limits['max_projects'] ?? 10);
$current = Project::where('tenant_id', $tenant->id)->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';
$this->assertNameUnique($tenant->id, (string) $data['name']);
$this->assertSourceUnique($tenant->id, $data);
$project = Project::create($data);
$this->ops->record(
tenantId: $project->tenant_id,
userId: auth()->id(),
entityType: 'project',
entityId: $project->id,
event: 'project.created',
payloadBefore: null,
payloadAfter: $project->toArray(),
ip: request()->ip(),
userAgent: request()->userAgent(),
);
SyncSupplierProjectJob::dispatch($project->id);
return $project->fresh();
}
}