Files
portal/app/app/Models/Project.php
T
Дмитрий e242e7d7fc fix(projects): Plan 5 Task 2 code-review fixes (2 Important + 2 Minor)
I-1/M-1: introduce resolvedSupplierProjects() private helper on Project
model; rewrite aggregateSyncStatus(), aggregateLastSyncedAt(),
getSupplierLinks() to read from eager-loaded supplierB1/B2/B3 relations
instead of SupplierProject::find() — eliminates up to 120 SELECTs/page.

I-2: aggregateLastSyncedAt() now uses sortBy(timestamp) instead of
Collection::min() on Carbon objects (string-comparison was unreliable).

M-2: add explanatory comment on intval+array_filter silent-drop behaviour
in the ?ids batch-fetch path.

M-3: new test — ?ids batch silently excludes foreign-tenant project IDs.
M-4: new test — show returns 200 for archived project (read preserved).

PHPStan baseline updated: 2 new test functions raise actingAs() count 7→9.
Tests: 9/9 passed (33 assertions). Larastan: 0 errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 18:15:36 +03:00

245 lines
8.7 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\Models;
use Carbon\CarbonInterface;
use Database\Factories\ProjectFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Collection;
/**
* Проект (лид-канал) внутри тенанта.
*
* Tenant-aware модель с RLS: SELECT/INSERT/UPDATE/DELETE фильтруются
* политикой `tenant_isolation` по `current_setting('app.current_tenant_id')`.
*
* Расширен в Plan 1/5 Task 1+10 для supplier integration: signal_type/identifier,
* sms_senders/keyword, delivered_in_month, supplier_b{1,2,3}_project_id (FK на
* SupplierProject — sharing-model между tenant'ами).
*
* Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §2.1
* Источник: db/schema.sql v8.16 §4, table `projects`.
*
* @mixin IdeHelperProject
*/
class Project extends Model
{
/** @use HasFactory<ProjectFactory> */
use HasFactory;
protected $fillable = [
'tenant_id',
'name',
'tag',
'type',
'is_active',
// Plan 5 Task 1 (schema v8.20): soft archive flow — lifecycle-state рядом с is_active.
'archived_at',
'daily_limit_target',
'effective_daily_limit_today',
'effective_limit_calculated_at',
'region_mask',
'region_mode',
'delivery_days_mask',
'assignment_strategy',
'ttfr_target_minutes',
// Supplier integration (Plan 1/5 Task 1+10):
'signal_type',
'signal_identifier',
'sms_senders',
'sms_keyword',
'delivered_in_month',
'supplier_b1_project_id',
'supplier_b2_project_id',
'supplier_b3_project_id',
// Plan 2/5 Task 1 (schema v8.18): дневной счётчик доставленных лидов
// (сбрасывается cron'ом в 00:00 МСК, используется LeadRouter'ом).
'delivered_today',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
'daily_limit_target' => 'integer',
'effective_daily_limit_today' => 'integer',
'region_mask' => 'integer',
'delivery_days_mask' => 'integer',
'ttfr_target_minutes' => 'integer',
'effective_limit_calculated_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
// Supplier integration:
'sms_senders' => 'array',
'delivered_in_month' => 'integer',
'delivered_today' => 'integer',
// Plan 5 Task 1 (schema v8.20): soft archive.
'archived_at' => 'datetime',
];
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/** @return BelongsTo<SupplierProject, $this> */
public function supplierB1(): BelongsTo
{
return $this->belongsTo(SupplierProject::class, 'supplier_b1_project_id');
}
/** @return BelongsTo<SupplierProject, $this> */
public function supplierB2(): BelongsTo
{
return $this->belongsTo(SupplierProject::class, 'supplier_b2_project_id');
}
/** @return BelongsTo<SupplierProject, $this> */
public function supplierB3(): BelongsTo
{
return $this->belongsTo(SupplierProject::class, 'supplier_b3_project_id');
}
/**
* Активные проекты, у которых сегодняшний день включён в delivery_days_mask.
*
* delivery_days_mask — битовая маска: bit 0 = понедельник, bit 6 = воскресенье
* (ISO day-of-week минус 1). Для ISO=1 (Mon) = 1<<0 = 1; для ISO=7 (Sun) = 1<<6 = 64.
*
* @param Builder<Project> $query
* @return Builder<Project>
*/
public function scopeActiveOnDay(Builder $query, int $isoDayOfWeek): Builder
{
$bit = 1 << ($isoDayOfWeek - 1);
return $query->where('is_active', true)
->whereRaw('(delivery_days_mask & ?) <> 0', [$bit]);
}
/**
* @param Builder<Project> $query
* @return Builder<Project>
*/
public function scopeForSignal(Builder $query, string $signalType, string $identifier): Builder
{
return $query->where('signal_type', $signalType)->where('signal_identifier', $identifier);
}
/**
* Не архивированные проекты (archived_at IS NULL).
*
* Внимание: scope не фильтрует is_active. Приостановленные (is_active=false)
* проекты сюда попадают — это разные lifecycle-состояния. Если нужны только
* «работающие» (не архив И не на паузе) — комбинируйте:
* ->active()->where('is_active', true).
*
* @param Builder<Project> $query
* @return Builder<Project>
*/
public function scopeActive(Builder $query): Builder
{
return $query->whereNull('archived_at');
}
/**
* Архивированные проекты (archived_at IS NOT NULL).
*
* @param Builder<Project> $query
* @return Builder<Project>
*/
public function scopeArchived(Builder $query): Builder
{
return $query->whereNotNull('archived_at');
}
/**
* Все связанные SupplierProject из eager-loaded BelongsTo отношений.
*
* Используется внутри aggregateSyncStatus(), aggregateLastSyncedAt(),
* getSupplierLinks() — устраняет N+1 (каждый из трёх методов вызывал
* SupplierProject::find() независимо; теперь читает из уже загруженных
* $this->supplierB1 / supplierB2 / supplierB3).
*
* Требует eager-load: Project::with(['supplierB1', 'supplierB2', 'supplierB3']).
*
* @return Collection<int, SupplierProject>
*/
private function resolvedSupplierProjects(): Collection
{
return collect([$this->supplierB1, $this->supplierB2, $this->supplierB3])->filter()->values();
}
/**
* Агрегированный статус синхронизации по всем связанным SupplierProject.
*
* Логика: если нет ни одного — pending; если есть failed — failed;
* если есть pending — pending; иначе — ok.
*
* Читает из eager-loaded отношений (см. resolvedSupplierProjects()).
*/
public function aggregateSyncStatus(): string
{
$statuses = $this->resolvedSupplierProjects()->pluck('sync_status');
if ($statuses->isEmpty()) {
return 'pending';
}
if ($statuses->contains('failed')) {
return 'failed';
}
if ($statuses->contains('pending')) {
return 'pending';
}
return 'ok';
}
/**
* Минимальная дата последней синхронизации по всем связанным SupplierProject.
*
* Использует sortBy по timestamp вместо Collection::min() на Carbon-объектах
* (min() сравнивает строковое представление, что ненадёжно для Carbon).
*
* Читает из eager-loaded отношений (см. resolvedSupplierProjects()).
*/
public function aggregateLastSyncedAt(): ?string
{
$ts = $this->resolvedSupplierProjects()
->pluck('last_synced_at')
->filter()
->sortBy(fn (CarbonInterface $c) => $c->timestamp)
->first();
return $ts?->toIso8601String();
}
/**
* Массив ссылок на связанные SupplierProject (для show endpoint).
*
* Читает из eager-loaded отношений (см. resolvedSupplierProjects()).
*
* @return array<int, array{platform: string, supplier_project_id: int, sync_status: string|null, last_synced_at: string|null}>
*/
public function getSupplierLinks(): array
{
return collect(['b1' => $this->supplierB1, 'b2' => $this->supplierB2, 'b3' => $this->supplierB3])
->filter()
->map(fn (SupplierProject $sp, string $platform) => [
'platform' => $platform,
'supplier_project_id' => $sp->id,
'sync_status' => $sp->sync_status,
'last_synced_at' => $sp->last_synced_at?->toIso8601String(),
])
->values()
->all();
}
}