Files
portal/app/app/Services/Project/ProjectService.php
T
Дмитрий 19644a1d36 feat(slepok): Task 2.8 — ProjectService exposes applies_from after slepok-sensitive update
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.
2026-05-28 06:49:43 +03:00

530 lines
22 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,
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();
}
}