381 lines
17 KiB
PHP
381 lines
17 KiB
PHP
<?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,
|
|
];
|
|
}
|
|
} |