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>
This commit is contained in:
parent
93111ef2e0
commit
e338f68ee8
@ -59,6 +59,7 @@ public function color(string $status): string
|
|||||||
'completed' => 'success',
|
'completed' => 'success',
|
||||||
'failed' => 'danger',
|
'failed' => 'danger',
|
||||||
'rejected' => 'warning', // manuális elutasítás - nem hiba, hanem szabályos lezárás
|
'rejected' => 'warning', // manuális elutasítás - nem hiba, hanem szabályos lezárás
|
||||||
|
'reverted' => 'warning', // a végrehajtás kompenzáló visszaállítással visszavonva
|
||||||
default => 'gray',
|
default => 'gray',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,12 @@ protected function getHeaderActions(): array
|
|||||||
return [
|
return [
|
||||||
$this->approveAction(),
|
$this->approveAction(),
|
||||||
$this->rejectAction(),
|
$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()
|
EditAction::make()
|
||||||
// A `fail` a validálásig tartó szakasz hibája, onnan a fájl újratöltése
|
// 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
|
// biztonságosan újraindítja a láncot. A hasExecutionStarted() extra
|
||||||
@ -94,6 +100,116 @@ protected function rejectAction(): Action
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 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
|
* A canBeApproved() a modellben él, mert ugyanezt a service is ellenőrzi a
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
namespace App\Filament\Resources\PricelistFiles\Schemas;
|
namespace App\Filament\Resources\PricelistFiles\Schemas;
|
||||||
|
|
||||||
use App\Enums\PricelistFileStatusEnum;
|
use App\Enums\PricelistFileStatusEnum;
|
||||||
|
use App\Models\PricelistFile;
|
||||||
use Filament\Schemas\Components\Grid;
|
use Filament\Schemas\Components\Grid;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Components\Text;
|
use Filament\Schemas\Components\Text;
|
||||||
@ -19,6 +20,50 @@ public static function configure(Schema $schema): Schema
|
|||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
->components([
|
->components([
|
||||||
|
// Döntéstámogató panel: megszakadt végrehajtás után a felhasználónak a
|
||||||
|
// Folytatás és a Visszavonás között kell választania. Enélkül vakon
|
||||||
|
// döntene - itt látja, mi történt már meg az importból.
|
||||||
|
Section::make('Megszakadt végrehajtás')
|
||||||
|
->description('A végrehajtás nem fejeződött be. Az árlista nem élesedett, tehát a felhasználók felé nem került ki ár. Döntsd el, folytatod-e, vagy visszavonod a már végrehajtott részt.')
|
||||||
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
|
->columnSpanFull()
|
||||||
|
->maxWidth(Width::Full)
|
||||||
|
->visible(fn ($record) => $record->needsExecutionDecision())
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('execution_error')
|
||||||
|
->label('Hiba')
|
||||||
|
->color('danger')
|
||||||
|
->weight(FontWeight::Bold)
|
||||||
|
->columnSpanFull()
|
||||||
|
->getStateUsing(fn ($record) => $record->isExecutionStuck()
|
||||||
|
? 'A végrehajtás ' . PricelistFile::STUCK_AFTER_MINUTES . ' percnél régebben nem jelzett haladást (elakadt folyamat).'
|
||||||
|
: ($record->processing_current_step ?: 'Ismeretlen hiba.')),
|
||||||
|
Grid::make(4)->schema([
|
||||||
|
TextEntry::make('created_products')
|
||||||
|
->label('Létrehozott termék')
|
||||||
|
->icon('heroicon-o-plus-circle')
|
||||||
|
->getStateUsing(fn ($record) => $record->executionSummary()['created_products']),
|
||||||
|
TextEntry::make('updated_products')
|
||||||
|
->label('Frissített termék')
|
||||||
|
->icon('heroicon-o-pencil-square')
|
||||||
|
->getStateUsing(fn ($record) => $record->executionSummary()['updated_products']),
|
||||||
|
TextEntry::make('priced_lines')
|
||||||
|
->label('Rögzített ár')
|
||||||
|
->icon('heroicon-o-currency-dollar')
|
||||||
|
->getStateUsing(fn ($record) => $record->executionSummary()['priced_lines']),
|
||||||
|
TextEntry::make('pending_lines')
|
||||||
|
->label('Hátralévő sor')
|
||||||
|
->icon('heroicon-o-clock')
|
||||||
|
->getStateUsing(fn ($record) => $record->executionSummary()['pending_lines']),
|
||||||
|
]),
|
||||||
|
TextEntry::make('revert_error')
|
||||||
|
->label('Utolsó visszaállítási kísérlet hibája')
|
||||||
|
->color('danger')
|
||||||
|
->columnSpanFull()
|
||||||
|
->visible(fn ($record) => ($record->file_meta['revert']['last_status'] ?? null) === 'failed')
|
||||||
|
->getStateUsing(fn ($record) => $record->file_meta['revert']['last_error'] ?? null),
|
||||||
|
]),
|
||||||
|
|
||||||
Section::make(null)
|
Section::make(null)
|
||||||
->poll('5s')
|
->poll('5s')
|
||||||
->columnSpanFull()
|
->columnSpanFull()
|
||||||
|
|||||||
@ -2,29 +2,53 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Enums\PricelistWorkflowStep;
|
||||||
use App\Models\PricelistFile;
|
use App\Models\PricelistFile;
|
||||||
|
use App\Services\PricelistFileProcessService;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class PricelistExecutionJob implements ShouldQueue
|
class PricelistExecutionJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Queueable;
|
use Queueable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new job instance.
|
* Nincs automatikus újrapróbálkozás: egy félbeszakadt végrehajtás után már
|
||||||
|
* létrejöhettek termékek és árak, egy vak újrafuttatás pedig félreérthető
|
||||||
|
* állapotot okozna. A folytatásról a felhasználó dönt a felületen.
|
||||||
*/
|
*/
|
||||||
|
public int $tries = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nagy árlistáknál a végrehajtás sokáig futhat, de nem korlátlanul: a timeout
|
||||||
|
* garantálja, hogy a job terminális állapotba jut, és a failed() lefut.
|
||||||
|
*/
|
||||||
|
public int $timeout = 3600;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public PricelistFile $pricelistFile
|
public PricelistFile $pricelistFile
|
||||||
)
|
) {
|
||||||
{
|
|
||||||
//
|
//
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function handle(PricelistFileProcessService $service): void
|
||||||
* Execute the job.
|
|
||||||
*/
|
|
||||||
public function handle(\App\Services\PricelistFileProcessService $service): void
|
|
||||||
{
|
{
|
||||||
$service->execute($this->pricelistFile);
|
$service->execute($this->pricelistFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A service catch ága csak PHP exceptionöket fogja el. Worker timeout, memórialimit
|
||||||
|
* vagy worker-újraindítás esetén a catch soha nem fut le, és a fájl inprogress-ben
|
||||||
|
* ragadna - egyik döntési gomb nélkül. Ez a hook zárja be ezt a rést.
|
||||||
|
*/
|
||||||
|
public function failed(?Throwable $exception): void
|
||||||
|
{
|
||||||
|
app(PricelistFileProcessService::class)->updateStepStatus(
|
||||||
|
$this->pricelistFile,
|
||||||
|
PricelistWorkflowStep::Execution,
|
||||||
|
'failed',
|
||||||
|
$exception?->getMessage() ?? 'A végrehajtás váratlanul megszakadt.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
43
app/Jobs/PricelistRevertJob.php
Normal file
43
app/Jobs/PricelistRevertJob.php
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\PricelistFile;
|
||||||
|
use App\Services\PricelistFileProcessService;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class PricelistRevertJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public int $tries = 1;
|
||||||
|
|
||||||
|
public int $timeout = 3600;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public PricelistFile $pricelistFile
|
||||||
|
) {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(PricelistFileProcessService $service): void
|
||||||
|
{
|
||||||
|
$service->revert($this->pricelistFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ugyanaz a rés, mint a végrehajtásnál - de itt nagyobb a tétje: a kényszerlezárás
|
||||||
|
* csak akkor érhető el, ha volt legalább egy TERMINÁLIS hibába futott visszaállítási
|
||||||
|
* kísérlet. Ha egy megszakadt visszaállítás inprogress-ben ragadna, a beszállító
|
||||||
|
* véglegesen blokkolva maradna, mindenféle kiút nélkül.
|
||||||
|
*/
|
||||||
|
public function failed(?Throwable $exception): void
|
||||||
|
{
|
||||||
|
app(PricelistFileProcessService::class)->failRevert(
|
||||||
|
$this->pricelistFile,
|
||||||
|
$exception?->getMessage() ?? 'A visszaállítás váratlanul megszakadt.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -95,6 +95,71 @@ public function hasExecutionStarted(): bool
|
|||||||
return ! in_array($this->stepStatus(PricelistWorkflowStep::Execution), ['pending', null], true);
|
return ! in_array($this->stepStatus(PricelistWorkflowStep::Execution), ['pending', null], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ennyi tétlenség után tekintjük elakadtnak a futó végrehajtást/visszaállítást.
|
||||||
|
* A job failed() hookja a hibák többségét lefedi, de egy `kill -9`-elt worker után
|
||||||
|
* az sem fut le - ilyenkor a fájl inprogress-ben ragadna, egyetlen elérhető döntési
|
||||||
|
* gomb nélkül. Minden chunk ír a rekordba, tehát az updated_at a heartbeat.
|
||||||
|
*/
|
||||||
|
public const STUCK_AFTER_MINUTES = 15;
|
||||||
|
|
||||||
|
public function isExecutionStuck(?int $minutes = null): bool
|
||||||
|
{
|
||||||
|
if ($this->status !== PricelistFileStatusEnum::inprogress || ! $this->hasExecutionStarted()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool) $this->updated_at?->lt(now()->subMinutes($minutes ?? self::STUCK_AFTER_MINUTES));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isRevertRunning(): bool
|
||||||
|
{
|
||||||
|
return $this->status === PricelistFileStatusEnum::inprogress
|
||||||
|
&& ($this->file_meta['revert']['last_status'] ?? null) === 'inprogress';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vár-e a fájl felhasználói döntésre (folytatás vagy visszaállítás).
|
||||||
|
*/
|
||||||
|
public function needsExecutionDecision(): bool
|
||||||
|
{
|
||||||
|
return $this->status === PricelistFileStatusEnum::execution_failed || $this->isExecutionStuck();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kényszerlezárható-e. Feltétel: volt legalább egy visszaállítási kísérlet, amely
|
||||||
|
* TERMINÁLIS hibaállapotba jutott. Így a kényszerlezárás nem alternatív útvonal a
|
||||||
|
* visszaállítás mellett, hanem csak annak kudarca után elérhető vészkijárat.
|
||||||
|
*/
|
||||||
|
public function canBeForceClosed(): bool
|
||||||
|
{
|
||||||
|
$revert = $this->file_meta['revert'] ?? [];
|
||||||
|
|
||||||
|
return $this->needsExecutionDecision()
|
||||||
|
&& (int) ($revert['attempts'] ?? 0) >= 1
|
||||||
|
&& ($revert['last_status'] ?? null) === 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mi történt már a végrehajtásból. A felhasználó ez alapján tud dönteni a folytatás
|
||||||
|
* és a visszaállítás között - enélkül vakon választana a két gomb közül.
|
||||||
|
*/
|
||||||
|
public function executionSummary(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'created_products' => $this->lines()
|
||||||
|
->where('status', PricelistFileLineStatusEnum::new_product->value)
|
||||||
|
->whereNotNull('product_id')
|
||||||
|
->count(),
|
||||||
|
'updated_products' => $this->lines()->whereNotNull('applied_snapshot')->count(),
|
||||||
|
'priced_lines' => $this->lines()->whereNotNull('executed_at')->count(),
|
||||||
|
'pending_lines' => $this->lines()
|
||||||
|
->where('status', '!=', PricelistFileLineStatusEnum::error->value)
|
||||||
|
->whereNull('executed_at')
|
||||||
|
->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Soronkénti státuszok darabszáma (státusz => darab), a jóváhagyás előtti
|
* Soronkénti státuszok darabszáma (státusz => darab), a jóváhagyás előtti
|
||||||
* összegző modalhoz.
|
* összegző modalhoz.
|
||||||
|
|||||||
@ -101,6 +101,12 @@ class PricelistFileProcessService
|
|||||||
|
|
||||||
private const PRICE_CHUNK_SIZE = 1000;
|
private const PRICE_CHUNK_SIZE = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A visszaállításból kihagyott sorok részleteiből ennyit tárolunk a file_meta-ban.
|
||||||
|
* A teljes darabszám a `skipped_count`-ban akkor is megmarad, ha ez levág.
|
||||||
|
*/
|
||||||
|
private const REVERT_SKIPPED_DETAIL_LIMIT = 100;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected PriceListService $priceListService
|
protected PriceListService $priceListService
|
||||||
) {}
|
) {}
|
||||||
@ -1515,6 +1521,13 @@ protected function updateExistingProducts(PricelistFile $pricelistFile): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
$product->update($productData);
|
$product->update($productData);
|
||||||
|
|
||||||
|
// A MI írásunk utáni updated_at: a visszavonás ehhez hasonlítja a
|
||||||
|
// termék akkori állapotát, és eltérés esetén kihagyja a sort, mert az
|
||||||
|
// azt jelenti, hogy azóta valaki kézzel módosította. (A snapshot
|
||||||
|
// 'updated_at' mezője az írás ELŐTTI érték, arra ez a check nem jó.)
|
||||||
|
$snapshot['applied_updated_at'] = $product->refresh()->updated_at?->toDateTimeString();
|
||||||
|
|
||||||
$line->update(['applied_snapshot' => $snapshot]);
|
$line->update(['applied_snapshot' => $snapshot]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -1711,4 +1724,305 @@ protected function getProductGroupTypeMap(): array
|
|||||||
{
|
{
|
||||||
return \App\Models\ProductGroup::pluck('type', 'id')->toArray();
|
return \App\Models\ProductGroup::pluck('type', 'id')->toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elindítja a visszaállítási jobot.
|
||||||
|
*/
|
||||||
|
public function dispatchRevertJob(PricelistFile $pricelistFile): void
|
||||||
|
{
|
||||||
|
dispatch(new \App\Jobs\PricelistRevertJob($pricelistFile));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kompenzáló visszaállítás: a végrehajtás által írt adatok visszavonása.
|
||||||
|
*
|
||||||
|
* Nem "rollback" a szó adatbázis-értelmében - a tranzakciók már lezárultak -, hanem
|
||||||
|
* a snapshotokból és a saját artefaktumokból vezetett fordított műveletsor. Ugyanúgy
|
||||||
|
* chunkolt és idempotens, mint a végrehajtás, tehát megszakadás után folytatható.
|
||||||
|
*/
|
||||||
|
public function revert(PricelistFile $pricelistFile): bool
|
||||||
|
{
|
||||||
|
$this->startRevertAttempt($pricelistFile);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$priceList = $pricelistFile->price_list_id
|
||||||
|
? PriceList::find($pricelistFile->price_list_id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$this->revertPrices($pricelistFile, $priceList);
|
||||||
|
$skipped = $this->revertUpdatedProducts($pricelistFile);
|
||||||
|
$this->revertCreatedProducts($pricelistFile);
|
||||||
|
$this->finalizeRevert($pricelistFile, $priceList, $skipped);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Pricelist revert error: ' . $e->getMessage(), [
|
||||||
|
'pricelist_file_id' => $pricelistFile->id,
|
||||||
|
'exception' => $e,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->failRevert($pricelistFile, $e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function startRevertAttempt(PricelistFile $pricelistFile): void
|
||||||
|
{
|
||||||
|
$pricelistFile->refresh();
|
||||||
|
|
||||||
|
$meta = $pricelistFile->file_meta ?? [];
|
||||||
|
$revert = $meta['revert'] ?? [];
|
||||||
|
|
||||||
|
$meta['revert'] = array_merge($revert, [
|
||||||
|
'attempts' => (int) ($revert['attempts'] ?? 0) + 1,
|
||||||
|
'last_status' => 'inprogress',
|
||||||
|
'last_error' => null,
|
||||||
|
'last_attempt_at' => now()->toDateTimeString(),
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// A fájl a visszaállítás idejére inprogress: ez rejti el a döntési gombokat,
|
||||||
|
// tehát egy második kattintás nem indíthat párhuzamos visszaállítást.
|
||||||
|
$pricelistFile->update([
|
||||||
|
'status' => PricelistFileStatusEnum::inprogress,
|
||||||
|
'file_meta' => $meta,
|
||||||
|
'processing_current_step' => 'Visszaállítás indítása...',
|
||||||
|
'processing_current_step_percentage' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function reportRevertProgress(PricelistFile $pricelistFile, int $percentage, string $message): void
|
||||||
|
{
|
||||||
|
$pricelistFile->update([
|
||||||
|
'processing_current_step' => mb_strcut($message, 0, 255),
|
||||||
|
'processing_current_step_percentage' => $percentage,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* R1 - Árak eltávolítása. Az egész árlista a miénk, ezért egyetlen törléssel megy.
|
||||||
|
*/
|
||||||
|
protected function revertPrices(PricelistFile $pricelistFile, ?PriceList $priceList): void
|
||||||
|
{
|
||||||
|
$this->reportRevertProgress($pricelistFile, 10, 'Árak eltávolítása...');
|
||||||
|
|
||||||
|
if (! $priceList) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('price_list_prices')->where('price_list_id', $priceList->id)->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* R2 - Frissített termékek visszaállítása a snapshotból.
|
||||||
|
*
|
||||||
|
* @return array a kihagyott (időközben kézzel módosított) sorok leírása
|
||||||
|
*/
|
||||||
|
protected function revertUpdatedProducts(PricelistFile $pricelistFile): array
|
||||||
|
{
|
||||||
|
$this->reportRevertProgress($pricelistFile, 30, 'Termékadatok visszaállítása...');
|
||||||
|
|
||||||
|
$query = $pricelistFile->lines()->whereNotNull('applied_snapshot');
|
||||||
|
|
||||||
|
$total = (clone $query)->count();
|
||||||
|
|
||||||
|
if ($total === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$skipped = [];
|
||||||
|
$processed = 0;
|
||||||
|
|
||||||
|
$query->chunkById(self::EXECUTION_CHUNK_SIZE, function ($lines) use ($pricelistFile, $total, &$processed, &$skipped) {
|
||||||
|
DB::transaction(function () use ($lines, &$skipped) {
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$snapshot = $line->applied_snapshot ?? [];
|
||||||
|
$product = \App\Models\Product::find($line->product_id);
|
||||||
|
|
||||||
|
if (! $product) {
|
||||||
|
// Nincs mit visszaállítani; a jelölőt elfogyasztjuk.
|
||||||
|
$line->update(['applied_snapshot' => null]);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$appliedAt = $snapshot['applied_updated_at'] ?? null;
|
||||||
|
$currentAt = $product->updated_at?->toDateTimeString();
|
||||||
|
|
||||||
|
if ($appliedAt !== null && $appliedAt !== $currentAt) {
|
||||||
|
// A terméket a végrehajtásunk óta valaki módosította. Vakon
|
||||||
|
// visszaírni azt jelentené, hogy elvesszük a kézi módosítást,
|
||||||
|
// ezért kihagyjuk és jelentjük.
|
||||||
|
$skipped[] = [
|
||||||
|
'row_number' => $line->row_number,
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'fields' => $snapshot['fields'] ?? [],
|
||||||
|
];
|
||||||
|
|
||||||
|
$line->addValidationError('revert', 'A termék a végrehajtás óta módosult, ezért a visszaállításból kimaradt.');
|
||||||
|
$line->applied_snapshot = null;
|
||||||
|
$line->save();
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$product->update($snapshot['fields'] ?? []);
|
||||||
|
$line->update(['applied_snapshot' => null]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$processed += $lines->count();
|
||||||
|
$this->reportRevertProgress(
|
||||||
|
$pricelistFile,
|
||||||
|
30 + (int) floor(30 * $processed / $total),
|
||||||
|
"Termékadatok visszaállítása ({$processed}/{$total})...",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $skipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* R3 - A végrehajtás által létrehozott termékek kivonása a forgalomból.
|
||||||
|
*
|
||||||
|
* Szándékosan NEM fizikai törlés: a termékre időközben hivatkozhat megrendelés vagy
|
||||||
|
* más rekord. A soft delete egyben a jelölő is: egy folytatott visszaállítás a már
|
||||||
|
* kezelt termékeket nem találja meg újra.
|
||||||
|
*/
|
||||||
|
protected function revertCreatedProducts(PricelistFile $pricelistFile): void
|
||||||
|
{
|
||||||
|
$this->reportRevertProgress($pricelistFile, 60, 'Létrehozott termékek visszavonása...');
|
||||||
|
|
||||||
|
$query = $pricelistFile->lines()
|
||||||
|
->where('status', PricelistFileLineStatusEnum::new_product->value)
|
||||||
|
->whereNotNull('product_id');
|
||||||
|
|
||||||
|
$total = (clone $query)->count();
|
||||||
|
|
||||||
|
if ($total === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$processed = 0;
|
||||||
|
|
||||||
|
$query->chunkById(self::EXECUTION_CHUNK_SIZE, function ($lines) use ($pricelistFile, $total, &$processed) {
|
||||||
|
DB::transaction(function () use ($lines) {
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$product = \App\Models\Product::find($line->product_id);
|
||||||
|
|
||||||
|
if (! $product) {
|
||||||
|
continue; // már visszavonva (soft delete) - a folytatás átugorja
|
||||||
|
}
|
||||||
|
|
||||||
|
$product->update([
|
||||||
|
'status' => DbStatusFieldEnum::deleted,
|
||||||
|
'canSee' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$product->delete();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$processed += $lines->count();
|
||||||
|
$this->reportRevertProgress(
|
||||||
|
$pricelistFile,
|
||||||
|
60 + (int) floor(30 * $processed / $total),
|
||||||
|
"Létrehozott termékek visszavonása ({$processed}/{$total})...",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* R4 - Lezárás: az árlista törlése és a fájl végleges lezárása.
|
||||||
|
*/
|
||||||
|
protected function finalizeRevert(PricelistFile $pricelistFile, ?PriceList $priceList, array $skipped): void
|
||||||
|
{
|
||||||
|
$this->reportRevertProgress($pricelistFile, 95, 'Visszaállítás lezárása...');
|
||||||
|
|
||||||
|
$priceList?->update(['status' => DbStatusFieldEnum::deleted]);
|
||||||
|
|
||||||
|
$pricelistFile->refresh();
|
||||||
|
|
||||||
|
$meta = $pricelistFile->file_meta ?? [];
|
||||||
|
$meta['revert'] = array_merge($meta['revert'] ?? [], [
|
||||||
|
'last_status' => 'completed',
|
||||||
|
'last_error' => null,
|
||||||
|
'finished_at' => now()->toDateTimeString(),
|
||||||
|
'skipped_count' => count($skipped),
|
||||||
|
// A részleteket korlátozottan tároljuk, hogy a file_meta ne hízzon el; a
|
||||||
|
// teljes darabszám a skipped_count-ban akkor is megmarad.
|
||||||
|
'skipped' => array_slice($skipped, 0, self::REVERT_SKIPPED_DETAIL_LIMIT),
|
||||||
|
'skipped_detail_truncated' => count($skipped) > self::REVERT_SKIPPED_DETAIL_LIMIT,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'reverted', 'A végrehajtás visszavonva.');
|
||||||
|
|
||||||
|
$pricelistFile->update([
|
||||||
|
'status' => PricelistFileStatusEnum::closed,
|
||||||
|
'file_meta' => $meta,
|
||||||
|
'processing_current_step' => null,
|
||||||
|
'processing_current_step_percentage' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A visszaállítás hibára futott. A fájl visszakerül execution_failed-be, és mostantól
|
||||||
|
* a kényszerlezárás is elérhető - ez a "legalább egy terminális hibába futott
|
||||||
|
* kísérlet" feltétel teljesülése.
|
||||||
|
*/
|
||||||
|
public function failRevert(PricelistFile $pricelistFile, string $message): void
|
||||||
|
{
|
||||||
|
$pricelistFile->refresh();
|
||||||
|
|
||||||
|
$meta = $pricelistFile->file_meta ?? [];
|
||||||
|
$meta['revert'] = array_merge($meta['revert'] ?? [], [
|
||||||
|
'last_status' => 'failed',
|
||||||
|
'last_error' => $message,
|
||||||
|
'finished_at' => now()->toDateTimeString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pricelistFile->update([
|
||||||
|
'status' => PricelistFileStatusEnum::execution_failed,
|
||||||
|
'file_meta' => $meta,
|
||||||
|
'processing_current_step' => mb_strcut('Visszaállítási hiba: ' . $message, 0, 255),
|
||||||
|
'processing_current_step_percentage' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Felügyelt kényszerlezárás: a fájlt lezárja anélkül, hogy bármit visszaállítana.
|
||||||
|
*
|
||||||
|
* Vészkijárat arra az esetre, amikor a visszaállítás sem tud lefutni - enélkül egy
|
||||||
|
* ilyen fájl véglegesen blokkolná a beszállítót az árlistázásból. Nem javítja meg az
|
||||||
|
* adatokat, csak a döntést és a felelősséget teszi explicitté.
|
||||||
|
*
|
||||||
|
* @return bool false, ha a fájl nem kényszerlezárható (pl. még nem volt sikertelen
|
||||||
|
* visszaállítási kísérlet)
|
||||||
|
*/
|
||||||
|
public function forceClose(PricelistFile $pricelistFile, string $reason): bool
|
||||||
|
{
|
||||||
|
$pricelistFile->refresh();
|
||||||
|
|
||||||
|
if (! $pricelistFile->canBeForceClosed()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = $pricelistFile->file_meta ?? [];
|
||||||
|
$meta['force_close'] = [
|
||||||
|
'reason' => $reason,
|
||||||
|
'revert_error' => $meta['revert']['last_error'] ?? null,
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'at' => now()->toDateTimeString(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$pricelistFile->update([
|
||||||
|
'status' => PricelistFileStatusEnum::closed,
|
||||||
|
'file_meta' => $meta,
|
||||||
|
'processing_current_step' => null,
|
||||||
|
'processing_current_step_percentage' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
292
tests/Feature/PricelistRevertTest.php
Normal file
292
tests/Feature/PricelistRevertTest.php
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\DbStatusFieldEnum;
|
||||||
|
use App\Enums\PricelistFileLineStatusEnum;
|
||||||
|
use App\Enums\PricelistFileStatusEnum;
|
||||||
|
use App\Enums\PricelistWorkflowStep;
|
||||||
|
use App\Jobs\PricelistExecutionJob;
|
||||||
|
use App\Models\PriceList;
|
||||||
|
use App\Models\PricelistFile;
|
||||||
|
use App\Models\PricelistFileLine;
|
||||||
|
use App\Models\Producer;
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\ProductGroup;
|
||||||
|
use App\Models\Supplier;
|
||||||
|
use App\Services\PriceListService;
|
||||||
|
use App\Services\PricelistFileProcessService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
function revertTestFile(Supplier $supplier): PricelistFile
|
||||||
|
{
|
||||||
|
return PricelistFile::create([
|
||||||
|
'filename' => 'arlista.xlsx',
|
||||||
|
'supplier_id' => $supplier->id,
|
||||||
|
'available_date' => now()->addWeek()->toDateString(),
|
||||||
|
'status' => PricelistFileStatusEnum::inprogress,
|
||||||
|
'workflow_steps' => [
|
||||||
|
['name' => PricelistWorkflowStep::Validation->value, 'label' => 'Validálás', 'status' => 'completed'],
|
||||||
|
['name' => PricelistWorkflowStep::Approval->value, 'label' => 'Jóváhagyás', 'status' => 'completed'],
|
||||||
|
['name' => PricelistWorkflowStep::Execution->value, 'label' => 'Végrehajtás', 'status' => 'inprogress'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function revertTestPayload(array $overrides = []): array
|
||||||
|
{
|
||||||
|
$h = PriceListService::EXPECTED_HEADERS;
|
||||||
|
|
||||||
|
return array_merge([
|
||||||
|
$h[0] => 'SKU-1',
|
||||||
|
$h[1] => 'Főcsoport',
|
||||||
|
$h[2] => 'Alcsoport 1',
|
||||||
|
$h[4] => 'Új név',
|
||||||
|
$h[5] => '6',
|
||||||
|
$h[6] => '1,5',
|
||||||
|
$h[7] => 'l',
|
||||||
|
$h[8] => 'Teszt Gyártó',
|
||||||
|
$h[9] => 'kart',
|
||||||
|
$h[10] => '6',
|
||||||
|
$h[11] => 'db',
|
||||||
|
$h[12] => '0,27',
|
||||||
|
$h[14] => '990',
|
||||||
|
$h[20] => 'Megjegyzés',
|
||||||
|
$h[21] => '',
|
||||||
|
$h[22] => '',
|
||||||
|
], $overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
function revertTestLine(PricelistFile $file, PricelistFileLineStatusEnum $status, array $payload, array $attributes = []): PricelistFileLine
|
||||||
|
{
|
||||||
|
return PricelistFileLine::create(array_merge([
|
||||||
|
'pricelist_file_id' => $file->id,
|
||||||
|
'row_number' => 5,
|
||||||
|
'status' => $status,
|
||||||
|
'payload' => $payload,
|
||||||
|
], $attributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Olyan service, amelynek a visszaállítása a létrehozott termékek fázisában elhal -
|
||||||
|
* az árak és a frissítések ekkor már visszaálltak.
|
||||||
|
*/
|
||||||
|
function failingRevertService(): PricelistFileProcessService
|
||||||
|
{
|
||||||
|
return new class(app(PriceListService::class)) extends PricelistFileProcessService
|
||||||
|
{
|
||||||
|
protected function revertCreatedProducts(PricelistFile $pricelistFile): void
|
||||||
|
{
|
||||||
|
throw new RuntimeException('Szimulált visszaállítási hiba.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->supplier = Supplier::factory()->create();
|
||||||
|
$this->group = ProductGroup::create(['name' => 'Alcsoport 1', 'type' => 'F', 'status' => DbStatusFieldEnum::active, 'canSee' => 1]);
|
||||||
|
$this->producer = Producer::create(['name' => 'Teszt Gyártó', 'status' => DbStatusFieldEnum::active, 'canSee' => 1]);
|
||||||
|
$this->service = app(PricelistFileProcessService::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a visszavonás visszaállítja a frissített terméket a snapshotból', function () {
|
||||||
|
$file = revertTestFile($this->supplier);
|
||||||
|
|
||||||
|
$product = Product::create([
|
||||||
|
'name' => 'Régi név',
|
||||||
|
'supplierProductNumber' => 'SKU-1',
|
||||||
|
'supplier_id' => $this->supplier->id,
|
||||||
|
'producer_id' => $this->producer->id,
|
||||||
|
'product_group_id' => $this->group->id,
|
||||||
|
'unitValue' => 1,
|
||||||
|
'note' => '',
|
||||||
|
'vat' => 5,
|
||||||
|
'status' => DbStatusFieldEnum::active,
|
||||||
|
'canSee' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$line = revertTestLine($file, PricelistFileLineStatusEnum::updated, revertTestPayload(), [
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'product_group_id' => $this->group->id,
|
||||||
|
'producer_id' => $this->producer->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->service->execute($file);
|
||||||
|
expect($product->refresh()->name)->toBe('Új név');
|
||||||
|
|
||||||
|
expect($this->service->revert($file->refresh()))->toBeTrue();
|
||||||
|
|
||||||
|
$file->refresh();
|
||||||
|
|
||||||
|
expect($product->refresh()->name)->toBe('Régi név')
|
||||||
|
->and((float) $product->vat)->toBe(5.0)
|
||||||
|
->and($line->refresh()->applied_snapshot)->toBeNull()
|
||||||
|
->and($file->status)->toBe(PricelistFileStatusEnum::closed)
|
||||||
|
->and($file->stepStatus(PricelistWorkflowStep::Execution))->toBe('reverted')
|
||||||
|
->and(PriceList::find($file->price_list_id)->status)->toBe(DbStatusFieldEnum::deleted)
|
||||||
|
->and(DB::table('price_list_prices')->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a visszavonás kivonja a forgalomból a létrehozott terméket', function () {
|
||||||
|
$file = revertTestFile($this->supplier);
|
||||||
|
|
||||||
|
revertTestLine($file, PricelistFileLineStatusEnum::new_product, revertTestPayload([
|
||||||
|
PriceListService::EXPECTED_HEADERS[0] => 'SKU-NEW',
|
||||||
|
]), ['product_group_id' => $this->group->id, 'producer_id' => $this->producer->id]);
|
||||||
|
|
||||||
|
$this->service->execute($file);
|
||||||
|
|
||||||
|
$productId = Product::where('supplierProductNumber', 'SKU-NEW')->value('id');
|
||||||
|
|
||||||
|
$this->service->revert($file->refresh());
|
||||||
|
|
||||||
|
$product = Product::withTrashed()->find($productId);
|
||||||
|
|
||||||
|
expect($product)->not->toBeNull() // nem fizikai törlés
|
||||||
|
->and($product->trashed())->toBeTrue()
|
||||||
|
->and($product->status)->toBe(DbStatusFieldEnum::deleted)
|
||||||
|
->and((bool) $product->canSee)->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a végrehajtás óta kézzel módosított terméket kihagyja és jelenti', function () {
|
||||||
|
$file = revertTestFile($this->supplier);
|
||||||
|
|
||||||
|
$product = Product::create([
|
||||||
|
'name' => 'Régi név',
|
||||||
|
'supplierProductNumber' => 'SKU-1',
|
||||||
|
'supplier_id' => $this->supplier->id,
|
||||||
|
'producer_id' => $this->producer->id,
|
||||||
|
'product_group_id' => $this->group->id,
|
||||||
|
'unitValue' => 1,
|
||||||
|
'note' => '',
|
||||||
|
'status' => DbStatusFieldEnum::active,
|
||||||
|
'canSee' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$line = revertTestLine($file, PricelistFileLineStatusEnum::updated, revertTestPayload(), [
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'product_group_id' => $this->group->id,
|
||||||
|
'producer_id' => $this->producer->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->service->execute($file);
|
||||||
|
|
||||||
|
// Kézi módosítás a végrehajtás után. Az updated_at-et explicit állítjuk, mert a
|
||||||
|
// MySQL timestamp másodperc pontosságú, és a teszt ennél gyorsabban futna le.
|
||||||
|
$product->update(['name' => 'Kézzel átírt név', 'updated_at' => now()->addMinute()]);
|
||||||
|
|
||||||
|
$this->service->revert($file->refresh());
|
||||||
|
|
||||||
|
$file->refresh();
|
||||||
|
|
||||||
|
expect($product->refresh()->name)->toBe('Kézzel átírt név') // NEM írtuk felül
|
||||||
|
->and($file->file_meta['revert']['skipped_count'])->toBe(1)
|
||||||
|
->and($file->file_meta['revert']['skipped'][0]['row_number'])->toBe(5)
|
||||||
|
->and($line->refresh()->validation_messages['revert'])->toContain('módosult')
|
||||||
|
->and($file->status)->toBe(PricelistFileStatusEnum::closed);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a visszavonás megismételhető, nem borul fel az állapot', function () {
|
||||||
|
$file = revertTestFile($this->supplier);
|
||||||
|
|
||||||
|
revertTestLine($file, PricelistFileLineStatusEnum::new_product, revertTestPayload([
|
||||||
|
PriceListService::EXPECTED_HEADERS[0] => 'SKU-NEW',
|
||||||
|
]), ['product_group_id' => $this->group->id, 'producer_id' => $this->producer->id]);
|
||||||
|
|
||||||
|
$this->service->execute($file);
|
||||||
|
|
||||||
|
$this->service->revert($file->refresh());
|
||||||
|
$this->service->revert($file->refresh());
|
||||||
|
|
||||||
|
expect(Product::withTrashed()->where('supplierProductNumber', 'SKU-NEW')->count())->toBe(1)
|
||||||
|
->and(DB::table('price_list_prices')->count())->toBe(0)
|
||||||
|
->and($file->refresh()->status)->toBe(PricelistFileStatusEnum::closed);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a sikertelen visszaállítás execution_failed-be tesz és megnyitja a kényszerlezárást', function () {
|
||||||
|
$file = revertTestFile($this->supplier);
|
||||||
|
|
||||||
|
revertTestLine($file, PricelistFileLineStatusEnum::new_product, revertTestPayload([
|
||||||
|
PriceListService::EXPECTED_HEADERS[0] => 'SKU-NEW',
|
||||||
|
]), ['product_group_id' => $this->group->id, 'producer_id' => $this->producer->id]);
|
||||||
|
|
||||||
|
$this->service->execute($file);
|
||||||
|
|
||||||
|
expect(failingRevertService()->revert($file->refresh()))->toBeFalse();
|
||||||
|
|
||||||
|
$file->refresh();
|
||||||
|
|
||||||
|
expect($file->status)->toBe(PricelistFileStatusEnum::execution_failed)
|
||||||
|
->and($file->file_meta['revert']['attempts'])->toBe(1)
|
||||||
|
->and($file->file_meta['revert']['last_status'])->toBe('failed')
|
||||||
|
->and($file->file_meta['revert']['last_error'])->toContain('Szimulált')
|
||||||
|
->and($file->canBeForceClosed())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kényszerlezárás nem indítható sikertelen visszaállítási kísérlet nélkül', function () {
|
||||||
|
$file = revertTestFile($this->supplier);
|
||||||
|
|
||||||
|
revertTestLine($file, PricelistFileLineStatusEnum::new_product, revertTestPayload([
|
||||||
|
PriceListService::EXPECTED_HEADERS[0] => 'SKU-NEW',
|
||||||
|
]), ['product_group_id' => $this->group->id, 'producer_id' => $this->producer->id]);
|
||||||
|
|
||||||
|
$this->service->execute($file);
|
||||||
|
$this->service->updateStepStatus($file, PricelistWorkflowStep::Execution, 'failed', 'Hiba.');
|
||||||
|
|
||||||
|
$file->refresh();
|
||||||
|
|
||||||
|
expect($file->status)->toBe(PricelistFileStatusEnum::execution_failed)
|
||||||
|
->and($file->canBeForceClosed())->toBeFalse()
|
||||||
|
->and($this->service->forceClose($file, 'Csak úgy.'))->toBeFalse()
|
||||||
|
->and($file->refresh()->status)->toBe(PricelistFileStatusEnum::execution_failed);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a kényszerlezárás rögzíti az indoklást és felszabadítja a fájlt', function () {
|
||||||
|
$file = revertTestFile($this->supplier);
|
||||||
|
|
||||||
|
revertTestLine($file, PricelistFileLineStatusEnum::new_product, revertTestPayload([
|
||||||
|
PriceListService::EXPECTED_HEADERS[0] => 'SKU-NEW',
|
||||||
|
]), ['product_group_id' => $this->group->id, 'producer_id' => $this->producer->id]);
|
||||||
|
|
||||||
|
$this->service->execute($file);
|
||||||
|
failingRevertService()->revert($file->refresh());
|
||||||
|
|
||||||
|
expect($this->service->forceClose($file->refresh(), 'Kézzel rendezzük az adatokat.'))->toBeTrue();
|
||||||
|
|
||||||
|
$file->refresh();
|
||||||
|
|
||||||
|
expect($file->status)->toBe(PricelistFileStatusEnum::closed)
|
||||||
|
->and($file->file_meta['force_close']['reason'])->toBe('Kézzel rendezzük az adatokat.')
|
||||||
|
->and($file->file_meta['force_close']['revert_error'])->toContain('Szimulált');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('az elakadt végrehajtás észlelhető és döntést kér', function () {
|
||||||
|
$file = revertTestFile($this->supplier);
|
||||||
|
|
||||||
|
expect($file->isExecutionStuck())->toBeFalse();
|
||||||
|
|
||||||
|
// A heartbeat (updated_at) nyers frissítése: az Eloquent felülírná a mostani időre.
|
||||||
|
DB::table('pricelist_files')
|
||||||
|
->where('id', $file->id)
|
||||||
|
->update(['updated_at' => now()->subMinutes(PricelistFile::STUCK_AFTER_MINUTES + 5)]);
|
||||||
|
|
||||||
|
$file->refresh();
|
||||||
|
|
||||||
|
expect($file->isExecutionStuck())->toBeTrue()
|
||||||
|
->and($file->needsExecutionDecision())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a job failed() hookja akkor is execution_failed-be teszi a fájlt, ha a catch nem futott le', function () {
|
||||||
|
$file = revertTestFile($this->supplier);
|
||||||
|
|
||||||
|
// Worker timeout / memórialimit szimulálása: a handle() catch ága ilyenkor nem fut.
|
||||||
|
(new PricelistExecutionJob($file))->failed(new RuntimeException('Job timeout.'));
|
||||||
|
|
||||||
|
$file->refresh();
|
||||||
|
|
||||||
|
expect($file->status)->toBe(PricelistFileStatusEnum::execution_failed)
|
||||||
|
->and($file->stepStatus(PricelistWorkflowStep::Execution))->toBe('failed')
|
||||||
|
->and($file->needsExecutionDecision())->toBeTrue();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user