Egy beszállítóhoz egyszerre legfeljebb egy nyitott végrehajtás tartozhat: egy execution_failed fájl után a termékek félig frissített állapotban vannak, egy új import erre a kevert alapra rétegződne rá (és a validálás is ehhez számolná a diffeket); két párhuzamos végrehajtás pedig nem determinisztikus eredményt adna. - új PricelistGuard service: egyetlen igazságforrás a blokkoláshoz - mind a NÉGY belépési pont véd: modern Filament create, régebbi PriceListProcessor oldal, legacy Admin\PriceListController import, konzol parancs. A legacy import a legfontosabb - az közvetlenül ír a products táblába, megkerülve a modult, és a beszállítókat ma még nagyrészt ott kezelik. - az `inprogress` önmagában nem blokkol: az előfeldolgozás és a validálás egyetlen terméket sem ír, csak a már elindult végrehajtás számít - a beszállító nem tűnik el a select listából, hanem konkrét magyarázatot kap a felhasználó arról, melyik fájl blokkol és miért - blokkolt beszállítónál másik fájl jóváhagyása sem indítható Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
272 lines
12 KiB
PHP
272 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Filament\Resources\PricelistFiles\Pages;
|
|
|
|
use App\Enums\PricelistFileLineStatusEnum;
|
|
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
|
|
{
|
|
protected static string $resource = PricelistFileResource::class;
|
|
|
|
protected function getHeaderActions(): array
|
|
{
|
|
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()
|
|
// 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
|
|
{
|
|
return [
|
|
// Később ide jöhetnek widgetek, pl. statisztika a feldolgozásról
|
|
];
|
|
}
|
|
}
|