From e338f68ee8a987215e755bbf6b2c6325a1b55071 Mon Sep 17 00:00:00 2001 From: E98Developer Date: Sat, 15 Aug 2026 06:36:21 +0200 Subject: [PATCH] =?UTF-8?q?ADD=20EV3-357=20=C3=81rlista=20feldolgoz=C3=A1s?= =?UTF-8?q?=20phase5/4=20visszavon=C3=A1s,=20elakad=C3=A1s-=C3=A9szlel?= =?UTF-8?q?=C3=A9s,=20k=C3=A9nyszerlez=C3=A1r=C3=A1s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/Enums/PricelistWorkflowStep.php | 1 + .../Pages/ViewPricelistFile.php | 116 +++++++ .../Schemas/PricelistFileInfolist.php | 45 +++ app/Jobs/PricelistExecutionJob.php | 38 ++- app/Jobs/PricelistRevertJob.php | 43 +++ app/Models/PricelistFile.php | 65 ++++ app/Services/PricelistFileProcessService.php | 314 ++++++++++++++++++ tests/Feature/PricelistRevertTest.php | 292 ++++++++++++++++ 8 files changed, 907 insertions(+), 7 deletions(-) create mode 100644 app/Jobs/PricelistRevertJob.php create mode 100644 tests/Feature/PricelistRevertTest.php diff --git a/app/Enums/PricelistWorkflowStep.php b/app/Enums/PricelistWorkflowStep.php index 1083049..64a10cc 100644 --- a/app/Enums/PricelistWorkflowStep.php +++ b/app/Enums/PricelistWorkflowStep.php @@ -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', }; } diff --git a/app/Filament/Resources/PricelistFiles/Pages/ViewPricelistFile.php b/app/Filament/Resources/PricelistFiles/Pages/ViewPricelistFile.php index ab8eb70..dded403 100644 --- a/app/Filament/Resources/PricelistFiles/Pages/ViewPricelistFile.php +++ b/app/Filament/Resources/PricelistFiles/Pages/ViewPricelistFile.php @@ -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 diff --git a/app/Filament/Resources/PricelistFiles/Schemas/PricelistFileInfolist.php b/app/Filament/Resources/PricelistFiles/Schemas/PricelistFileInfolist.php index 5f8309a..4b062b9 100644 --- a/app/Filament/Resources/PricelistFiles/Schemas/PricelistFileInfolist.php +++ b/app/Filament/Resources/PricelistFiles/Schemas/PricelistFileInfolist.php @@ -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() diff --git a/app/Jobs/PricelistExecutionJob.php b/app/Jobs/PricelistExecutionJob.php index a3f3874..54a9bd7 100644 --- a/app/Jobs/PricelistExecutionJob.php +++ b/app/Jobs/PricelistExecutionJob.php @@ -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.', + ); + } } diff --git a/app/Jobs/PricelistRevertJob.php b/app/Jobs/PricelistRevertJob.php new file mode 100644 index 0000000..c4ff6b3 --- /dev/null +++ b/app/Jobs/PricelistRevertJob.php @@ -0,0 +1,43 @@ +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.', + ); + } +} diff --git a/app/Models/PricelistFile.php b/app/Models/PricelistFile.php index 6427f18..efe50b3 100644 --- a/app/Models/PricelistFile.php +++ b/app/Models/PricelistFile.php @@ -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. diff --git a/app/Services/PricelistFileProcessService.php b/app/Services/PricelistFileProcessService.php index 266cca8..c02d091 100644 --- a/app/Services/PricelistFileProcessService.php +++ b/app/Services/PricelistFileProcessService.php @@ -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; + } } diff --git a/tests/Feature/PricelistRevertTest.php b/tests/Feature/PricelistRevertTest.php new file mode 100644 index 0000000..e27128c --- /dev/null +++ b/tests/Feature/PricelistRevertTest.php @@ -0,0 +1,292 @@ + '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(); +});