Compare commits
5 Commits
cbda830c5b
...
8bcfeba0f9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bcfeba0f9 | ||
|
|
e338f68ee8 | ||
|
|
93111ef2e0 | ||
|
|
eee27aa659 | ||
|
|
5bf2f83092 |
@ -35,6 +35,16 @@ public function handle(PricelistFileProcessService $service): int
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A konzolos indítás ugyanúgy a termékadatokat érinti, mint a felületi - a
|
||||||
|
// beszállítói blokkolás itt sem kerülhető meg.
|
||||||
|
$guard = app(\App\Services\PricelistGuard::class);
|
||||||
|
|
||||||
|
if ($blockingFile = $guard->blockingFileFor($pricelistFile->supplier_id, $pricelistFile->id)) {
|
||||||
|
$this->error($guard->blockingMessage($blockingFile));
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
$this->info("Folyamat elindítása: {$pricelistFile->filename} (ID: {$id})");
|
$this->info("Folyamat elindítása: {$pricelistFile->filename} (ID: {$id})");
|
||||||
$this->info("Workflow állapotok alaphelyzetbe állítása...");
|
$this->info("Workflow állapotok alaphelyzetbe állítása...");
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,13 @@ 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
|
||||||
{
|
{
|
||||||
@ -20,6 +27,24 @@ 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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,6 +58,8 @@ 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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -82,6 +82,19 @@ public function save()
|
|||||||
{
|
{
|
||||||
$data = $this->form->getState();
|
$data = $this->form->getState();
|
||||||
|
|
||||||
|
// Ugyanaz a védelem, mint a modern felületen: enélkül ez az oldal megkerülné a
|
||||||
|
// beszállítói blokkolást, hiszen ugyanúgy PricelistFile-t hoz létre és láncot indít.
|
||||||
|
if ($blockingFile = app(\App\Services\PricelistGuard::class)->blockingFileFor($data['supplier_id'] ?? null)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('A beszállítóhoz jelenleg nem vihető fel árlista')
|
||||||
|
->body(app(\App\Services\PricelistGuard::class)->blockingMessage($blockingFile))
|
||||||
|
->danger()
|
||||||
|
->persistent()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$pricelistFile = PricelistFile::create([
|
$pricelistFile = PricelistFile::create([
|
||||||
'filename' => $data['filename'],
|
'filename' => $data['filename'],
|
||||||
'supplier_id' => $data['supplier_id'],
|
'supplier_id' => $data['supplier_id'],
|
||||||
|
|||||||
@ -3,12 +3,40 @@
|
|||||||
namespace App\Filament\Resources\PricelistFiles\Pages;
|
namespace App\Filament\Resources\PricelistFiles\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\PricelistFiles\PricelistFileResource;
|
use App\Filament\Resources\PricelistFiles\PricelistFileResource;
|
||||||
|
use App\Services\PricelistGuard;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
use Filament\Support\Exceptions\Halt;
|
||||||
|
|
||||||
class CreatePricelistFile extends CreateRecord
|
class CreatePricelistFile extends CreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = PricelistFileResource::class;
|
protected static string $resource = PricelistFileResource::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A beszállító nem blokkolt-e egy befejezetlen végrehajtás miatt.
|
||||||
|
*
|
||||||
|
* A beszállító szándékosan választható marad az űrlapon: ha egyszerűen kivennénk a
|
||||||
|
* listából, a felhasználó csak annyit látna, hogy "eltűnt a beszállító", és keresné
|
||||||
|
* az okát. Így viszont konkrét magyarázatot kap arról, melyik fájl blokkol.
|
||||||
|
*/
|
||||||
|
protected function beforeCreate(): void
|
||||||
|
{
|
||||||
|
$blockingFile = app(PricelistGuard::class)->blockingFileFor($this->data['supplier_id'] ?? null);
|
||||||
|
|
||||||
|
if (! $blockingFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('A beszállítóhoz jelenleg nem vihető fel árlista')
|
||||||
|
->body(app(PricelistGuard::class)->blockingMessage($blockingFile))
|
||||||
|
->danger()
|
||||||
|
->persistent()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
throw new Halt;
|
||||||
|
}
|
||||||
|
|
||||||
protected function afterCreate(): void
|
protected function afterCreate(): void
|
||||||
{
|
{
|
||||||
app(\App\Services\PricelistFileProcessService::class)->dispatchInitialChain($this->record);
|
app(\App\Services\PricelistFileProcessService::class)->dispatchInitialChain($this->record);
|
||||||
|
|||||||
@ -27,7 +27,10 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
protected function afterSave(): void
|
protected function afterSave(): void
|
||||||
{
|
{
|
||||||
if ($this->record->status === PricelistFileStatusEnum::fail) {
|
// A hasExecutionStarted() védelem itt a lényegi: ez a metódus indítja újra a
|
||||||
|
// TELJES feldolgozási láncot. Ha a végrehajtás egyszer már futott, egy
|
||||||
|
// nulláról induló újrafuttatás duplikált termékeket és árakat hozna létre.
|
||||||
|
if ($this->record->status === PricelistFileStatusEnum::fail && ! $this->record->hasExecutionStarted()) {
|
||||||
app(PricelistFileProcessService::class)->dispatchInitialChain($this->record);
|
app(PricelistFileProcessService::class)->dispatchInitialChain($this->record);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,10 +2,18 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\PricelistFiles\Pages;
|
namespace App\Filament\Resources\PricelistFiles\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\PricelistFiles\PricelistFileResource;
|
use App\Enums\PricelistFileLineStatusEnum;
|
||||||
use Filament\Actions\EditAction;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
use App\Enums\PricelistFileStatusEnum;
|
use App\Enums\PricelistFileStatusEnum;
|
||||||
|
use App\Filament\Resources\PricelistFiles\PricelistFileResource;
|
||||||
|
use App\Models\PricelistFile;
|
||||||
|
use App\Services\PricelistFileProcessService;
|
||||||
|
use App\Services\PricelistGuard;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
use Laravel\Pennant\Feature;
|
||||||
|
|
||||||
class ViewPricelistFile extends ViewRecord
|
class ViewPricelistFile extends ViewRecord
|
||||||
{
|
{
|
||||||
@ -14,11 +22,246 @@ 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()
|
||||||
->visible(fn ($record) => $record->status === PricelistFileStatusEnum::fail),
|
// A `fail` a validálásig tartó szakasz hibája, onnan a fájl újratöltése
|
||||||
|
// biztonságosan újraindítja a láncot. A hasExecutionStarted() extra
|
||||||
|
// feltétel a maradék kockázatot zárja ki: ha a végrehajtás egyszer már
|
||||||
|
// elindult, a nulláról induló újrafuttatás duplikált termékeket és
|
||||||
|
// árakat hozna létre.
|
||||||
|
->visible(fn (PricelistFile $record) => $record->status === PricelistFileStatusEnum::fail
|
||||||
|
&& ! $record->hasExecutionStarted()),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function approveAction(): Action
|
||||||
|
{
|
||||||
|
return Action::make('approve')
|
||||||
|
->label('Jóváhagyás')
|
||||||
|
->icon('heroicon-o-check-circle')
|
||||||
|
->color('success')
|
||||||
|
->visible(fn (PricelistFile $record) => $this->canDecide($record))
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Árlista jóváhagyása')
|
||||||
|
->modalDescription(fn (PricelistFile $record) => 'A jóváhagyás elindítja a tényleges importot: '
|
||||||
|
. $this->summarizeLines($record)
|
||||||
|
. ' A művelet a termékadatokat is módosítja.')
|
||||||
|
->modalSubmitActionLabel('Jóváhagyás és végrehajtás')
|
||||||
|
->action(function (PricelistFile $record) {
|
||||||
|
if (! app(PricelistFileProcessService::class)->approve($record)) {
|
||||||
|
$this->notifyStateChanged();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Végrehajtás elindítva')
|
||||||
|
->body('Az árlista importja elindult, a haladás ezen az oldalon követhető.')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function rejectAction(): Action
|
||||||
|
{
|
||||||
|
return Action::make('reject')
|
||||||
|
->label('Elutasítás')
|
||||||
|
->icon('heroicon-o-x-circle')
|
||||||
|
->color('danger')
|
||||||
|
->visible(fn (PricelistFile $record) => $this->canDecide($record))
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Árlista elutasítása')
|
||||||
|
->modalDescription('A fájl lezárva státuszba kerül, import nem indul. A művelet nem vonható vissza.')
|
||||||
|
->modalSubmitActionLabel('Elutasítás')
|
||||||
|
->schema([
|
||||||
|
Textarea::make('reason')
|
||||||
|
->label('Elutasítás indoka')
|
||||||
|
->rows(3)
|
||||||
|
->maxLength(1000),
|
||||||
|
])
|
||||||
|
->action(function (PricelistFile $record, array $data) {
|
||||||
|
if (! app(PricelistFileProcessService::class)->reject($record, $data['reason'] ?? null)) {
|
||||||
|
$this->notifyStateChanged();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Árlista elutasítva')
|
||||||
|
->body('A fájl lezárva státuszba került.')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
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;
|
||||||
@ -19,6 +21,71 @@ 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()
|
||||||
@ -47,14 +114,7 @@ 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 => match ($state) {
|
->color(fn (PricelistFileStatusEnum $state): string => $state->color()),
|
||||||
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([
|
||||||
|
|||||||
@ -32,14 +32,7 @@ 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 => match ($state) {
|
->color(fn (PricelistFileStatusEnum $state): string => $state->color())
|
||||||
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')
|
||||||
@ -59,7 +52,10 @@ public static function configure(Table $table): Table
|
|||||||
->recordActions([
|
->recordActions([
|
||||||
ViewAction::make(),
|
ViewAction::make(),
|
||||||
EditAction::make()
|
EditAction::make()
|
||||||
->visible(fn ($record) => $record->status === PricelistFileStatusEnum::fail),
|
// Ha a végrehajtás már elindult, az újratöltés (és a vele járó
|
||||||
|
// teljes lánc-újraindítás) duplikált termékeket/árakat okozna.
|
||||||
|
->visible(fn ($record) => $record->status === PricelistFileStatusEnum::fail
|
||||||
|
&& ! $record->hasExecutionStarted()),
|
||||||
])
|
])
|
||||||
->toolbarActions([
|
->toolbarActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
|
|||||||
@ -338,6 +338,19 @@ private function importPriceList(Request $request): JsonResponse
|
|||||||
$supplierId = $request->input('supplier');
|
$supplierId = $request->input('supplier');
|
||||||
$availableDate = $request->input('availableDate');
|
$availableDate = $request->input('availableDate');
|
||||||
|
|
||||||
|
// Ez a legacy import közvetlenül ír a products táblába, teljesen megkerülve az
|
||||||
|
// árlista feldolgozó modult - ezért itt a legfontosabb a beszállítói blokkolás.
|
||||||
|
// Enélkül egy félig frissített termékhalmazra rétegződne rá egy újabb import.
|
||||||
|
$guard = app(\App\Services\PricelistGuard::class);
|
||||||
|
|
||||||
|
if ($blockingFile = $guard->blockingFileFor((int) $supplierId)) {
|
||||||
|
return \response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $guard->blockingMessage($blockingFile),
|
||||||
|
'res' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$otherField = [];
|
$otherField = [];
|
||||||
$otherField['note'] = $request->input('note');
|
$otherField['note'] = $request->input('note');
|
||||||
$res = $this->service->importPriceList($supplierId, $availableDate, $collection->slice($importStartLine, $importLength)->toArray(), $otherField);
|
$res = $this->service->importPriceList($supplierId, $availableDate, $collection->slice($importStartLine, $importLength)->toArray(), $otherField);
|
||||||
|
|||||||
@ -2,29 +2,53 @@
|
|||||||
|
|
||||||
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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new job instance.
|
* Nincs automatikus újrapróbálkozás: egy félbeszakadt végrehajtás után már
|
||||||
|
* 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.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
43
app/Jobs/PricelistRevertJob.php
Normal file
43
app/Jobs/PricelistRevertJob.php
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<?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.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\PricelistFileLineStatusEnum;
|
||||||
use App\Enums\PricelistFileStatusEnum;
|
use App\Enums\PricelistFileStatusEnum;
|
||||||
|
use App\Enums\PricelistWorkflowStep;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
@ -42,4 +44,132 @@ 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,8 @@ 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
|
||||||
|
|||||||
@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -64,6 +66,47 @@ 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
|
||||||
) {}
|
) {}
|
||||||
@ -106,6 +149,92 @@ 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,
|
||||||
@ -164,7 +293,13 @@ public function updateStepStatus(
|
|||||||
$updateData['status'] = PricelistFileStatusEnum::inprogress;
|
$updateData['status'] = PricelistFileStatusEnum::inprogress;
|
||||||
}
|
}
|
||||||
} elseif ($status === 'failed') {
|
} elseif ($status === 'failed') {
|
||||||
$updateData['status'] = PricelistFileStatusEnum::fail;
|
// A végrehajtás hibája külön státuszt kap: a `fail` a validálásig tartó
|
||||||
|
// 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);
|
||||||
@ -1191,26 +1326,711 @@ protected function runBusinessValidation(PricelistFile $pricelistFile, bool $has
|
|||||||
*/
|
*/
|
||||||
public function execute(PricelistFile $pricelistFile): bool
|
public function execute(PricelistFile $pricelistFile): bool
|
||||||
{
|
{
|
||||||
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'inprogress', 'Árlista frissítése...', 0);
|
$this->reportExecutionProgress($pricelistFile, 0, 'Végrehajtás indítása...');
|
||||||
|
|
||||||
DB::beginTransaction();
|
|
||||||
try {
|
try {
|
||||||
// TODO: Tényleges importálás végrehajtása
|
// Szándékosan NINCS egyetlen, mindent átfogó tranzakció: több ezer sornál a
|
||||||
// $this->priceListService->importPriceList(...);
|
// termék-insert + pivot-insert + visibility update percekig tartana lockokat,
|
||||||
sleep(1); // Szimuláció
|
// és egy timeout minden visszajelzés nélkül dobna el mindent. Helyette
|
||||||
|
// 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->updateStepStatus($pricelistFile, PricelistWorkflowStep::Execution, 'completed', 'Sikeresen befejezve.', 100);
|
$this->createMissingProducers($pricelistFile);
|
||||||
|
$this->createNewProducts($pricelistFile);
|
||||||
|
$this->updateExistingProducts($pricelistFile);
|
||||||
|
$this->attachPrices($pricelistFile, $priceList);
|
||||||
|
$this->finalizeExecution($pricelistFile, $priceList);
|
||||||
|
|
||||||
DB::commit();
|
|
||||||
$pricelistFile->update([
|
|
||||||
'status' => PricelistFileStatusEnum::done,
|
|
||||||
]);
|
|
||||||
return true;
|
return true;
|
||||||
} catch (\Exception $e) {
|
} catch (\Throwable $e) {
|
||||||
DB::rollBack();
|
Log::error('Pricelist execution error: ' . $e->getMessage(), [
|
||||||
Log::error('Pricelist execution error: ' . $e->getMessage());
|
'pricelist_file_id' => $pricelistFile->id,
|
||||||
|
'exception' => $e,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// A lépés 'failed'-re állítása az updateStepStatus-on keresztül a fájlt
|
||||||
|
// execution_failed státuszba teszi (lásd ott a leképezést).
|
||||||
$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`-vá, 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
78
app/Services/PricelistGuard.php
Normal file
78
app/Services/PricelistGuard.php
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Enums\PricelistFileStatusEnum;
|
||||||
|
use App\Models\PricelistFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Egy beszállítóhoz egyszerre legfeljebb egy "nyitott" végrehajtás tartozhat.
|
||||||
|
*
|
||||||
|
* Amíg egy fájl végrehajtása hibára futott (`execution_failed`), a beszállító
|
||||||
|
* termékei félig frissített állapotban vannak: egy részük már az új árlista adatait
|
||||||
|
* hordozza, más részük még a régit. Ha erre az állapotra új árlistát vinnénk fel, a
|
||||||
|
* validálás ehhez a kevert állapothoz számolná a diffeket - egyes sorok tévesen
|
||||||
|
* "rendben"-nek látszanának, mások fölöslegesen "módosítás"-nak -, az import pedig
|
||||||
|
* egy inkonzisztens alapra rétegződne rá.
|
||||||
|
*
|
||||||
|
* A futó (`inprogress`) végrehajtás blokkolása még közvetlenebb: két párhuzamos
|
||||||
|
* import ugyanarra a termékhalmazra nem determinisztikus végeredményt adna.
|
||||||
|
*
|
||||||
|
* FONTOS: ezt a védelmet MINDEN belépési ponton alkalmazni kell (modern Filament
|
||||||
|
* felület, régebbi PriceListProcessor oldal, legacy admin import, konzol parancs) -
|
||||||
|
* ha csak az űrlapot védjük, a blokkolás látszatvédelem marad, mert a beszállítókat
|
||||||
|
* ma még nagyrészt a legacy felületen kezelik.
|
||||||
|
*/
|
||||||
|
class PricelistGuard
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* A beszállítót blokkoló árlista fájl, ha van ilyen.
|
||||||
|
*
|
||||||
|
* @param int|null $exceptFileId ezt a fájlt hagyjuk figyelmen kívül (önmagát ne blokkolja)
|
||||||
|
*/
|
||||||
|
public function blockingFileFor(?int $supplierId, ?int $exceptFileId = null): ?PricelistFile
|
||||||
|
{
|
||||||
|
if (! $supplierId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return PricelistFile::query()
|
||||||
|
->where('supplier_id', $supplierId)
|
||||||
|
->when($exceptFileId, fn ($query) => $query->whereKeyNot($exceptFileId))
|
||||||
|
->whereIn('status', [
|
||||||
|
PricelistFileStatusEnum::execution_failed->value,
|
||||||
|
PricelistFileStatusEnum::inprogress->value,
|
||||||
|
])
|
||||||
|
->latest('id')
|
||||||
|
->get()
|
||||||
|
// Az `inprogress` önmagában nem blokkol: az előfeldolgozás és a validálás
|
||||||
|
// egyetlen terméket sem ír, ezért közben nyugodtan feltölthető másik fájl.
|
||||||
|
// Csak az számít, ahol a végrehajtás már elindult - ezt a workflow_steps
|
||||||
|
// JSON tartalma dönti el, amit SQL-ben nem szűrnénk tisztán, a beszállítónkénti
|
||||||
|
// jelöltek száma viszont elenyésző.
|
||||||
|
->first(fn (PricelistFile $file) => $file->status === PricelistFileStatusEnum::execution_failed
|
||||||
|
|| $file->hasExecutionStarted());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isBlocked(?int $supplierId, ?int $exceptFileId = null): bool
|
||||||
|
{
|
||||||
|
return $this->blockingFileFor($supplierId, $exceptFileId) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Felhasználónak szóló magyarázat: melyik fájl blokkol és miért.
|
||||||
|
*/
|
||||||
|
public function blockingMessage(PricelistFile $blockingFile): string
|
||||||
|
{
|
||||||
|
$state = $blockingFile->status === PricelistFileStatusEnum::execution_failed
|
||||||
|
? 'végrehajtása hibára futott'
|
||||||
|
: 'végrehajtása jelenleg fut';
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'A beszállítóhoz tartozó "%s" árlista (#%d) %s. Amíg ez nincs lezárva (folytatás vagy visszavonás), nem indítható újabb árlista-feldolgozás, mert a termékadatok félig frissített állapotban lehetnek.',
|
||||||
|
$blockingFile->filename,
|
||||||
|
$blockingFile->id,
|
||||||
|
$state,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
<?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");
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
<?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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -46,5 +46,19 @@ 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'],
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
205
tests/Feature/PricelistApprovalTest.php
Normal file
205
tests/Feature/PricelistApprovalTest.php
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\PricelistFileLineStatusEnum;
|
||||||
|
use App\Enums\PricelistFileStatusEnum;
|
||||||
|
use App\Enums\PricelistWorkflowStep;
|
||||||
|
use App\Filament\Resources\PricelistFiles\Pages\ViewPricelistFile;
|
||||||
|
use App\Jobs\PricelistExecutionJob;
|
||||||
|
use App\Models\FeatureFlag;
|
||||||
|
use App\Models\PricelistFile;
|
||||||
|
use App\Models\PricelistFileLine;
|
||||||
|
use App\Models\Role;
|
||||||
|
use App\Models\Supplier;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\FeatureFlagRegistrar;
|
||||||
|
use App\Services\PricelistFileProcessService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jóváhagyásra váró fájl: a validálás lezárult, a jóváhagyás a soron következő lépés.
|
||||||
|
*/
|
||||||
|
function approvableFile(array $attributes = []): PricelistFile
|
||||||
|
{
|
||||||
|
return PricelistFile::create(array_merge([
|
||||||
|
'filename' => 'arlista.xlsx',
|
||||||
|
'supplier_id' => Supplier::factory()->create()->id,
|
||||||
|
'available_date' => now()->addWeek(),
|
||||||
|
'status' => PricelistFileStatusEnum::waiting_for_approval,
|
||||||
|
'workflow_steps' => [
|
||||||
|
['name' => PricelistWorkflowStep::Preprocessing->value, 'label' => 'Előfeldolgozás', 'status' => 'completed'],
|
||||||
|
['name' => PricelistWorkflowStep::Validation->value, 'label' => 'Validálás', 'status' => 'completed'],
|
||||||
|
['name' => PricelistWorkflowStep::Approval->value, 'label' => 'Jóváhagyás', 'status' => 'inprogress'],
|
||||||
|
['name' => PricelistWorkflowStep::Execution->value, 'label' => 'Végrehajtás', 'status' => 'pending'],
|
||||||
|
],
|
||||||
|
], $attributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine(PricelistFile $file, PricelistFileLineStatusEnum $status, int $rowNumber = 5): PricelistFileLine
|
||||||
|
{
|
||||||
|
return PricelistFileLine::create([
|
||||||
|
'pricelist_file_id' => $file->id,
|
||||||
|
'row_number' => $rowNumber,
|
||||||
|
'status' => $status,
|
||||||
|
'payload' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function developerUser(): User
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$user->addRole(Role::create(['name' => 'developer', 'display_name' => 'Developer']));
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function definePricelistExecutionFlag(array $attributes = []): void
|
||||||
|
{
|
||||||
|
FeatureFlag::create(array_merge([
|
||||||
|
'name' => 'PricelistExecution',
|
||||||
|
'label' => 'Árlista végrehajtás',
|
||||||
|
'enabled' => true,
|
||||||
|
'stages' => null,
|
||||||
|
'roles' => ['developer'],
|
||||||
|
], $attributes));
|
||||||
|
|
||||||
|
app(FeatureFlagRegistrar::class)->registerAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a hibátlan, jóváhagyásra váró fájl jóváhagyható', function () {
|
||||||
|
$file = approvableFile();
|
||||||
|
addLine($file, PricelistFileLineStatusEnum::new_product);
|
||||||
|
addLine($file, PricelistFileLineStatusEnum::updated, 6);
|
||||||
|
|
||||||
|
expect($file->canBeApproved())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hibás sor esetén nem hagyható jóvá', function () {
|
||||||
|
$file = approvableFile();
|
||||||
|
addLine($file, PricelistFileLineStatusEnum::ok);
|
||||||
|
addLine($file, PricelistFileLineStatusEnum::error, 6);
|
||||||
|
|
||||||
|
expect($file->canBeApproved())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nem jóváhagyásra váró státuszban nem hagyható jóvá', function () {
|
||||||
|
$file = approvableFile(['status' => PricelistFileStatusEnum::inprogress]);
|
||||||
|
|
||||||
|
expect($file->canBeApproved())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('befejezetlen validálás esetén nem hagyható jóvá', function () {
|
||||||
|
$file = approvableFile([
|
||||||
|
'workflow_steps' => [
|
||||||
|
['name' => PricelistWorkflowStep::Validation->value, 'label' => 'Validálás', 'status' => 'failed'],
|
||||||
|
['name' => PricelistWorkflowStep::Approval->value, 'label' => 'Jóváhagyás', 'status' => 'pending'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($file->canBeApproved())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a jóváhagyás elindítja a végrehajtást és azonnal átbillenti a státuszt', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$file = approvableFile();
|
||||||
|
addLine($file, PricelistFileLineStatusEnum::new_product);
|
||||||
|
|
||||||
|
expect(app(PricelistFileProcessService::class)->approve($file))->toBeTrue();
|
||||||
|
|
||||||
|
Queue::assertPushed(PricelistExecutionJob::class);
|
||||||
|
|
||||||
|
$file->refresh();
|
||||||
|
|
||||||
|
// A státusz még a dispatch előtt átbillen, különben a queue-latency alatt a
|
||||||
|
// Jóváhagyás gomb látszana, és egy második kattintás párhuzamos importot indítana.
|
||||||
|
expect($file->status)->toBe(PricelistFileStatusEnum::inprogress)
|
||||||
|
->and($file->stepStatus(PricelistWorkflowStep::Approval))->toBe('completed')
|
||||||
|
->and($file->stepStatus(PricelistWorkflowStep::Execution))->toBe('inprogress')
|
||||||
|
->and($file->file_meta['approval']['decision'])->toBe('approved');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a már nem jóváhagyható fájlra a jóváhagyás nem indít végrehajtást', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$file = approvableFile();
|
||||||
|
addLine($file, PricelistFileLineStatusEnum::error);
|
||||||
|
|
||||||
|
expect(app(PricelistFileProcessService::class)->approve($file))->toBeFalse();
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
expect($file->refresh()->status)->toBe(PricelistFileStatusEnum::waiting_for_approval);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('az elutasítás lezárja a fájlt indoklással, végrehajtás nélkül', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$file = approvableFile();
|
||||||
|
addLine($file, PricelistFileLineStatusEnum::updated);
|
||||||
|
|
||||||
|
expect(app(PricelistFileProcessService::class)->reject($file, 'Rossz árlistát töltöttek fel.'))->toBeTrue();
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
|
||||||
|
$file->refresh();
|
||||||
|
|
||||||
|
expect($file->status)->toBe(PricelistFileStatusEnum::closed)
|
||||||
|
->and($file->stepStatus(PricelistWorkflowStep::Approval))->toBe('rejected')
|
||||||
|
->and($file->file_meta['approval']['reason'])->toBe('Rossz árlistát töltöttek fel.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a végrehajtás elindulása után a fájl nem tölthető újra', function () {
|
||||||
|
$file = approvableFile();
|
||||||
|
|
||||||
|
expect($file->hasExecutionStarted())->toBeFalse();
|
||||||
|
|
||||||
|
app(PricelistFileProcessService::class)->updateStepStatus($file, PricelistWorkflowStep::Execution, 'failed', 'Hiba.');
|
||||||
|
|
||||||
|
expect($file->refresh()->hasExecutionStarted())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a döntési gombok csak aktív flaggel látszanak', function () {
|
||||||
|
definePricelistExecutionFlag();
|
||||||
|
|
||||||
|
$file = approvableFile();
|
||||||
|
addLine($file, PricelistFileLineStatusEnum::new_product);
|
||||||
|
|
||||||
|
$this->actingAs(developerUser());
|
||||||
|
|
||||||
|
Livewire::test(ViewPricelistFile::class, ['record' => $file->id])
|
||||||
|
->assertActionVisible('approve')
|
||||||
|
->assertActionVisible('reject');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a döntési gombok rejtve maradnak a flag nélküli felhasználónak', function () {
|
||||||
|
definePricelistExecutionFlag();
|
||||||
|
|
||||||
|
$file = approvableFile();
|
||||||
|
addLine($file, PricelistFileLineStatusEnum::new_product);
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$user->addRole(Role::create(['name' => 'admin', 'display_name' => 'Admin']));
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Livewire::test(ViewPricelistFile::class, ['record' => $file->id])
|
||||||
|
->assertActionHidden('approve')
|
||||||
|
->assertActionHidden('reject');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hibás sor esetén a döntési gombok rejtve maradnak flaggel is', function () {
|
||||||
|
definePricelistExecutionFlag();
|
||||||
|
|
||||||
|
$file = approvableFile();
|
||||||
|
addLine($file, PricelistFileLineStatusEnum::error);
|
||||||
|
|
||||||
|
$this->actingAs(developerUser());
|
||||||
|
|
||||||
|
Livewire::test(ViewPricelistFile::class, ['record' => $file->id])
|
||||||
|
->assertActionHidden('approve')
|
||||||
|
->assertActionHidden('reject');
|
||||||
|
});
|
||||||
322
tests/Feature/PricelistExecutionTest.php
Normal file
322
tests/Feature/PricelistExecutionTest.php
Normal file
@ -0,0 +1,322 @@
|
|||||||
|
<?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);
|
||||||
|
});
|
||||||
133
tests/Feature/PricelistGuardTest.php
Normal file
133
tests/Feature/PricelistGuardTest.php
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\PricelistFileLineStatusEnum;
|
||||||
|
use App\Enums\PricelistFileStatusEnum;
|
||||||
|
use App\Enums\PricelistWorkflowStep;
|
||||||
|
use App\Models\PricelistFile;
|
||||||
|
use App\Models\PricelistFileLine;
|
||||||
|
use App\Models\Supplier;
|
||||||
|
use App\Services\PricelistFileProcessService;
|
||||||
|
use App\Services\PricelistGuard;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
function guardTestFile(Supplier $supplier, PricelistFileStatusEnum $status, string $executionStepStatus, array $attributes = []): PricelistFile
|
||||||
|
{
|
||||||
|
return PricelistFile::create(array_merge([
|
||||||
|
'filename' => 'arlista.xlsx',
|
||||||
|
'supplier_id' => $supplier->id,
|
||||||
|
'available_date' => now()->addWeek()->toDateString(),
|
||||||
|
'status' => $status,
|
||||||
|
'workflow_steps' => [
|
||||||
|
['name' => PricelistWorkflowStep::Preprocessing->value, 'label' => 'Előfeldolgozás', 'status' => 'completed'],
|
||||||
|
['name' => PricelistWorkflowStep::Validation->value, 'label' => 'Validálás', 'status' => 'completed'],
|
||||||
|
['name' => PricelistWorkflowStep::Approval->value, 'label' => 'Jóváhagyás', 'status' => 'completed'],
|
||||||
|
['name' => PricelistWorkflowStep::Execution->value, 'label' => 'Végrehajtás', 'status' => $executionStepStatus],
|
||||||
|
],
|
||||||
|
], $attributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->supplier = Supplier::factory()->create();
|
||||||
|
$this->guard = app(PricelistGuard::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a hibára futott végrehajtás blokkolja a beszállítót', function () {
|
||||||
|
$blocking = guardTestFile($this->supplier, PricelistFileStatusEnum::execution_failed, 'failed');
|
||||||
|
|
||||||
|
$found = $this->guard->blockingFileFor($this->supplier->id);
|
||||||
|
|
||||||
|
expect($found)->not->toBeNull()
|
||||||
|
->and($found->id)->toBe($blocking->id)
|
||||||
|
->and($this->guard->blockingMessage($found))->toContain('hibára futott');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a futó végrehajtás blokkolja a beszállítót', function () {
|
||||||
|
guardTestFile($this->supplier, PricelistFileStatusEnum::inprogress, 'inprogress');
|
||||||
|
|
||||||
|
expect($this->guard->isBlocked($this->supplier->id))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a validálás alatt álló fájl NEM blokkol', function () {
|
||||||
|
// Az előfeldolgozás és a validálás egyetlen terméket sem ír, ezért közben
|
||||||
|
// nyugodtan feltölthető másik árlista ugyanahhoz a beszállítóhoz.
|
||||||
|
guardTestFile($this->supplier, PricelistFileStatusEnum::inprogress, 'pending');
|
||||||
|
|
||||||
|
expect($this->guard->isBlocked($this->supplier->id))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a lezárt és elkészült fájlok nem blokkolnak', function () {
|
||||||
|
guardTestFile($this->supplier, PricelistFileStatusEnum::done, 'completed');
|
||||||
|
guardTestFile($this->supplier, PricelistFileStatusEnum::closed, 'reverted');
|
||||||
|
|
||||||
|
expect($this->guard->isBlocked($this->supplier->id))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a blokkolás beszállítónként külön él', function () {
|
||||||
|
guardTestFile($this->supplier, PricelistFileStatusEnum::execution_failed, 'failed');
|
||||||
|
|
||||||
|
$otherSupplier = Supplier::factory()->create();
|
||||||
|
|
||||||
|
expect($this->guard->isBlocked($this->supplier->id))->toBeTrue()
|
||||||
|
->and($this->guard->isBlocked($otherSupplier->id))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a fájl önmagát nem blokkolja', function () {
|
||||||
|
$file = guardTestFile($this->supplier, PricelistFileStatusEnum::execution_failed, 'failed');
|
||||||
|
|
||||||
|
expect($this->guard->isBlocked($this->supplier->id, $file->id))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a visszavonás után a beszállító újra felszabadul', function () {
|
||||||
|
$file = guardTestFile($this->supplier, PricelistFileStatusEnum::execution_failed, 'failed');
|
||||||
|
|
||||||
|
expect($this->guard->isBlocked($this->supplier->id))->toBeTrue();
|
||||||
|
|
||||||
|
$file->update(['status' => PricelistFileStatusEnum::closed]);
|
||||||
|
|
||||||
|
expect($this->guard->isBlocked($this->supplier->id))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blokkolt beszállítónál másik fájl nem hagyható jóvá', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
guardTestFile($this->supplier, PricelistFileStatusEnum::execution_failed, 'failed');
|
||||||
|
|
||||||
|
$waiting = guardTestFile($this->supplier, PricelistFileStatusEnum::waiting_for_approval, 'pending', [
|
||||||
|
'filename' => 'masodik.xlsx',
|
||||||
|
]);
|
||||||
|
|
||||||
|
PricelistFileLine::create([
|
||||||
|
'pricelist_file_id' => $waiting->id,
|
||||||
|
'row_number' => 5,
|
||||||
|
'status' => PricelistFileLineStatusEnum::new_product,
|
||||||
|
'payload' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// A fájl önmagában jóváhagyható lenne - csak a beszállítói blokkolás állítja meg.
|
||||||
|
expect($waiting->canBeApproved())->toBeTrue()
|
||||||
|
->and(app(PricelistFileProcessService::class)->approve($waiting))->toBeFalse();
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
|
||||||
|
expect($waiting->refresh()->status)->toBe(PricelistFileStatusEnum::waiting_for_approval);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a konzolos indítás is elakad blokkolt beszállítónál', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
guardTestFile($this->supplier, PricelistFileStatusEnum::execution_failed, 'failed');
|
||||||
|
|
||||||
|
$todo = guardTestFile($this->supplier, PricelistFileStatusEnum::todo, 'pending', [
|
||||||
|
'filename' => 'harmadik.xlsx',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('pricelist:process', ['id' => $todo->id])
|
||||||
|
->expectsOutputToContain('nem indítható újabb árlista-feldolgozás')
|
||||||
|
->assertExitCode(1);
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
});
|
||||||
292
tests/Feature/PricelistRevertTest.php
Normal file
292
tests/Feature/PricelistRevertTest.php
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
<?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();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user