From eee27aa659db5bb6a4f29b76c1d4021730ef0586 Mon Sep 17 00:00:00 2001 From: E98Developer Date: Mon, 10 Aug 2026 15:00:33 +0200 Subject: [PATCH] =?UTF-8?q?ADD=20EV3-357=20=C3=81rlista=20feldolgoz=C3=A1s?= =?UTF-8?q?=20phase5/2=20j=C3=B3v=C3=A1hagy=C3=A1s/elutas=C3=ADt=C3=A1s=20?= =?UTF-8?q?akci=C3=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PricelistFile::canBeApproved() / hasExecutionStarted(): a jóváhagyás feltételei egy helyen, hogy a felületi gomb és a service ugyanazt ellenőrizze - approve(): a státusz MÉG A DISPATCH ELŐTT billen át, különben a queue-latency alatt a gomb látszana és egy második kattintás párhuzamos importot indítana - reject(): új 'rejected' lépésstátusz - nem 'failed', mert azt az updateStepStatus fail fájlstátuszra fordítaná, az elutasítás viszont closed - a döntés (ki, mikor, milyen indokkal) a file_meta.approval-ba kerül, mert a BaseAuditable a nevével ellentétben nem naplóz - Edit gomb szűkítése hasExecutionStarted()-tel három helyen, köztük az EditPricelistFile::afterSave()-ben, ami a teljes láncot újraindítja Co-Authored-By: Claude Opus 5 --- app/Enums/PricelistWorkflowStep.php | 1 + .../Pages/EditPricelistFile.php | 5 +- .../Pages/ViewPricelistFile.php | 132 ++++++++++- .../Tables/PricelistFilesTable.php | 5 +- app/Models/PricelistFile.php | 65 ++++++ app/Services/PricelistFileProcessService.php | 78 +++++++ tests/Feature/PricelistApprovalTest.php | 205 ++++++++++++++++++ 7 files changed, 485 insertions(+), 6 deletions(-) create mode 100644 tests/Feature/PricelistApprovalTest.php diff --git a/app/Enums/PricelistWorkflowStep.php b/app/Enums/PricelistWorkflowStep.php index 1b974d6..1083049 100644 --- a/app/Enums/PricelistWorkflowStep.php +++ b/app/Enums/PricelistWorkflowStep.php @@ -58,6 +58,7 @@ public function color(string $status): string 'inprogress' => 'primary', 'completed' => 'success', 'failed' => 'danger', + 'rejected' => 'warning', // manuális elutasítás - nem hiba, hanem szabályos lezárás default => 'gray', }; } diff --git a/app/Filament/Resources/PricelistFiles/Pages/EditPricelistFile.php b/app/Filament/Resources/PricelistFiles/Pages/EditPricelistFile.php index ccc1bdb..3439258 100644 --- a/app/Filament/Resources/PricelistFiles/Pages/EditPricelistFile.php +++ b/app/Filament/Resources/PricelistFiles/Pages/EditPricelistFile.php @@ -27,7 +27,10 @@ protected function getHeaderActions(): array protected function afterSave(): void { - if ($this->record->status === PricelistFileStatusEnum::fail) { + // A hasExecutionStarted() védelem itt a lényegi: ez a metódus indítja újra a + // TELJES feldolgozási láncot. Ha a végrehajtás egyszer már futott, egy + // nulláról induló újrafuttatás duplikált termékeket és árakat hozna létre. + if ($this->record->status === PricelistFileStatusEnum::fail && ! $this->record->hasExecutionStarted()) { app(PricelistFileProcessService::class)->dispatchInitialChain($this->record); } } diff --git a/app/Filament/Resources/PricelistFiles/Pages/ViewPricelistFile.php b/app/Filament/Resources/PricelistFiles/Pages/ViewPricelistFile.php index bd8e950..ab8eb70 100644 --- a/app/Filament/Resources/PricelistFiles/Pages/ViewPricelistFile.php +++ b/app/Filament/Resources/PricelistFiles/Pages/ViewPricelistFile.php @@ -2,10 +2,17 @@ namespace App\Filament\Resources\PricelistFiles\Pages; -use App\Filament\Resources\PricelistFiles\PricelistFileResource; -use Filament\Actions\EditAction; -use Filament\Resources\Pages\ViewRecord; +use App\Enums\PricelistFileLineStatusEnum; use App\Enums\PricelistFileStatusEnum; +use App\Filament\Resources\PricelistFiles\PricelistFileResource; +use App\Models\PricelistFile; +use App\Services\PricelistFileProcessService; +use Filament\Actions\Action; +use Filament\Actions\EditAction; +use Filament\Forms\Components\Textarea; +use Filament\Notifications\Notification; +use Filament\Resources\Pages\ViewRecord; +use Laravel\Pennant\Feature; class ViewPricelistFile extends ViewRecord { @@ -14,11 +21,128 @@ class ViewPricelistFile extends ViewRecord protected function getHeaderActions(): array { return [ + $this->approveAction(), + $this->rejectAction(), EditAction::make() - ->visible(fn ($record) => $record->status === PricelistFileStatusEnum::fail), + // A `fail` a validálásig tartó szakasz hibája, onnan a fájl újratöltése + // biztonságosan újraindítja a láncot. A hasExecutionStarted() extra + // feltétel a maradék kockázatot zárja ki: ha a végrehajtás egyszer már + // elindult, a nulláról induló újrafuttatás duplikált termékeket és + // árakat hozna létre. + ->visible(fn (PricelistFile $record) => $record->status === PricelistFileStatusEnum::fail + && ! $record->hasExecutionStarted()), ]; } + protected function approveAction(): Action + { + return Action::make('approve') + ->label('Jóváhagyás') + ->icon('heroicon-o-check-circle') + ->color('success') + ->visible(fn (PricelistFile $record) => $this->canDecide($record)) + ->requiresConfirmation() + ->modalHeading('Árlista jóváhagyása') + ->modalDescription(fn (PricelistFile $record) => 'A jóváhagyás elindítja a tényleges importot: ' + . $this->summarizeLines($record) + . ' A művelet a termékadatokat is módosítja.') + ->modalSubmitActionLabel('Jóváhagyás és végrehajtás') + ->action(function (PricelistFile $record) { + if (! app(PricelistFileProcessService::class)->approve($record)) { + $this->notifyStateChanged(); + + return; + } + + Notification::make() + ->title('Végrehajtás elindítva') + ->body('Az árlista importja elindult, a haladás ezen az oldalon követhető.') + ->success() + ->send(); + }); + } + + protected function rejectAction(): Action + { + return Action::make('reject') + ->label('Elutasítás') + ->icon('heroicon-o-x-circle') + ->color('danger') + ->visible(fn (PricelistFile $record) => $this->canDecide($record)) + ->requiresConfirmation() + ->modalHeading('Árlista elutasítása') + ->modalDescription('A fájl lezárva státuszba kerül, import nem indul. A művelet nem vonható vissza.') + ->modalSubmitActionLabel('Elutasítás') + ->schema([ + Textarea::make('reason') + ->label('Elutasítás indoka') + ->rows(3) + ->maxLength(1000), + ]) + ->action(function (PricelistFile $record, array $data) { + if (! app(PricelistFileProcessService::class)->reject($record, $data['reason'] ?? null)) { + $this->notifyStateChanged(); + + return; + } + + Notification::make() + ->title('Árlista elutasítva') + ->body('A fájl lezárva státuszba került.') + ->success() + ->send(); + }); + } + + /** + * A döntési gombok közös feltétele: a rollout flag és a fájl jóváhagyhatósága. + * A canBeApproved() a modellben él, mert ugyanezt a service is ellenőrzi a + * művelet végrehajtásakor - a felület csak elrejti a gombot, a védelmet a + * service adja. + */ + protected function canDecide(PricelistFile $record): bool + { + return Feature::for(auth()->user())->active('PricelistExecution') + && $record->canBeApproved(); + } + + /** + * A jóváhagyás előtti összegzés: mit fog csinálni az import. + */ + protected function summarizeLines(PricelistFile $record): string + { + $counts = $record->lineStatusCounts(); + + $parts = []; + foreach ([ + PricelistFileLineStatusEnum::new_product, + PricelistFileLineStatusEnum::updated, + PricelistFileLineStatusEnum::ok, + PricelistFileLineStatusEnum::warning, + ] as $status) { + $count = (int) ($counts[$status->value] ?? 0); + if ($count > 0) { + $parts[] = $count . ' ' . $status->label(); + } + } + + return $parts === [] ? 'nincs feldolgozandó sor.' : implode(', ', $parts) . '.'; + } + + /** + * A felület 5 mp-enként pollozik, így a gomb megjelenítése és a kattintás között + * változhat az állapot (pl. más felhasználó közben jóváhagyta). Ilyenkor a service + * elutasítja a műveletet, a felhasználónak pedig ezt meg kell mondani. + */ + protected function notifyStateChanged(): void + { + Notification::make() + ->title('A művelet nem hajtható végre') + ->body('A fájl állapota időközben megváltozott. Frissítsd az oldalt a jelenlegi állapotért.') + ->warning() + ->send(); + } + protected function getHeaderWidgets(): array { return [ diff --git a/app/Filament/Resources/PricelistFiles/Tables/PricelistFilesTable.php b/app/Filament/Resources/PricelistFiles/Tables/PricelistFilesTable.php index 91929e0..db81c0f 100644 --- a/app/Filament/Resources/PricelistFiles/Tables/PricelistFilesTable.php +++ b/app/Filament/Resources/PricelistFiles/Tables/PricelistFilesTable.php @@ -52,7 +52,10 @@ public static function configure(Table $table): Table ->recordActions([ ViewAction::make(), EditAction::make() - ->visible(fn ($record) => $record->status === PricelistFileStatusEnum::fail), + // Ha a végrehajtás már elindult, az újratöltés (és a vele járó + // teljes lánc-újraindítás) duplikált termékeket/árakat okozna. + ->visible(fn ($record) => $record->status === PricelistFileStatusEnum::fail + && ! $record->hasExecutionStarted()), ]) ->toolbarActions([ BulkActionGroup::make([ diff --git a/app/Models/PricelistFile.php b/app/Models/PricelistFile.php index 23a53e1..6427f18 100644 --- a/app/Models/PricelistFile.php +++ b/app/Models/PricelistFile.php @@ -2,7 +2,9 @@ namespace App\Models; +use App\Enums\PricelistFileLineStatusEnum; use App\Enums\PricelistFileStatusEnum; +use App\Enums\PricelistWorkflowStep; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; @@ -42,4 +44,67 @@ public function lines(): HasMany { return $this->hasMany(PricelistFileLine::class); } + + /** + * Egy workflow lépés aktuális státusza a `workflow_steps` tömbből + * ('pending' / 'inprogress' / 'completed' / 'failed'), vagy null, ha a lépés + * nem szerepel a rekordban (régi fájloknál előfordulhat). + */ + public function stepStatus(PricelistWorkflowStep $step): ?string + { + foreach ($this->workflow_steps ?? [] as $workflowStep) { + if (($workflowStep['name'] ?? null) === $step->value) { + return $workflowStep['status'] ?? null; + } + } + + return null; + } + + /** + * Van-e a fájlban blokkoló (hibás) sor. Ez a jóváhagyás egyik feltétele: + * hibás sorral nem indítható végrehajtás. + */ + public function hasErrorLines(): bool + { + return $this->lines() + ->where('status', PricelistFileLineStatusEnum::error->value) + ->exists(); + } + + /** + * Jóváhagyható-e a fájl. Egyetlen igazságforrás: ezt nézi a felületi gomb + * láthatósága ÉS a végrehajtási job is induláskor - a felület 5 mp-enként + * pollozik, így a gomb megjelenítése és a kattintás között változhat az állapot. + */ + public function canBeApproved(): bool + { + return $this->status === PricelistFileStatusEnum::waiting_for_approval + && $this->stepStatus(PricelistWorkflowStep::Validation) === 'completed' + && ! $this->hasErrorLines(); + } + + /** + * Elindult-e már a végrehajtás. Ha igen, a fájl újratöltése (Edit -> a teljes + * lánc újraindítása) TILOS: addigra már létrejöhettek termékek és árak, egy + * nulláról induló újrafuttatás duplikálna. Helyette a Folytatás vagy a + * Visszavonás akció használható. + */ + public function hasExecutionStarted(): bool + { + return ! in_array($this->stepStatus(PricelistWorkflowStep::Execution), ['pending', null], true); + } + + /** + * Soronkénti státuszok darabszáma (státusz => darab), a jóváhagyás előtti + * összegző modalhoz. + */ + public function lineStatusCounts(): array + { + return $this->lines() + ->selectRaw('status, COUNT(*) as total') + ->groupBy('status') + ->pluck('total', 'status') + ->all(); + } } diff --git a/app/Services/PricelistFileProcessService.php b/app/Services/PricelistFileProcessService.php index 2dcb705..34c6ab1 100644 --- a/app/Services/PricelistFileProcessService.php +++ b/app/Services/PricelistFileProcessService.php @@ -106,6 +106,84 @@ public function dispatchExecutionJob(PricelistFile $pricelistFile): void } } + /** + * Jóváhagyás: lezárja a manuális Jóváhagyás lépést és elindítja a végrehajtást. + * + * @return bool false, ha a fájl közben már nem jóváhagyható állapotba került + */ + public function approve(PricelistFile $pricelistFile): bool + { + // Újraellenőrzés a friss rekordon: a felület 5 mp-enként pollozik, tehát a + // gomb megjelenítése és a kattintás között változhatott az állapot. + $pricelistFile->refresh(); + + if (! $pricelistFile->canBeApproved()) { + return false; + } + + $meta = $pricelistFile->file_meta ?? []; + $meta['approval'] = [ + 'decision' => 'approved', + 'user_id' => auth()->id(), + 'at' => now()->toDateTimeString(), + ]; + $pricelistFile->update(['file_meta' => $meta]); + + $this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Approval, 'completed', 'Jóváhagyva.', 100); + + // A státuszt MÉG A DISPATCH ELŐTT billentjük át (az Execution lépés + // 'inprogress'-re állítása a fájlt is inprogress-re teszi). Ha ezt a jobra + // bíznánk, a queue-latency alatt a fájl waiting_for_approval maradna, a + // Jóváhagyás gomb továbbra is látszana, és egy második kattintás párhuzamos + // végrehajtást indítana ugyanarra a termékhalmazra. + $this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'inprogress', 'Végrehajtás előkészítése...', 0); + + $this->dispatchExecutionJob($pricelistFile); + + return true; + } + + /** + * Elutasítás: a fájl lezárva (`closed`) státuszba kerül, végrehajtás nem indul. + * + * @return bool false, ha a fájl közben már nem elutasítható állapotba került + */ + public function reject(PricelistFile $pricelistFile, ?string $reason = null): bool + { + $pricelistFile->refresh(); + + if (! $pricelistFile->canBeApproved()) { + return false; + } + + $this->updateStepStatus( + $pricelistFile, + PricelistWorkflowStep::Approval, + 'rejected', + $reason ? 'Elutasítva: ' . $reason : 'Elutasítva.', + ); + + $meta = $pricelistFile->file_meta ?? []; + $meta['approval'] = [ + 'decision' => 'rejected', + 'reason' => $reason, + 'user_id' => auth()->id(), + 'at' => now()->toDateTimeString(), + ]; + + // A 'rejected' lépésstátusz szándékosan nem 'failed': az updateStepStatus a + // 'failed'-et fail fájlstátuszra fordítaná, az elutasítás viszont nem hiba, + // hanem szabályos lezárás. + $pricelistFile->update([ + 'status' => PricelistFileStatusEnum::closed, + 'file_meta' => $meta, + 'processing_current_step' => null, + 'processing_current_step_percentage' => null, + ]); + + return true; + } + public function updateStepStatus( PricelistFile $pricelistFile, PricelistWorkflowStep $stepEnum, diff --git a/tests/Feature/PricelistApprovalTest.php b/tests/Feature/PricelistApprovalTest.php new file mode 100644 index 0000000..939b90a --- /dev/null +++ b/tests/Feature/PricelistApprovalTest.php @@ -0,0 +1,205 @@ + 'arlista.xlsx', + 'supplier_id' => Supplier::factory()->create()->id, + 'available_date' => now()->addWeek(), + 'status' => PricelistFileStatusEnum::waiting_for_approval, + 'workflow_steps' => [ + ['name' => PricelistWorkflowStep::Preprocessing->value, 'label' => 'Előfeldolgozás', 'status' => 'completed'], + ['name' => PricelistWorkflowStep::Validation->value, 'label' => 'Validálás', 'status' => 'completed'], + ['name' => PricelistWorkflowStep::Approval->value, 'label' => 'Jóváhagyás', 'status' => 'inprogress'], + ['name' => PricelistWorkflowStep::Execution->value, 'label' => 'Végrehajtás', 'status' => 'pending'], + ], + ], $attributes)); +} + +function addLine(PricelistFile $file, PricelistFileLineStatusEnum $status, int $rowNumber = 5): PricelistFileLine +{ + return PricelistFileLine::create([ + 'pricelist_file_id' => $file->id, + 'row_number' => $rowNumber, + 'status' => $status, + 'payload' => [], + ]); +} + +function developerUser(): User +{ + $user = User::factory()->create(); + $user->addRole(Role::create(['name' => 'developer', 'display_name' => 'Developer'])); + + return $user; +} + +function definePricelistExecutionFlag(array $attributes = []): void +{ + FeatureFlag::create(array_merge([ + 'name' => 'PricelistExecution', + 'label' => 'Árlista végrehajtás', + 'enabled' => true, + 'stages' => null, + 'roles' => ['developer'], + ], $attributes)); + + app(FeatureFlagRegistrar::class)->registerAll(); +} + +test('a hibátlan, jóváhagyásra váró fájl jóváhagyható', function () { + $file = approvableFile(); + addLine($file, PricelistFileLineStatusEnum::new_product); + addLine($file, PricelistFileLineStatusEnum::updated, 6); + + expect($file->canBeApproved())->toBeTrue(); +}); + +test('hibás sor esetén nem hagyható jóvá', function () { + $file = approvableFile(); + addLine($file, PricelistFileLineStatusEnum::ok); + addLine($file, PricelistFileLineStatusEnum::error, 6); + + expect($file->canBeApproved())->toBeFalse(); +}); + +test('nem jóváhagyásra váró státuszban nem hagyható jóvá', function () { + $file = approvableFile(['status' => PricelistFileStatusEnum::inprogress]); + + expect($file->canBeApproved())->toBeFalse(); +}); + +test('befejezetlen validálás esetén nem hagyható jóvá', function () { + $file = approvableFile([ + 'workflow_steps' => [ + ['name' => PricelistWorkflowStep::Validation->value, 'label' => 'Validálás', 'status' => 'failed'], + ['name' => PricelistWorkflowStep::Approval->value, 'label' => 'Jóváhagyás', 'status' => 'pending'], + ], + ]); + + expect($file->canBeApproved())->toBeFalse(); +}); + +test('a jóváhagyás elindítja a végrehajtást és azonnal átbillenti a státuszt', function () { + Queue::fake(); + + $file = approvableFile(); + addLine($file, PricelistFileLineStatusEnum::new_product); + + expect(app(PricelistFileProcessService::class)->approve($file))->toBeTrue(); + + Queue::assertPushed(PricelistExecutionJob::class); + + $file->refresh(); + + // A státusz még a dispatch előtt átbillen, különben a queue-latency alatt a + // Jóváhagyás gomb látszana, és egy második kattintás párhuzamos importot indítana. + expect($file->status)->toBe(PricelistFileStatusEnum::inprogress) + ->and($file->stepStatus(PricelistWorkflowStep::Approval))->toBe('completed') + ->and($file->stepStatus(PricelistWorkflowStep::Execution))->toBe('inprogress') + ->and($file->file_meta['approval']['decision'])->toBe('approved'); +}); + +test('a már nem jóváhagyható fájlra a jóváhagyás nem indít végrehajtást', function () { + Queue::fake(); + + $file = approvableFile(); + addLine($file, PricelistFileLineStatusEnum::error); + + expect(app(PricelistFileProcessService::class)->approve($file))->toBeFalse(); + + Queue::assertNothingPushed(); + expect($file->refresh()->status)->toBe(PricelistFileStatusEnum::waiting_for_approval); +}); + +test('az elutasítás lezárja a fájlt indoklással, végrehajtás nélkül', function () { + Queue::fake(); + + $file = approvableFile(); + addLine($file, PricelistFileLineStatusEnum::updated); + + expect(app(PricelistFileProcessService::class)->reject($file, 'Rossz árlistát töltöttek fel.'))->toBeTrue(); + + Queue::assertNothingPushed(); + + $file->refresh(); + + expect($file->status)->toBe(PricelistFileStatusEnum::closed) + ->and($file->stepStatus(PricelistWorkflowStep::Approval))->toBe('rejected') + ->and($file->file_meta['approval']['reason'])->toBe('Rossz árlistát töltöttek fel.'); +}); + +test('a végrehajtás elindulása után a fájl nem tölthető újra', function () { + $file = approvableFile(); + + expect($file->hasExecutionStarted())->toBeFalse(); + + app(PricelistFileProcessService::class)->updateStepStatus($file, PricelistWorkflowStep::Execution, 'failed', 'Hiba.'); + + expect($file->refresh()->hasExecutionStarted())->toBeTrue(); +}); + +test('a döntési gombok csak aktív flaggel látszanak', function () { + definePricelistExecutionFlag(); + + $file = approvableFile(); + addLine($file, PricelistFileLineStatusEnum::new_product); + + $this->actingAs(developerUser()); + + Livewire::test(ViewPricelistFile::class, ['record' => $file->id]) + ->assertActionVisible('approve') + ->assertActionVisible('reject'); +}); + +test('a döntési gombok rejtve maradnak a flag nélküli felhasználónak', function () { + definePricelistExecutionFlag(); + + $file = approvableFile(); + addLine($file, PricelistFileLineStatusEnum::new_product); + + $user = User::factory()->create(); + $user->addRole(Role::create(['name' => 'admin', 'display_name' => 'Admin'])); + + $this->actingAs($user); + + Livewire::test(ViewPricelistFile::class, ['record' => $file->id]) + ->assertActionHidden('approve') + ->assertActionHidden('reject'); +}); + +test('hibás sor esetén a döntési gombok rejtve maradnak flaggel is', function () { + definePricelistExecutionFlag(); + + $file = approvableFile(); + addLine($file, PricelistFileLineStatusEnum::error); + + $this->actingAs(developerUser()); + + Livewire::test(ViewPricelistFile::class, ['record' => $file->id]) + ->assertActionHidden('approve') + ->assertActionHidden('reject'); +});