d2d.emegrendeles.hu/app/Filament/Resources/PricelistFiles/Pages/ViewPricelistFile.php
E98Developer e338f68ee8 ADD EV3-357 Árlista feldolgozás phase5/4 visszavonás, elakadás-észlelés, kényszerlezárás
- PricelistExecutionJob + új PricelistRevertJob: $tries=1, $timeout, failed()
  hook. A service catch ága csak PHP exceptiont fog el; worker timeout vagy
  memórialimit esetén a fájl inprogress-ben ragadna, döntési gomb nélkül.
- heartbeat backstop a kill -9 esetére, ahol a failed() sem fut: minden chunk ír
  a rekordba, így az updated_at a szívverés (isExecutionStuck). Ezzel garantált,
  hogy minden kísérlet véges időn belül terminális állapotba jut - enélkül a
  kényszerlezárás sem nyílna ki soha.
- kompenzáló visszaállítás R1-R4: árak törlése, termékek visszaállítása a
  snapshotból, létrehozott termékek kivonása (soft delete, NEM fizikai törlés,
  mert lehet rájuk hivatkozás), árlista + fájl lezárása
- konfliktuskezelés: a végrehajtás óta kézzel módosított terméket kihagyjuk és
  jelentjük, nem írjuk felül vakon
- FIX phase5/3: a snapshot csak az írás ELŐTTI updated_at-et tárolta, amihez
  képest a termék a végrehajtás után mindig eltér - a konfliktus-ellenőrzés így
  minden sort kihagyott volna. Most a saját írásunk utáni updated_at is bekerül.
- kényszerlezárás: csak legalább egy terminális hibába futott visszaállítási
  kísérlet után, flag + developer szerepkör mögött, kötelező indoklással
- döntéstámogató panel: mi történt már meg az importból, hogy a felhasználó ne
  vakon válasszon a Folytatás és a Visszavonás között

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 06:36:21 +02:00

269 lines
11 KiB
PHP

<?php
namespace App\Filament\Resources\PricelistFiles\Pages;
use App\Enums\PricelistFileLineStatusEnum;
use App\Enums\PricelistFileStatusEnum;
use App\Filament\Resources\PricelistFiles\PricelistFileResource;
use App\Models\PricelistFile;
use App\Services\PricelistFileProcessService;
use Filament\Actions\Action;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
use Laravel\Pennant\Feature;
class ViewPricelistFile extends ViewRecord
{
protected static string $resource = PricelistFileResource::class;
protected function getHeaderActions(): array
{
return [
$this->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
];
}
}