diff --git a/.junie/guidelines.md b/.junie/guidelines.md index 5069ec4..e0cbcaa 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -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 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. +- **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 - A JS kód alapja egy egyedi objektumorientált rendszer. diff --git a/app/Enums/PricelistFileLineStatusEnum.php b/app/Enums/PricelistFileLineStatusEnum.php new file mode 100644 index 0000000..749cd22 --- /dev/null +++ b/app/Enums/PricelistFileLineStatusEnum.php @@ -0,0 +1,45 @@ + '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', + }; + } +} diff --git a/app/Enums/PricelistSellerUnitEnum.php b/app/Enums/PricelistSellerUnitEnum.php new file mode 100644 index 0000000..54a5883 --- /dev/null +++ b/app/Enums/PricelistSellerUnitEnum.php @@ -0,0 +1,28 @@ + + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Enums/PricelistUnitEnum.php b/app/Enums/PricelistUnitEnum.php new file mode 100644 index 0000000..7f49e42 --- /dev/null +++ b/app/Enums/PricelistUnitEnum.php @@ -0,0 +1,21 @@ + + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Enums/PricelistVatEnum.php b/app/Enums/PricelistVatEnum.php new file mode 100644 index 0000000..5adc498 --- /dev/null +++ b/app/Enums/PricelistVatEnum.php @@ -0,0 +1,30 @@ + + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * Visszaadja a megjelenítendő címkéket (pl. '5%') + * + * @return array + */ + public static function labels(): array + { + return array_map(fn($case) => $case->value . '%', self::cases()); + } +} diff --git a/app/Filament/Pages/PriceListProcessor.php b/app/Filament/Pages/PriceListProcessor.php index 69446c7..941f4ca 100644 --- a/app/Filament/Pages/PriceListProcessor.php +++ b/app/Filament/Pages/PriceListProcessor.php @@ -87,16 +87,9 @@ public function save() 'supplier_id' => $data['supplier_id'], 'available_date' => $data['available_date'], '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() ->title('Sikeres feltöltés') diff --git a/app/Filament/Resources/PricelistFiles/PricelistFileResource.php b/app/Filament/Resources/PricelistFiles/PricelistFileResource.php index 0a3ccf4..483e4bf 100644 --- a/app/Filament/Resources/PricelistFiles/PricelistFileResource.php +++ b/app/Filament/Resources/PricelistFiles/PricelistFileResource.php @@ -56,7 +56,7 @@ public static function table(Table $table): Table public static function getRelations(): array { return [ - // + RelationManagers\LinesRelationManager::class, ]; } diff --git a/app/Filament/Resources/PricelistFiles/RelationManagers/LinesRelationManager.php b/app/Filament/Resources/PricelistFiles/RelationManagers/LinesRelationManager.php new file mode 100644 index 0000000..39e923d --- /dev/null +++ b/app/Filament/Resources/PricelistFiles/RelationManagers/LinesRelationManager.php @@ -0,0 +1,142 @@ +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( + '' + )), + ]) + ->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( + '
+ %s Ft + %s Ft %s %s%% +
', + 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'); + } +} diff --git a/app/Filament/Resources/PricelistFiles/Schemas/PricelistFileInfolist.php b/app/Filament/Resources/PricelistFiles/Schemas/PricelistFileInfolist.php index 42c6b1a..0413c7a 100644 --- a/app/Filament/Resources/PricelistFiles/Schemas/PricelistFileInfolist.php +++ b/app/Filament/Resources/PricelistFiles/Schemas/PricelistFileInfolist.php @@ -3,11 +3,15 @@ namespace App\Filament\Resources\PricelistFiles\Schemas; 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\Section; +use Filament\Schemas\Components\Text; +use Filament\Infolists\Components\RepeatableEntry; +use Filament\Infolists\Components\TextEntry; use Filament\Schemas\Schema; +use Filament\Support\Enums\FontWeight; +use Filament\Support\Enums\IconPosition; +use Filament\Support\Enums\Width; class PricelistFileInfolist { @@ -17,119 +21,157 @@ public static function configure(Schema $schema): Schema ->components([ Section::make(null) ->poll('5s') + ->columnSpanFull() + ->maxWidth(Width::Full) ->schema([ - Section::make('Fájl információk') + Grid::make(2) + ->columnSpanFull() + ->maxWidth(Width::Full) ->schema([ - Grid::make(3) + Section::make('Fájl információk') ->schema([ - TextEntry::make('filename') - ->label('Fájlnév') - ->icon('heroicon-o-document-text') - ->color('primary'), - TextEntry::make('supplier.name') - ->label('Beszállító') - ->icon('heroicon-o-user'), - TextEntry::make('status') - ->label('Státusz') - ->badge() - ->formatStateUsing(fn (PricelistFileStatusEnum $state): string => $state->label()) - ->color(fn (PricelistFileStatusEnum $state): string => match ($state) { - PricelistFileStatusEnum::todo => 'gray', - PricelistFileStatusEnum::inprogress => 'info', - PricelistFileStatusEnum::done => 'success', - PricelistFileStatusEnum::fail => 'danger', - PricelistFileStatusEnum::waiting_for_approval => 'primary', - PricelistFileStatusEnum::closed => 'warning', - }), + Grid::make(3) + ->schema([ + TextEntry::make('filename') + ->label('Fájlnév') + ->icon('heroicon-o-document-text') + ->color('primary'), + TextEntry::make('supplier.name') + ->label('Beszállító') + ->icon('heroicon-o-user'), + TextEntry::make('status') + ->label('Státusz') + ->badge() + ->formatStateUsing(fn (PricelistFileStatusEnum $state): string => $state->label()) + ->color(fn (PricelistFileStatusEnum $state): string => match ($state) { + PricelistFileStatusEnum::todo => 'gray', + PricelistFileStatusEnum::inprogress => 'info', + PricelistFileStatusEnum::done => 'success', + PricelistFileStatusEnum::fail => 'danger', + PricelistFileStatusEnum::waiting_for_approval => 'primary', + PricelistFileStatusEnum::closed => 'warning', + }), + ]), + Grid::make(3) + ->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([ - 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}%)"), + Grid::make(5)->schema([ + TextEntry::make('lines_count') + ->label('Összes sor') + ->icon('heroicon-o-list-bullet') + ->getStateUsing(fn ($record) => $record->lines()->count()), + TextEntry::make('error_lines_count') + ->label('Hibás sorok') + ->icon('heroicon-o-x-circle') + ->color('danger') + ->getStateUsing(fn ($record) => $record->lines()->where('status', 'error')->count()), + 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(), ]), ]), ]); diff --git a/app/Jobs/PricelistPreProcessJob.php b/app/Jobs/PricelistPreProcessJob.php index ea669c0..0c0d2bf 100644 --- a/app/Jobs/PricelistPreProcessJob.php +++ b/app/Jobs/PricelistPreProcessJob.php @@ -25,6 +25,8 @@ public function __construct( */ public function handle(\App\Services\PricelistFileProcessService $service): void { - $service->preProcess($this->pricelistFile); + if (!$service->preProcess($this->pricelistFile)) { + throw new \Exception('Pricelist pre-processing failed.'); + } } } diff --git a/app/Models/PricelistFile.php b/app/Models/PricelistFile.php index bca5685..23a53e1 100644 --- a/app/Models/PricelistFile.php +++ b/app/Models/PricelistFile.php @@ -4,6 +4,7 @@ use App\Enums\PricelistFileStatusEnum; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; class PricelistFile extends BaseAuditable @@ -36,4 +37,9 @@ public function priceList(): BelongsTo { return $this->belongsTo(PriceList::class); } + + public function lines(): HasMany + { + return $this->hasMany(PricelistFileLine::class); + } } diff --git a/app/Models/PricelistFileLine.php b/app/Models/PricelistFileLine.php new file mode 100644 index 0000000..b82d6b5 --- /dev/null +++ b/app/Models/PricelistFileLine.php @@ -0,0 +1,51 @@ + 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; + } +} diff --git a/app/Services/PricelistFileProcessService.php b/app/Services/PricelistFileProcessService.php index 358f4ab..900bd84 100644 --- a/app/Services/PricelistFileProcessService.php +++ b/app/Services/PricelistFileProcessService.php @@ -6,6 +6,11 @@ use App\Enums\PricelistWorkflowStep; use App\Models\PricelistFile; 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\DB; use Illuminate\Support\Facades\Log; @@ -39,6 +44,8 @@ public function resetWorkflow(PricelistFile $pricelistFile): void */ public function dispatchInitialChain(PricelistFile $pricelistFile): void { + $this->resetWorkflow($pricelistFile); + Bus::chain([ PricelistWorkflowStep::Preprocessing->getJob($pricelistFile), PricelistWorkflowStep::Validation->getJob($pricelistFile), @@ -95,9 +102,13 @@ public function updateStepStatus( $updateData = [ 'workflow_steps' => $steps, - 'processing_current_step' => ($message && $message !== $stepEnum->label()) - ? $stepEnum->label() . ': ' . $message - : $effectiveMessage, + 'processing_current_step' => mb_strcut( + ($message && $message !== $stepEnum->label()) + ? $stepEnum->label() . ': ' . $message + : $effectiveMessage, + 0, + 255 + ), '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 * @return bool */ public function validate(PricelistFile $pricelistFile): bool { - sleep(15); // Szimuláció - $this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Validation, 'inprogress', 'Üzleti szabályok ellenőrzése...', 60); + $startTime = microtime(true); + $this->updateStepStatus($pricelistFile, PricelistWorkflowStep::Validation, 'inprogress', 'Sorok beolvasása és mentése...', 0); try { - // TODO: Validációs logika - sleep(15); // Szimuláció + // Korábbi sorok törlése az újraindíthatóság érdekében + $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) $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 * diff --git a/database/migrations/2026_03_08_222836_create_pricelist_file_lines_table.php b/database/migrations/2026_03_08_222836_create_pricelist_file_lines_table.php new file mode 100644 index 0000000..f4a18dc --- /dev/null +++ b/database/migrations/2026_03_08_222836_create_pricelist_file_lines_table.php @@ -0,0 +1,36 @@ +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'); + } +}; diff --git a/database/migrations/2026_03_08_223838_add_audit_fields_to_pricelist_file_lines_table.php b/database/migrations/2026_03_08_223838_add_audit_fields_to_pricelist_file_lines_table.php new file mode 100644 index 0000000..bb6f687 --- /dev/null +++ b/database/migrations/2026_03_08_223838_add_audit_fields_to_pricelist_file_lines_table.php @@ -0,0 +1,30 @@ +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']); + }); + } +}; diff --git a/database/migrations/2026_03_09_212047_add_relation_ids_to_pricelist_file_lines_table.php b/database/migrations/2026_03_09_212047_add_relation_ids_to_pricelist_file_lines_table.php new file mode 100644 index 0000000..4ca987c --- /dev/null +++ b/database/migrations/2026_03_09_212047_add_relation_ids_to_pricelist_file_lines_table.php @@ -0,0 +1,33 @@ +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']); + }); + } +}; diff --git a/database/migrations/2026_03_11_153652_update_enums_in_pricelist_tables.php b/database/migrations/2026_03_11_153652_update_enums_in_pricelist_tables.php new file mode 100644 index 0000000..a673f2c --- /dev/null +++ b/database/migrations/2026_03_11_153652_update_enums_in_pricelist_tables.php @@ -0,0 +1,33 @@ + "'{$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 + } +};