ADD EV3-357 Árlista feldolgozás phase5/5 beszállítói blokkolás (PricelistGuard)

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 <noreply@anthropic.com>
This commit is contained in:
E98Developer 2026-08-15 06:46:40 +02:00
parent e338f68ee8
commit 8bcfeba0f9
9 changed files with 309 additions and 1 deletions

View File

@ -35,6 +35,16 @@ public function handle(PricelistFileProcessService $service): int
return 1; 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("Folyamat elindítása: {$pricelistFile->filename} (ID: {$id})");
$this->info("Workflow állapotok alaphelyzetbe állítása..."); $this->info("Workflow állapotok alaphelyzetbe állítása...");

View File

@ -82,6 +82,19 @@ public function save()
{ {
$data = $this->form->getState(); $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([ $pricelistFile = PricelistFile::create([
'filename' => $data['filename'], 'filename' => $data['filename'],
'supplier_id' => $data['supplier_id'], 'supplier_id' => $data['supplier_id'],

View File

@ -3,12 +3,40 @@
namespace App\Filament\Resources\PricelistFiles\Pages; namespace App\Filament\Resources\PricelistFiles\Pages;
use App\Filament\Resources\PricelistFiles\PricelistFileResource; use App\Filament\Resources\PricelistFiles\PricelistFileResource;
use App\Services\PricelistGuard;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
use Filament\Support\Exceptions\Halt;
class CreatePricelistFile extends CreateRecord class CreatePricelistFile extends CreateRecord
{ {
protected static string $resource = PricelistFileResource::class; 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 protected function afterCreate(): void
{ {
app(\App\Services\PricelistFileProcessService::class)->dispatchInitialChain($this->record); app(\App\Services\PricelistFileProcessService::class)->dispatchInitialChain($this->record);

View File

@ -7,6 +7,7 @@
use App\Filament\Resources\PricelistFiles\PricelistFileResource; use App\Filament\Resources\PricelistFiles\PricelistFileResource;
use App\Models\PricelistFile; use App\Models\PricelistFile;
use App\Services\PricelistFileProcessService; use App\Services\PricelistFileProcessService;
use App\Services\PricelistGuard;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
@ -219,7 +220,9 @@ protected function summarizeExecution(PricelistFile $record): string
protected function canDecide(PricelistFile $record): bool protected function canDecide(PricelistFile $record): bool
{ {
return Feature::for(auth()->user())->active('PricelistExecution') 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);
} }
/** /**

View File

@ -4,6 +4,7 @@
use App\Enums\PricelistFileStatusEnum; use App\Enums\PricelistFileStatusEnum;
use App\Models\PricelistFile; use App\Models\PricelistFile;
use App\Services\PricelistGuard;
use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Text; use Filament\Schemas\Components\Text;
@ -64,6 +65,27 @@ public static function configure(Schema $schema): Schema
->getStateUsing(fn ($record) => $record->file_meta['revert']['last_error'] ?? null), ->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) Section::make(null)
->poll('5s') ->poll('5s')
->columnSpanFull() ->columnSpanFull()

View File

@ -338,6 +338,19 @@ private function importPriceList(Request $request): JsonResponse
$supplierId = $request->input('supplier'); $supplierId = $request->input('supplier');
$availableDate = $request->input('availableDate'); $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 = [];
$otherField['note'] = $request->input('note'); $otherField['note'] = $request->input('note');
$res = $this->service->importPriceList($supplierId, $availableDate, $collection->slice($importStartLine, $importLength)->toArray(), $otherField); $res = $this->service->importPriceList($supplierId, $availableDate, $collection->slice($importStartLine, $importLength)->toArray(), $otherField);

View File

@ -164,6 +164,14 @@ public function approve(PricelistFile $pricelistFile): bool
return false; 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 = $pricelistFile->file_meta ?? [];
$meta['approval'] = [ $meta['approval'] = [
'decision' => 'approved', 'decision' => 'approved',

View File

@ -0,0 +1,78 @@
<?php
namespace App\Services;
use App\Enums\PricelistFileStatusEnum;
use App\Models\PricelistFile;
/**
* Egy beszállítóhoz egyszerre legfeljebb egy "nyitott" végrehajtás tartozhat.
*
* Amíg egy fájl végrehajtása hibára futott (`execution_failed`), a beszállító
* termékei félig frissített állapotban vannak: egy részük már az új árlista adatait
* hordozza, más részük még a régit. Ha erre az állapotra új árlistát vinnénk fel, a
* validálás ehhez a kevert állapothoz számolná a diffeket - egyes sorok tévesen
* "rendben"-nek látszanának, mások fölöslegesen "módosítás"-nak -, az import pedig
* egy inkonzisztens alapra rétegződne .
*
* A futó (`inprogress`) végrehajtás blokkolása még közvetlenebb: két párhuzamos
* import ugyanarra a termékhalmazra nem determinisztikus végeredményt adna.
*
* FONTOS: ezt a védelmet MINDEN belépési ponton alkalmazni kell (modern Filament
* felület, régebbi PriceListProcessor oldal, legacy admin import, konzol parancs) -
* ha csak az űrlapot védjük, a blokkolás látszatvédelem marad, mert a beszállítókat
* ma még nagyrészt a legacy felületen kezelik.
*/
class PricelistGuard
{
/**
* A beszállítót blokkoló árlista fájl, ha van ilyen.
*
* @param int|null $exceptFileId ezt a fájlt hagyjuk figyelmen kívül (önmagát ne blokkolja)
*/
public function blockingFileFor(?int $supplierId, ?int $exceptFileId = null): ?PricelistFile
{
if (! $supplierId) {
return null;
}
return PricelistFile::query()
->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,
);
}
}

View File

@ -0,0 +1,133 @@
<?php
use App\Enums\PricelistFileLineStatusEnum;
use App\Enums\PricelistFileStatusEnum;
use App\Enums\PricelistWorkflowStep;
use App\Models\PricelistFile;
use App\Models\PricelistFileLine;
use App\Models\Supplier;
use App\Services\PricelistFileProcessService;
use App\Services\PricelistGuard;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function guardTestFile(Supplier $supplier, PricelistFileStatusEnum $status, string $executionStepStatus, array $attributes = []): PricelistFile
{
return PricelistFile::create(array_merge([
'filename' => '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();
});