Files
portal/app/app/Models/Project.php
T

238 lines
8.9 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
namespace App\Models;
use App\Casts\PostgresIntArray;
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\Database\Eloquent\Relations\BelongsToMany;
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',
'paused_at',
'daily_limit_target',
'effective_daily_limit_today',
'effective_limit_calculated_at',
'region_mask',
'region_mode',
// Plan 6 (schema v8.20): Subject-level regions array (89 codes из resources/js/constants/regions.ts).
// Источник истины с Plan 6+; region_mask/region_mode — DEPRECATED (Plan 6.5 cleanup).
'regions',
'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',
// Billing v2 Spec C: флаг точечной блокировки проекта по преfflight (NULL = не заблокирован).
'preflight_blocked_at',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
'paused_at' => 'datetime',
'daily_limit_target' => 'integer',
'effective_daily_limit_today' => 'integer',
'region_mask' => 'integer',
// Plan 6: Subject-level regions array (89 codes). Используется кастомный
// PostgresIntArray cast — Laravel stock 'array' посылает JSON `[1,2,3]`,
// что Postgres отвергает на INT[] (ожидает literal `{1,2,3}`).
'regions' => PostgresIntArray::class,
'delivery_days_mask' => 'integer',
'ttfr_target_minutes' => 'integer',
'effective_limit_calculated_at' => 'datetime',
// Billing v2 Spec C: флаг преfflight-блокировки проекта.
'preflight_blocked_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
// Supplier integration:
'sms_senders' => 'array',
'delivered_in_month' => 'integer',
'delivered_today' => 'integer',
];
}
/** @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');
}
/**
* @return BelongsToMany<SupplierProject, $this>
*/
public function supplierProjects(): BelongsToMany
{
return $this->belongsToMany(SupplierProject::class, 'project_supplier_links')
->withPivot(['platform', 'subject_code']);
}
/**
* Активные проекты, у которых сегодняшний день включён в 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);
}
/**
* Все связанные 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();
}
}