add PricelistFileLine business logic part1

This commit is contained in:
E98Developer 2026-03-13 08:52:25 +01:00
parent dd22d93d16
commit e4c1ea326d
17 changed files with 1030 additions and 130 deletions

View File

@ -51,6 +51,10 @@ ##### Modernizációs és Design Irányelvek (Filament & Tailwind)
- A legacy és modern részek közötti váltáshoz sima linkeket használunk (teljes oldalújratöltés). - A legacy és modern részek közötti váltáshoz sima linkeket használunk (teljes oldalújratöltés).
- A navigációs linkeknél a `data-no-ajax="true"` attribútumot kell használni, hogy a legacy JS (`app.js`) ne próbálja AJAX-szal betölteni a modern tartalmat. - A navigációs linkeknél a `data-no-ajax="true"` attribútumot kell használni, hogy a legacy JS (`app.js`) ne próbálja AJAX-szal betölteni a modern tartalmat.
- **Közös Elemek (Render Hooks)**: A megszokott elemeket (pl. `speedButtonNavBar`) Filamentben Render Hook-ok segítségével injektáljuk a fejlécbe (`TOPBAR_START`), biztosítva a funkcionális folytonosságot. - **Közös Elemek (Render Hooks)**: A megszokott elemeket (pl. `speedButtonNavBar`) Filamentben Render Hook-ok segítségével injektáljuk a fejlécbe (`TOPBAR_START`), biztosítva a funkcionális folytonosságot.
- **Filament 4 és Schemas rendszer**: A projekt egy egyedi Filament 4 alapú architektúrát és a `Filament\Schemas` rendszert használja.
- **Komponensek**: Sémák (Infolist/Form) definiálásakor elsődlegesen a `Filament\Schemas\Components` namespace alatti osztályokat (Section, Grid, Text stb.) kell használni a standard Filament komponensek helyett a kompatibilitás és a polling működése érdekében.
- **Namespace korrekciók**: Figyeljünk a v3-hoz képest megváltozott namespace-ekre, például a `ViewAction` a `Filament\Actions\ViewAction` alatt érhető el (nem a `Filament\Tables\Actions` vagy `Filament\Infolists\Actions` alatt).
- **Dinamikus frissítés (Polling)**: Az adatok automatikus frissítéséhez a `.poll('5s')` hívást a `Schemas\Components\Section` szinten kell alkalmazni a szülő konténeren, amely biztosítja a teljes nézet újrarenderelését.
##### JavaScript Architektúra és Modulok ##### JavaScript Architektúra és Modulok
- A JS kód alapja egy egyedi objektumorientált rendszer. - A JS kód alapja egy egyedi objektumorientált rendszer.

View File

@ -0,0 +1,45 @@
<?php
namespace App\Enums;
enum PricelistFileLineStatusEnum: string
{
case ok = 'ok';
case warning = 'warning';
case error = 'error';
case new_product = 'new_product';
case updated = 'updated';
public function label(): string
{
return match ($this) {
self::ok => 'rendben',
self::warning => 'figyelmeztetés',
self::error => 'hiba',
self::new_product => 'új termék',
self::updated => 'módosítás',
};
}
public function color(): string
{
return match ($this) {
self::ok => 'success',
self::warning => 'warning',
self::error => 'danger',
self::new_product => 'info',
self::updated => 'primary',
};
}
public function icon(): string
{
return match ($this) {
self::ok => 'heroicon-o-check-circle',
self::warning => 'heroicon-o-exclamation-triangle',
self::error => 'heroicon-o-x-circle',
self::new_product => 'heroicon-o-sparkles',
self::updated => 'heroicon-o-pencil-square',
};
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Enums;
enum PricelistSellerUnitEnum: string
{
case csom = 'csom';
case kart = 'kart';
case rúd = 'rúd';
case db = 'db';
case dob = 'dob';
case zsák = 'zsák';
case tálca = 'tálca';
case kg = 'kg';
case liter = 'liter';
case pár = 'pár';
case tekercs = 'tekercs';
/**
* Visszaadja az összes értéket tömbként
*
* @return array<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Enums;
enum PricelistUnitEnum: string
{
case l = 'l';
case ml = 'ml';
case kg = 'kg';
case g = 'g';
/**
* Visszaadja az összes értéket tömbként
*
* @return array<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Enums;
enum PricelistVatEnum: int
{
case Percent5 = 5;
case Percent18 = 18;
case Percent27 = 27;
/**
* Visszaadja az összes értéket tömbként (5, 18, 27)
*
* @return array<int>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* Visszaadja a megjelenítendő címkéket (pl. '5%')
*
* @return array<string>
*/
public static function labels(): array
{
return array_map(fn($case) => $case->value . '%', self::cases());
}
}

View File

@ -87,16 +87,9 @@ public function save()
'supplier_id' => $data['supplier_id'], 'supplier_id' => $data['supplier_id'],
'available_date' => $data['available_date'], 'available_date' => $data['available_date'],
'note' => $data['note'], 'note' => $data['note'],
'status' => \App\Enums\PricelistFileStatusEnum::todo,
'workflow_steps' => [
['name' => 'preprocess', 'label' => 'Előfeldolgozás', 'status' => 'pending'],
['name' => 'validation', 'label' => 'Validálás', 'status' => 'pending'],
['name' => 'approval', 'label' => 'Jóváhagyás', 'status' => 'pending'],
['name' => 'execution', 'label' => 'Végrehajtás', 'status' => 'pending']
],
]); ]);
PricelistPreProcessJob::dispatch($pricelistFile); app(\App\Services\PricelistFileProcessService::class)->dispatchInitialChain($pricelistFile);
Notification::make() Notification::make()
->title('Sikeres feltöltés') ->title('Sikeres feltöltés')

View File

@ -56,7 +56,7 @@ public static function table(Table $table): Table
public static function getRelations(): array public static function getRelations(): array
{ {
return [ return [
// RelationManagers\LinesRelationManager::class,
]; ];
} }

View File

@ -0,0 +1,142 @@
<?php
namespace App\Filament\Resources\PricelistFiles\RelationManagers;
use App\Enums\PricelistFileLineStatusEnum;
use App\Enums\PricelistFileStatusEnum;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Placeholder;
use Filament\Infolists\Components\TextEntry;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Support\HtmlString;
class LinesRelationManager extends RelationManager
{
protected static string $relationship = 'lines';
protected static ?string $title = 'Feldolgozott sorok';
public function form(Schema $schema): Schema
{
return $schema
->components([
Grid::make(2)
->schema([
TextEntry::make('row_number')
->label('Sor száma'),
TextEntry::make('status')
->label('Státusz')
->badge()
->color(fn (PricelistFileLineStatusEnum $state) => $state->color())
->formatStateUsing(fn (PricelistFileLineStatusEnum $state) => $state->label()),
]),
Section::make('Adatok')
->schema([
KeyValue::make('payload')
->label('Excel adatok')
->columnSpanFull(),
]),
Section::make('Validációs üzenetek')
->schema([
Placeholder::make('validation_messages_list')
->label(false)
->content(fn ($record) => new HtmlString(
'<ul class="list-disc ml-5">' .
collect($record->validation_messages ?? [])->map(fn ($msg) => "<li>$msg</li>")->join('') .
'</ul>'
)),
])
->visible(fn ($record) => !empty($record->validation_messages)),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('id')
->columns([
TextColumn::make('row_number')
->label('#')
->sortable()
->width('50px'),
TextColumn::make('status')
->label('Státusz')
->badge()
->color(fn (PricelistFileLineStatusEnum $state) => $state->color())
->formatStateUsing(fn (PricelistFileLineStatusEnum $state) => $state->label())
->icon(fn (PricelistFileLineStatusEnum $state) => $state->icon())
->sortable(),
TextColumn::make('product_info')
->label('Termék')
->description(fn ($record) => $record->payload['Termék megnevezése'] ?? '')
->getStateUsing(fn ($record) => $record->payload['Szállítói cikkszám'] ?? 'N/A')
->searchable(query: function ($query, string $search) {
$query->where('payload->Szállítói cikkszám', 'like', "%{$search}%")
->orWhere('payload->Termék megnevezése', 'like', "%{$search}%");
}),
TextColumn::make('price_diff')
->label('Árváltozás')
->html()
->getStateUsing(function ($record) {
$oldPrice = $record->diff['changes']['price']['old'] ?? null;
$newPrice = $record->payload['Új számlázási nettó ár'] ?? null;
if ($oldPrice === null) {
return number_format((float)$newPrice, 2, ',', ' ') . ' Ft';
}
$diffPercent = (($newPrice / $oldPrice) - 1) * 100;
$colorClass = $diffPercent > 0 ? 'text-danger-600' : ($diffPercent < 0 ? 'text-success-600' : 'text-gray-600');
$icon = $diffPercent > 0 ? '↑' : ($diffPercent < 0 ? '↓' : '');
return sprintf(
'<div class="flex flex-col">
<span class="text-xs text-gray-500 line-through">%s Ft</span>
<span class="font-bold">%s Ft <span class="%s">%s %s%%</span></span>
</div>',
number_format($oldPrice, 2, ',', ' '),
number_format((float)$newPrice, 2, ',', ' '),
$colorClass,
$icon,
number_format(abs($diffPercent), 1, ',', ' ')
);
})
->visible(fn ($livewire) => $livewire->getOwnerRecord()->status !== PricelistFileStatusEnum::todo),
TextColumn::make('validation_messages')
->label('Megjegyzés')
->listWithLineBreaks()
->bulleted()
->color(fn ($state) => collect($state)->contains(fn ($msg) => str_contains($msg, 'Hiányzó') || str_contains($msg, 'Ismeretlen') || str_contains($msg, 'Érvénytelen')) ? 'danger' : 'info')
->getStateUsing(fn ($record) => $record->validation_messages ?? []),
])
->filters([
SelectFilter::make('status')
->label('Státusz')
->options(collect(PricelistFileLineStatusEnum::cases())->mapWithKeys(fn ($e) => [$e->value => $e->label()])),
])
->headerActions([
//
])
->actions([
ViewAction::make()
->slideOver(),
])
->bulkActions([
//
])
->defaultSort('row_number', 'asc')
->striped()
->poll('5s');
}
}

View File

@ -3,11 +3,15 @@
namespace App\Filament\Resources\PricelistFiles\Schemas; namespace App\Filament\Resources\PricelistFiles\Schemas;
use App\Enums\PricelistFileStatusEnum; use App\Enums\PricelistFileStatusEnum;
use Filament\Schemas\Components\Section;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Text;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Support\Enums\FontWeight;
use Filament\Support\Enums\IconPosition;
use Filament\Support\Enums\Width;
class PricelistFileInfolist class PricelistFileInfolist
{ {
@ -17,119 +21,157 @@ public static function configure(Schema $schema): Schema
->components([ ->components([
Section::make(null) Section::make(null)
->poll('5s') ->poll('5s')
->columnSpanFull()
->maxWidth(Width::Full)
->schema([ ->schema([
Section::make('Fájl információk') Grid::make(2)
->columnSpanFull()
->maxWidth(Width::Full)
->schema([ ->schema([
Grid::make(3) Section::make('Fájl információk')
->schema([ ->schema([
TextEntry::make('filename') Grid::make(3)
->label('Fájlnév') ->schema([
->icon('heroicon-o-document-text') TextEntry::make('filename')
->color('primary'), ->label('Fájlnév')
TextEntry::make('supplier.name') ->icon('heroicon-o-document-text')
->label('Beszállító') ->color('primary'),
->icon('heroicon-o-user'), TextEntry::make('supplier.name')
TextEntry::make('status') ->label('Beszállító')
->label('Státusz') ->icon('heroicon-o-user'),
->badge() TextEntry::make('status')
->formatStateUsing(fn (PricelistFileStatusEnum $state): string => $state->label()) ->label('Státusz')
->color(fn (PricelistFileStatusEnum $state): string => match ($state) { ->badge()
PricelistFileStatusEnum::todo => 'gray', ->formatStateUsing(fn (PricelistFileStatusEnum $state): string => $state->label())
PricelistFileStatusEnum::inprogress => 'info', ->color(fn (PricelistFileStatusEnum $state): string => match ($state) {
PricelistFileStatusEnum::done => 'success', PricelistFileStatusEnum::todo => 'gray',
PricelistFileStatusEnum::fail => 'danger', PricelistFileStatusEnum::inprogress => 'info',
PricelistFileStatusEnum::waiting_for_approval => 'primary', PricelistFileStatusEnum::done => 'success',
PricelistFileStatusEnum::closed => 'warning', PricelistFileStatusEnum::fail => 'danger',
}), PricelistFileStatusEnum::waiting_for_approval => 'primary',
PricelistFileStatusEnum::closed => 'warning',
}),
]),
Grid::make(3)
->schema([
TextEntry::make('available_date')
->label('Életbelépés dátuma')
->date('Y.m.d'),
TextEntry::make('created_at')
->label('Feltöltés időpontja')
->dateTime('Y.m.d H:i'),
TextEntry::make('processing_current_step')
->label('Aktuális folyamat')
->weight('bold')
->color('info')
->formatStateUsing(fn ($state, $record) => "$state ({$record->processing_current_step_percentage}%)"),
]),
TextEntry::make('note')
->label('Megjegyzés')
->placeholder('Nincs megjegyzés')
->columnSpanFull(),
]), ]),
Grid::make(3)
Section::make('Validáció statisztika')
->schema([ ->schema([
TextEntry::make('available_date') Grid::make(5)->schema([
->label('Életbelépés dátuma') TextEntry::make('lines_count')
->date('Y.m.d'), ->label('Összes sor')
TextEntry::make('created_at') ->icon('heroicon-o-list-bullet')
->label('Feltöltés időpontja') ->getStateUsing(fn ($record) => $record->lines()->count()),
->dateTime('Y.m.d H:i'), TextEntry::make('error_lines_count')
TextEntry::make('processing_current_step') ->label('Hibás sorok')
->label('Aktuális folyamat') ->icon('heroicon-o-x-circle')
->weight('bold') ->color('danger')
->color('info') ->getStateUsing(fn ($record) => $record->lines()->where('status', 'error')->count()),
->formatStateUsing(fn ($state, $record) => "$state ({$record->processing_current_step_percentage}%)"), TextEntry::make('warning_lines_count')
->label('Figyelmeztetések')
->icon('heroicon-o-exclamation-triangle')
->color('warning')
->getStateUsing(fn ($record) => $record->lines()->where('status', 'warning')->count()),
TextEntry::make('new_lines_count')
->label('Új termékek')
->icon('heroicon-o-sparkles')
->color('info')
->getStateUsing(fn ($record) => $record->lines()->where('status', 'new_product')->count()),
TextEntry::make('updated_lines_count')
->label('Módosult termékek')
->icon('heroicon-o-pencil-square')
->color('primary')
->getStateUsing(fn ($record) => $record->lines()->where('status', 'updated')->count()),
]),
])
->visible(fn ($record) => $record->lines()->exists()),
Section::make('Előfeldolgozás eredménye')
->schema([
Grid::make(4)->schema([
TextEntry::make('file_meta.preprocess.statistics.data_rows')
->label('Adatsorok')
->icon('heroicon-o-table-cells'),
TextEntry::make('file_meta.preprocess.statistics.column_count')
->label('Oszlopok száma'),
TextEntry::make('file_meta.preprocess.file_info.file_size_human')
->label('Fájlméret'),
TextEntry::make('file_meta.preprocess.duration_seconds')
->label('Feldolgozási idő')
->suffix(' mp'),
]),
RepeatableEntry::make('file_meta.preprocess.warnings')
->label('Figyelmeztetések')
->schema([
TextEntry::make('message')->label('Leírás')->color('warning'),
TextEntry::make('severity')->label('Súlyosság')->badge()->color('warning'),
])
->visible(fn ($record) => !empty($record->file_meta['preprocess']['warnings'] ?? [])),
RepeatableEntry::make('file_meta.preprocess.errors')
->label('Hibák')
->schema([
TextEntry::make('message')->label('Leírás')->color('danger'),
TextEntry::make('severity')->label('Súlyosság')->badge()->color('danger'),
])
->visible(fn ($record) => !empty($record->file_meta['preprocess']['errors'] ?? [])),
])
->visible(fn ($record) => !empty($record->file_meta['preprocess'] ?? [])),
Section::make('Feldolgozási folyamat (Workflow)')
->schema([
RepeatableEntry::make('workflow_steps')
->label(false)
->schema([
TextEntry::make('label')
->label('Lépés')
->weight('bold')
->inlineLabel(),
TextEntry::make('status')
->label('Állapot')
->badge()
->inlineLabel()
->formatStateUsing(fn (string $state): string => match ($state) {
'pending' => 'várakozik',
'inprogress' => 'folyamatban',
'completed' => 'kész',
'failed' => 'hiba',
default => $state,
})
->color(fn (string $state): string => match ($state) {
'pending' => 'gray',
'inprogress' => 'info',
'completed' => 'success',
'failed' => 'danger',
default => 'gray',
})
->icon(fn (string $state): string => match ($state) {
'pending' => 'heroicon-o-clock',
'inprogress' => 'heroicon-o-arrow-path',
'completed' => 'heroicon-o-check-circle',
'failed' => 'heroicon-o-x-circle',
default => 'heroicon-o-question-mark-circle',
}),
])
->columns(2)
->columnSpanFull(),
]), ]),
TextEntry::make('note')
->label('Megjegyzés')
->placeholder('Nincs megjegyzés')
->columnSpanFull(),
]),
Section::make('Előfeldolgozás eredménye')
->schema([
Grid::make(4)->schema([
TextEntry::make('file_meta.preprocess.statistics.data_rows')
->label('Adatsorok')
->icon('heroicon-o-table-cells'),
TextEntry::make('file_meta.preprocess.statistics.column_count')
->label('Oszlopok száma'),
TextEntry::make('file_meta.preprocess.file_info.file_size_human')
->label('Fájlméret'),
TextEntry::make('file_meta.preprocess.duration_seconds')
->label('Feldolgozási idő')
->suffix(' mp'),
]),
RepeatableEntry::make('file_meta.preprocess.warnings')
->label('Figyelmeztetések')
->schema([
TextEntry::make('message')->label('Leírás')->color('warning'),
TextEntry::make('severity')->label('Súlyosság')->badge()->color('warning'),
])
->visible(fn ($record) => !empty($record->file_meta['preprocess']['warnings'] ?? [])),
RepeatableEntry::make('file_meta.preprocess.errors')
->label('Hibák')
->schema([
TextEntry::make('message')->label('Leírás')->color('danger'),
TextEntry::make('severity')->label('Súlyosság')->badge()->color('danger'),
])
->visible(fn ($record) => !empty($record->file_meta['preprocess']['errors'] ?? [])),
])
->visible(fn ($record) => !empty($record->file_meta['preprocess'] ?? [])),
Section::make('Feldolgozási folyamat (Workflow)')
->schema([
RepeatableEntry::make('workflow_steps')
->label(false)
->schema([
TextEntry::make('label')
->label('Lépés')
->weight('bold')
->inlineLabel(),
TextEntry::make('status')
->label('Állapot')
->badge()
->inlineLabel()
->formatStateUsing(fn (string $state): string => match ($state) {
'pending' => 'várakozik',
'inprogress' => 'folyamatban',
'completed' => 'kész',
'failed' => 'hiba',
default => $state,
})
->color(fn (string $state): string => match ($state) {
'pending' => 'gray',
'inprogress' => 'info',
'completed' => 'success',
'failed' => 'danger',
default => 'gray',
})
->icon(fn (string $state): string => match ($state) {
'pending' => 'heroicon-o-clock',
'inprogress' => 'heroicon-o-arrow-path',
'completed' => 'heroicon-o-check-circle',
'failed' => 'heroicon-o-x-circle',
default => 'heroicon-o-question-mark-circle',
}),
])
->columns(2)
->columnSpanFull(),
]), ]),
]), ]),
]); ]);

View File

@ -25,6 +25,8 @@ public function __construct(
*/ */
public function handle(\App\Services\PricelistFileProcessService $service): void public function handle(\App\Services\PricelistFileProcessService $service): void
{ {
$service->preProcess($this->pricelistFile); if (!$service->preProcess($this->pricelistFile)) {
throw new \Exception('Pricelist pre-processing failed.');
}
} }
} }

View File

@ -4,6 +4,7 @@
use App\Enums\PricelistFileStatusEnum; use App\Enums\PricelistFileStatusEnum;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
class PricelistFile extends BaseAuditable class PricelistFile extends BaseAuditable
@ -36,4 +37,9 @@ public function priceList(): BelongsTo
{ {
return $this->belongsTo(PriceList::class); return $this->belongsTo(PriceList::class);
} }
public function lines(): HasMany
{
return $this->hasMany(PricelistFileLine::class);
}
} }

View File

@ -0,0 +1,51 @@
<?php
namespace App\Models;
use App\Enums\PricelistFileLineStatusEnum;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PricelistFileLine extends BaseAuditable
{
protected $casts = [
'status' => PricelistFileLineStatusEnum::class,
'row_number' => 'integer',
'product_id' => 'integer',
'product_group_id' => 'integer',
'producer_id' => 'integer',
'payload' => 'array',
'validation_messages' => 'array',
'diff' => 'array',
];
public function pricelistFile(): BelongsTo
{
return $this->belongsTo(PricelistFile::class);
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function productGroup(): BelongsTo
{
return $this->belongsTo(ProductGroup::class);
}
public function producer(): BelongsTo
{
return $this->belongsTo(Producer::class);
}
/**
* Hibaüzenet hozzáadása a sorhoz
*/
public function addValidationError(string $field, string $message): void
{
$messages = $this->validation_messages ?? [];
$messages[$field] = $message;
$this->validation_messages = $messages;
$this->status = PricelistFileLineStatusEnum::error;
}
}

View File

@ -6,6 +6,11 @@
use App\Enums\PricelistWorkflowStep; use App\Enums\PricelistWorkflowStep;
use App\Models\PricelistFile; use App\Models\PricelistFile;
use App\Enums\PricelistFileStatusEnum; use App\Enums\PricelistFileStatusEnum;
use App\Models\PricelistFileLine;
use App\Enums\PricelistFileLineStatusEnum;
use App\Enums\PricelistUnitEnum;
use App\Enums\PricelistSellerUnitEnum;
use App\Enums\PricelistVatEnum;
use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@ -39,6 +44,8 @@ public function resetWorkflow(PricelistFile $pricelistFile): void
*/ */
public function dispatchInitialChain(PricelistFile $pricelistFile): void public function dispatchInitialChain(PricelistFile $pricelistFile): void
{ {
$this->resetWorkflow($pricelistFile);
Bus::chain([ Bus::chain([
PricelistWorkflowStep::Preprocessing->getJob($pricelistFile), PricelistWorkflowStep::Preprocessing->getJob($pricelistFile),
PricelistWorkflowStep::Validation->getJob($pricelistFile), PricelistWorkflowStep::Validation->getJob($pricelistFile),
@ -95,9 +102,13 @@ public function updateStepStatus(
$updateData = [ $updateData = [
'workflow_steps' => $steps, 'workflow_steps' => $steps,
'processing_current_step' => ($message && $message !== $stepEnum->label()) 'processing_current_step' => mb_strcut(
? $stepEnum->label() . ': ' . $message ($message && $message !== $stepEnum->label())
: $effectiveMessage, ? $stepEnum->label() . ': ' . $message
: $effectiveMessage,
0,
255
),
'processing_current_step_percentage' => $percentage, 'processing_current_step_percentage' => $percentage,
]; ];
@ -410,23 +421,82 @@ private function humanFileSize(int $bytes): string
} }
/** /**
* Validálás (Validation) - Üzleti szabályok ellenőrzése * Validálás (Validation) - Üzleti szabályok ellenőrzése és sorok rögzítése
* *
* @param PricelistFile $pricelistFile * @param PricelistFile $pricelistFile
* @return bool * @return bool
*/ */
public function validate(PricelistFile $pricelistFile): bool public function validate(PricelistFile $pricelistFile): bool
{ {
sleep(15); // Szimuláció $startTime = microtime(true);
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Validation, 'inprogress', 'Üzleti szabályok ellenőrzése...', 60); $this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Validation, 'inprogress', 'Sorok beolvasása és mentése...', 0);
try { try {
// TODO: Validációs logika // Korábbi sorok törlése az újraindíthatóság érdekében
sleep(15); // Szimuláció $pricelistFile->lines()->delete();
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Validation, 'completed', 'Validálás sikeres.', 100); $fullPath = Storage::disk('public')->path($pricelistFile->filename);
$spreadsheet = IOFactory::load($fullPath);
$worksheet = $spreadsheet->getActiveSheet();
$highestRow = $worksheet->getHighestDataRow();
$expectedHeaders = PriceListService::EXPECTED_HEADERS;
$batchSize = 50;
$dataToInsert = [];
$processedCount = 0;
$totalDataRows = $highestRow >= 5 ? ($highestRow - 4) : 0;
if ($totalDataRows === 0) {
throw new \Exception('A fájl nem tartalmaz feldolgozható adatsort az 5. sortól.');
}
for ($row = 5; $row <= $highestRow; $row++) {
$rowData = [];
$hasData = false;
foreach ($expectedHeaders as $index => $label) {
$value = $worksheet->getCellByColumnAndRow($index + 1, $row)->getValue();
$rowData[$label] = $value;
if (!empty(trim((string)$value))) {
$hasData = true;
}
}
// Csak akkor mentjük, ha a sor nem teljesen üres
if ($hasData) {
$dataToInsert[] = [
'pricelist_file_id' => $pricelistFile->id,
'row_number' => $row,
'status' => PricelistFileLineStatusEnum::ok->value,
'payload' => json_encode($rowData),
'created_at' => now(),
'updated_at' => now(),
// A BaseAuditable trait miatt ezeket is érdemes lehet kitölteni,
// de a tömeges beszúrásnál (insert) nem futnak le a boot események.
'created_by' => auth()->id() ?? 1,
];
}
$processedCount++;
if (count($dataToInsert) >= $batchSize) {
PricelistFileLine::insert($dataToInsert);
$dataToInsert = [];
$percentage = round(($processedCount / $totalDataRows) * 100);
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Validation, 'inprogress', "Sorok mentése ({$processedCount}/{$totalDataRows})...", $percentage);
}
}
if (!empty($dataToInsert)) {
PricelistFileLine::insert($dataToInsert);
}
$this->runBusinessValidation($pricelistFile);
$duration = round(microtime(true) - $startTime, 2);
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Validation, 'completed', "Sikeres validálás ({$processedCount} sor, {$duration} mp).", 100);
sleep(15); // Szimuláció
// A validálás végén átadjuk a lépést a jóváhagyásnak (Approval) // A validálás végén átadjuk a lépést a jóváhagyásnak (Approval)
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Approval, 'inprogress'); $this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Approval, 'inprogress');
@ -438,6 +508,340 @@ public function validate(PricelistFile $pricelistFile): bool
} }
} }
/**
* Szöveg normalizálása az összehasonlításhoz (mb_strtoupper, trim, speciális karakterek kezelése)
*/
protected function normalizeForComparison(string $text): string
{
// 1. Kis/Nagybetű érzéketlenség UTF-8 támogatással
$text = mb_strtoupper(trim($text), 'UTF-8');
// 2. Szeparátorok egységesítése (perjel és alulvonás cseréje)
// A hibaüzenetben látható alulvonás és a felhasználó által említett perjel miatt
$text = str_replace(['/', '_'], ' ', $text);
// 3. Felesleges szóközök eltávolítása (ha a csere után több szóköz maradt)
$text = preg_replace('/\s+/', ' ', $text);
return trim($text);
}
/**
* Termékcsoport lookup tábla felépítése az adatbázisból (hierarchia útvonala -> id)
*/
protected function getProductGroupLookupMap(): array
{
$allGroups = (new \App\Models\ProductGroup)->allWithPath(3);
$groupLookup = [];
foreach ($allGroups as $group) {
$parentsNames = $group['parentsNames'] ?? [];
// Technikai gyökér elhagyása (0. szint, pl. "Főcsoportok")
if (isset($group['depth']) && $group['depth'] > 0 && !empty($parentsNames)) {
array_shift($parentsNames);
}
$pathParts = array_merge($parentsNames, [$group['name']]);
$normalizedParts = array_map(fn($v) => $this->normalizeForComparison((string)$v), $pathParts);
$pathKey = implode('|', $normalizedParts);
$groupLookup[$pathKey] = $group['id'];
}
return $groupLookup;
}
/**
* Gyártó lookup tábla felépítése (név -> id)
*/
protected function getProducerLookupMap(): array
{
return \App\Models\Producer::all()
->keyBy(fn($p) => $this->normalizeForComparison($p->name))
->map->id
->toArray();
}
/**
* Termék lookup tábla felépítése a beszállítóhoz (cikkszám -> id)
*/
protected function getProductLookupMap(int $supplierId): array
{
return \App\Models\Product::where('supplier_id', $supplierId)
->whereNotNull('supplierProductNumber')
->get()
->keyBy('supplierProductNumber')
->toArray();
}
/**
* Termékcsoport útvonal összeállítása a payload-ból
*/
protected function getRowPathFromPayload(array $payload): string
{
$pathComponents = [
$payload[PriceListService::EXPECTED_HEADERS[1]] ?? null,
$payload[PriceListService::EXPECTED_HEADERS[2]] ?? null,
$payload[PriceListService::EXPECTED_HEADERS[3]] ?? null
];
return collect($pathComponents)
->filter(fn($v) => !empty(trim((string)$v)))
->map(fn($v) => $this->normalizeForComparison((string)$v))
->implode('|');
}
/**
* Termékcsoport validáció egy sorra
*/
protected function validateProductGroup(string $rowPath, array $groupLookup): ?string
{
if (empty($rowPath)) {
return "Hiányzó termékcsoport adatok.";
}
if (!isset($groupLookup[$rowPath])) {
return "Ismeretlen termékcsoport útvonal: $rowPath";
}
return null;
}
/**
* Mértékegység validáció egy sorra
*/
protected function validateUnit(array $payload): ?string
{
$unitValue = $payload[PriceListService::EXPECTED_HEADERS[7]] ?? null;
if (empty(trim((string)$unitValue))) {
return "Hiányzó mértékegység.";
}
$normalizedUnit = mb_strtolower(trim((string)$unitValue));
if (!PricelistUnitEnum::tryFrom($normalizedUnit)) {
return "Érvénytelen mértékegység: $unitValue. Elfogadott: " . implode(', ', PricelistUnitEnum::values());
}
return null;
}
/**
* Legkisebb eladási egység validáció egy sorra
*/
protected function validateSellerUnit(array $payload): ?string
{
$sellerUnitValue = $payload[PriceListService::EXPECTED_HEADERS[9]] ?? null;
if (empty(trim((string)$sellerUnitValue))) {
return "Hiányzó legkisebb eladási egység.";
}
$normalizedUnit = mb_strtolower(trim((string)$sellerUnitValue));
if (!PricelistSellerUnitEnum::tryFrom($normalizedUnit)) {
return "Érvénytelen legkisebb eladási egység: $sellerUnitValue. Elfogadott: " . implode(', ', PricelistSellerUnitEnum::values());
}
return null;
}
/**
* Mennyiségi egység validáció egy sorra
*/
protected function validateAmountUnit(array $payload): ?string
{
$amountUnitValue = $payload[PriceListService::EXPECTED_HEADERS[11]] ?? null;
if (empty(trim((string)$amountUnitValue))) {
return "Hiányzó mennyiségi egység.";
}
$normalizedUnit = mb_strtolower(trim((string)$amountUnitValue));
if (!PricelistSellerUnitEnum::tryFrom($normalizedUnit)) {
return "Érvénytelen mennyiségi egység: $amountUnitValue. Elfogadott: " . implode(', ', PricelistSellerUnitEnum::values());
}
return null;
}
/**
* ÁFA % validáció egy sorra
*/
protected function validateVat(array $payload): ?string
{
$vatValue = $payload[PriceListService::EXPECTED_HEADERS[12]] ?? null;
if ($vatValue === null || (is_string($vatValue) && trim($vatValue) === '')) {
return "Hiányzó ÁFA %.";
}
// Kinyerjük a numerikus értéket, kezelve a százalékjelet, szóközöket és a tizedesvesszőt
$numericValue = (float) str_replace(['%', ' ', ','], ['', '', '.'], (string)$vatValue);
// Ha az érték 0 és 1 közé esik (pl. 0.05, 0.27), akkor valószínűleg Excel százalék formátum
// Ebben az esetben megszorozzuk 100-zal, hogy megkapjuk az egész értéket (pl. 5, 27)
if ($numericValue > 0 && $numericValue <= 1) {
$numericValue *= 100;
}
$finalVat = (int) round($numericValue);
if (!PricelistVatEnum::tryFrom($finalVat)) {
return "Érvénytelen ÁFA %: $vatValue. Elfogadott: " . implode(', ', PricelistVatEnum::labels());
}
return null;
}
/**
* Gyártó validáció egy sorra
*/
protected function validateProducer(array $payload, array $producerLookup): ?string
{
$producerName = $payload[PriceListService::EXPECTED_HEADERS[8]] ?? null;
if (empty(trim((string)$producerName))) {
return "Hiányzó gyártó megnevezés.";
}
$normalizedName = $this->normalizeForComparison((string)$producerName);
if (!isset($producerLookup[$normalizedName])) {
return "NEW_PRODUCER";
}
return null;
}
/**
* Üzleti validáció futtatása a mentett sorokon (Batch feldolgozás)
*
* @param PricelistFile $pricelistFile
*/
protected function runBusinessValidation(PricelistFile $pricelistFile): void
{
$this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Validation, 'inprogress', 'Üzleti validálás...', 100);
$groupLookup = $this->getProductGroupLookupMap();
$producerLookup = $this->getProducerLookupMap();
$productLookup = $this->getProductLookupMap($pricelistFile->supplier_id);
$pricelistFile->lines()->chunk(1000, function ($lines) use ($groupLookup, $producerLookup, $productLookup) {
$updates = [];
foreach ($lines as $line) {
$payload = $line->payload;
$validationMessages = [];
// Termék ID kinyerése (cikkszám alapján)
$sku = (string)($payload[PriceListService::EXPECTED_HEADERS[0]] ?? '');
$existingProduct = $productLookup[$sku] ?? null;
$productId = $existingProduct['id'] ?? null;
// Termékcsoport útvonal és ID kinyerése
$rowPath = $this->getRowPathFromPayload($payload);
$groupId = $groupLookup[$rowPath] ?? null;
// Gyártó ID kinyerése
$producerName = $payload[PriceListService::EXPECTED_HEADERS[8]] ?? null;
$producerId = $producerLookup[$this->normalizeForComparison((string)$producerName)] ?? null;
// Különálló validációk meghívása
if ($groupError = $this->validateProductGroup($rowPath, $groupLookup)) {
$validationMessages['product_group'] = $groupError;
}
$hasNewProducer = false;
if ($producerError = $this->validateProducer($payload, $producerLookup)) {
if ($producerError === 'NEW_PRODUCER') {
$validationMessages['producer'] = "Új gyártó: " . ($payload[PriceListService::EXPECTED_HEADERS[8]] ?? '');
$hasNewProducer = true;
} else {
$validationMessages['producer'] = $producerError;
}
}
if ($unitError = $this->validateUnit($payload)) {
$validationMessages['product_unit'] = $unitError;
}
if ($sellerUnitError = $this->validateSellerUnit($payload)) {
$validationMessages['seller_unit'] = $sellerUnitError;
}
if ($amountUnitError = $this->validateAmountUnit($payload)) {
$validationMessages['amount_unit'] = $amountUnitError;
}
if ($vatError = $this->validateVat($payload)) {
$validationMessages['vat'] = $vatError;
}
// Státusz meghatározása
$hasError = false;
foreach ($validationMessages as $key => $msg) {
if (str_contains($msg, 'Hiányzó') || str_contains($msg, 'Ismeretlen') || str_contains($msg, 'Érvénytelen')) {
$hasError = true;
break;
}
}
$status = PricelistFileLineStatusEnum::ok;
if ($hasError) {
$status = PricelistFileLineStatusEnum::error;
} elseif ($productId === null) {
// Ha nincs meglévő termék -> Új termék
$status = PricelistFileLineStatusEnum::new_product;
} else {
// Meglévő termék esetén nézzük a módosulásokat
$isUpdated = false;
// 1. Gyártó módosulás
if ($hasNewProducer) {
$isUpdated = true;
} elseif ($producerId !== null && (int)($existingProduct['producer_id'] ?? 0) !== (int)$producerId) {
$isUpdated = true;
$validationMessages['producer_update'] = "Módosult gyártó";
}
// 2. Termékcsoport módosulás
if ($groupId !== null && (int)($existingProduct['product_group_id'] ?? 0) !== (int)$groupId) {
$isUpdated = true;
$validationMessages['product_group_update'] = "Módosult termékcsoport";
}
if ($isUpdated) {
$status = PricelistFileLineStatusEnum::updated;
}
}
$updates[] = [
'id' => $line->id,
'pricelist_file_id' => $line->pricelist_file_id,
'row_number' => $line->row_number,
'status' => $status->value,
'product_id' => $productId,
'product_group_id' => $groupId,
'producer_id' => $producerId,
'payload' => json_encode($line->payload),
'validation_messages' => json_encode($validationMessages),
'created_at' => $line->created_at->toDateTimeString(),
'updated_at' => now()->toDateTimeString(),
];
}
// Kötegelt frissítés (upsert) a teljesítmény optimalizálásához
if (!empty($updates)) {
PricelistFileLine::upsert(
$updates,
['id'], // Egyedi azonosító
['status', 'validation_messages', 'product_id', 'product_group_id', 'producer_id', 'updated_at'] // Frissítendő mezők
);
}
});
}
/** /**
* Végrehajtás (Execution) - Módosítások tényleges alkalmazása tranzakcióban * Végrehajtás (Execution) - Módosítások tényleges alkalmazása tranzakcióban
* *

View File

@ -0,0 +1,36 @@
<?php
use App\Enums\PricelistFileLineStatusEnum;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('pricelist_file_lines', function (Blueprint $table) {
$table->id();
$table->foreignId('pricelist_file_id')->constrained()->cascadeOnDelete();
$table->integer('row_number');
$table->enum('status', array_column(PricelistFileLineStatusEnum::cases(), 'value'))->index();
$table->json('payload');
$table->json('validation_messages')->nullable();
$table->json('diff')->nullable();
$table->timestamps();
$table->index(['pricelist_file_id', 'status']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('pricelist_file_lines');
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('pricelist_file_lines', function (Blueprint $table) {
$table->unsignedInteger('created_by')->nullable()->after('updated_at');
$table->unsignedInteger('updated_by')->nullable()->after('created_by');
$table->unsignedInteger('deleted_by')->nullable()->after('updated_by');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('pricelist_file_lines', function (Blueprint $table) {
$table->dropColumn(['created_by', 'updated_by', 'deleted_by']);
});
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('pricelist_file_lines', function (Blueprint $table) {
$table->foreignId('product_id')->nullable()->constrained('products')->nullOnDelete();
$table->foreignId('product_group_id')->nullable()->constrained('product_groups')->nullOnDelete();
$table->foreignId('producer_id')->nullable()->constrained('producers')->nullOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('pricelist_file_lines', function (Blueprint $table) {
$table->dropForeign(['product_id']);
$table->dropForeign(['product_group_id']);
$table->dropForeign(['producer_id']);
$table->dropColumn(['product_id', 'product_group_id', 'producer_id']);
});
}
};

View File

@ -0,0 +1,33 @@
<?php
use App\Enums\PricelistFileStatusEnum;
use App\Enums\PricelistFileLineStatusEnum;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 1. PricelistFileStatusEnum frissítése a pricelist_files táblában
$fileStatuses = array_map(fn($case) => "'{$case->value}'", PricelistFileStatusEnum::cases());
$fileStatusesStr = implode(', ', $fileStatuses);
DB::statement("ALTER TABLE pricelist_files MODIFY COLUMN status ENUM({$fileStatusesStr}) DEFAULT 'todo' NOT NULL");
// 2. PricelistFileLineStatusEnum frissítése a pricelist_file_lines táblában
$lineStatuses = array_map(fn($case) => "'{$case->value}'", PricelistFileLineStatusEnum::cases());
$lineStatusesStr = implode(', ', $lineStatuses);
DB::statement("ALTER TABLE pricelist_file_lines MODIFY COLUMN status ENUM({$lineStatusesStr}) NOT NULL");
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// A visszagörgetésnél nem csinálunk semmit, mert az enumok csökkentése adatvesztéssel járhatna
}
};