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:
parent
e338f68ee8
commit
8bcfeba0f9
@ -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...");
|
||||
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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',
|
||||
|
||||
78
app/Services/PricelistGuard.php
Normal file
78
app/Services/PricelistGuard.php
Normal 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 rá.
|
||||
*
|
||||
* 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
133
tests/Feature/PricelistGuardTest.php
Normal file
133
tests/Feature/PricelistGuardTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user