- 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>
269 lines
11 KiB
PHP
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
|
|
];
|
|
}
|
|
}
|