metadataCompletenessScore($collection); $freshness = $this->freshnessScore($collection); $engagement = $this->engagementScore($collection); $readiness = $this->editorialReadinessScore($collection, $metadataCompleteness, $freshness, $engagement); $flags = $this->flags($collection, $metadataCompleteness, $freshness, $engagement, $readiness); $healthState = $this->healthStateFromFlags($flags); $healthScore = $this->healthScore($metadataCompleteness, $freshness, $engagement, $readiness, $flags); $placementEligibility = $this->placementEligibility($collection, $healthState, $readiness); return [ 'metadata_completeness_score' => $metadataCompleteness, 'freshness_score' => $freshness, 'engagement_score' => $engagement, 'editorial_readiness_score' => $readiness, 'health_score' => $healthScore, 'health_state' => $healthState, 'health_flags_json' => $flags, 'readiness_state' => $this->readinessState($placementEligibility, $flags), 'placement_eligibility' => $placementEligibility, 'duplicate_cluster_key' => $this->duplicateClusterKey($collection), 'trust_tier' => $this->trustTier($collection, $healthScore), 'last_health_check_at' => now(), ]; } public function refresh(Collection $collection, ?User $actor = null, string $reason = 'refresh'): Collection { $payload = $this->evaluate($collection->fresh()); $snapshotDate = now()->toDateString(); $collection->forceFill($payload)->save(); $snapshot = CollectionQualitySnapshot::query() ->where('collection_id', $collection->id) ->whereDate('snapshot_date', $snapshotDate) ->first(); if ($snapshot) { $snapshot->forceFill([ 'quality_score' => $collection->quality_score, 'health_score' => $payload['health_score'], 'metadata_completeness_score' => $payload['metadata_completeness_score'], 'freshness_score' => $payload['freshness_score'], 'engagement_score' => $payload['engagement_score'], 'readiness_score' => $payload['editorial_readiness_score'], 'flags_json' => $payload['health_flags_json'], ])->save(); } else { CollectionQualitySnapshot::query()->create([ 'collection_id' => $collection->id, 'snapshot_date' => $snapshotDate, 'quality_score' => $collection->quality_score, 'health_score' => $payload['health_score'], 'metadata_completeness_score' => $payload['metadata_completeness_score'], 'freshness_score' => $payload['freshness_score'], 'engagement_score' => $payload['engagement_score'], 'readiness_score' => $payload['editorial_readiness_score'], 'flags_json' => $payload['health_flags_json'], ]); } $fresh = $collection->fresh(); app(CollectionHistoryService::class)->record( $fresh, $actor, 'health_refreshed', sprintf('Collection health refreshed via %s.', $reason), null, [ 'health_state' => $fresh->health_state, 'readiness_state' => $fresh->readiness_state, 'placement_eligibility' => (bool) $fresh->placement_eligibility, 'health_score' => (float) ($fresh->health_score ?? 0), 'flags' => $fresh->health_flags_json, ] ); return $fresh; } public function summary(Collection $collection): array { return [ 'health_state' => $collection->health_state, 'readiness_state' => $collection->readiness_state, 'health_score' => $collection->health_score !== null ? (float) $collection->health_score : null, 'metadata_completeness_score' => $collection->metadata_completeness_score !== null ? (float) $collection->metadata_completeness_score : null, 'editorial_readiness_score' => $collection->editorial_readiness_score !== null ? (float) $collection->editorial_readiness_score : null, 'freshness_score' => $collection->freshness_score !== null ? (float) $collection->freshness_score : null, 'engagement_score' => $collection->engagement_score !== null ? (float) $collection->engagement_score : null, 'placement_eligibility' => (bool) $collection->placement_eligibility, 'flags' => is_array($collection->health_flags_json) ? $collection->health_flags_json : [], 'duplicate_cluster_key' => $collection->duplicate_cluster_key, 'canonical_collection_id' => $collection->canonical_collection_id ? (int) $collection->canonical_collection_id : null, 'last_health_check_at' => optional($collection->last_health_check_at)?->toISOString(), ]; } private function metadataCompletenessScore(Collection $collection): float { $score = 0.0; $score += filled($collection->title) ? 18.0 : 0.0; $score += filled($collection->summary) ? 18.0 : 0.0; $score += filled($collection->description) ? 14.0 : 0.0; $score += $this->hasStrongCover($collection) ? 18.0 : ($collection->resolvedCoverArtwork(false) ? 8.0 : 0.0); $score += (int) $collection->artworks_count >= 6 ? 16.0 : ((int) $collection->artworks_count >= 4 ? 10.0 : ((int) $collection->artworks_count >= 2 ? 5.0 : 0.0)); $score += $collection->usesPremiumPresentation() ? 8.0 : 0.0; $score += filled($collection->campaign_key) || filled($collection->event_key) || filled($collection->season_key) ? 8.0 : 0.0; return round(min(100.0, $score), 2); } private function freshnessScore(Collection $collection): float { $reference = $collection->last_activity_at ?: $collection->updated_at ?: $collection->published_at; if ($reference === null) { return 0.0; } $days = max(0, now()->diffInDays($reference)); if ($days >= 45) { return 0.0; } return round(max(0.0, 100.0 - (($days / 45) * 100.0)), 2); } private function engagementScore(Collection $collection): float { $weighted = ((int) $collection->likes_count * 3.0) + ((int) $collection->followers_count * 4.5) + ((int) $collection->saves_count * 4.0) + ((int) $collection->comments_count * 2.0) + ((int) $collection->shares_count * 2.5) + ((int) $collection->views_count * 0.08); return round(min(100.0, $weighted), 2); } private function editorialReadinessScore(Collection $collection, float $metadataCompleteness, float $freshness, float $engagement): float { $score = ($metadataCompleteness * 0.45) + ($freshness * 0.2) + ($engagement * 0.2); $score += $collection->moderation_status === Collection::MODERATION_ACTIVE ? 10.0 : -20.0; $score += $collection->visibility === Collection::VISIBILITY_PUBLIC ? 10.0 : -10.0; $score += in_array((string) $collection->workflow_state, [Collection::WORKFLOW_APPROVED, Collection::WORKFLOW_PROGRAMMED], true) ? 10.0 : 0.0; return round(max(0.0, min(100.0, $score)), 2); } private function flags(Collection $collection, float $metadataCompleteness, float $freshness, float $engagement, float $readiness): array { $flags = []; $artworksCount = (int) $collection->artworks_count; if ($metadataCompleteness < 55) { $flags[] = Collection::HEALTH_NEEDS_METADATA; } if ($artworksCount < 6) { $flags[] = Collection::HEALTH_LOW_CONTENT; } if (! $this->hasStrongCover($collection)) { $flags[] = Collection::HEALTH_WEAK_COVER; } if ($freshness <= 0.0 && $collection->isPubliclyAccessible()) { $flags[] = Collection::HEALTH_STALE; } if ($engagement < 15 && $collection->isPubliclyAccessible() && $collection->published_at?->lt(now()->subDays(21))) { $flags[] = Collection::HEALTH_LOW_ENGAGEMENT; } if ($collection->moderation_status !== Collection::MODERATION_ACTIVE || (string) $collection->workflow_state === Collection::WORKFLOW_IN_REVIEW) { $flags[] = Collection::HEALTH_NEEDS_REVIEW; } if ($this->brokenItemsRatio($collection) > 0.25) { $flags[] = Collection::HEALTH_BROKEN_ITEMS; } if ($this->hasDuplicateRisk($collection)) { $flags[] = Collection::HEALTH_DUPLICATE_RISK; } if ($collection->canonical_collection_id !== null) { $flags[] = Collection::HEALTH_MERGE_CANDIDATE; } if ($readiness < 45 && $collection->type === Collection::TYPE_EDITORIAL && $artworksCount < 6) { $flags[] = Collection::HEALTH_ATTRIBUTION_INCOMPLETE; } return array_values(array_unique($flags)); } private function healthStateFromFlags(array $flags): string { foreach ([ Collection::HEALTH_MERGE_CANDIDATE, Collection::HEALTH_DUPLICATE_RISK, Collection::HEALTH_NEEDS_REVIEW, Collection::HEALTH_BROKEN_ITEMS, Collection::HEALTH_LOW_CONTENT, Collection::HEALTH_WEAK_COVER, Collection::HEALTH_NEEDS_METADATA, Collection::HEALTH_STALE, Collection::HEALTH_LOW_ENGAGEMENT, Collection::HEALTH_ATTRIBUTION_INCOMPLETE, ] as $flag) { if (in_array($flag, $flags, true)) { return $flag; } } return Collection::HEALTH_HEALTHY; } private function healthScore(float $metadataCompleteness, float $freshness, float $engagement, float $readiness, array $flags): float { $score = ($metadataCompleteness * 0.35) + ($freshness * 0.2) + ($engagement * 0.2) + ($readiness * 0.25); $score -= count($flags) * 6.5; return round(max(0.0, min(100.0, $score)), 2); } private function placementEligibility(Collection $collection, string $healthState, float $readiness): bool { if ($collection->visibility !== Collection::VISIBILITY_PUBLIC) { return false; } if ($collection->moderation_status !== Collection::MODERATION_ACTIVE) { return false; } if (! in_array($collection->lifecycle_state, [Collection::LIFECYCLE_PUBLISHED, Collection::LIFECYCLE_FEATURED], true)) { return false; } if (in_array($healthState, [Collection::HEALTH_BROKEN_ITEMS, Collection::HEALTH_MERGE_CANDIDATE], true)) { return false; } if ($collection->workflow_state === Collection::WORKFLOW_IN_REVIEW) { return false; } return $readiness >= 45.0; } private function readinessState(bool $placementEligibility, array $flags): string { if (! $placementEligibility) { return Collection::READINESS_BLOCKED; } if ($flags !== []) { return Collection::READINESS_NEEDS_WORK; } return Collection::READINESS_READY; } private function duplicateClusterKey(Collection $collection): ?string { $existing = trim((string) ($collection->duplicate_cluster_key ?? '')); return $existing !== '' ? $existing : null; } private function trustTier(Collection $collection, float $healthScore): string { if ($collection->type === Collection::TYPE_EDITORIAL) { return 'editorial'; } if ($healthScore >= 80) { return 'high'; } if ($healthScore >= 50) { return 'standard'; } return 'limited'; } private function brokenItemsRatio(Collection $collection): float { if ($collection->isSmart() || (int) $collection->artworks_count === 0) { return 0.0; } $visibleCount = DB::table('collection_artwork as ca') ->join('artworks as a', 'a.id', '=', 'ca.artwork_id') ->where('ca.collection_id', $collection->id) ->whereNull('a.deleted_at') ->where('a.is_public', true) ->where('a.is_approved', true) ->whereNotNull('a.published_at') ->where('a.published_at', '<=', now()) ->count(); return max(0.0, ((int) $collection->artworks_count - $visibleCount) / max(1, (int) $collection->artworks_count)); } private function hasDuplicateRisk(Collection $collection): bool { return Collection::query() ->where('id', '!=', $collection->id) ->where('user_id', $collection->user_id) ->whereRaw('LOWER(title) = ?', [mb_strtolower(trim((string) $collection->title))]) ->exists(); } private function hasStrongCover(Collection $collection): bool { if (! $collection->cover_artwork_id) { return false; } $cover = $collection->relationLoaded('coverArtwork') ? $collection->coverArtwork : $collection->coverArtwork()->first(); if (! $cover instanceof Artwork) { return false; } if ($collection->isPubliclyAccessible() && ! $cover->published_at) { return false; } $width = (int) ($cover->width ?? 0); $height = (int) ($cover->height ?? 0); return $width >= 320 && $height >= 220; } }