Compare commits

..

No commits in common. "8bcfeba0f914483fa0bde363ca3508d1cb922cf2" and "cbda830c5bc30f06566ac4f5fc4908f9c289fa69" have entirely different histories.

23 changed files with 41 additions and 2579 deletions

View File

@ -35,16 +35,6 @@ 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

@ -10,13 +10,6 @@ enum PricelistFileStatusEnum: string
case done = 'done'; case done = 'done';
case waiting_for_approval = 'waiting_for_approval'; case waiting_for_approval = 'waiting_for_approval';
case closed = 'closed'; case closed = 'closed';
/**
* A Végrehajtás (Execution) lépés futott hibára. Szándékosan külön áll a `fail`-től:
* a `fail` a validálásig tartó szakasz hibája, ahonnan a fájl újratöltésével (Edit)
* biztonságosan újraindítható a lánc - itt viszont már történhettek termék- és
* árírások, ezért csak a Folytatás vagy a Visszavonás akció indítható.
*/
case execution_failed = 'execution_failed';
public function label(): string public function label(): string
{ {
@ -27,24 +20,6 @@ public function label(): string
self::done => 'elkészült', self::done => 'elkészült',
self::waiting_for_approval => 'jóváhagyásra vár', self::waiting_for_approval => 'jóváhagyásra vár',
self::closed => 'lezárva', self::closed => 'lezárva',
self::execution_failed => 'végrehajtási hiba',
};
}
/**
* Badge szín a felülethez (a PricelistFileLineStatusEnum mintáját követve, hogy a
* színek egy helyen legyenek, és egy új státusz ne törjön el minden match-et).
*/
public function color(): string
{
return match ($this) {
self::todo => 'gray',
self::inprogress => 'info',
self::done => 'success',
self::fail => 'danger',
self::waiting_for_approval => 'primary',
self::closed => 'warning',
self::execution_failed => 'danger',
}; };
} }
} }

View File

@ -58,8 +58,6 @@ 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
'reverted' => 'warning', // a végrehajtás kompenzáló visszaállítással visszavonva
default => 'gray', default => 'gray',
}; };
} }

View File

@ -82,19 +82,6 @@ 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,40 +3,12 @@
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

@ -27,10 +27,7 @@ protected function getHeaderActions(): array
protected function afterSave(): void protected function afterSave(): void
{ {
// A hasExecutionStarted() védelem itt a lényegi: ez a metódus indítja újra a if ($this->record->status === PricelistFileStatusEnum::fail) {
// 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,18 +2,10 @@
namespace App\Filament\Resources\PricelistFiles\Pages; namespace App\Filament\Resources\PricelistFiles\Pages;
use App\Enums\PricelistFileLineStatusEnum;
use App\Enums\PricelistFileStatusEnum;
use App\Filament\Resources\PricelistFiles\PricelistFileResource; 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\Actions\EditAction;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use Laravel\Pennant\Feature; use App\Enums\PricelistFileStatusEnum;
class ViewPricelistFile extends ViewRecord class ViewPricelistFile extends ViewRecord
{ {
@ -22,246 +14,11 @@ class ViewPricelistFile extends ViewRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
$this->approveAction(),
$this->rejectAction(),
// A visszavonás az elsődleges (primary) akció: egy megszakadt végrehajtás
// után a gyakoribb helyes válasz a visszavonás + új árlista, a folytatás
// jellemzően csak átmeneti hibánál (deadlock, kapcsolatvesztés) segít.
$this->revertAction(),
$this->continueExecutionAction(),
$this->forceCloseAction(),
EditAction::make() EditAction::make()
// A `fail` a validálásig tartó szakasz hibája, onnan a fájl újratöltése ->visible(fn ($record) => $record->status === PricelistFileStatusEnum::fail),
// 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();
});
}
protected function revertAction(): Action
{
return Action::make('revert')
->label('Végrehajtás visszavonása')
->icon('heroicon-o-arrow-uturn-left')
->color('primary')
->visible(fn (PricelistFile $record) => $this->canDecideOnExecution($record))
->requiresConfirmation()
->modalHeading('Végrehajtás visszavonása')
->modalDescription(fn (PricelistFile $record) => $this->summarizeExecution($record)
. ' A visszavonás visszaállítja a frissített termékeket a mentett állapotukra, a létrehozott termékeket kivonja a forgalomból, és törli a rögzített árakat.'
. ' Ha egy terméket a végrehajtás óta kézzel módosítottak, azt a sort kihagyjuk, és a végén jelentjük.')
->modalSubmitActionLabel('Visszavonás indítása')
->action(function (PricelistFile $record) {
app(PricelistFileProcessService::class)->dispatchRevertJob($record);
Notification::make()
->title('Visszavonás elindítva')
->body('A visszaállítás fut, a haladás ezen az oldalon követhető.')
->success()
->send();
});
}
protected function continueExecutionAction(): Action
{
return Action::make('continueExecution')
->label('Végrehajtás folytatása')
->icon('heroicon-o-play')
->color('gray')
->visible(fn (PricelistFile $record) => $this->canDecideOnExecution($record))
->requiresConfirmation()
->modalHeading('Végrehajtás folytatása')
->modalDescription(fn (PricelistFile $record) => $this->summarizeExecution($record)
. ' A folytatás onnan viszi tovább a feldolgozást, ahol megszakadt - a már feldolgozott sorok kimaradnak, tehát nem keletkezik duplikátum.')
->modalSubmitActionLabel('Folytatás')
->action(function (PricelistFile $record) {
app(PricelistFileProcessService::class)->dispatchExecutionJob($record);
Notification::make()
->title('Végrehajtás folytatása elindítva')
->success()
->send();
});
}
/**
* Felügyelt kényszerlezárás. Két külön kapu védi: a rollout flag ÉS a developer
* szerepkör. Utóbbi a flag eltávolítása után is megmarad - a "lezárom anélkül, hogy
* rendbe tenném az adatokat" művelet tartósan nem való minden árlistakezelőnek.
*/
protected function forceCloseAction(): Action
{
return Action::make('forceClose')
->label('Kényszerlezárás')
->icon('heroicon-o-lock-closed')
->color('danger')
->visible(fn (PricelistFile $record) => Feature::for(auth()->user())->active('PricelistExecution')
&& auth()->user()?->hasRole('developer')
&& $record->canBeForceClosed())
->requiresConfirmation()
->modalHeading('Kényszerlezárás')
->modalDescription('A fájl lezárva státuszba kerül anélkül, hogy bármit visszaállítanánk. A végrehajtás által írt termék- és áradatok az adatbázisban maradnak, rendezetlenül. Csak akkor használd, ha a visszaállítás nem tud lefutni.')
->modalSubmitActionLabel('Kényszerlezárás')
->schema([
Textarea::make('reason')
->label('Indoklás')
->helperText('Rögzítjük, ki, mikor és miért oldotta fel a fájlt visszaállítás nélkül.')
->required()
->rows(3)
->maxLength(1000)
->default(fn (PricelistFile $record) => $record->file_meta['revert']['last_error'] ?? null),
])
->action(function (PricelistFile $record, array $data) {
if (! app(PricelistFileProcessService::class)->forceClose($record, $data['reason'])) {
$this->notifyStateChanged();
return;
}
Notification::make()
->title('A fájl kényszerlezárva')
->body('A beszállító árlista-felvitele újra engedélyezett. Az adatok rendezése kézi feladat maradt.')
->warning()
->send();
});
}
/**
* A megszakadt végrehajtás utáni döntési gombok közös feltétele.
*/
protected function canDecideOnExecution(PricelistFile $record): bool
{
return Feature::for(auth()->user())->active('PricelistExecution')
&& $record->needsExecutionDecision();
}
protected function summarizeExecution(PricelistFile $record): string
{
$summary = $record->executionSummary();
return sprintf(
'A megszakadt végrehajtásból eddig: %d létrehozott termék, %d frissített termék, %d rögzített ár, %d hátralévő sor.',
$summary['created_products'],
$summary['updated_products'],
$summary['priced_lines'],
$summary['pending_lines'],
);
}
/**
* 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()
// Ugyanahhoz a beszállítóhoz nem lehet két nyitott végrehajtás.
&& ! app(PricelistGuard::class)->isBlocked($record->supplier_id, $record->id);
}
/**
* 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

@ -3,8 +3,6 @@
namespace App\Filament\Resources\PricelistFiles\Schemas; namespace App\Filament\Resources\PricelistFiles\Schemas;
use App\Enums\PricelistFileStatusEnum; use App\Enums\PricelistFileStatusEnum;
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;
@ -21,71 +19,6 @@ public static function configure(Schema $schema): Schema
{ {
return $schema return $schema
->components([ ->components([
// Döntéstámogató panel: megszakadt végrehajtás után a felhasználónak a
// Folytatás és a Visszavonás között kell választania. Enélkül vakon
// döntene - itt látja, mi történt már meg az importból.
Section::make('Megszakadt végrehajtás')
->description('A végrehajtás nem fejeződött be. Az árlista nem élesedett, tehát a felhasználók felé nem került ki ár. Döntsd el, folytatod-e, vagy visszavonod a már végrehajtott részt.')
->icon('heroicon-o-exclamation-triangle')
->columnSpanFull()
->maxWidth(Width::Full)
->visible(fn ($record) => $record->needsExecutionDecision())
->schema([
TextEntry::make('execution_error')
->label('Hiba')
->color('danger')
->weight(FontWeight::Bold)
->columnSpanFull()
->getStateUsing(fn ($record) => $record->isExecutionStuck()
? 'A végrehajtás ' . PricelistFile::STUCK_AFTER_MINUTES . ' percnél régebben nem jelzett haladást (elakadt folyamat).'
: ($record->processing_current_step ?: 'Ismeretlen hiba.')),
Grid::make(4)->schema([
TextEntry::make('created_products')
->label('Létrehozott termék')
->icon('heroicon-o-plus-circle')
->getStateUsing(fn ($record) => $record->executionSummary()['created_products']),
TextEntry::make('updated_products')
->label('Frissített termék')
->icon('heroicon-o-pencil-square')
->getStateUsing(fn ($record) => $record->executionSummary()['updated_products']),
TextEntry::make('priced_lines')
->label('Rögzített ár')
->icon('heroicon-o-currency-dollar')
->getStateUsing(fn ($record) => $record->executionSummary()['priced_lines']),
TextEntry::make('pending_lines')
->label('Hátralévő sor')
->icon('heroicon-o-clock')
->getStateUsing(fn ($record) => $record->executionSummary()['pending_lines']),
]),
TextEntry::make('revert_error')
->label('Utolsó visszaállítási kísérlet hibája')
->color('danger')
->columnSpanFull()
->visible(fn ($record) => ($record->file_meta['revert']['last_status'] ?? null) === 'failed')
->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()
@ -114,7 +47,14 @@ public static function configure(Schema $schema): Schema
->label('Státusz') ->label('Státusz')
->badge() ->badge()
->formatStateUsing(fn (PricelistFileStatusEnum $state): string => $state->label()) ->formatStateUsing(fn (PricelistFileStatusEnum $state): string => $state->label())
->color(fn (PricelistFileStatusEnum $state): string => $state->color()), ->color(fn (PricelistFileStatusEnum $state): string => match ($state) {
PricelistFileStatusEnum::todo => 'gray',
PricelistFileStatusEnum::inprogress => 'info',
PricelistFileStatusEnum::done => 'success',
PricelistFileStatusEnum::fail => 'danger',
PricelistFileStatusEnum::waiting_for_approval => 'primary',
PricelistFileStatusEnum::closed => 'warning',
}),
]), ]),
Grid::make(3) Grid::make(3)
->schema([ ->schema([

View File

@ -32,7 +32,14 @@ public static function configure(Table $table): Table
->label('Státusz') ->label('Státusz')
->badge() ->badge()
->formatStateUsing(fn (PricelistFileStatusEnum $state): string => $state->label()) ->formatStateUsing(fn (PricelistFileStatusEnum $state): string => $state->label())
->color(fn (PricelistFileStatusEnum $state): string => $state->color()) ->color(fn (PricelistFileStatusEnum $state): string => match ($state) {
PricelistFileStatusEnum::todo => 'gray',
PricelistFileStatusEnum::inprogress => 'info',
PricelistFileStatusEnum::done => 'success',
PricelistFileStatusEnum::fail => 'danger',
PricelistFileStatusEnum::waiting_for_approval => 'primary',
PricelistFileStatusEnum::closed => 'warning',
})
->sortable(), ->sortable(),
TextColumn::make('processing_current_step') TextColumn::make('processing_current_step')
->label('Aktuális lépés') ->label('Aktuális lépés')
@ -52,10 +59,7 @@ public static function configure(Table $table): Table
->recordActions([ ->recordActions([
ViewAction::make(), ViewAction::make(),
EditAction::make() EditAction::make()
// Ha a végrehajtás már elindult, az újratöltés (és a vele járó ->visible(fn ($record) => $record->status === PricelistFileStatusEnum::fail),
// 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

@ -338,19 +338,6 @@ 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

@ -2,53 +2,29 @@
namespace App\Jobs; namespace App\Jobs;
use App\Enums\PricelistWorkflowStep;
use App\Models\PricelistFile; use App\Models\PricelistFile;
use App\Services\PricelistFileProcessService;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
use Throwable;
class PricelistExecutionJob implements ShouldQueue class PricelistExecutionJob implements ShouldQueue
{ {
use Queueable; use Queueable;
/** /**
* Nincs automatikus újrapróbálkozás: egy félbeszakadt végrehajtás után már * Create a new job instance.
* létrejöhettek termékek és árak, egy vak újrafuttatás pedig félreérthető
* állapotot okozna. A folytatásról a felhasználó dönt a felületen.
*/ */
public int $tries = 1;
/**
* Nagy árlistáknál a végrehajtás sokáig futhat, de nem korlátlanul: a timeout
* garantálja, hogy a job terminális állapotba jut, és a failed() lefut.
*/
public int $timeout = 3600;
public function __construct( public function __construct(
public PricelistFile $pricelistFile public PricelistFile $pricelistFile
) { )
{
// //
} }
public function handle(PricelistFileProcessService $service): void /**
* Execute the job.
*/
public function handle(\App\Services\PricelistFileProcessService $service): void
{ {
$service->execute($this->pricelistFile); $service->execute($this->pricelistFile);
} }
/**
* A service catch ága csak PHP exceptionöket fogja el. Worker timeout, memórialimit
* vagy worker-újraindítás esetén a catch soha nem fut le, és a fájl inprogress-ben
* ragadna - egyik döntési gomb nélkül. Ez a hook zárja be ezt a rést.
*/
public function failed(?Throwable $exception): void
{
app(PricelistFileProcessService::class)->updateStepStatus(
$this->pricelistFile,
PricelistWorkflowStep::Execution,
'failed',
$exception?->getMessage() ?? 'A végrehajtás váratlanul megszakadt.',
);
}
} }

View File

@ -1,43 +0,0 @@
<?php
namespace App\Jobs;
use App\Models\PricelistFile;
use App\Services\PricelistFileProcessService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
class PricelistRevertJob implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public int $timeout = 3600;
public function __construct(
public PricelistFile $pricelistFile
) {
//
}
public function handle(PricelistFileProcessService $service): void
{
$service->revert($this->pricelistFile);
}
/**
* Ugyanaz a rés, mint a végrehajtásnál - de itt nagyobb a tétje: a kényszerlezárás
* csak akkor érhető el, ha volt legalább egy TERMINÁLIS hibába futott visszaállítási
* kísérlet. Ha egy megszakadt visszaállítás inprogress-ben ragadna, a beszállító
* véglegesen blokkolva maradna, mindenféle kiút nélkül.
*/
public function failed(?Throwable $exception): void
{
app(PricelistFileProcessService::class)->failRevert(
$this->pricelistFile,
$exception?->getMessage() ?? 'A visszaállítás váratlanul megszakadt.',
);
}
}

View File

@ -2,9 +2,7 @@
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;
@ -44,132 +42,4 @@ 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);
}
/**
* Ennyi tétlenség után tekintjük elakadtnak a futó végrehajtást/visszaállítást.
* A job failed() hookja a hibák többségét lefedi, de egy `kill -9`-elt worker után
* az sem fut le - ilyenkor a fájl inprogress-ben ragadna, egyetlen elérhető döntési
* gomb nélkül. Minden chunk ír a rekordba, tehát az updated_at a heartbeat.
*/
public const STUCK_AFTER_MINUTES = 15;
public function isExecutionStuck(?int $minutes = null): bool
{
if ($this->status !== PricelistFileStatusEnum::inprogress || ! $this->hasExecutionStarted()) {
return false;
}
return (bool) $this->updated_at?->lt(now()->subMinutes($minutes ?? self::STUCK_AFTER_MINUTES));
}
public function isRevertRunning(): bool
{
return $this->status === PricelistFileStatusEnum::inprogress
&& ($this->file_meta['revert']['last_status'] ?? null) === 'inprogress';
}
/**
* Vár-e a fájl felhasználói döntésre (folytatás vagy visszaállítás).
*/
public function needsExecutionDecision(): bool
{
return $this->status === PricelistFileStatusEnum::execution_failed || $this->isExecutionStuck();
}
/**
* Kényszerlezárható-e. Feltétel: volt legalább egy visszaállítási kísérlet, amely
* TERMINÁLIS hibaállapotba jutott. Így a kényszerlezárás nem alternatív útvonal a
* visszaállítás mellett, hanem csak annak kudarca után elérhető vészkijárat.
*/
public function canBeForceClosed(): bool
{
$revert = $this->file_meta['revert'] ?? [];
return $this->needsExecutionDecision()
&& (int) ($revert['attempts'] ?? 0) >= 1
&& ($revert['last_status'] ?? null) === 'failed';
}
/**
* Mi történt már a végrehajtásból. A felhasználó ez alapján tud dönteni a folytatás
* és a visszaállítás között - enélkül vakon választana a két gomb közül.
*/
public function executionSummary(): array
{
return [
'created_products' => $this->lines()
->where('status', PricelistFileLineStatusEnum::new_product->value)
->whereNotNull('product_id')
->count(),
'updated_products' => $this->lines()->whereNotNull('applied_snapshot')->count(),
'priced_lines' => $this->lines()->whereNotNull('executed_at')->count(),
'pending_lines' => $this->lines()
->where('status', '!=', PricelistFileLineStatusEnum::error->value)
->whereNull('executed_at')
->count(),
];
}
/**
* 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

@ -16,8 +16,6 @@ class PricelistFileLine extends BaseAuditable
'payload' => 'array', 'payload' => 'array',
'validation_messages' => 'array', 'validation_messages' => 'array',
'diff' => 'array', 'diff' => 'array',
'applied_snapshot' => 'array',
'executed_at' => 'datetime',
]; ];
public function pricelistFile(): BelongsTo public function pricelistFile(): BelongsTo

View File

@ -2,10 +2,8 @@
namespace App\Services; namespace App\Services;
use App\Enums\DbStatusFieldEnum;
use App\Enums\PreProcessErrorCode; use App\Enums\PreProcessErrorCode;
use App\Enums\PricelistWorkflowStep; use App\Enums\PricelistWorkflowStep;
use App\Models\PriceList;
use App\Models\PricelistFile; use App\Models\PricelistFile;
use App\Enums\PricelistFileStatusEnum; use App\Enums\PricelistFileStatusEnum;
use App\Models\PricelistFileLine; use App\Models\PricelistFileLine;
@ -66,47 +64,6 @@ class PricelistFileProcessService
], ],
]; ];
/**
* A végrehajtás mezőleképezése: Excel oszlop indexe -> `products` tábla mezője.
*
* Bővebb, mint a PRODUCT_FIELD_MAP: az csak a diff-számításhoz kell, tehát azokat a
* mezőket tartalmazza, amelyek változását ki akarjuk mutatni. Importáláskor viszont
* olyan mezőket is írunk, amelyeket nem hasonlítunk össze (cikkszám, kiszerelés,
* vevői megnevezés, megjegyzés, KREL, akció) - a legacy importtal azonos körben.
*/
protected const EXECUTION_FIELD_MAP = [
'supplierProductNumber' => ['index' => 0, 'type' => 'string'],
'name' => ['index' => 4, 'type' => 'string'],
'packing' => ['index' => 5, 'type' => 'int'],
'unitValue' => ['index' => 6, 'type' => 'float'],
'productUnit' => ['index' => 7, 'type' => 'string'],
'sellerUnit' => ['index' => 9, 'type' => 'string'],
'unitMultiplier' => ['index' => 10, 'type' => 'float'],
'amountUnit' => ['index' => 11, 'type' => 'string'],
'vat' => ['index' => 12, 'type' => 'float'],
'hooreycaId' => ['index' => 16, 'type' => 'string'],
'HooreycaUnit' => ['index' => 17, 'type' => 'string'],
'HooreycaMultiplier' => ['index' => 18, 'type' => 'float'],
'buyerProductName' => ['index' => 19, 'type' => 'string'],
'note' => ['index' => 20, 'type' => 'string'],
'krel' => ['index' => 21, 'type' => 'bool'],
'specialOffer' => ['index' => 22, 'type' => 'bool'],
];
/**
* Chunk méretek a végrehajtáshoz. A termékírás soronként több query-t jelent,
* ezért kisebb köteg; az árak kötegelt insertje elbír nagyobbat.
*/
private const EXECUTION_CHUNK_SIZE = 500;
private const PRICE_CHUNK_SIZE = 1000;
/**
* A visszaállításból kihagyott sorok részleteiből ennyit tárolunk a file_meta-ban.
* A teljes darabszám a `skipped_count`-ban akkor is megmarad, ha ez levág.
*/
private const REVERT_SKIPPED_DETAIL_LIMIT = 100;
public function __construct( public function __construct(
protected PriceListService $priceListService protected PriceListService $priceListService
) {} ) {}
@ -149,92 +106,6 @@ 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;
}
// 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',
'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,
@ -293,13 +164,7 @@ public function updateStepStatus(
$updateData['status'] = PricelistFileStatusEnum::inprogress; $updateData['status'] = PricelistFileStatusEnum::inprogress;
} }
} elseif ($status === 'failed') { } elseif ($status === 'failed') {
// A végrehajtás hibája külön státuszt kap: a `fail` a validálásig tartó $updateData['status'] = PricelistFileStatusEnum::fail;
// szakaszt jelenti, ahonnan a fájl újratöltése biztonságosan újraindítja a
// láncot - a végrehajtásnál viszont már történhettek termék- és árírások,
// ott csak a Folytatás vagy a Visszavonás megengedett.
$updateData['status'] = $stepEnum === PricelistWorkflowStep::Execution
? PricelistFileStatusEnum::execution_failed
: PricelistFileStatusEnum::fail;
} }
$pricelistFile->update($updateData); $pricelistFile->update($updateData);
@ -1326,711 +1191,26 @@ protected function runBusinessValidation(PricelistFile $pricelistFile, bool $has
*/ */
public function execute(PricelistFile $pricelistFile): bool public function execute(PricelistFile $pricelistFile): bool
{ {
$this->reportExecutionProgress($pricelistFile, 0, 'Végrehajtás indítása...'); $this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'inprogress', 'Árlista frissítése...', 0);
DB::beginTransaction();
try { try {
// Szándékosan NINCS egyetlen, mindent átfogó tranzakció: több ezer sornál a // TODO: Tényleges importálás végrehajtása
// termék-insert + pivot-insert + visibility update percekig tartana lockokat, // $this->priceListService->importPriceList(...);
// és egy timeout minden visszajelzés nélkül dobna el mindent. Helyette sleep(1); // Szimuláció
// fázisonként/chunkonként tranzakciózunk, az "atomicitást" pedig az adja, hogy
// az árlista E6-ig `draft` marad - a felhasználói felület és a statisztika
// csak az `active` árlistákat nézi, tehát félbeszakadás esetén sem kerül ki
// félkész ár. A megszakadt futás a kurzoroktól folytatható (idempotens).
$priceList = $this->prepareExecution($pricelistFile);
$this->createMissingProducers($pricelistFile); $this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'completed', 'Sikeresen befejezve.', 100);
$this->createNewProducts($pricelistFile);
$this->updateExistingProducts($pricelistFile);
$this->attachPrices($pricelistFile, $priceList);
$this->finalizeExecution($pricelistFile, $priceList);
return true; DB::commit();
} catch (\Throwable $e) { $pricelistFile->update([
Log::error('Pricelist execution error: ' . $e->getMessage(), [ 'status' => PricelistFileStatusEnum::done,
'pricelist_file_id' => $pricelistFile->id,
'exception' => $e,
]); ]);
return true;
// A lépés 'failed'-re állítása az updateStepStatus-on keresztül a fájlt } catch (\Exception $e) {
// execution_failed státuszba teszi (lásd ott a leképezést). DB::rollBack();
Log::error('Pricelist execution error: ' . $e->getMessage());
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'failed', $e->getMessage()); $this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'failed', $e->getMessage());
return false; return false;
} }
} }
protected function reportExecutionProgress(PricelistFile $pricelistFile, int $percentage, string $message): void
{
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'inprogress', $message, $percentage);
}
/**
* E1 - Előkészítés: az árlista fejrekord létrehozása (vagy újraindításkor a
* korábbi továbbhasználata).
*/
protected function prepareExecution(PricelistFile $pricelistFile): PriceList
{
$this->reportExecutionProgress($pricelistFile, 2, 'Árlista rekord előkészítése...');
// Újraindításkor NEM hozunk létre új árlistát: a fájlra mentett price_list_id az
// egész folytatás horgonya, enélkül minden újrafuttatás duplikálna.
if ($pricelistFile->price_list_id && $priceList = PriceList::find($pricelistFile->price_list_id)) {
return $priceList;
}
$priceList = PriceList::create([
'supplier_id' => $pricelistFile->supplier_id,
'available' => $pricelistFile->available_date,
'note' => $pricelistFile->note,
'canSee' => 1,
'status' => DbStatusFieldEnum::draft,
]);
$pricelistFile->update(['price_list_id' => $priceList->id]);
return $priceList;
}
/**
* E2 - Új gyártók létrehozása. A validálás az ismeretlen gyártójú sorokon
* producer_id nélkül hagyta a rekordot ("új gyártó"), itt pótoljuk őket.
* Kis halmaz, ezért egyetlen tranzakcióban fut.
*/
protected function createMissingProducers(PricelistFile $pricelistFile): void
{
$this->reportExecutionProgress($pricelistFile, 5, 'Új gyártók létrehozása...');
$lines = $pricelistFile->lines()
->whereNull('producer_id')
->where('status', '!=', PricelistFileLineStatusEnum::error->value)
->get();
if ($lines->isEmpty()) {
return;
}
DB::transaction(function () use ($lines) {
$createdByName = [];
foreach ($lines as $line) {
$producerName = trim((string) ($line->payload[PriceListService::EXPECTED_HEADERS[8]] ?? ''));
if ($producerName === '') {
continue;
}
// A fájlon belül ugyanaz a gyártó többféle írásmóddal is előfordulhat,
// ezért normalizált kulccsal gyűjtjük, mint a validálás.
$key = $this->normalizeForComparison($producerName);
$createdByName[$key] ??= \App\Models\Producer::firstOrCreate(
['name' => $producerName],
['canSee' => 1, 'status' => DbStatusFieldEnum::active],
)->id;
$line->update(['producer_id' => $createdByName[$key]]);
}
});
}
/**
* E3 - Új termékek létrehozása. Idempotencia: a már létrehozott sorokon beáll a
* product_id, a folytatás csak a product_id nélkülieket veszi.
*/
protected function createNewProducts(PricelistFile $pricelistFile): void
{
$this->reportExecutionProgress($pricelistFile, 10, 'Új termékek létrehozása...');
$query = $pricelistFile->lines()
->where('status', PricelistFileLineStatusEnum::new_product->value)
->whereNull('product_id');
$total = (clone $query)->count();
if ($total === 0) {
return;
}
$groupTypes = $this->getProductGroupTypeMap();
$processed = 0;
$query->chunkById(self::EXECUTION_CHUNK_SIZE, function ($lines) use ($pricelistFile, $groupTypes, $total, &$processed) {
DB::transaction(function () use ($lines, $pricelistFile, $groupTypes) {
foreach ($lines as $line) {
$product = \App\Models\Product::create(array_merge(
$this->buildProductData($line, $pricelistFile, $groupTypes),
['canSee' => 1, 'status' => DbStatusFieldEnum::active],
));
$line->update(['product_id' => $product->id]);
}
});
$processed += $lines->count();
$this->reportExecutionProgress(
$pricelistFile,
10 + (int) floor(30 * $processed / $total),
"Új termékek létrehozása ({$processed}/{$total})...",
);
});
}
/**
* E4 - Meglévő termékek frissítése. Idempotencia és visszavonhatóság: a frissítés
* ELŐTTI tényleges DB-állapot ugyanabban a tranzakcióban a sor applied_snapshot
* mezőjébe kerül, és ennek megléte jelzi, hogy a sor már feldolgozott.
*/
protected function updateExistingProducts(PricelistFile $pricelistFile): void
{
$this->reportExecutionProgress($pricelistFile, 40, 'Termékadatok frissítése...');
$query = $pricelistFile->lines()
->where('status', PricelistFileLineStatusEnum::updated->value)
->whereNotNull('product_id')
->whereNull('applied_snapshot');
$total = (clone $query)->count();
if ($total === 0) {
return;
}
$groupTypes = $this->getProductGroupTypeMap();
$processed = 0;
$query->chunkById(self::EXECUTION_CHUNK_SIZE, function ($lines) use ($pricelistFile, $groupTypes, $total, &$processed) {
DB::transaction(function () use ($lines, $pricelistFile, $groupTypes) {
foreach ($lines as $line) {
$product = \App\Models\Product::find($line->product_id);
if (! $product) {
// A terméket a validálás óta törölték. A sort kivesszük a
// további feldolgozásból (így árat sem kap), és megjelöljük,
// hogy a felhasználó lássa, mi maradt ki.
$line->addValidationError('product', 'A termék időközben megszűnt, a sor kimaradt a végrehajtásból.');
$line->product_id = null;
$line->save();
continue;
}
$productData = $this->buildProductData($line, $pricelistFile, $groupTypes);
// A snapshot a TÉNYLEGES, írás előtti állapot - szándékosan nem a
// validáláskor számolt diff['old'], mert az a jóváhagyás pillanatában
// készült, és egy időközbeni kézi módosítást írna felül a visszavonás.
$snapshot = [
'updated_at' => $product->updated_at?->toDateTimeString(),
'fields' => [],
];
foreach (array_keys($productData) as $field) {
$snapshot['fields'][$field] = $product->getAttribute($field);
}
$product->update($productData);
// A MI írásunk utáni updated_at: a visszavonás ehhez hasonlítja a
// termék akkori állapotát, és eltérés esetén kihagyja a sort, mert az
// azt jelenti, hogy azóta valaki kézzel módosította. (A snapshot
// 'updated_at' mezője az írás ELŐTTI érték, arra ez a check nem jó.)
$snapshot['applied_updated_at'] = $product->refresh()->updated_at?->toDateTimeString();
$line->update(['applied_snapshot' => $snapshot]);
}
});
$processed += $lines->count();
$this->reportExecutionProgress(
$pricelistFile,
40 + (int) floor(30 * $processed / $total),
"Termékadatok frissítése ({$processed}/{$total})...",
);
});
}
/**
* E5 - Árak csatolása az árlistához. A price_list_prices táblának nincs `id`
* oszlopa, ezért kötegelt query builder inserttel dolgozunk; a duplikáció ellen a
* már meglévő (price_list_id, product_id) párok kiszűrése véd.
*/
protected function attachPrices(PricelistFile $pricelistFile, PriceList $priceList): void
{
$this->reportExecutionProgress($pricelistFile, 70, 'Árak rögzítése...');
// A `warning` sorok is importálódnak: csak az `error` státusz zár ki (ilyen sor
// egyébként sem lehet, mert azzal a fájl nem hagyható jóvá).
$query = $pricelistFile->lines()
->where('status', '!=', PricelistFileLineStatusEnum::error->value)
->whereNotNull('product_id')
->whereNull('executed_at');
$total = (clone $query)->count();
if ($total === 0) {
return;
}
$processed = 0;
$query->chunkById(self::PRICE_CHUNK_SIZE, function ($lines) use ($pricelistFile, $priceList, $total, &$processed) {
DB::transaction(function () use ($lines, $priceList) {
$productIds = $lines->pluck('product_id')->all();
// Újraindítás után előfordulhat, hogy egy sor ára már bekerült.
$alreadyPriced = array_flip(
DB::table('price_list_prices')
->where('price_list_id', $priceList->id)
->whereIn('product_id', $productIds)
->pluck('product_id')
->all(),
);
$now = now();
$rows = [];
foreach ($lines as $line) {
if (isset($alreadyPriced[$line->product_id])) {
continue;
}
$rows[] = [
'price_list_id' => $priceList->id,
'product_id' => $line->product_id,
'price' => $this->castExecutionFloat(
$line->payload[PriceListService::EXPECTED_HEADERS[14]] ?? 0,
),
'created_at' => $now,
'updated_at' => $now,
];
// Ugyanaz a cikkszám kétszer is szerepelhet a fájlban - a pivotnak
// nincs egyedi indexe, ezért itt kell kiszűrni.
$alreadyPriced[$line->product_id] = true;
}
if ($rows !== []) {
DB::table('price_list_prices')->insert($rows);
}
PricelistFileLine::whereIn('id', $lines->pluck('id')->all())
->update(['executed_at' => $now]);
});
$processed += $lines->count();
$this->reportExecutionProgress(
$pricelistFile,
70 + (int) floor(20 * $processed / $total),
"Árak rögzítése ({$processed}/{$total})...",
);
});
}
/**
* E6 - Lezárás: az árlista élesítése és az érintett termékek láthatóvá tétele.
* Csak itt válik az árlista `active`-, azaz a felhasználók számára láthatóvá.
*/
protected function finalizeExecution(PricelistFile $pricelistFile, PriceList $priceList): void
{
$this->reportExecutionProgress($pricelistFile, 90, 'Árlista lezárása...');
$priceList->update(['status' => DbStatusFieldEnum::active]);
$affected = 0;
$pricelistFile->lines()
->whereNotNull('product_id')
->whereNotNull('executed_at')
->select(['id', 'product_id'])
->chunkById(self::PRICE_CHUNK_SIZE, function ($lines) use (&$affected) {
$productIds = $lines->pluck('product_id')->unique()->all();
\App\Models\Product::whereIn('id', $productIds)->update(['canSee' => true]);
$affected += count($productIds);
});
$meta = $pricelistFile->file_meta ?? [];
$meta['execution'] = [
'price_list_id' => $priceList->id,
'affected_products' => $affected,
'line_status_counts' => $pricelistFile->lineStatusCounts(),
'finished_at' => now()->toDateTimeString(),
];
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'completed', 'Sikeresen befejezve.', 100);
$pricelistFile->update([
'status' => PricelistFileStatusEnum::done,
'file_meta' => $meta,
]);
}
/**
* Termék-mezők összeállítása egy sorból (létrehozáshoz és frissítéshez egyaránt).
*/
protected function buildProductData(PricelistFileLine $line, PricelistFile $pricelistFile, array $groupTypes): array
{
$payload = $line->payload ?? [];
$data = [];
foreach (self::EXECUTION_FIELD_MAP as $field => $config) {
$header = PriceListService::EXPECTED_HEADERS[$config['index']];
// Az opcionális oszlopok (pl. "Akció") hiányozhatnak a fájlból. Ilyenkor a
// mezőt NEM írjuk felül: a termék meglévő értéke marad érvényben.
if (! array_key_exists($header, $payload)) {
continue;
}
$data[$field] = $this->castExecutionValue($payload[$header], $config['type'], $field);
}
$data['supplier_id'] = $pricelistFile->supplier_id;
$data['producer_id'] = $line->producer_id;
$data['product_group_id'] = $line->product_group_id;
if (isset($groupTypes[$line->product_group_id])) {
$data['type'] = $groupTypes[$line->product_group_id];
}
return $data;
}
protected function castExecutionValue(mixed $value, string $type, string $field): mixed
{
return match ($type) {
'float' => $this->castExecutionFloat($value, $field),
'int' => (int) $this->castExecutionFloat($value, $field),
// booleanCustom: üres érték => false, bármilyen más érték (pl. "X") => true
'bool' => strlen(trim((string) $value)) > 0,
default => $value === null ? null : trim((string) $value),
};
}
protected function castExecutionFloat(mixed $value, string $field = ''): float
{
$number = is_string($value)
? (float) str_replace([' ', ','], ['', '.'], $value)
: (float) $value;
// ÁFA: az Excelben 0,27 formában is szerepelhet - a validálás is így számol
if ($field === 'vat') {
if ($number > 0 && $number <= 1) {
$number *= 100;
}
$number = (float) round($number);
}
return $number;
}
/**
* Termékcsoport ID => típus ('F' / 'N' / 'X'). A termék `type` mezője a
* termékcsoportjától öröklődik, ahogy a legacy importban is.
*/
protected function getProductGroupTypeMap(): array
{
return \App\Models\ProductGroup::pluck('type', 'id')->toArray();
}
/**
* Elindítja a visszaállítási jobot.
*/
public function dispatchRevertJob(PricelistFile $pricelistFile): void
{
dispatch(new \App\Jobs\PricelistRevertJob($pricelistFile));
}
/**
* Kompenzáló visszaállítás: a végrehajtás által írt adatok visszavonása.
*
* Nem "rollback" a szó adatbázis-értelmében - a tranzakciók már lezárultak -, hanem
* a snapshotokból és a saját artefaktumokból vezetett fordított műveletsor. Ugyanúgy
* chunkolt és idempotens, mint a végrehajtás, tehát megszakadás után folytatható.
*/
public function revert(PricelistFile $pricelistFile): bool
{
$this->startRevertAttempt($pricelistFile);
try {
$priceList = $pricelistFile->price_list_id
? PriceList::find($pricelistFile->price_list_id)
: null;
$this->revertPrices($pricelistFile, $priceList);
$skipped = $this->revertUpdatedProducts($pricelistFile);
$this->revertCreatedProducts($pricelistFile);
$this->finalizeRevert($pricelistFile, $priceList, $skipped);
return true;
} catch (\Throwable $e) {
Log::error('Pricelist revert error: ' . $e->getMessage(), [
'pricelist_file_id' => $pricelistFile->id,
'exception' => $e,
]);
$this->failRevert($pricelistFile, $e->getMessage());
return false;
}
}
protected function startRevertAttempt(PricelistFile $pricelistFile): void
{
$pricelistFile->refresh();
$meta = $pricelistFile->file_meta ?? [];
$revert = $meta['revert'] ?? [];
$meta['revert'] = array_merge($revert, [
'attempts' => (int) ($revert['attempts'] ?? 0) + 1,
'last_status' => 'inprogress',
'last_error' => null,
'last_attempt_at' => now()->toDateTimeString(),
'user_id' => auth()->id(),
]);
// A fájl a visszaállítás idejére inprogress: ez rejti el a döntési gombokat,
// tehát egy második kattintás nem indíthat párhuzamos visszaállítást.
$pricelistFile->update([
'status' => PricelistFileStatusEnum::inprogress,
'file_meta' => $meta,
'processing_current_step' => 'Visszaállítás indítása...',
'processing_current_step_percentage' => 0,
]);
}
protected function reportRevertProgress(PricelistFile $pricelistFile, int $percentage, string $message): void
{
$pricelistFile->update([
'processing_current_step' => mb_strcut($message, 0, 255),
'processing_current_step_percentage' => $percentage,
]);
}
/**
* R1 - Árak eltávolítása. Az egész árlista a miénk, ezért egyetlen törléssel megy.
*/
protected function revertPrices(PricelistFile $pricelistFile, ?PriceList $priceList): void
{
$this->reportRevertProgress($pricelistFile, 10, 'Árak eltávolítása...');
if (! $priceList) {
return;
}
DB::table('price_list_prices')->where('price_list_id', $priceList->id)->delete();
}
/**
* R2 - Frissített termékek visszaállítása a snapshotból.
*
* @return array a kihagyott (időközben kézzel módosított) sorok leírása
*/
protected function revertUpdatedProducts(PricelistFile $pricelistFile): array
{
$this->reportRevertProgress($pricelistFile, 30, 'Termékadatok visszaállítása...');
$query = $pricelistFile->lines()->whereNotNull('applied_snapshot');
$total = (clone $query)->count();
if ($total === 0) {
return [];
}
$skipped = [];
$processed = 0;
$query->chunkById(self::EXECUTION_CHUNK_SIZE, function ($lines) use ($pricelistFile, $total, &$processed, &$skipped) {
DB::transaction(function () use ($lines, &$skipped) {
foreach ($lines as $line) {
$snapshot = $line->applied_snapshot ?? [];
$product = \App\Models\Product::find($line->product_id);
if (! $product) {
// Nincs mit visszaállítani; a jelölőt elfogyasztjuk.
$line->update(['applied_snapshot' => null]);
continue;
}
$appliedAt = $snapshot['applied_updated_at'] ?? null;
$currentAt = $product->updated_at?->toDateTimeString();
if ($appliedAt !== null && $appliedAt !== $currentAt) {
// A terméket a végrehajtásunk óta valaki módosította. Vakon
// visszaírni azt jelentené, hogy elvesszük a kézi módosítást,
// ezért kihagyjuk és jelentjük.
$skipped[] = [
'row_number' => $line->row_number,
'product_id' => $product->id,
'fields' => $snapshot['fields'] ?? [],
];
$line->addValidationError('revert', 'A termék a végrehajtás óta módosult, ezért a visszaállításból kimaradt.');
$line->applied_snapshot = null;
$line->save();
continue;
}
$product->update($snapshot['fields'] ?? []);
$line->update(['applied_snapshot' => null]);
}
});
$processed += $lines->count();
$this->reportRevertProgress(
$pricelistFile,
30 + (int) floor(30 * $processed / $total),
"Termékadatok visszaállítása ({$processed}/{$total})...",
);
});
return $skipped;
}
/**
* R3 - A végrehajtás által létrehozott termékek kivonása a forgalomból.
*
* Szándékosan NEM fizikai törlés: a termékre időközben hivatkozhat megrendelés vagy
* más rekord. A soft delete egyben a jelölő is: egy folytatott visszaállítás a már
* kezelt termékeket nem találja meg újra.
*/
protected function revertCreatedProducts(PricelistFile $pricelistFile): void
{
$this->reportRevertProgress($pricelistFile, 60, 'Létrehozott termékek visszavonása...');
$query = $pricelistFile->lines()
->where('status', PricelistFileLineStatusEnum::new_product->value)
->whereNotNull('product_id');
$total = (clone $query)->count();
if ($total === 0) {
return;
}
$processed = 0;
$query->chunkById(self::EXECUTION_CHUNK_SIZE, function ($lines) use ($pricelistFile, $total, &$processed) {
DB::transaction(function () use ($lines) {
foreach ($lines as $line) {
$product = \App\Models\Product::find($line->product_id);
if (! $product) {
continue; // már visszavonva (soft delete) - a folytatás átugorja
}
$product->update([
'status' => DbStatusFieldEnum::deleted,
'canSee' => false,
]);
$product->delete();
}
});
$processed += $lines->count();
$this->reportRevertProgress(
$pricelistFile,
60 + (int) floor(30 * $processed / $total),
"Létrehozott termékek visszavonása ({$processed}/{$total})...",
);
});
}
/**
* R4 - Lezárás: az árlista törlése és a fájl végleges lezárása.
*/
protected function finalizeRevert(PricelistFile $pricelistFile, ?PriceList $priceList, array $skipped): void
{
$this->reportRevertProgress($pricelistFile, 95, 'Visszaállítás lezárása...');
$priceList?->update(['status' => DbStatusFieldEnum::deleted]);
$pricelistFile->refresh();
$meta = $pricelistFile->file_meta ?? [];
$meta['revert'] = array_merge($meta['revert'] ?? [], [
'last_status' => 'completed',
'last_error' => null,
'finished_at' => now()->toDateTimeString(),
'skipped_count' => count($skipped),
// A részleteket korlátozottan tároljuk, hogy a file_meta ne hízzon el; a
// teljes darabszám a skipped_count-ban akkor is megmarad.
'skipped' => array_slice($skipped, 0, self::REVERT_SKIPPED_DETAIL_LIMIT),
'skipped_detail_truncated' => count($skipped) > self::REVERT_SKIPPED_DETAIL_LIMIT,
]);
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'reverted', 'A végrehajtás visszavonva.');
$pricelistFile->update([
'status' => PricelistFileStatusEnum::closed,
'file_meta' => $meta,
'processing_current_step' => null,
'processing_current_step_percentage' => null,
]);
}
/**
* A visszaállítás hibára futott. A fájl visszakerül execution_failed-be, és mostantól
* a kényszerlezárás is elérhető - ez a "legalább egy terminális hibába futott
* kísérlet" feltétel teljesülése.
*/
public function failRevert(PricelistFile $pricelistFile, string $message): void
{
$pricelistFile->refresh();
$meta = $pricelistFile->file_meta ?? [];
$meta['revert'] = array_merge($meta['revert'] ?? [], [
'last_status' => 'failed',
'last_error' => $message,
'finished_at' => now()->toDateTimeString(),
]);
$pricelistFile->update([
'status' => PricelistFileStatusEnum::execution_failed,
'file_meta' => $meta,
'processing_current_step' => mb_strcut('Visszaállítási hiba: ' . $message, 0, 255),
'processing_current_step_percentage' => null,
]);
}
/**
* Felügyelt kényszerlezárás: a fájlt lezárja anélkül, hogy bármit visszaállítana.
*
* Vészkijárat arra az esetre, amikor a visszaállítás sem tud lefutni - enélkül egy
* ilyen fájl véglegesen blokkolná a beszállítót az árlistázásból. Nem javítja meg az
* adatokat, csak a döntést és a felelősséget teszi explicitté.
*
* @return bool false, ha a fájl nem kényszerlezárható (pl. még nem volt sikertelen
* visszaállítási kísérlet)
*/
public function forceClose(PricelistFile $pricelistFile, string $reason): bool
{
$pricelistFile->refresh();
if (! $pricelistFile->canBeForceClosed()) {
return false;
}
$meta = $pricelistFile->file_meta ?? [];
$meta['force_close'] = [
'reason' => $reason,
'revert_error' => $meta['revert']['last_error'] ?? null,
'user_id' => auth()->id(),
'at' => now()->toDateTimeString(),
];
$pricelistFile->update([
'status' => PricelistFileStatusEnum::closed,
'file_meta' => $meta,
'processing_current_step' => null,
'processing_current_step_percentage' => null,
]);
return true;
}
} }

View File

@ -1,78 +0,0 @@
<?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

@ -1,45 +0,0 @@
<?php
use App\Enums\PricelistFileStatusEnum;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* A `pricelist_files.status` valódi DB enum oszlop, ezért egy új PHP enum case
* (execution_failed) önmagában nem elég - az oszlop definícióját is bővíteni kell.
*
* A 2026_03_11_153652_update_enums_in_pricelist_tables mintáját követjük: az enum
* értékkészletet a PHP enumból generáljuk, így nem csúszhat el a kettő egymástól.
*/
public function up(): void
{
$statuses = array_map(fn ($case) => "'{$case->value}'", PricelistFileStatusEnum::cases());
$statusesStr = implode(', ', $statuses);
DB::statement("ALTER TABLE pricelist_files MODIFY COLUMN status ENUM({$statusesStr}) DEFAULT 'todo' NOT NULL");
}
/**
* Visszagörgetéskor az execution_failed státuszú rekordokat előbb `fail`-re állítjuk,
* különben a szűkített enum miatt az ALTER hibára futna (vagy némán ürítené a mezőt).
*/
public function down(): void
{
DB::table('pricelist_files')
->where('status', PricelistFileStatusEnum::execution_failed->value)
->update(['status' => PricelistFileStatusEnum::fail->value]);
$statuses = array_map(
fn ($case) => "'{$case->value}'",
array_filter(
PricelistFileStatusEnum::cases(),
fn ($case) => $case !== PricelistFileStatusEnum::execution_failed,
),
);
$statusesStr = implode(', ', $statuses);
DB::statement("ALTER TABLE pricelist_files MODIFY COLUMN status ENUM({$statusesStr}) DEFAULT 'todo' NOT NULL");
}
};

View File

@ -1,37 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* A chunkolt végrehajtás két dolgot igényel soronként:
*
* - `applied_snapshot`: a termék TÉNYLEGES, írás előtti DB-állapota, ugyanabban a
* chunk-tranzakcióban rögzítve, mint maga az írás. Ebből áll vissza a termék a
* Visszavonás akcióban. Szándékosan NEM a validáláskor számolt `diff['old']`-ot
* használjuk erre: az a jóváhagyás pillanatában készült, és egy időközbeni kézi
* módosítást felülírna. A snapshotban a termék `updated_at`-jét is eltároljuk,
* hogy a visszavonás ki tudja hagyni (és jelenteni tudja) a közben módosított
* termékeket.
* - `executed_at`: a sor feldolgozottságának jelölése. Ez teszi a végrehajtást
* idempotenssé, azaz a megszakadt futás a kurzortól folytathatóvá - a már
* megjelölt sorok kimaradnak, nem jön létre duplikált termék vagy ár.
*/
public function up(): void
{
Schema::table('pricelist_file_lines', function (Blueprint $table) {
$table->json('applied_snapshot')->nullable()->after('diff');
$table->timestamp('executed_at')->nullable()->after('applied_snapshot');
});
}
public function down(): void
{
Schema::table('pricelist_file_lines', function (Blueprint $table) {
$table->dropColumn(['applied_snapshot', 'executed_at']);
});
}
};

View File

@ -46,19 +46,5 @@ public function run(): void
'roles' => ['developer'], 'roles' => ['developer'],
], ],
); );
FeatureFlag::updateOrCreate(
['name' => 'PricelistExecution'],
[
'label' => 'Árlista jóváhagyás és végrehajtás',
'description' => 'Az árlista feldolgozó Jóváhagyás/Elutasítás, Végrehajtás folytatása és Visszavonás akcióit, valamint a tényleges árlista-importot szabályozza a rollout alatt. Átmeneti kapcsoló, a funkció élesítése után eltávolítható - a kényszerlezárás jogosultsági korlátozása viszont a flag megszűnése után is maradjon.',
// enabled=true + roles=['developer']: a developer felhasználók automatikusan
// megkapják, rajtuk kívül senki. Lásd a fenti magyarázatot arról, miért nem
// enabled=false-szal fejezzük ki a "csak developer lássa" állapotot.
'enabled' => true,
'stages' => null,
'roles' => ['developer'],
],
);
} }
} }

View File

@ -1,205 +0,0 @@
<?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');
});

View File

@ -1,322 +0,0 @@
<?php
use App\Enums\DbStatusFieldEnum;
use App\Enums\PricelistFileLineStatusEnum;
use App\Enums\PricelistFileStatusEnum;
use App\Enums\PricelistWorkflowStep;
use App\Models\PriceList;
use App\Models\PricelistFile;
use App\Models\PricelistFileLine;
use App\Models\Producer;
use App\Models\Product;
use App\Models\ProductGroup;
use App\Models\Supplier;
use App\Services\PriceListService;
use App\Services\PricelistFileProcessService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
/**
* Egy jóváhagyott állapotú fájl, amelyen a végrehajtás elindulhat.
*/
function executableFile(Supplier $supplier): PricelistFile
{
return PricelistFile::create([
'filename' => 'arlista.xlsx',
'supplier_id' => $supplier->id,
'available_date' => now()->addWeek()->toDateString(),
'note' => 'Teszt árlista',
'status' => PricelistFileStatusEnum::inprogress,
'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' => 'inprogress'],
],
]);
}
/**
* Sor payload a kanonikus fejlécnevekkel, ahogy a validálás elmenti.
*/
function linePayload(array $overrides = []): array
{
$h = PriceListService::EXPECTED_HEADERS;
return array_merge([
$h[0] => 'SKU-1',
$h[1] => 'Főcsoport',
$h[2] => 'Alcsoport 1',
$h[4] => 'Termék megnevezés',
$h[5] => '6',
$h[6] => '1,5',
$h[7] => 'l',
$h[8] => 'Teszt Gyártó',
$h[9] => 'kart',
$h[10] => '6',
$h[11] => 'db',
$h[12] => '0,27',
$h[14] => '1 250,50',
$h[20] => 'Megjegyzés',
$h[21] => '',
$h[22] => '',
], $overrides);
}
function executionLine(PricelistFile $file, PricelistFileLineStatusEnum $status, array $payload, array $attributes = []): PricelistFileLine
{
return PricelistFileLine::create(array_merge([
'pricelist_file_id' => $file->id,
'row_number' => 5,
'status' => $status,
'payload' => $payload,
], $attributes));
}
beforeEach(function () {
$this->supplier = Supplier::factory()->create();
$this->group = ProductGroup::create(['name' => 'Alcsoport 1', 'type' => 'F', 'status' => DbStatusFieldEnum::active, 'canSee' => 1]);
$this->producer = Producer::create(['name' => 'Teszt Gyártó', 'status' => DbStatusFieldEnum::active, 'canSee' => 1]);
});
test('a végrehajtás létrehozza az árlistát, az új terméket és az árakat', function () {
$file = executableFile($this->supplier);
executionLine($file, PricelistFileLineStatusEnum::new_product, linePayload([
PriceListService::EXPECTED_HEADERS[0] => 'SKU-NEW',
PriceListService::EXPECTED_HEADERS[4] => 'Új termék',
PriceListService::EXPECTED_HEADERS[8] => 'Ismeretlen Gyártó',
PriceListService::EXPECTED_HEADERS[14] => '990',
]), ['product_group_id' => $this->group->id]);
expect(app(PricelistFileProcessService::class)->execute($file))->toBeTrue();
$file->refresh();
$priceList = PriceList::find($file->price_list_id);
$product = Product::where('supplierProductNumber', 'SKU-NEW')->first();
expect($priceList)->not->toBeNull()
->and($priceList->status)->toBe(DbStatusFieldEnum::active)
->and($priceList->supplier_id)->toBe($this->supplier->id)
->and($product)->not->toBeNull()
->and($product->name)->toBe('Új termék')
->and($product->supplier_id)->toBe($this->supplier->id)
->and($product->product_group_id)->toBe($this->group->id)
->and($product->type)->toBe('F')
->and((float) $product->vat)->toBe(27.0) // 0,27 -> 27
->and((float) $product->unitValue)->toBe(1.5) // vesszős tizedes
->and((bool) $product->canSee)->toBeTrue()
->and(DB::table('price_list_prices')
->where('price_list_id', $priceList->id)
->where('product_id', $product->id)
->value('price'))->toBe(990.0)
->and($file->status)->toBe(PricelistFileStatusEnum::done)
->and($file->stepStatus(PricelistWorkflowStep::Execution))->toBe('completed')
->and($file->processing_current_step_percentage)->toBe(100);
});
test('az ismeretlen gyártó létrejön és a termékhez kapcsolódik', function () {
$file = executableFile($this->supplier);
executionLine($file, PricelistFileLineStatusEnum::new_product, linePayload([
PriceListService::EXPECTED_HEADERS[0] => 'SKU-NEW',
PriceListService::EXPECTED_HEADERS[8] => 'Vadonatúj Gyártó',
]), ['product_group_id' => $this->group->id]);
app(PricelistFileProcessService::class)->execute($file);
$producer = Producer::where('name', 'Vadonatúj Gyártó')->first();
$product = Product::where('supplierProductNumber', 'SKU-NEW')->first();
expect($producer)->not->toBeNull()
->and($producer->status)->toBe(DbStatusFieldEnum::active)
->and($product->producer_id)->toBe($producer->id);
});
test('a meglévő termék frissül és a snapshot az írás előtti állapotot őrzi', function () {
$file = executableFile($this->supplier);
$product = Product::create([
'name' => 'Régi név',
'supplierProductNumber' => 'SKU-1',
'supplier_id' => $this->supplier->id,
'producer_id' => $this->producer->id,
'product_group_id' => $this->group->id,
'unitValue' => 1,
'note' => '',
'vat' => 5,
'status' => DbStatusFieldEnum::active,
'canSee' => 1,
]);
$line = executionLine($file, PricelistFileLineStatusEnum::updated, linePayload([
PriceListService::EXPECTED_HEADERS[4] => 'Új név',
]), [
'product_id' => $product->id,
'product_group_id' => $this->group->id,
'producer_id' => $this->producer->id,
]);
app(PricelistFileProcessService::class)->execute($file);
$product->refresh();
$line->refresh();
expect($product->name)->toBe('Új név')
->and((float) $product->vat)->toBe(27.0)
->and($line->applied_snapshot['fields']['name'])->toBe('Régi név')
->and((float) $line->applied_snapshot['fields']['vat'])->toBe(5.0)
->and($line->applied_snapshot['updated_at'])->not->toBeNull()
->and($line->executed_at)->not->toBeNull();
});
test('a hiányzó "Akció" oszlop nem írja felül a termék akciós jelölését', function () {
$file = executableFile($this->supplier);
$product = Product::create([
'name' => 'Akciós termék',
'supplierProductNumber' => 'SKU-1',
'supplier_id' => $this->supplier->id,
'producer_id' => $this->producer->id,
'product_group_id' => $this->group->id,
'unitValue' => 1,
'note' => '',
'specialOffer' => 1,
'status' => DbStatusFieldEnum::active,
'canSee' => 1,
]);
$payload = linePayload([PriceListService::EXPECTED_HEADERS[4] => 'Új név']);
unset($payload[PriceListService::EXPECTED_HEADERS[22]]); // az "Akció" oszlop nincs a fájlban
executionLine($file, PricelistFileLineStatusEnum::updated, $payload, [
'product_id' => $product->id,
'product_group_id' => $this->group->id,
'producer_id' => $this->producer->id,
]);
app(PricelistFileProcessService::class)->execute($file);
expect((bool) $product->refresh()->specialOffer)->toBeTrue();
});
test('a végrehajtás megismétlése nem duplikál terméket, árlistát és árat', function () {
$file = executableFile($this->supplier);
executionLine($file, PricelistFileLineStatusEnum::new_product, linePayload([
PriceListService::EXPECTED_HEADERS[0] => 'SKU-NEW',
]), ['product_group_id' => $this->group->id]);
$service = app(PricelistFileProcessService::class);
$service->execute($file);
$service->execute($file->refresh());
expect(Product::where('supplierProductNumber', 'SKU-NEW')->count())->toBe(1)
->and(PriceList::count())->toBe(1)
->and(DB::table('price_list_prices')->count())->toBe(1);
});
test('hiba esetén a fájl execution_failed lesz és az árlista draft marad', function () {
$file = executableFile($this->supplier);
executionLine($file, PricelistFileLineStatusEnum::new_product, linePayload([
PriceListService::EXPECTED_HEADERS[0] => 'SKU-NEW',
]), ['product_group_id' => $this->group->id]);
// Az árak fázisában elhaló végrehajtás: a termék ekkor már létrejött.
$service = new class(app(PriceListService::class)) extends PricelistFileProcessService
{
protected function attachPrices(PricelistFile $pricelistFile, PriceList $priceList): void
{
throw new RuntimeException('Szimulált adatbázishiba.');
}
};
expect($service->execute($file))->toBeFalse();
$file->refresh();
expect($file->status)->toBe(PricelistFileStatusEnum::execution_failed)
->and($file->stepStatus(PricelistWorkflowStep::Execution))->toBe('failed')
// A draft árlista a felhasználói felületen és a statisztikában sem látszik,
// tehát a félbeszakadt végrehajtás nem hoz nyilvánosságra félkész árat.
->and(PriceList::find($file->price_list_id)->status)->toBe(DbStatusFieldEnum::draft)
// A termék viszont már létrejött - ezt csak a visszavonás tudja rendbe tenni.
->and(Product::where('supplierProductNumber', 'SKU-NEW')->exists())->toBeTrue();
});
test('a megszakadt végrehajtás folytatható és nem kezd elölről', function () {
$file = executableFile($this->supplier);
executionLine($file, PricelistFileLineStatusEnum::new_product, linePayload([
PriceListService::EXPECTED_HEADERS[0] => 'SKU-NEW',
]), ['product_group_id' => $this->group->id]);
$failing = new class(app(PriceListService::class)) extends PricelistFileProcessService
{
protected function attachPrices(PricelistFile $pricelistFile, PriceList $priceList): void
{
throw new RuntimeException('Szimulált adatbázishiba.');
}
};
$failing->execute($file);
$priceListId = $file->refresh()->price_list_id;
// Folytatás a rendes service-szel
expect(app(PricelistFileProcessService::class)->execute($file))->toBeTrue();
$file->refresh();
expect($file->price_list_id)->toBe($priceListId) // ugyanaz az árlista
->and(PriceList::count())->toBe(1)
->and(Product::where('supplierProductNumber', 'SKU-NEW')->count())->toBe(1)
->and(DB::table('price_list_prices')->count())->toBe(1)
->and($file->status)->toBe(PricelistFileStatusEnum::done);
});
test('az időközben törölt termék sora kimarad, a többi lefut', function () {
$file = executableFile($this->supplier);
$deleted = Product::create([
'name' => 'Törölt termék',
'supplierProductNumber' => 'SKU-1',
'supplier_id' => $this->supplier->id,
'producer_id' => $this->producer->id,
'product_group_id' => $this->group->id,
'unitValue' => 1,
'note' => '',
'status' => DbStatusFieldEnum::active,
'canSee' => 1,
]);
$line = executionLine($file, PricelistFileLineStatusEnum::updated, linePayload(), [
'product_id' => $deleted->id,
'product_group_id' => $this->group->id,
'producer_id' => $this->producer->id,
]);
// A validálás óta törölték a terméket. A Product SoftDeletes-et használ, tehát a
// sor product_id-ja érvényes marad (a FK nem nullázza), a Product::find() viszont
// már nem adja vissza - pontosan ezt az esetet kell a végrehajtásnak kezelnie.
$deleted->delete();
executionLine($file, PricelistFileLineStatusEnum::new_product, linePayload([
PriceListService::EXPECTED_HEADERS[0] => 'SKU-NEW',
]), ['product_group_id' => $this->group->id, 'row_number' => 6]);
expect(app(PricelistFileProcessService::class)->execute($file))->toBeTrue();
$line->refresh();
expect($line->product_id)->toBeNull()
->and($line->status)->toBe(PricelistFileLineStatusEnum::error)
->and($line->validation_messages['product'])->toContain('megszűnt')
->and(DB::table('price_list_prices')->count())->toBe(1) // csak az ép sor kapott árat
->and($file->refresh()->status)->toBe(PricelistFileStatusEnum::done);
});

View File

@ -1,133 +0,0 @@
<?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();
});

View File

@ -1,292 +0,0 @@
<?php
use App\Enums\DbStatusFieldEnum;
use App\Enums\PricelistFileLineStatusEnum;
use App\Enums\PricelistFileStatusEnum;
use App\Enums\PricelistWorkflowStep;
use App\Jobs\PricelistExecutionJob;
use App\Models\PriceList;
use App\Models\PricelistFile;
use App\Models\PricelistFileLine;
use App\Models\Producer;
use App\Models\Product;
use App\Models\ProductGroup;
use App\Models\Supplier;
use App\Services\PriceListService;
use App\Services\PricelistFileProcessService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function revertTestFile(Supplier $supplier): PricelistFile
{
return PricelistFile::create([
'filename' => 'arlista.xlsx',
'supplier_id' => $supplier->id,
'available_date' => now()->addWeek()->toDateString(),
'status' => PricelistFileStatusEnum::inprogress,
'workflow_steps' => [
['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' => 'inprogress'],
],
]);
}
function revertTestPayload(array $overrides = []): array
{
$h = PriceListService::EXPECTED_HEADERS;
return array_merge([
$h[0] => 'SKU-1',
$h[1] => 'Főcsoport',
$h[2] => 'Alcsoport 1',
$h[4] => 'Új név',
$h[5] => '6',
$h[6] => '1,5',
$h[7] => 'l',
$h[8] => 'Teszt Gyártó',
$h[9] => 'kart',
$h[10] => '6',
$h[11] => 'db',
$h[12] => '0,27',
$h[14] => '990',
$h[20] => 'Megjegyzés',
$h[21] => '',
$h[22] => '',
], $overrides);
}
function revertTestLine(PricelistFile $file, PricelistFileLineStatusEnum $status, array $payload, array $attributes = []): PricelistFileLine
{
return PricelistFileLine::create(array_merge([
'pricelist_file_id' => $file->id,
'row_number' => 5,
'status' => $status,
'payload' => $payload,
], $attributes));
}
/**
* Olyan service, amelynek a visszaállítása a létrehozott termékek fázisában elhal -
* az árak és a frissítések ekkor már visszaálltak.
*/
function failingRevertService(): PricelistFileProcessService
{
return new class(app(PriceListService::class)) extends PricelistFileProcessService
{
protected function revertCreatedProducts(PricelistFile $pricelistFile): void
{
throw new RuntimeException('Szimulált visszaállítási hiba.');
}
};
}
beforeEach(function () {
$this->supplier = Supplier::factory()->create();
$this->group = ProductGroup::create(['name' => 'Alcsoport 1', 'type' => 'F', 'status' => DbStatusFieldEnum::active, 'canSee' => 1]);
$this->producer = Producer::create(['name' => 'Teszt Gyártó', 'status' => DbStatusFieldEnum::active, 'canSee' => 1]);
$this->service = app(PricelistFileProcessService::class);
});
test('a visszavonás visszaállítja a frissített terméket a snapshotból', function () {
$file = revertTestFile($this->supplier);
$product = Product::create([
'name' => 'Régi név',
'supplierProductNumber' => 'SKU-1',
'supplier_id' => $this->supplier->id,
'producer_id' => $this->producer->id,
'product_group_id' => $this->group->id,
'unitValue' => 1,
'note' => '',
'vat' => 5,
'status' => DbStatusFieldEnum::active,
'canSee' => 1,
]);
$line = revertTestLine($file, PricelistFileLineStatusEnum::updated, revertTestPayload(), [
'product_id' => $product->id,
'product_group_id' => $this->group->id,
'producer_id' => $this->producer->id,
]);
$this->service->execute($file);
expect($product->refresh()->name)->toBe('Új név');
expect($this->service->revert($file->refresh()))->toBeTrue();
$file->refresh();
expect($product->refresh()->name)->toBe('Régi név')
->and((float) $product->vat)->toBe(5.0)
->and($line->refresh()->applied_snapshot)->toBeNull()
->and($file->status)->toBe(PricelistFileStatusEnum::closed)
->and($file->stepStatus(PricelistWorkflowStep::Execution))->toBe('reverted')
->and(PriceList::find($file->price_list_id)->status)->toBe(DbStatusFieldEnum::deleted)
->and(DB::table('price_list_prices')->count())->toBe(0);
});
test('a visszavonás kivonja a forgalomból a létrehozott terméket', function () {
$file = revertTestFile($this->supplier);
revertTestLine($file, PricelistFileLineStatusEnum::new_product, revertTestPayload([
PriceListService::EXPECTED_HEADERS[0] => 'SKU-NEW',
]), ['product_group_id' => $this->group->id, 'producer_id' => $this->producer->id]);
$this->service->execute($file);
$productId = Product::where('supplierProductNumber', 'SKU-NEW')->value('id');
$this->service->revert($file->refresh());
$product = Product::withTrashed()->find($productId);
expect($product)->not->toBeNull() // nem fizikai törlés
->and($product->trashed())->toBeTrue()
->and($product->status)->toBe(DbStatusFieldEnum::deleted)
->and((bool) $product->canSee)->toBeFalse();
});
test('a végrehajtás óta kézzel módosított terméket kihagyja és jelenti', function () {
$file = revertTestFile($this->supplier);
$product = Product::create([
'name' => 'Régi név',
'supplierProductNumber' => 'SKU-1',
'supplier_id' => $this->supplier->id,
'producer_id' => $this->producer->id,
'product_group_id' => $this->group->id,
'unitValue' => 1,
'note' => '',
'status' => DbStatusFieldEnum::active,
'canSee' => 1,
]);
$line = revertTestLine($file, PricelistFileLineStatusEnum::updated, revertTestPayload(), [
'product_id' => $product->id,
'product_group_id' => $this->group->id,
'producer_id' => $this->producer->id,
]);
$this->service->execute($file);
// Kézi módosítás a végrehajtás után. Az updated_at-et explicit állítjuk, mert a
// MySQL timestamp másodperc pontosságú, és a teszt ennél gyorsabban futna le.
$product->update(['name' => 'Kézzel átírt név', 'updated_at' => now()->addMinute()]);
$this->service->revert($file->refresh());
$file->refresh();
expect($product->refresh()->name)->toBe('Kézzel átírt név') // NEM írtuk felül
->and($file->file_meta['revert']['skipped_count'])->toBe(1)
->and($file->file_meta['revert']['skipped'][0]['row_number'])->toBe(5)
->and($line->refresh()->validation_messages['revert'])->toContain('módosult')
->and($file->status)->toBe(PricelistFileStatusEnum::closed);
});
test('a visszavonás megismételhető, nem borul fel az állapot', function () {
$file = revertTestFile($this->supplier);
revertTestLine($file, PricelistFileLineStatusEnum::new_product, revertTestPayload([
PriceListService::EXPECTED_HEADERS[0] => 'SKU-NEW',
]), ['product_group_id' => $this->group->id, 'producer_id' => $this->producer->id]);
$this->service->execute($file);
$this->service->revert($file->refresh());
$this->service->revert($file->refresh());
expect(Product::withTrashed()->where('supplierProductNumber', 'SKU-NEW')->count())->toBe(1)
->and(DB::table('price_list_prices')->count())->toBe(0)
->and($file->refresh()->status)->toBe(PricelistFileStatusEnum::closed);
});
test('a sikertelen visszaállítás execution_failed-be tesz és megnyitja a kényszerlezárást', function () {
$file = revertTestFile($this->supplier);
revertTestLine($file, PricelistFileLineStatusEnum::new_product, revertTestPayload([
PriceListService::EXPECTED_HEADERS[0] => 'SKU-NEW',
]), ['product_group_id' => $this->group->id, 'producer_id' => $this->producer->id]);
$this->service->execute($file);
expect(failingRevertService()->revert($file->refresh()))->toBeFalse();
$file->refresh();
expect($file->status)->toBe(PricelistFileStatusEnum::execution_failed)
->and($file->file_meta['revert']['attempts'])->toBe(1)
->and($file->file_meta['revert']['last_status'])->toBe('failed')
->and($file->file_meta['revert']['last_error'])->toContain('Szimulált')
->and($file->canBeForceClosed())->toBeTrue();
});
test('kényszerlezárás nem indítható sikertelen visszaállítási kísérlet nélkül', function () {
$file = revertTestFile($this->supplier);
revertTestLine($file, PricelistFileLineStatusEnum::new_product, revertTestPayload([
PriceListService::EXPECTED_HEADERS[0] => 'SKU-NEW',
]), ['product_group_id' => $this->group->id, 'producer_id' => $this->producer->id]);
$this->service->execute($file);
$this->service->updateStepStatus($file, PricelistWorkflowStep::Execution, 'failed', 'Hiba.');
$file->refresh();
expect($file->status)->toBe(PricelistFileStatusEnum::execution_failed)
->and($file->canBeForceClosed())->toBeFalse()
->and($this->service->forceClose($file, 'Csak úgy.'))->toBeFalse()
->and($file->refresh()->status)->toBe(PricelistFileStatusEnum::execution_failed);
});
test('a kényszerlezárás rögzíti az indoklást és felszabadítja a fájlt', function () {
$file = revertTestFile($this->supplier);
revertTestLine($file, PricelistFileLineStatusEnum::new_product, revertTestPayload([
PriceListService::EXPECTED_HEADERS[0] => 'SKU-NEW',
]), ['product_group_id' => $this->group->id, 'producer_id' => $this->producer->id]);
$this->service->execute($file);
failingRevertService()->revert($file->refresh());
expect($this->service->forceClose($file->refresh(), 'Kézzel rendezzük az adatokat.'))->toBeTrue();
$file->refresh();
expect($file->status)->toBe(PricelistFileStatusEnum::closed)
->and($file->file_meta['force_close']['reason'])->toBe('Kézzel rendezzük az adatokat.')
->and($file->file_meta['force_close']['revert_error'])->toContain('Szimulált');
});
test('az elakadt végrehajtás észlelhető és döntést kér', function () {
$file = revertTestFile($this->supplier);
expect($file->isExecutionStuck())->toBeFalse();
// A heartbeat (updated_at) nyers frissítése: az Eloquent felülírná a mostani időre.
DB::table('pricelist_files')
->where('id', $file->id)
->update(['updated_at' => now()->subMinutes(PricelistFile::STUCK_AFTER_MINUTES + 5)]);
$file->refresh();
expect($file->isExecutionStuck())->toBeTrue()
->and($file->needsExecutionDecision())->toBeTrue();
});
test('a job failed() hookja akkor is execution_failed-be teszi a fájlt, ha a catch nem futott le', function () {
$file = revertTestFile($this->supplier);
// Worker timeout / memórialimit szimulálása: a handle() catch ága ilyenkor nem fut.
(new PricelistExecutionJob($file))->failed(new RuntimeException('Job timeout.'));
$file->refresh();
expect($file->status)->toBe(PricelistFileStatusEnum::execution_failed)
->and($file->stepStatus(PricelistWorkflowStep::Execution))->toBe('failed')
->and($file->needsExecutionDecision())->toBeTrue();
});