From 3cfaa6c9d55198ca71528073892ab5a4850f0595 Mon Sep 17 00:00:00 2001 From: E98Developer Date: Mon, 24 Aug 2026 13:51:11 +0200 Subject: [PATCH] =?UTF-8?q?FIX=20EV3-357=20rich=20text=20cell=C3=A1k=20kez?= =?UTF-8?q?el=C3=A9se=20az=20=C3=A1rlista=20beolvas=C3=A1sban?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A d2d-n a megrendelő fájljának megnyitásakor a sorok táblája eltört: TextColumn::getDescriptionBelow(): Return value must be of type Htmlable|string|null, array returned. Nem UI-hiba volt, hanem adatvesztés. Ha egy cellán belül több formázású szövegrész van (pl. a terméknév egy része félkövér), a PhpSpreadsheet getValue()-ja RichText objektumot ad vissza. A json_encode ezt ÜRESRE serializálja (a szövegrészek protected propertyben vannak), így a payloadba {} kerül, ami visszaolvasva üres tömb - a cella tartalma nyomtalanul eltűnt, a felület pedig tömböt kapott string helyett. - normalizeCellValue(): a cella értéke a payloadba kerülés ELŐTT skalárrá alakul (RichText -> getPlainText, dátum -> szöveg, tömb -> összefűzött szöveg, __toString-es objektum -> string) - LinesRelationManager::payloadText(): a MÁR ELMENTETT rekordokban maradt tömbök nem törhetik el a nézetet - a beolvasási javítás azokat visszamenőleg nem gyógyítja meg - isBlankPayloadValue(): a kötelező mező ellenőrzése is_string() alapú volt, ezért az üres tömböt KITÖLTÖTTNEK látta - egy elveszett terméknév némán átment a validáláson. Ugyanez a védelem a Hooreyca-blokk vizsgálatában és a végrehajtás mezőkonverziójában is (utóbbi különben szó szerint "Array" néven hozta volna létre a terméket). Co-Authored-By: Claude Opus 5 --- .../RelationManagers/LinesRelationManager.php | 28 ++++- app/Services/PricelistFileProcessService.php | 73 +++++++++++- tests/Feature/PricelistRichTextTest.php | 108 ++++++++++++++++++ 3 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 tests/Feature/PricelistRichTextTest.php diff --git a/app/Filament/Resources/PricelistFiles/RelationManagers/LinesRelationManager.php b/app/Filament/Resources/PricelistFiles/RelationManagers/LinesRelationManager.php index 4d61518..a310480 100644 --- a/app/Filament/Resources/PricelistFiles/RelationManagers/LinesRelationManager.php +++ b/app/Filament/Resources/PricelistFiles/RelationManagers/LinesRelationManager.php @@ -162,6 +162,30 @@ public function form(Schema $schema): Schema ]); } + /** + * Egy payload mező szövegként, a nézet eltörése nélkül. + * + * A régebbi rekordokban a rich text cellák ÜRES TÖMBKÉNT landoltak a payloadban + * (a `json_encode` a RichText objektumot `{}`-ra serializálta). A TextColumn + * `description()`-je viszont csak stringet vagy Htmlable-t fogad el, tömbre + * TypeError-ral elszáll, és ilyenkor a teljes sorok-blokk nem jelenik meg. + * A beolvasás azóta normalizál (PricelistFileProcessService::normalizeCellValue), + * de a MÁR ELMENTETT sorok csak így maradnak megjeleníthetők. + */ + protected static function payloadText($record, int $headerIndex): string + { + $value = $record->payload[PriceListService::EXPECTED_HEADERS[$headerIndex]] ?? null; + + if (is_array($value)) { + $value = implode(' ', array_filter( + \Illuminate\Support\Arr::flatten($value), + fn ($item) => is_scalar($item), + )); + } + + return is_scalar($value) ? trim((string) $value) : ''; + } + public function table(Table $table): Table { return $table @@ -182,8 +206,8 @@ public function table(Table $table): Table TextColumn::make('product_info') ->label('Termék') - ->description(fn ($record) => $record->payload[PriceListService::EXPECTED_HEADERS[4]] ?? '') - ->getStateUsing(fn ($record) => $record->payload[PriceListService::EXPECTED_HEADERS[0]] ?? 'N/A') + ->description(fn ($record) => self::payloadText($record, 4)) + ->getStateUsing(fn ($record) => self::payloadText($record, 0) ?: 'N/A') ->color(fn ($record) => !empty($record->diff) ? 'info' : null) ->wrap() ->searchable(query: function ($query, string $search) { diff --git a/app/Services/PricelistFileProcessService.php b/app/Services/PricelistFileProcessService.php index 1ab04fd..beb27e6 100644 --- a/app/Services/PricelistFileProcessService.php +++ b/app/Services/PricelistFileProcessService.php @@ -740,8 +740,8 @@ public function validate(PricelistFile $pricelistFile): bool } } - $rowData[$label] = $value; - if (!empty(trim((string)$value))) { + $rowData[$label] = $this->normalizeCellValue($value); + if (!empty(trim((string)$rowData[$label]))) { $hasData = true; } } @@ -1074,7 +1074,7 @@ protected function runBusinessValidation(PricelistFile $pricelistFile, bool $has foreach ($mandatoryIndexes as $mIndex) { $mLabel = PriceListService::EXPECTED_HEADERS[$mIndex]; $mValue = $payload[$mLabel] ?? null; - if ($mValue === null || (is_string($mValue) && trim($mValue) === '')) { + if ($this->isBlankPayloadValue($mValue)) { // Megfelelő kulcsot választunk, hogy a specifikus validátorok felülírhassák $mKey = match($mIndex) { 7 => 'product_unit', @@ -1095,7 +1095,7 @@ protected function runBusinessValidation(PricelistFile $pricelistFile, bool $has foreach ($hooreycaIndexes as $hIndex) { $hLabel = PriceListService::EXPECTED_HEADERS[$hIndex]; $hValue = $payload[$hLabel] ?? null; - if ($hValue !== null && !(is_string($hValue) && trim($hValue) === '')) { + if (! $this->isBlankPayloadValue($hValue)) { $hasAnyHooreyca = true; $hooreycaData[$hIndex] = $hValue; } else { @@ -1702,7 +1702,7 @@ protected function castExecutionValue(mixed $value, string $type, string $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), + default => $this->isBlankPayloadValue($value) ? null : trim((string) $this->normalizeCellValue($value)), }; } @@ -1724,6 +1724,69 @@ protected function castExecutionFloat(mixed $value, string $field = ''): float return $number; } + /** + * Egy Excel cella értékének normalizálása skalárrá, MÉG a payloadba kerülés előtt. + * + * A `Cell::getValue()` nem csak skalárt adhat vissza. A gyakorlatban a `RichText` + * okozott adatvesztést: ha egy cellán belül több formázású szövegrész van (pl. a + * terméknév egy része félkövér), az érték egy RichText objektum, amit a `json_encode` + * ÜRESRE serializál - a szövegrészek protected propertyben vannak -, így a payloadba + * `{}` kerül, ami visszaolvasva üres tömb. A cella szövege ilyenkor nyomtalanul + * elveszik, a felület pedig eltörik, mert tömböt kap string helyett. + */ + protected function normalizeCellValue(mixed $value): string|int|float|bool|null + { + if ($value === null || is_scalar($value)) { + return $value; + } + + if ($value instanceof \PhpOffice\PhpSpreadsheet\RichText\RichText) { + return $value->getPlainText(); + } + + if ($value instanceof \DateTimeInterface) { + return $value->format('Y-m-d H:i:s'); + } + + if (is_array($value)) { + $flat = array_filter( + \Illuminate\Support\Arr::flatten($value), + fn ($item) => $item !== null && $item !== '', + ); + + return $flat === [] ? null : trim(implode(' ', array_map( + fn ($item) => is_scalar($item) ? (string) $item : '', + $flat, + ))); + } + + if (is_object($value) && method_exists($value, '__toString')) { + return (string) $value; + } + + return null; + } + + /** + * Üresnek számít-e egy payload érték. A tömböt külön kell kezelni: a fenti RichText + * hiba miatt a régebbi rekordokban üres tömbök is előfordulnak, azokat pedig a + * korábbi `is_string()` alapú vizsgálat KITÖLTÖTTNEK látta - egy elveszett terméknév + * így némán átment a kötelező mező ellenőrzésén. + */ + protected function isBlankPayloadValue(mixed $value): bool + { + if ($value === null) { + return true; + } + + if (is_array($value) || is_object($value)) { + return $this->normalizeCellValue($value) === null + || trim((string) $this->normalizeCellValue($value)) === ''; + } + + return trim((string) $value) === ''; + } + /** * 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. diff --git a/tests/Feature/PricelistRichTextTest.php b/tests/Feature/PricelistRichTextTest.php new file mode 100644 index 0000000..e66acb7 --- /dev/null +++ b/tests/Feature/PricelistRichTextTest.php @@ -0,0 +1,108 @@ +setAccessible(true); + + return $reflection->invoke($service, ...$args); +} + +/** + * Formázott (rich text) cella: a terméknév egy része félkövér. A PhpSpreadsheet ilyenkor + * nem stringet, hanem RichText objektumot ad vissza a getValue()-ból. + */ +function richTextCellValue(): RichText +{ + $richText = new RichText(); + $richText->createText('Trappista sajt '); + $richText->createTextRun('PRÉMIUM')->getFont()->setBold(true); + + return $richText; +} + +test('a rich text cella értéke sima szövegként normalizálódik', function () { + // json_encode a nyers objektumot üresre serializálná (a szövegrészek protected + // propertyben vannak), így a payloadba üres tömb kerülne és a szöveg elveszne. + expect(json_encode(['x' => richTextCellValue()]))->toBe('{"x":{}}'); + + expect(invokeProtected('normalizeCellValue', richTextCellValue())) + ->toBe('Trappista sajt PRÉMIUM'); +}); + +test('a normalizálás a skalárokat változatlanul hagyja', function () { + expect(invokeProtected('normalizeCellValue', 'Sajt'))->toBe('Sajt') + ->and(invokeProtected('normalizeCellValue', 1250.5))->toBe(1250.5) + ->and(invokeProtected('normalizeCellValue', null))->toBeNull(); +}); + +test('a dátum cella szöveggé alakul, nem tömbbé', function () { + expect(invokeProtected('normalizeCellValue', new DateTimeImmutable('2026-08-24 10:30:00'))) + ->toBe('2026-08-24 10:30:00'); +}); + +test('az üres tömb hiányzó kötelező értéknek számít', function () { + // A régi rekordokban a rich text cellák üres tömbként landoltak. A korábbi, + // is_string() alapú vizsgálat ezeket KITÖLTÖTTNEK látta, így egy elveszett + // terméknév némán átment a kötelező mező ellenőrzésén. + expect(invokeProtected('isBlankPayloadValue', []))->toBeTrue() + ->and(invokeProtected('isBlankPayloadValue', null))->toBeTrue() + ->and(invokeProtected('isBlankPayloadValue', ' '))->toBeTrue() + ->and(invokeProtected('isBlankPayloadValue', 'Sajt'))->toBeFalse() + ->and(invokeProtected('isBlankPayloadValue', 0))->toBeFalse(); +}); + +test('a már elmentett tömbös payload nem töri el a sorok táblát', function () { + $supplier = Supplier::factory()->create(); + + $file = PricelistFile::create([ + 'filename' => 'arlista.xlsx', + 'supplier_id' => $supplier->id, + 'available_date' => now()->addWeek()->toDateString(), + 'status' => PricelistFileStatusEnum::waiting_for_approval, + ]); + + // Pontosan az az állapot, ami a d2d-n a nézetet eltörte: + // TextColumn::getDescriptionBelow(): Return value must be of type + // Htmlable|string|null, array returned + PricelistFileLine::create([ + 'pricelist_file_id' => $file->id, + 'row_number' => 5, + 'status' => PricelistFileLineStatusEnum::error, + 'payload' => [ + PriceListService::EXPECTED_HEADERS[0] => 'SKU-1', + PriceListService::EXPECTED_HEADERS[4] => [], + ], + 'validation_messages' => ['name' => 'Hiányzó kötelező mező'], + ]); + + $user = User::factory()->create(); + $user->addRole(Role::create(['name' => 'admin', 'display_name' => 'Admin'])); + $this->actingAs($user); + + Livewire::test(LinesRelationManager::class, [ + 'ownerRecord' => $file, + 'pageClass' => ViewPricelistFile::class, + ]) + ->assertOk() + ->assertSee('SKU-1'); +});