*/ 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 */ public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class); } /** @return BelongsTo */ public function supplierB1(): BelongsTo { return $this->belongsTo(SupplierProject::class, 'supplier_b1_project_id'); } /** @return BelongsTo */ public function supplierB2(): BelongsTo { return $this->belongsTo(SupplierProject::class, 'supplier_b2_project_id'); } /** @return BelongsTo */ 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 $query * @return Builder */ 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 $query * @return Builder */ 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 $query * @return Builder */ public function scopeActive(Builder $query): Builder { return $query->whereNull('archived_at'); } /** * Архивированные проекты (archived_at IS NOT NULL). * * @param Builder $query * @return Builder */ 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 */ 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 */ 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(); } }