FIX EV3-357 rich text cellák kezelése az árlista beolvasásban

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 <noreply@anthropic.com>
This commit is contained in:
E98Developer 2026-08-24 13:51:11 +02:00
parent 11a18eb1a6
commit 3cfaa6c9d5
3 changed files with 202 additions and 7 deletions

View File

@ -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) {

View File

@ -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.

View File

@ -0,0 +1,108 @@
<?php
use App\Enums\PricelistFileLineStatusEnum;
use App\Enums\PricelistFileStatusEnum;
use App\Filament\Resources\PricelistFiles\RelationManagers\LinesRelationManager;
use App\Filament\Resources\PricelistFiles\Pages\ViewPricelistFile;
use App\Models\PricelistFile;
use App\Models\PricelistFileLine;
use App\Models\Role;
use App\Models\Supplier;
use App\Models\User;
use App\Services\PriceListService;
use App\Services\PricelistFileProcessService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use PhpOffice\PhpSpreadsheet\RichText\RichText;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function invokeProtected(string $method, mixed ...$args): mixed
{
$service = app(PricelistFileProcessService::class);
$reflection = new ReflectionMethod($service, $method);
$reflection->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');
});