optimizations

This commit is contained in:
2026-03-28 19:15:39 +01:00
parent 0b25d9570a
commit cab4fbd83e
509 changed files with 1016804 additions and 1605 deletions

View File

@@ -0,0 +1,381 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Collection;
use App\Models\CollectionMergeAction;
use App\Models\User;
use App\Services\CollectionCanonicalService;
use App\Services\CollectionHealthService;
use App\Services\CollectionHistoryService;
use App\Services\CollectionService;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection as SupportCollection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class CollectionMergeService
{
public function __construct(
private readonly CollectionCanonicalService $canonical,
private readonly CollectionService $collections,
) {
}
public function duplicateCandidates(Collection $collection, int $limit = 5): EloquentCollection
{
$normalizedTitle = mb_strtolower(trim((string) $collection->title));
return Collection::query()
->where('id', '!=', $collection->id)
->whereNotExists(function ($query) use ($collection): void {
$query->select(DB::raw('1'))
->from('collection_merge_actions as cma')
->where('cma.action_type', 'rejected')
->where(function ($pair) use ($collection): void {
$pair->where(function ($forward) use ($collection): void {
$forward->where('cma.source_collection_id', $collection->id)
->whereColumn('cma.target_collection_id', 'collections.id');
})->orWhere(function ($reverse) use ($collection): void {
$reverse->where('cma.target_collection_id', $collection->id)
->whereColumn('cma.source_collection_id', 'collections.id');
});
});
})
->where(function ($query) use ($collection, $normalizedTitle): void {
$query->where('user_id', $collection->user_id)
->orWhere(function ($inner) use ($collection, $normalizedTitle): void {
$inner->whereRaw('LOWER(title) = ?', [$normalizedTitle])
->when(filled($collection->campaign_key), fn ($builder) => $builder->orWhere('campaign_key', $collection->campaign_key))
->when(filled($collection->series_key), fn ($builder) => $builder->orWhere('series_key', $collection->series_key));
});
})
->with(['user:id,username,name', 'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at'])
->orderByDesc('updated_at')
->limit(max(1, min($limit, 10)))
->get();
}
public function reviewCandidates(Collection $collection, bool $ownerView = true, int $limit = 5): array
{
$candidates = $this->duplicateCandidates($collection, $limit);
$candidateIds = $candidates->pluck('id')->map(static fn ($id): int => (int) $id)->values()->all();
$cards = collect($this->collections->mapCollectionCardPayloads($candidates, $ownerView))->keyBy('id');
$latestActions = $this->latestActionsForPair($collection, $candidateIds);
return $candidates->map(function (Collection $candidate) use ($collection, $cards, $latestActions): array {
return [
'collection' => $cards->get((int) $candidate->id),
'comparison' => $this->comparisonForCollections($collection, $candidate),
'decision' => $latestActions[$this->pairKey((int) $collection->id, (int) $candidate->id)] ?? null,
'is_current_canonical_target' => (int) ($collection->canonical_collection_id ?? 0) === (int) $candidate->id,
];
})->values()->all();
}
public function queueOverview(bool $ownerView = true, int $pendingLimit = 8, int $recentLimit = 8): array
{
$latestActions = CollectionMergeAction::query()
->with([
'sourceCollection.user:id,username,name',
'sourceCollection.coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
'targetCollection.user:id,username,name',
'targetCollection.coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
'actor:id,username,name',
])
->orderByDesc('id')
->get()
->filter(fn (CollectionMergeAction $action): bool => $action->sourceCollection !== null && $action->targetCollection !== null)
->groupBy(fn (CollectionMergeAction $action): string => $this->pairKey((int) $action->source_collection_id, (int) $action->target_collection_id))
->map(fn (SupportCollection $actions): CollectionMergeAction => $actions->first())
->values();
$pending = $latestActions
->filter(fn (CollectionMergeAction $action): bool => $action->action_type === 'suggested')
->sortByDesc(fn (CollectionMergeAction $action) => optional($action->updated_at)?->timestamp ?? 0)
->take($pendingLimit)
->values();
$recent = $latestActions
->filter(fn (CollectionMergeAction $action): bool => $action->action_type !== 'suggested')
->sortByDesc(fn (CollectionMergeAction $action) => optional($action->updated_at)?->timestamp ?? 0)
->take($recentLimit)
->values();
return [
'summary' => [
'pending' => $latestActions->where('action_type', 'suggested')->count(),
'approved' => $latestActions->where('action_type', 'approved')->count(),
'rejected' => $latestActions->where('action_type', 'rejected')->count(),
'completed' => $latestActions->where('action_type', 'completed')->count(),
],
'pending' => $pending->map(fn (CollectionMergeAction $action): array => $this->mapQueueAction($action, $ownerView))->values()->all(),
'recent' => $recent->map(fn (CollectionMergeAction $action): array => $this->mapQueueAction($action, $ownerView))->values()->all(),
];
}
public function syncSuggestedCandidates(Collection $collection, ?User $actor = null, int $limit = 5): array
{
$candidates = $this->duplicateCandidates($collection, $limit);
$candidateIds = $candidates->pluck('id')->map(static fn ($id): int => (int) $id)->values()->all();
$staleSuggestions = CollectionMergeAction::query()
->where('source_collection_id', $collection->id)
->where('action_type', 'suggested');
if ($candidateIds === []) {
$staleSuggestions->delete();
} else {
$staleSuggestions->whereNotIn('target_collection_id', $candidateIds)->delete();
}
foreach ($candidates as $candidate) {
CollectionMergeAction::query()->updateOrCreate(
[
'source_collection_id' => $collection->id,
'target_collection_id' => $candidate->id,
'action_type' => 'suggested',
],
[
'actor_user_id' => $actor?->id,
'summary' => 'Potential duplicate candidate detected.',
]
);
}
$this->syncDuplicateClusterKeys($collection, $candidateIds);
app(CollectionHistoryService::class)->record(
$collection->fresh(),
$actor,
'duplicate_candidates_synced',
sprintf('Collection duplicate candidates scanned. %d potential matches found.', count($candidateIds)),
null,
['candidate_collection_ids' => $candidateIds]
);
return [
'count' => count($candidateIds),
'items' => $candidates->map(fn (Collection $candidate): array => [
'id' => (int) $candidate->id,
'title' => (string) $candidate->title,
'slug' => (string) $candidate->slug,
])->values()->all(),
];
}
public function rejectCandidate(Collection $source, Collection $target, ?User $actor = null): Collection
{
if ((int) $source->id === (int) $target->id) {
throw ValidationException::withMessages([
'target_collection_id' => 'A collection cannot reject itself as a duplicate.',
]);
}
CollectionMergeAction::query()
->where(function ($query) use ($source, $target): void {
$query->where(function ($forward) use ($source, $target): void {
$forward->where('source_collection_id', $source->id)
->where('target_collection_id', $target->id);
})->orWhere(function ($reverse) use ($source, $target): void {
$reverse->where('source_collection_id', $target->id)
->where('target_collection_id', $source->id);
});
})
->whereIn('action_type', ['suggested'])
->delete();
CollectionMergeAction::query()->updateOrCreate(
[
'source_collection_id' => $source->id,
'target_collection_id' => $target->id,
'action_type' => 'rejected',
],
[
'actor_user_id' => $actor?->id,
'summary' => 'Marked as not a duplicate.',
]
);
app(CollectionHistoryService::class)->record(
$source->fresh(),
$actor,
'duplicate_rejected',
'Duplicate candidate dismissed.',
null,
['target_collection_id' => (int) $target->id]
);
$this->syncDuplicateClusterKeys($source->fresh(), $this->duplicateCandidates($source->fresh())->pluck('id')->map(static fn ($id): int => (int) $id)->all());
$this->syncDuplicateClusterKeys($target->fresh(), $this->duplicateCandidates($target->fresh())->pluck('id')->map(static fn ($id): int => (int) $id)->all());
return app(CollectionHealthService::class)->refresh($source->fresh(), $actor, 'duplicate-rejected');
}
public function mergeInto(Collection $source, Collection $target, ?User $actor = null): array
{
if ((int) $source->id === (int) $target->id) {
throw ValidationException::withMessages([
'target_collection_id' => 'A collection cannot merge into itself.',
]);
}
if ($target->isSmart()) {
throw ValidationException::withMessages([
'target_collection_id' => 'Target collection must be manual so merged artworks can be referenced safely.',
]);
}
return DB::transaction(function () use ($source, $target, $actor): array {
$artworkIds = $source->artworks()->pluck('artworks.id')->map(static fn ($id) => (int) $id)->all();
$this->collections->attachArtworkIds($target, $artworkIds);
$source = $this->canonical->designate($source->fresh(), $target->fresh(), $actor);
$source->forceFill([
'workflow_state' => Collection::WORKFLOW_ARCHIVED,
'lifecycle_state' => Collection::LIFECYCLE_ARCHIVED,
'archived_at' => now(),
'placement_eligibility' => false,
])->save();
CollectionMergeAction::query()->create([
'source_collection_id' => $source->id,
'target_collection_id' => $target->id,
'action_type' => 'completed',
'actor_user_id' => $actor?->id,
'summary' => 'Collection merge completed.',
]);
app(CollectionHistoryService::class)->record($target->fresh(), $actor, 'merged_into_target', 'Collection absorbed merge references.', null, [
'source_collection_id' => (int) $source->id,
'artwork_ids' => $artworkIds,
]);
app(CollectionHistoryService::class)->record($source->fresh(), $actor, 'merged_into_canonical', 'Collection archived after merge.', null, [
'target_collection_id' => (int) $target->id,
]);
return [
'source' => $source->fresh(),
'target' => $target->fresh(),
'attached_artwork_ids' => $artworkIds,
];
});
}
/**
* @param array<int, int> $candidateIds
* @return array<string, array<string, mixed>>
*/
private function latestActionsForPair(Collection $source, array $candidateIds): array
{
if ($candidateIds === []) {
return [];
}
return CollectionMergeAction::query()
->where(function ($query) use ($source, $candidateIds): void {
$query->where('source_collection_id', $source->id)
->whereIn('target_collection_id', $candidateIds)
->orWhere(function ($reverse) use ($source, $candidateIds): void {
$reverse->where('target_collection_id', $source->id)
->whereIn('source_collection_id', $candidateIds);
});
})
->orderByDesc('id')
->get()
->groupBy(function (CollectionMergeAction $action): string {
return $this->pairKey((int) $action->source_collection_id, (int) $action->target_collection_id);
})
->map(fn ($actions): array => [
'action_type' => (string) $actions->first()->action_type,
'summary' => $actions->first()->summary,
'updated_at' => optional($actions->first()->updated_at)?->toISOString(),
])
->all();
}
private function pairKey(int $leftId, int $rightId): string
{
$pair = [$leftId, $rightId];
sort($pair);
return implode(':', $pair);
}
private function comparisonForCollections(Collection $source, Collection $target): array
{
$sourceArtworkIds = $source->artworks()->pluck('artworks.id')->map(static fn ($id): int => (int) $id)->values()->all();
$targetArtworkIds = $target->artworks()->pluck('artworks.id')->map(static fn ($id): int => (int) $id)->values()->all();
$sharedArtworkIds = array_values(array_intersect($sourceArtworkIds, $targetArtworkIds));
$reasons = array_values(array_filter([
(int) $target->user_id === (int) $source->user_id ? 'same_owner' : null,
mb_strtolower(trim((string) $target->title)) === mb_strtolower(trim((string) $source->title)) ? 'same_title' : null,
filled($source->campaign_key) && $target->campaign_key === $source->campaign_key ? 'same_campaign' : null,
filled($source->series_key) && $target->series_key === $source->series_key ? 'same_series' : null,
$sharedArtworkIds !== [] ? 'shared_artworks' : null,
]));
return [
'same_owner' => (int) $target->user_id === (int) $source->user_id,
'same_title' => mb_strtolower(trim((string) $target->title)) === mb_strtolower(trim((string) $source->title)),
'same_campaign' => filled($source->campaign_key) && $target->campaign_key === $source->campaign_key,
'same_series' => filled($source->series_key) && $target->series_key === $source->series_key,
'shared_artworks_count' => count($sharedArtworkIds),
'source_artworks_count' => count($sourceArtworkIds),
'target_artworks_count' => count($targetArtworkIds),
'match_reasons' => $reasons,
];
}
/**
* @param array<int, int> $candidateIds
*/
private function syncDuplicateClusterKeys(Collection $collection, array $candidateIds): void
{
$clusterIds = collect([$collection->id])
->merge($candidateIds)
->map(static fn ($id): int => (int) $id)
->unique()
->values();
if ($clusterIds->count() <= 1) {
Collection::query()
->where('id', $collection->id)
->whereNull('canonical_collection_id')
->update(['duplicate_cluster_key' => null]);
return;
}
$clusterKey = sprintf('dup:%d:%d', $clusterIds->min(), $clusterIds->count());
Collection::query()
->whereIn('id', $clusterIds->all())
->whereNull('canonical_collection_id')
->update(['duplicate_cluster_key' => $clusterKey]);
}
private function mapQueueAction(CollectionMergeAction $action, bool $ownerView): array
{
$source = $action->sourceCollection;
$target = $action->targetCollection;
return [
'id' => (int) $action->id,
'action_type' => (string) $action->action_type,
'summary' => $action->summary,
'updated_at' => optional($action->updated_at)?->toISOString(),
'source' => $source ? $this->collections->mapCollectionCardPayloads([$source], $ownerView)[0] : null,
'target' => $target ? $this->collections->mapCollectionCardPayloads([$target], $ownerView)[0] : null,
'comparison' => ($source && $target) ? $this->comparisonForCollections($source, $target) : null,
'actor' => $action->actor ? [
'id' => (int) $action->actor->id,
'username' => (string) $action->actor->username,
'name' => $action->actor->name,
] : null,
];
}
}