ADD EV3-357 Árlista feldolgozás phase5/2 jóváhagyás/elutasítás akció

- 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 <noreply@anthropic.com>
This commit is contained in:
E98Developer 2026-08-10 15:00:33 +02:00
parent 5bf2f83092
commit eee27aa659
7 changed files with 485 additions and 6 deletions

View File

@ -58,6 +58,7 @@ public function color(string $status): string
'inprogress' => 'primary', 'inprogress' => 'primary',
'completed' => 'success', 'completed' => 'success',
'failed' => 'danger', 'failed' => 'danger',
'rejected' => 'warning', // manuális elutasítás - nem hiba, hanem szabályos lezárás
default => 'gray', default => 'gray',
}; };
} }

View File

@ -27,7 +27,10 @@ protected function getHeaderActions(): array
protected function afterSave(): void 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); app(PricelistFileProcessService::class)->dispatchInitialChain($this->record);
} }
} }

View File

@ -2,10 +2,17 @@
namespace App\Filament\Resources\PricelistFiles\Pages; namespace App\Filament\Resources\PricelistFiles\Pages;
use App\Filament\Resources\PricelistFiles\PricelistFileResource; use App\Enums\PricelistFileLineStatusEnum;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
use App\Enums\PricelistFileStatusEnum; 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 class ViewPricelistFile extends ViewRecord
{ {
@ -14,11 +21,128 @@ class ViewPricelistFile extends ViewRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
$this->approveAction(),
$this->rejectAction(),
EditAction::make() 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 protected function getHeaderWidgets(): array
{ {
return [ return [

View File

@ -52,7 +52,10 @@ public static function configure(Table $table): Table
->recordActions([ ->recordActions([
ViewAction::make(), ViewAction::make(),
EditAction::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([ ->toolbarActions([
BulkActionGroup::make([ BulkActionGroup::make([

View File

@ -2,7 +2,9 @@
namespace App\Models; namespace App\Models;
use App\Enums\PricelistFileLineStatusEnum;
use App\Enums\PricelistFileStatusEnum; use App\Enums\PricelistFileStatusEnum;
use App\Enums\PricelistWorkflowStep;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
@ -42,4 +44,67 @@ public function lines(): HasMany
{ {
return $this->hasMany(PricelistFileLine::class); 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();
}
} }

View File

@ -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( public function updateStepStatus(
PricelistFile $pricelistFile, PricelistFile $pricelistFile,
PricelistWorkflowStep $stepEnum, PricelistWorkflowStep $stepEnum,

View File

@ -0,0 +1,205 @@
<?php
use App\Enums\PricelistFileLineStatusEnum;
use App\Enums\PricelistFileStatusEnum;
use App\Enums\PricelistWorkflowStep;
use App\Filament\Resources\PricelistFiles\Pages\ViewPricelistFile;
use App\Jobs\PricelistExecutionJob;
use App\Models\FeatureFlag;
use App\Models\PricelistFile;
use App\Models\PricelistFileLine;
use App\Models\Role;
use App\Models\Supplier;
use App\Models\User;
use App\Services\FeatureFlagRegistrar;
use App\Services\PricelistFileProcessService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
/**
* Jóváhagyásra váró fájl: a validálás lezárult, a jóváhagyás a soron következő lépés.
*/
function approvableFile(array $attributes = []): PricelistFile
{
return PricelistFile::create(array_merge([
'filename' => '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');
});