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:
E98Developer 2026-08-15 06:36:21 +02:00
parent 93111ef2e0
commit e338f68ee8
8 changed files with 907 additions and 7 deletions

View File

@ -59,6 +59,7 @@ public function color(string $status): string
'completed' => 'success',
'failed' => 'danger',
'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',
};
}

View File

@ -23,6 +23,12 @@ 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
@ -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 canBeApproved() a modellben él, mert ugyanezt a service is ellenőrzi a

View File

@ -3,6 +3,7 @@
namespace App\Filament\Resources\PricelistFiles\Schemas;
use App\Enums\PricelistFileStatusEnum;
use App\Models\PricelistFile;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Text;
@ -19,6 +20,50 @@ public static function configure(Schema $schema): Schema
{
return $schema
->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)
->poll('5s')
->columnSpanFull()

View File

@ -2,29 +2,53 @@
namespace App\Jobs;
use App\Enums\PricelistWorkflowStep;
use App\Models\PricelistFile;
use App\Services\PricelistFileProcessService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
class PricelistExecutionJob implements ShouldQueue
{
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 PricelistFile $pricelistFile
)
{
) {
//
}
/**
* Execute the job.
*/
public function handle(\App\Services\PricelistFileProcessService $service): void
public function handle(PricelistFileProcessService $service): void
{
$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.',
);
}
}

View 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.',
);
}
}

View File

@ -95,6 +95,71 @@ public function hasExecutionStarted(): bool
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
* összegző modalhoz.

View File

@ -101,6 +101,12 @@ class PricelistFileProcessService
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(
protected PriceListService $priceListService
) {}
@ -1515,6 +1521,13 @@ protected function updateExistingProducts(PricelistFile $pricelistFile): void
}
$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]);
}
});
@ -1711,4 +1724,305 @@ protected function getProductGroupTypeMap(): array
{
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;
}
}

View 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();
});