From 8bcfeba0f914483fa0bde363ca3508d1cb922cf2 Mon Sep 17 00:00:00 2001 From: E98Developer Date: Sat, 15 Aug 2026 06:46:40 +0200 Subject: [PATCH] =?UTF-8?q?ADD=20EV3-357=20=C3=81rlista=20feldolgoz=C3=A1s?= =?UTF-8?q?=20phase5/5=20besz=C3=A1ll=C3=ADt=C3=B3i=20blokkol=C3=A1s=20(Pr?= =?UTF-8?q?icelistGuard)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Egy beszállítóhoz egyszerre legfeljebb egy nyitott végrehajtás tartozhat: egy execution_failed fájl után a termékek félig frissített állapotban vannak, egy új import erre a kevert alapra rétegződne rá (és a validálás is ehhez számolná a diffeket); két párhuzamos végrehajtás pedig nem determinisztikus eredményt adna. - új PricelistGuard service: egyetlen igazságforrás a blokkoláshoz - mind a NÉGY belépési pont véd: modern Filament create, régebbi PriceListProcessor oldal, legacy Admin\PriceListController import, konzol parancs. A legacy import a legfontosabb - az közvetlenül ír a products táblába, megkerülve a modult, és a beszállítókat ma még nagyrészt ott kezelik. - az `inprogress` önmagában nem blokkol: az előfeldolgozás és a validálás egyetlen terméket sem ír, csak a már elindult végrehajtás számít - a beszállító nem tűnik el a select listából, hanem konkrét magyarázatot kap a felhasználó arról, melyik fájl blokkol és miért - blokkolt beszállítónál másik fájl jóváhagyása sem indítható Co-Authored-By: Claude Opus 5 --- .../Commands/PricelistProcessCommand.php | 10 ++ app/Filament/Pages/PriceListProcessor.php | 13 ++ .../Pages/CreatePricelistFile.php | 28 ++++ .../Pages/ViewPricelistFile.php | 5 +- .../Schemas/PricelistFileInfolist.php | 22 +++ .../Controllers/Admin/PriceListController.php | 13 ++ app/Services/PricelistFileProcessService.php | 8 ++ app/Services/PricelistGuard.php | 78 ++++++++++ tests/Feature/PricelistGuardTest.php | 133 ++++++++++++++++++ 9 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 app/Services/PricelistGuard.php create mode 100644 tests/Feature/PricelistGuardTest.php diff --git a/app/Console/Commands/PricelistProcessCommand.php b/app/Console/Commands/PricelistProcessCommand.php index a09438e..9aa0ab2 100644 --- a/app/Console/Commands/PricelistProcessCommand.php +++ b/app/Console/Commands/PricelistProcessCommand.php @@ -35,6 +35,16 @@ public function handle(PricelistFileProcessService $service): int return 1; } + // A konzolos indítás ugyanúgy a termékadatokat érinti, mint a felületi - a + // beszállítói blokkolás itt sem kerülhető meg. + $guard = app(\App\Services\PricelistGuard::class); + + if ($blockingFile = $guard->blockingFileFor($pricelistFile->supplier_id, $pricelistFile->id)) { + $this->error($guard->blockingMessage($blockingFile)); + + return 1; + } + $this->info("Folyamat elindítása: {$pricelistFile->filename} (ID: {$id})"); $this->info("Workflow állapotok alaphelyzetbe állítása..."); diff --git a/app/Filament/Pages/PriceListProcessor.php b/app/Filament/Pages/PriceListProcessor.php index 941f4ca..da38e04 100644 --- a/app/Filament/Pages/PriceListProcessor.php +++ b/app/Filament/Pages/PriceListProcessor.php @@ -82,6 +82,19 @@ public function save() { $data = $this->form->getState(); + // Ugyanaz a védelem, mint a modern felületen: enélkül ez az oldal megkerülné a + // beszállítói blokkolást, hiszen ugyanúgy PricelistFile-t hoz létre és láncot indít. + if ($blockingFile = app(\App\Services\PricelistGuard::class)->blockingFileFor($data['supplier_id'] ?? null)) { + Notification::make() + ->title('A beszállítóhoz jelenleg nem vihető fel árlista') + ->body(app(\App\Services\PricelistGuard::class)->blockingMessage($blockingFile)) + ->danger() + ->persistent() + ->send(); + + return; + } + $pricelistFile = PricelistFile::create([ 'filename' => $data['filename'], 'supplier_id' => $data['supplier_id'], diff --git a/app/Filament/Resources/PricelistFiles/Pages/CreatePricelistFile.php b/app/Filament/Resources/PricelistFiles/Pages/CreatePricelistFile.php index 454d392..81c4e1f 100644 --- a/app/Filament/Resources/PricelistFiles/Pages/CreatePricelistFile.php +++ b/app/Filament/Resources/PricelistFiles/Pages/CreatePricelistFile.php @@ -3,12 +3,40 @@ namespace App\Filament\Resources\PricelistFiles\Pages; use App\Filament\Resources\PricelistFiles\PricelistFileResource; +use App\Services\PricelistGuard; +use Filament\Notifications\Notification; use Filament\Resources\Pages\CreateRecord; +use Filament\Support\Exceptions\Halt; class CreatePricelistFile extends CreateRecord { protected static string $resource = PricelistFileResource::class; + /** + * A beszállító nem blokkolt-e egy befejezetlen végrehajtás miatt. + * + * A beszállító szándékosan választható marad az űrlapon: ha egyszerűen kivennénk a + * listából, a felhasználó csak annyit látna, hogy "eltűnt a beszállító", és keresné + * az okát. Így viszont konkrét magyarázatot kap arról, melyik fájl blokkol. + */ + protected function beforeCreate(): void + { + $blockingFile = app(PricelistGuard::class)->blockingFileFor($this->data['supplier_id'] ?? null); + + if (! $blockingFile) { + return; + } + + Notification::make() + ->title('A beszállítóhoz jelenleg nem vihető fel árlista') + ->body(app(PricelistGuard::class)->blockingMessage($blockingFile)) + ->danger() + ->persistent() + ->send(); + + throw new Halt; + } + protected function afterCreate(): void { app(\App\Services\PricelistFileProcessService::class)->dispatchInitialChain($this->record); diff --git a/app/Filament/Resources/PricelistFiles/Pages/ViewPricelistFile.php b/app/Filament/Resources/PricelistFiles/Pages/ViewPricelistFile.php index dded403..942c4f4 100644 --- a/app/Filament/Resources/PricelistFiles/Pages/ViewPricelistFile.php +++ b/app/Filament/Resources/PricelistFiles/Pages/ViewPricelistFile.php @@ -7,6 +7,7 @@ use App\Filament\Resources\PricelistFiles\PricelistFileResource; use App\Models\PricelistFile; use App\Services\PricelistFileProcessService; +use App\Services\PricelistGuard; use Filament\Actions\Action; use Filament\Actions\EditAction; use Filament\Forms\Components\Textarea; @@ -219,7 +220,9 @@ protected function summarizeExecution(PricelistFile $record): string protected function canDecide(PricelistFile $record): bool { return Feature::for(auth()->user())->active('PricelistExecution') - && $record->canBeApproved(); + && $record->canBeApproved() + // Ugyanahhoz a beszállítóhoz nem lehet két nyitott végrehajtás. + && ! app(PricelistGuard::class)->isBlocked($record->supplier_id, $record->id); } /** diff --git a/app/Filament/Resources/PricelistFiles/Schemas/PricelistFileInfolist.php b/app/Filament/Resources/PricelistFiles/Schemas/PricelistFileInfolist.php index 4b062b9..d063476 100644 --- a/app/Filament/Resources/PricelistFiles/Schemas/PricelistFileInfolist.php +++ b/app/Filament/Resources/PricelistFiles/Schemas/PricelistFileInfolist.php @@ -4,6 +4,7 @@ use App\Enums\PricelistFileStatusEnum; use App\Models\PricelistFile; +use App\Services\PricelistGuard; use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Text; @@ -64,6 +65,27 @@ public static function configure(Schema $schema): Schema ->getStateUsing(fn ($record) => $record->file_meta['revert']['last_error'] ?? null), ]), + // A jóváhagyás gombjai némán tűnnének el, ha ugyanahhoz a beszállítóhoz + // egy másik fájl végrehajtása van nyitva - ez megmondja, miért. + Section::make('Blokkolt beszállító') + ->icon('heroicon-o-no-symbol') + ->columnSpanFull() + ->maxWidth(Width::Full) + ->visible(fn ($record) => $record->status === PricelistFileStatusEnum::waiting_for_approval + && app(PricelistGuard::class)->isBlocked($record->supplier_id, $record->id)) + ->schema([ + TextEntry::make('blocking_file') + ->hiddenLabel() + ->color('danger') + ->columnSpanFull() + ->getStateUsing(function ($record) { + $guard = app(PricelistGuard::class); + $blockingFile = $guard->blockingFileFor($record->supplier_id, $record->id); + + return $blockingFile ? $guard->blockingMessage($blockingFile) : null; + }), + ]), + Section::make(null) ->poll('5s') ->columnSpanFull() diff --git a/app/Http/Controllers/Admin/PriceListController.php b/app/Http/Controllers/Admin/PriceListController.php index 553947d..bb56870 100644 --- a/app/Http/Controllers/Admin/PriceListController.php +++ b/app/Http/Controllers/Admin/PriceListController.php @@ -338,6 +338,19 @@ private function importPriceList(Request $request): JsonResponse $supplierId = $request->input('supplier'); $availableDate = $request->input('availableDate'); + // Ez a legacy import közvetlenül ír a products táblába, teljesen megkerülve az + // árlista feldolgozó modult - ezért itt a legfontosabb a beszállítói blokkolás. + // Enélkül egy félig frissített termékhalmazra rétegződne rá egy újabb import. + $guard = app(\App\Services\PricelistGuard::class); + + if ($blockingFile = $guard->blockingFileFor((int) $supplierId)) { + return \response()->json([ + 'success' => false, + 'error' => $guard->blockingMessage($blockingFile), + 'res' => false, + ]); + } + $otherField = []; $otherField['note'] = $request->input('note'); $res = $this->service->importPriceList($supplierId, $availableDate, $collection->slice($importStartLine, $importLength)->toArray(), $otherField); diff --git a/app/Services/PricelistFileProcessService.php b/app/Services/PricelistFileProcessService.php index c02d091..1ab04fd 100644 --- a/app/Services/PricelistFileProcessService.php +++ b/app/Services/PricelistFileProcessService.php @@ -164,6 +164,14 @@ public function approve(PricelistFile $pricelistFile): bool return false; } + // Egy beszállítóhoz egyszerre csak egy nyitott végrehajtás tartozhat: két + // párhuzamos import ugyanarra a termékhalmazra nem determinisztikus eredményt + // adna, egy befejezetlen (execution_failed) végrehajtásra pedig félig frissített + // állapotra rétegződne rá az új. + if (app(PricelistGuard::class)->isBlocked($pricelistFile->supplier_id, $pricelistFile->id)) { + return false; + } + $meta = $pricelistFile->file_meta ?? []; $meta['approval'] = [ 'decision' => 'approved', diff --git a/app/Services/PricelistGuard.php b/app/Services/PricelistGuard.php new file mode 100644 index 0000000..f0bb925 --- /dev/null +++ b/app/Services/PricelistGuard.php @@ -0,0 +1,78 @@ +where('supplier_id', $supplierId) + ->when($exceptFileId, fn ($query) => $query->whereKeyNot($exceptFileId)) + ->whereIn('status', [ + PricelistFileStatusEnum::execution_failed->value, + PricelistFileStatusEnum::inprogress->value, + ]) + ->latest('id') + ->get() + // Az `inprogress` önmagában nem blokkol: az előfeldolgozás és a validálás + // egyetlen terméket sem ír, ezért közben nyugodtan feltölthető másik fájl. + // Csak az számít, ahol a végrehajtás már elindult - ezt a workflow_steps + // JSON tartalma dönti el, amit SQL-ben nem szűrnénk tisztán, a beszállítónkénti + // jelöltek száma viszont elenyésző. + ->first(fn (PricelistFile $file) => $file->status === PricelistFileStatusEnum::execution_failed + || $file->hasExecutionStarted()); + } + + public function isBlocked(?int $supplierId, ?int $exceptFileId = null): bool + { + return $this->blockingFileFor($supplierId, $exceptFileId) !== null; + } + + /** + * Felhasználónak szóló magyarázat: melyik fájl blokkol és miért. + */ + public function blockingMessage(PricelistFile $blockingFile): string + { + $state = $blockingFile->status === PricelistFileStatusEnum::execution_failed + ? 'végrehajtása hibára futott' + : 'végrehajtása jelenleg fut'; + + return sprintf( + 'A beszállítóhoz tartozó "%s" árlista (#%d) %s. Amíg ez nincs lezárva (folytatás vagy visszavonás), nem indítható újabb árlista-feldolgozás, mert a termékadatok félig frissített állapotban lehetnek.', + $blockingFile->filename, + $blockingFile->id, + $state, + ); + } +} diff --git a/tests/Feature/PricelistGuardTest.php b/tests/Feature/PricelistGuardTest.php new file mode 100644 index 0000000..508b641 --- /dev/null +++ b/tests/Feature/PricelistGuardTest.php @@ -0,0 +1,133 @@ + 'arlista.xlsx', + 'supplier_id' => $supplier->id, + 'available_date' => now()->addWeek()->toDateString(), + 'status' => $status, + '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' => 'completed'], + ['name' => PricelistWorkflowStep::Execution->value, 'label' => 'Végrehajtás', 'status' => $executionStepStatus], + ], + ], $attributes)); +} + +beforeEach(function () { + $this->supplier = Supplier::factory()->create(); + $this->guard = app(PricelistGuard::class); +}); + +test('a hibára futott végrehajtás blokkolja a beszállítót', function () { + $blocking = guardTestFile($this->supplier, PricelistFileStatusEnum::execution_failed, 'failed'); + + $found = $this->guard->blockingFileFor($this->supplier->id); + + expect($found)->not->toBeNull() + ->and($found->id)->toBe($blocking->id) + ->and($this->guard->blockingMessage($found))->toContain('hibára futott'); +}); + +test('a futó végrehajtás blokkolja a beszállítót', function () { + guardTestFile($this->supplier, PricelistFileStatusEnum::inprogress, 'inprogress'); + + expect($this->guard->isBlocked($this->supplier->id))->toBeTrue(); +}); + +test('a validálás alatt álló fájl NEM blokkol', function () { + // Az előfeldolgozás és a validálás egyetlen terméket sem ír, ezért közben + // nyugodtan feltölthető másik árlista ugyanahhoz a beszállítóhoz. + guardTestFile($this->supplier, PricelistFileStatusEnum::inprogress, 'pending'); + + expect($this->guard->isBlocked($this->supplier->id))->toBeFalse(); +}); + +test('a lezárt és elkészült fájlok nem blokkolnak', function () { + guardTestFile($this->supplier, PricelistFileStatusEnum::done, 'completed'); + guardTestFile($this->supplier, PricelistFileStatusEnum::closed, 'reverted'); + + expect($this->guard->isBlocked($this->supplier->id))->toBeFalse(); +}); + +test('a blokkolás beszállítónként külön él', function () { + guardTestFile($this->supplier, PricelistFileStatusEnum::execution_failed, 'failed'); + + $otherSupplier = Supplier::factory()->create(); + + expect($this->guard->isBlocked($this->supplier->id))->toBeTrue() + ->and($this->guard->isBlocked($otherSupplier->id))->toBeFalse(); +}); + +test('a fájl önmagát nem blokkolja', function () { + $file = guardTestFile($this->supplier, PricelistFileStatusEnum::execution_failed, 'failed'); + + expect($this->guard->isBlocked($this->supplier->id, $file->id))->toBeFalse(); +}); + +test('a visszavonás után a beszállító újra felszabadul', function () { + $file = guardTestFile($this->supplier, PricelistFileStatusEnum::execution_failed, 'failed'); + + expect($this->guard->isBlocked($this->supplier->id))->toBeTrue(); + + $file->update(['status' => PricelistFileStatusEnum::closed]); + + expect($this->guard->isBlocked($this->supplier->id))->toBeFalse(); +}); + +test('blokkolt beszállítónál másik fájl nem hagyható jóvá', function () { + Queue::fake(); + + guardTestFile($this->supplier, PricelistFileStatusEnum::execution_failed, 'failed'); + + $waiting = guardTestFile($this->supplier, PricelistFileStatusEnum::waiting_for_approval, 'pending', [ + 'filename' => 'masodik.xlsx', + ]); + + PricelistFileLine::create([ + 'pricelist_file_id' => $waiting->id, + 'row_number' => 5, + 'status' => PricelistFileLineStatusEnum::new_product, + 'payload' => [], + ]); + + // A fájl önmagában jóváhagyható lenne - csak a beszállítói blokkolás állítja meg. + expect($waiting->canBeApproved())->toBeTrue() + ->and(app(PricelistFileProcessService::class)->approve($waiting))->toBeFalse(); + + Queue::assertNothingPushed(); + + expect($waiting->refresh()->status)->toBe(PricelistFileStatusEnum::waiting_for_approval); +}); + +test('a konzolos indítás is elakad blokkolt beszállítónál', function () { + Queue::fake(); + + guardTestFile($this->supplier, PricelistFileStatusEnum::execution_failed, 'failed'); + + $todo = guardTestFile($this->supplier, PricelistFileStatusEnum::todo, 'pending', [ + 'filename' => 'harmadik.xlsx', + ]); + + $this->artisan('pricelist:process', ['id' => $todo->id]) + ->expectsOutputToContain('nem indítható újabb árlista-feldolgozás') + ->assertExitCode(1); + + Queue::assertNothingPushed(); +});