approveAction(), $this->rejectAction(), // A visszavonás az elsődleges (primary) akció: egy megszakadt végrehajtás // után a gyakoribb helyes válasz a visszavonás + új árlista, a folytatás // jellemzően csak átmeneti hibánál (deadlock, kapcsolatvesztés) segít. $this->revertAction(), $this->continueExecutionAction(), $this->forceCloseAction(), EditAction::make() // A `fail` a validálásig tartó szakasz hibája, onnan a fájl újratöltése // biztonságosan újraindítja a láncot. A hasExecutionStarted() extra // feltétel a maradék kockázatot zárja ki: ha a végrehajtás egyszer már // elindult, a nulláról induló újrafuttatás duplikált termékeket és // árakat hozna létre. ->visible(fn (PricelistFile $record) => $record->status === PricelistFileStatusEnum::fail && ! $record->hasExecutionStarted()), ]; } protected function approveAction(): Action { return Action::make('approve') ->label('Jóváhagyás') ->icon('heroicon-o-check-circle') ->color('success') ->visible(fn (PricelistFile $record) => $this->canDecide($record)) ->requiresConfirmation() ->modalHeading('Árlista jóváhagyása') ->modalDescription(fn (PricelistFile $record) => 'A jóváhagyás elindítja a tényleges importot: ' . $this->summarizeLines($record) . ' A művelet a termékadatokat is módosítja.') ->modalSubmitActionLabel('Jóváhagyás és végrehajtás') ->action(function (PricelistFile $record) { if (! app(PricelistFileProcessService::class)->approve($record)) { $this->notifyStateChanged(); return; } Notification::make() ->title('Végrehajtás elindítva') ->body('Az árlista importja elindult, a haladás ezen az oldalon követhető.') ->success() ->send(); }); } protected function rejectAction(): Action { return Action::make('reject') ->label('Elutasítás') ->icon('heroicon-o-x-circle') ->color('danger') ->visible(fn (PricelistFile $record) => $this->canDecide($record)) ->requiresConfirmation() ->modalHeading('Árlista elutasítása') ->modalDescription('A fájl lezárva státuszba kerül, import nem indul. A művelet nem vonható vissza.') ->modalSubmitActionLabel('Elutasítás') ->schema([ Textarea::make('reason') ->label('Elutasítás indoka') ->rows(3) ->maxLength(1000), ]) ->action(function (PricelistFile $record, array $data) { if (! app(PricelistFileProcessService::class)->reject($record, $data['reason'] ?? null)) { $this->notifyStateChanged(); return; } Notification::make() ->title('Árlista elutasítva') ->body('A fájl lezárva státuszba került.') ->success() ->send(); }); } protected function revertAction(): Action { return Action::make('revert') ->label('Végrehajtás visszavonása') ->icon('heroicon-o-arrow-uturn-left') ->color('primary') ->visible(fn (PricelistFile $record) => $this->canDecideOnExecution($record)) ->requiresConfirmation() ->modalHeading('Végrehajtás visszavonása') ->modalDescription(fn (PricelistFile $record) => $this->summarizeExecution($record) . ' A visszavonás visszaállítja a frissített termékeket a mentett állapotukra, a létrehozott termékeket kivonja a forgalomból, és törli a rögzített árakat.' . ' Ha egy terméket a végrehajtás óta kézzel módosítottak, azt a sort kihagyjuk, és a végén jelentjük.') ->modalSubmitActionLabel('Visszavonás indítása') ->action(function (PricelistFile $record) { app(PricelistFileProcessService::class)->dispatchRevertJob($record); Notification::make() ->title('Visszavonás elindítva') ->body('A visszaállítás fut, a haladás ezen az oldalon követhető.') ->success() ->send(); }); } protected function continueExecutionAction(): Action { return Action::make('continueExecution') ->label('Végrehajtás folytatása') ->icon('heroicon-o-play') ->color('gray') ->visible(fn (PricelistFile $record) => $this->canDecideOnExecution($record)) ->requiresConfirmation() ->modalHeading('Végrehajtás folytatása') ->modalDescription(fn (PricelistFile $record) => $this->summarizeExecution($record) . ' A folytatás onnan viszi tovább a feldolgozást, ahol megszakadt - a már feldolgozott sorok kimaradnak, tehát nem keletkezik duplikátum.') ->modalSubmitActionLabel('Folytatás') ->action(function (PricelistFile $record) { app(PricelistFileProcessService::class)->dispatchExecutionJob($record); Notification::make() ->title('Végrehajtás folytatása elindítva') ->success() ->send(); }); } /** * Felügyelt kényszerlezárás. Két külön kapu védi: a rollout flag ÉS a developer * szerepkör. Utóbbi a flag eltávolítása után is megmarad - a "lezárom anélkül, hogy * rendbe tenném az adatokat" művelet tartósan nem való minden árlistakezelőnek. */ protected function forceCloseAction(): Action { return Action::make('forceClose') ->label('Kényszerlezárás') ->icon('heroicon-o-lock-closed') ->color('danger') ->visible(fn (PricelistFile $record) => Feature::for(auth()->user())->active('PricelistExecution') && auth()->user()?->hasRole('developer') && $record->canBeForceClosed()) ->requiresConfirmation() ->modalHeading('Kényszerlezárás') ->modalDescription('A fájl lezárva státuszba kerül anélkül, hogy bármit visszaállítanánk. A végrehajtás által írt termék- és áradatok az adatbázisban maradnak, rendezetlenül. Csak akkor használd, ha a visszaállítás nem tud lefutni.') ->modalSubmitActionLabel('Kényszerlezárás') ->schema([ Textarea::make('reason') ->label('Indoklás') ->helperText('Rögzítjük, ki, mikor és miért oldotta fel a fájlt visszaállítás nélkül.') ->required() ->rows(3) ->maxLength(1000) ->default(fn (PricelistFile $record) => $record->file_meta['revert']['last_error'] ?? null), ]) ->action(function (PricelistFile $record, array $data) { if (! app(PricelistFileProcessService::class)->forceClose($record, $data['reason'])) { $this->notifyStateChanged(); return; } Notification::make() ->title('A fájl kényszerlezárva') ->body('A beszállító árlista-felvitele újra engedélyezett. Az adatok rendezése kézi feladat maradt.') ->warning() ->send(); }); } /** * A megszakadt végrehajtás utáni döntési gombok közös feltétele. */ protected function canDecideOnExecution(PricelistFile $record): bool { return Feature::for(auth()->user())->active('PricelistExecution') && $record->needsExecutionDecision(); } protected function summarizeExecution(PricelistFile $record): string { $summary = $record->executionSummary(); return sprintf( 'A megszakadt végrehajtásból eddig: %d létrehozott termék, %d frissített termék, %d rögzített ár, %d hátralévő sor.', $summary['created_products'], $summary['updated_products'], $summary['priced_lines'], $summary['pending_lines'], ); } /** * A döntési gombok közös feltétele: a rollout flag és a fájl jóváhagyhatósága. * A canBeApproved() a modellben él, mert ugyanezt a service is ellenőrzi a * művelet végrehajtásakor - a felület csak elrejti a gombot, a védelmet a * service adja. */ protected function canDecide(PricelistFile $record): bool { return Feature::for(auth()->user())->active('PricelistExecution') && $record->canBeApproved(); } /** * A jóváhagyás előtti összegzés: mit fog csinálni az import. */ protected function summarizeLines(PricelistFile $record): string { $counts = $record->lineStatusCounts(); $parts = []; foreach ([ PricelistFileLineStatusEnum::new_product, PricelistFileLineStatusEnum::updated, PricelistFileLineStatusEnum::ok, PricelistFileLineStatusEnum::warning, ] as $status) { $count = (int) ($counts[$status->value] ?? 0); if ($count > 0) { $parts[] = $count . ' ' . $status->label(); } } return $parts === [] ? 'nincs feldolgozandó sor.' : implode(', ', $parts) . '.'; } /** * A felület 5 mp-enként pollozik, így a gomb megjelenítése és a kattintás között * változhat az állapot (pl. más felhasználó közben jóváhagyta). Ilyenkor a service * elutasítja a műveletet, a felhasználónak pedig ezt meg kell mondani. */ protected function notifyStateChanged(): void { Notification::make() ->title('A művelet nem hajtható végre') ->body('A fájl állapota időközben megváltozott. Frissítsd az oldalt a jelenlegi állapotért.') ->warning() ->send(); } protected function getHeaderWidgets(): array { return [ // Később ide jöhetnek widgetek, pl. statisztika a feldolgozásról ]; } }