19644a1d36
ProjectService::update() теперь возвращает Project с dynamic applies_from attribute (CarbonImmutable | null), который ProjectResource подхватит для UI («изменения вступят в силу с DD.MM 21:00»). Логика: для каждого изменённого поля из SupplierSnapshotGuard::SLEPOK_SENSITIVE_FIELDS вычисляется максимум appliesFrom() — slepok-инвариант (до 18:00 МСК = today 21:00, после = tomorrow 21:00). NULL = применяется немедленно (none changed / no supplier links). Plan: docs/superpowers/plans/2026-05-26-slepok-routing-protection.md §Task 2.8 Spec: docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md §4.2.5 Tests: tests/Feature/Services/Project/ProjectServiceAppliesFromTest.php — 4/4 PASS. ProjectService regression — 7/7 PASS.
530 lines
22 KiB
PHP
530 lines
22 KiB
PHP
<?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,
|
||
private readonly SupplierSnapshotGuard $snapshotGuard = new SupplierSnapshotGuard,
|
||
) {}
|
||
|
||
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'],
|
||
);
|
||
|
||
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md
|
||
// Если меняем источник (signal_identifier / sms_senders / sms_keyword) — guard.
|
||
$sourceFieldsTouched = array_key_exists('signal_identifier', $data)
|
||
|| array_key_exists('sms_senders', $data)
|
||
|| array_key_exists('sms_keyword', $data);
|
||
if ($sourceFieldsTouched) {
|
||
$this->snapshotGuard->assertCanMutateSource($project, 'change_source');
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// Task 2.8 (Spec §4.2.5): для каждого изменённого slepok-sensitive поля
|
||
// вычислить applies_from — момент, с которого правка реально вступит в силу
|
||
// (slepok-инвариант: до 18:00 МСК → сегодня 21:00 МСК, после → завтра 21:00 МСК).
|
||
// Берём максимум среди затронутых полей. NULL = применяется немедленно.
|
||
$appliesFrom = null;
|
||
foreach (SupplierSnapshotGuard::SLEPOK_SENSITIVE_FIELDS as $field) {
|
||
if (! array_key_exists($field, $data)) {
|
||
continue;
|
||
}
|
||
$candidate = $this->snapshotGuard->appliesFrom($project, $field);
|
||
if ($candidate !== null && ($appliesFrom === null || $candidate->gt($appliesFrom))) {
|
||
$appliesFrom = $candidate;
|
||
}
|
||
}
|
||
|
||
$fresh = $project->fresh();
|
||
// Dynamic attribute — не в БД, сериализуется ProjectResource (Task 2.11).
|
||
$fresh->applies_from = $appliesFrom;
|
||
|
||
return $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
|
||
{
|
||
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md
|
||
// Guard поставщикова слепка ПЕРЕД has-deals (приоритетней) — клиент должен
|
||
// увидеть формулировку про «уже заказали лиды», а не «есть сделки».
|
||
$this->snapshotGuard->assertCanMutateSource($project, 'delete');
|
||
|
||
$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();
|
||
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 11).
|
||
// paused_at — anchor для SupplierSnapshotGuard grace-расчёта. Mass-update НЕ
|
||
// триггерит model events, поэтому пишем явно в одном UPDATE.
|
||
$updated = $query->update([
|
||
'is_active' => $isActive,
|
||
'paused_at' => $isActive ? null : DB::raw('NOW()'),
|
||
]);
|
||
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 $e) {
|
||
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 12).
|
||
// Разделяем причину: guard поставщика (нужно подождать) vs has-deals.
|
||
$body = json_decode((string) $e->getResponse()->getContent(), true);
|
||
$message = (string) ($body['errors']['project'][0] ?? '');
|
||
$reason = str_contains($message, 'Мы уже начали сбор лидов')
|
||
? 'supplier_snapshot_locked'
|
||
: 'has_deals';
|
||
$skipped[] = ['id' => $p->id, 'reason' => $reason];
|
||
}
|
||
}
|
||
|
||
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();
|
||
}
|
||
}
|