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:
parent
11a18eb1a6
commit
3cfaa6c9d5
@ -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
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
@ -182,8 +206,8 @@ public function table(Table $table): Table
|
|||||||
|
|
||||||
TextColumn::make('product_info')
|
TextColumn::make('product_info')
|
||||||
->label('Termék')
|
->label('Termék')
|
||||||
->description(fn ($record) => $record->payload[PriceListService::EXPECTED_HEADERS[4]] ?? '')
|
->description(fn ($record) => self::payloadText($record, 4))
|
||||||
->getStateUsing(fn ($record) => $record->payload[PriceListService::EXPECTED_HEADERS[0]] ?? 'N/A')
|
->getStateUsing(fn ($record) => self::payloadText($record, 0) ?: 'N/A')
|
||||||
->color(fn ($record) => !empty($record->diff) ? 'info' : null)
|
->color(fn ($record) => !empty($record->diff) ? 'info' : null)
|
||||||
->wrap()
|
->wrap()
|
||||||
->searchable(query: function ($query, string $search) {
|
->searchable(query: function ($query, string $search) {
|
||||||
|
|||||||
@ -740,8 +740,8 @@ public function validate(PricelistFile $pricelistFile): bool
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$rowData[$label] = $value;
|
$rowData[$label] = $this->normalizeCellValue($value);
|
||||||
if (!empty(trim((string)$value))) {
|
if (!empty(trim((string)$rowData[$label]))) {
|
||||||
$hasData = true;
|
$hasData = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1074,7 +1074,7 @@ protected function runBusinessValidation(PricelistFile $pricelistFile, bool $has
|
|||||||
foreach ($mandatoryIndexes as $mIndex) {
|
foreach ($mandatoryIndexes as $mIndex) {
|
||||||
$mLabel = PriceListService::EXPECTED_HEADERS[$mIndex];
|
$mLabel = PriceListService::EXPECTED_HEADERS[$mIndex];
|
||||||
$mValue = $payload[$mLabel] ?? null;
|
$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
|
// Megfelelő kulcsot választunk, hogy a specifikus validátorok felülírhassák
|
||||||
$mKey = match($mIndex) {
|
$mKey = match($mIndex) {
|
||||||
7 => 'product_unit',
|
7 => 'product_unit',
|
||||||
@ -1095,7 +1095,7 @@ protected function runBusinessValidation(PricelistFile $pricelistFile, bool $has
|
|||||||
foreach ($hooreycaIndexes as $hIndex) {
|
foreach ($hooreycaIndexes as $hIndex) {
|
||||||
$hLabel = PriceListService::EXPECTED_HEADERS[$hIndex];
|
$hLabel = PriceListService::EXPECTED_HEADERS[$hIndex];
|
||||||
$hValue = $payload[$hLabel] ?? null;
|
$hValue = $payload[$hLabel] ?? null;
|
||||||
if ($hValue !== null && !(is_string($hValue) && trim($hValue) === '')) {
|
if (! $this->isBlankPayloadValue($hValue)) {
|
||||||
$hasAnyHooreyca = true;
|
$hasAnyHooreyca = true;
|
||||||
$hooreycaData[$hIndex] = $hValue;
|
$hooreycaData[$hIndex] = $hValue;
|
||||||
} else {
|
} else {
|
||||||
@ -1702,7 +1702,7 @@ protected function castExecutionValue(mixed $value, string $type, string $field)
|
|||||||
'int' => (int) $this->castExecutionFloat($value, $field),
|
'int' => (int) $this->castExecutionFloat($value, $field),
|
||||||
// booleanCustom: üres érték => false, bármilyen más érték (pl. "X") => true
|
// booleanCustom: üres érték => false, bármilyen más érték (pl. "X") => true
|
||||||
'bool' => strlen(trim((string) $value)) > 0,
|
'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;
|
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é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.
|
* termékcsoportjától öröklődik, ahogy a legacy importban is.
|
||||||
|
|||||||
108
tests/Feature/PricelistRichTextTest.php
Normal file
108
tests/Feature/PricelistRichTextTest.php
Normal 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');
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user