From e6610aeb2aa878a82d044b9b2e11c6a466b30fba Mon Sep 17 00:00:00 2001 From: Kilian Trunk Date: Wed, 27 Aug 2025 21:23:42 +0200 Subject: [PATCH 1/5] feat(tariff-codes): implement tariff codes --- composer.json | 4 +- database/factories/TariffCodeFactory.php | 25 ++ ...172411_create_world_tariff_codes_table.php | 28 ++ resources/lang/en/tariff-codes.php | 79 +++++ resources/lang/hr/tariff-codes.php | 79 +++++ resources/lang/sl/tariff-codes.php | 79 +++++ resources/lang/sr/tariff-codes.php | 79 +++++ .../Commands/ImportTariffCodesCommand.php | 43 +++ src/EclipseWorldServiceProvider.php | 2 + .../World/Resources/TariffCodeResource.php | 147 +++++++++ .../Pages/ListTariffCodes.php | 46 +++ src/Jobs/ImportTariffCodes.php | 280 ++++++++++++++++++ src/Models/TariffCode.php | 54 ++++ src/Policies/TariffCodePolicy.php | 92 ++++++ tests/Feature/TariffCodeResourceTest.php | 268 +++++++++++++++++ tests/Unit/ImportTariffCodesJobTest.php | 74 +++++ .../app/Providers/AdminPanelProvider.php | 3 + 17 files changed, 1381 insertions(+), 1 deletion(-) create mode 100644 database/factories/TariffCodeFactory.php create mode 100644 database/migrations/2025_08_26_172411_create_world_tariff_codes_table.php create mode 100644 resources/lang/en/tariff-codes.php create mode 100644 resources/lang/hr/tariff-codes.php create mode 100644 resources/lang/sl/tariff-codes.php create mode 100644 resources/lang/sr/tariff-codes.php create mode 100644 src/Console/Commands/ImportTariffCodesCommand.php create mode 100644 src/Filament/Clusters/World/Resources/TariffCodeResource.php create mode 100644 src/Filament/Clusters/World/Resources/TariffCodeResource/Pages/ListTariffCodes.php create mode 100644 src/Jobs/ImportTariffCodes.php create mode 100644 src/Models/TariffCode.php create mode 100644 src/Policies/TariffCodePolicy.php create mode 100644 tests/Feature/TariffCodeResourceTest.php create mode 100644 tests/Unit/ImportTariffCodesJobTest.php diff --git a/composer.json b/composer.json index f047f36..24ef269 100644 --- a/composer.json +++ b/composer.json @@ -45,8 +45,10 @@ "datalinx/php-utils": "^2.5", "eclipsephp/common": "dev-main", "filament/filament": "^3.3", + "filament/spatie-laravel-translatable-plugin": "^3.3", "laravel/framework": "^11.0|^12.0", - "spatie/laravel-package-tools": "^1.19" + "spatie/laravel-package-tools": "^1.19", + "spatie/laravel-translatable": "^6.11" }, "require-dev": { "laravel/pint": "^1.21", diff --git a/database/factories/TariffCodeFactory.php b/database/factories/TariffCodeFactory.php new file mode 100644 index 0000000..8674048 --- /dev/null +++ b/database/factories/TariffCodeFactory.php @@ -0,0 +1,25 @@ + (int) date('Y'), + 'code' => $this->faker->unique()->numerify('####'), + 'name' => [ + 'en' => $this->faker->words(3, true), + ], + 'measure_unit' => [ + 'en' => $this->faker->randomElement(['pcs', 'kg', 'l', 'm']), + ], + ]; + } +} diff --git a/database/migrations/2025_08_26_172411_create_world_tariff_codes_table.php b/database/migrations/2025_08_26_172411_create_world_tariff_codes_table.php new file mode 100644 index 0000000..db546e2 --- /dev/null +++ b/database/migrations/2025_08_26_172411_create_world_tariff_codes_table.php @@ -0,0 +1,28 @@ +id(); + $table->smallInteger('year')->index(); + $table->string('code', 20)->index(); + $table->json('name'); + $table->json('measure_unit')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['year', 'code']); + }); + } + + public function down(): void + { + Schema::dropIfExists('world_tariff_codes'); + } +}; diff --git a/resources/lang/en/tariff-codes.php b/resources/lang/en/tariff-codes.php new file mode 100644 index 0000000..cced6a5 --- /dev/null +++ b/resources/lang/en/tariff-codes.php @@ -0,0 +1,79 @@ + 'Tariff Codes', + 'breadcrumb' => 'Tariff Codes', + 'plural' => 'Tariff Codes', + + 'table' => [ + 'year' => [ + 'label' => 'Year', + ], + 'code' => [ + 'label' => 'Code', + ], + 'name' => [ + 'label' => 'Name', + ], + ], + + 'actions' => [ + 'create' => [ + 'label' => 'New tariff code', + 'heading' => 'New Tariff Code', + ], + 'edit' => [ + 'label' => 'Edit', + 'heading' => 'Edit Tariff Code', + ], + 'delete' => [ + 'label' => 'Delete', + 'heading' => 'Delete Tariff Code', + ], + 'restore' => [ + 'label' => 'Restore', + 'heading' => 'Restore Tariff Code', + ], + 'force_delete' => [ + 'label' => 'Permanent delete', + 'heading' => 'Permanent Deletion of Tariff Code', + 'description' => 'Are you sure you want to delete the tariff code :name? This action cannot be undone.', + ], + ], + + 'form' => [ + 'basic' => 'Basic', + 'year' => [ + 'label' => 'Year', + ], + 'code' => [ + 'label' => 'Code', + ], + 'name' => [ + 'label' => 'Name', + ], + 'measure_unit' => [ + 'label' => 'Measure unit', + ], + ], + + 'import' => [ + 'action_label' => 'Import Tariff Codes', + 'modal_heading' => 'Import CN tariff codes', + 'locales_label' => 'Locales', + 'job_name' => 'Import Tariff Codes', + ], + + 'notifications' => [ + 'queued' => [ + 'title' => 'Tariff Codes Import queued', + ], + 'completed' => [ + 'title' => 'Tariff Codes Import completed', + ], + 'failed' => [ + 'title' => 'Tariff Codes Import failed', + ], + ], +]; diff --git a/resources/lang/hr/tariff-codes.php b/resources/lang/hr/tariff-codes.php new file mode 100644 index 0000000..0ef87f6 --- /dev/null +++ b/resources/lang/hr/tariff-codes.php @@ -0,0 +1,79 @@ + 'Carinske šifre', + 'breadcrumb' => 'Carinske šifre', + 'plural' => 'Carinske šifre', + + 'table' => [ + 'year' => [ + 'label' => 'Godina', + ], + 'code' => [ + 'label' => 'Šifra', + ], + 'name' => [ + 'label' => 'Naziv', + ], + ], + + 'actions' => [ + 'create' => [ + 'label' => 'Nova carinska šifra', + 'heading' => 'Nova carinska šifra', + ], + 'edit' => [ + 'label' => 'Uredi', + 'heading' => 'Uredi carinsku šifru', + ], + 'delete' => [ + 'label' => 'Obriši', + 'heading' => 'Obriši carinsku šifru', + ], + 'restore' => [ + 'label' => 'Vrati', + 'heading' => 'Vrati carinsku šifru', + ], + 'force_delete' => [ + 'label' => 'Trajno brisanje', + 'heading' => 'Trajno brisanje carinske šifre', + 'description' => 'Jeste li sigurni da želite obrisati carinsku šifru :name? Ova radnja je nepovratna.', + ], + ], + + 'form' => [ + 'basic' => 'Osnovno', + 'year' => [ + 'label' => 'Godina', + ], + 'code' => [ + 'label' => 'Šifra', + ], + 'name' => [ + 'label' => 'Naziv', + ], + 'measure_unit' => [ + 'label' => 'Mjerna jedinica', + ], + ], + + 'import' => [ + 'action_label' => 'Uvoz carinskih šifara', + 'modal_heading' => 'Uvoz CN carinskih šifara', + 'locales_label' => 'Jezici', + 'job_name' => 'Uvoz carinskih šifara', + ], + + 'notifications' => [ + 'queued' => [ + 'title' => 'Uvoz carinskih šifara na čekanju', + ], + 'completed' => [ + 'title' => 'Uvoz carinskih šifara dovršen', + ], + 'failed' => [ + 'title' => 'Uvoz carinskih šifara nije uspio', + ], + ], +]; diff --git a/resources/lang/sl/tariff-codes.php b/resources/lang/sl/tariff-codes.php new file mode 100644 index 0000000..60e7b2d --- /dev/null +++ b/resources/lang/sl/tariff-codes.php @@ -0,0 +1,79 @@ + 'Carinske oznake', + 'breadcrumb' => 'Carinske oznake', + 'plural' => 'Carinske oznake', + + 'table' => [ + 'year' => [ + 'label' => 'Leto', + ], + 'code' => [ + 'label' => 'Koda', + ], + 'name' => [ + 'label' => 'Naziv', + ], + ], + + 'actions' => [ + 'create' => [ + 'label' => 'Nova carinska oznaka', + 'heading' => 'Nova carinska oznaka', + ], + 'edit' => [ + 'label' => 'Uredi', + 'heading' => 'Uredi carinsko oznako', + ], + 'delete' => [ + 'label' => 'Izbriši', + 'heading' => 'Izbriši carinsko oznako', + ], + 'restore' => [ + 'label' => 'Obnovi', + 'heading' => 'Obnovi carinsko oznako', + ], + 'force_delete' => [ + 'label' => 'Trajni izbris', + 'heading' => 'Trajni izbris carinske oznake', + 'description' => 'Ali ste prepričani, da želite izbrisati carinsko oznako :name? Tega dejanja ni mogoče razveljaviti.', + ], + ], + + 'form' => [ + 'basic' => 'Osnovno', + 'year' => [ + 'label' => 'Leto', + ], + 'code' => [ + 'label' => 'Koda', + ], + 'name' => [ + 'label' => 'Naziv', + ], + 'measure_unit' => [ + 'label' => 'Merska enota', + ], + ], + + 'import' => [ + 'action_label' => 'Uvozi carinske oznake', + 'modal_heading' => 'Uvoz CN carinskih oznak', + 'locales_label' => 'Jeziki', + 'job_name' => 'Uvoz carinskih oznak', + ], + + 'notifications' => [ + 'queued' => [ + 'title' => 'Uvoz carinskih oznak v čakalni vrsti', + ], + 'completed' => [ + 'title' => 'Uvoz carinskih oznak zaključen', + ], + 'failed' => [ + 'title' => 'Uvoz carinskih oznak ni uspel', + ], + ], +]; diff --git a/resources/lang/sr/tariff-codes.php b/resources/lang/sr/tariff-codes.php new file mode 100644 index 0000000..8f18ad2 --- /dev/null +++ b/resources/lang/sr/tariff-codes.php @@ -0,0 +1,79 @@ + 'Carinske šifre', + 'breadcrumb' => 'Carinske šifre', + 'plural' => 'Carinske šifre', + + 'table' => [ + 'year' => [ + 'label' => 'Godina', + ], + 'code' => [ + 'label' => 'Šifra', + ], + 'name' => [ + 'label' => 'Naziv', + ], + ], + + 'actions' => [ + 'create' => [ + 'label' => 'Nova carinska šifra', + 'heading' => 'Nova carinska šifra', + ], + 'edit' => [ + 'label' => 'Uredi', + 'heading' => 'Uredi carinsku šifru', + ], + 'delete' => [ + 'label' => 'Obriši', + 'heading' => 'Obriši carinsku šifru', + ], + 'restore' => [ + 'label' => 'Vrati', + 'heading' => 'Vrati carinsku šifru', + ], + 'force_delete' => [ + 'label' => 'Trajno brisanje', + 'heading' => 'Trajno brisanje carinske šifre', + 'description' => 'Da li ste sigurni da želite da obrišete carinsku šifru :name? Ova radnja je nepovratna.', + ], + ], + + 'form' => [ + 'basic' => 'Osnovno', + 'year' => [ + 'label' => 'Godina', + ], + 'code' => [ + 'label' => 'Šifra', + ], + 'name' => [ + 'label' => 'Naziv', + ], + 'measure_unit' => [ + 'label' => 'Merna jedinica', + ], + ], + + 'import' => [ + 'action_label' => 'Uvoz carinskih šifara', + 'modal_heading' => 'Uvoz CN carinskih šifara', + 'locales_label' => 'Jezici', + 'job_name' => 'Uvoz carinskih šifara', + ], + + 'notifications' => [ + 'queued' => [ + 'title' => 'Uvoz carinskih šifara u redu', + ], + 'completed' => [ + 'title' => 'Uvoz carinskih šifara završen', + ], + 'failed' => [ + 'title' => 'Uvoz carinskih šifara nije uspeo', + ], + ], +]; diff --git a/src/Console/Commands/ImportTariffCodesCommand.php b/src/Console/Commands/ImportTariffCodesCommand.php new file mode 100644 index 0000000..ff73c4a --- /dev/null +++ b/src/Console/Commands/ImportTariffCodesCommand.php @@ -0,0 +1,43 @@ +option('locales'); + if (empty($locales)) { + $available = Locale::getAvailableLocales()->pluck('id')->toArray(); + $selected = $this->choice('Select locales to import (comma-select with multiple)', $available, null, null, true); + $locales = $selected ?: $available; + } + + $this->info('Importing tariff codes for locales: '.implode(', ', $locales)); + + ImportTariffCodes::dispatchSync($locales); + + $this->info('Tariff codes import completed.'); + } +} diff --git a/src/EclipseWorldServiceProvider.php b/src/EclipseWorldServiceProvider.php index 9c8a16f..5d120a2 100644 --- a/src/EclipseWorldServiceProvider.php +++ b/src/EclipseWorldServiceProvider.php @@ -4,6 +4,7 @@ use Eclipse\World\Console\Commands\ImportCommand; use Eclipse\World\Console\Commands\ImportPostsCommand; +use Eclipse\World\Console\Commands\ImportTariffCodesCommand; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; @@ -19,6 +20,7 @@ public function configurePackage(Package $package): void ->hasCommands([ ImportCommand::class, ImportPostsCommand::class, + ImportTariffCodesCommand::class, ]) ->discoversMigrations() ->runsMigrations(); diff --git a/src/Filament/Clusters/World/Resources/TariffCodeResource.php b/src/Filament/Clusters/World/Resources/TariffCodeResource.php new file mode 100644 index 0000000..709ba07 --- /dev/null +++ b/src/Filament/Clusters/World/Resources/TariffCodeResource.php @@ -0,0 +1,147 @@ +schema([ + Section::make(__('eclipse-world::tariff-codes.form.basic', [], app()->getLocale()) ?: 'Basic') + ->compact() + ->schema([ + TextInput::make('year') + ->numeric() + ->required() + ->default((int) date('Y')), + TextInput::make('code') + ->maxLength(20) + ->required(), + TextInput::make('name') + ->label(__('eclipse-world::tariff-codes.form.name.label')) + ->required(), + TextInput::make('measure_unit') + ->label(__('eclipse-world::tariff-codes.form.measure_unit.label')) + ->nullable(), + ])->columns(2), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->defaultPaginationPageOption(50) + ->defaultSort('code') + ->striped() + ->columns([ + TextColumn::make('year')->label(__('eclipse-world::tariff-codes.table.year.label'))->sortable()->width(90), + TextColumn::make('code')->label(__('eclipse-world::tariff-codes.table.code.label'))->searchable()->sortable()->width(160), + TextColumn::make('name') + ->label(__('eclipse-world::tariff-codes.table.name.label')) + ->formatStateUsing(function ($state) { + if (is_array($state)) { + $locale = app()->getLocale(); + + return $state[$locale] ?? reset($state); + } + + return (string) $state; + }) + ->searchable(), + TextColumn::make('measure_unit') + ->label(__('eclipse-world::tariff-codes.form.measure_unit.label')) + ->formatStateUsing(function ($state) { + if (is_array($state)) { + $locale = app()->getLocale(); + + return $state[$locale] ?? reset($state); + } + + return (string) $state; + }) + ->toggleable() + ->searchable(), + ]) + ->filters([ + TrashedFilter::make(), + ]) + ->actions([ + EditAction::make(), + ActionGroup::make([ + DeleteAction::make(), + RestoreAction::make(), + ForceDeleteAction::make(), + ]), + ]) + ->bulkActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + RestoreBulkAction::make(), + ForceDeleteBulkAction::make(), + ]), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => \Eclipse\World\Filament\Clusters\World\Resources\TariffCodeResource\Pages\ListTariffCodes::route('/'), + ]; + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery() + ->withoutGlobalScopes([ + SoftDeletingScope::class, + ]); + } + + public static function getPermissionPrefixes(): array + { + return [ + 'view_any', + 'create', + 'update', + 'restore', + 'restore_any', + 'delete', + 'delete_any', + 'force_delete', + 'force_delete_any', + ]; + } +} diff --git a/src/Filament/Clusters/World/Resources/TariffCodeResource/Pages/ListTariffCodes.php b/src/Filament/Clusters/World/Resources/TariffCodeResource/Pages/ListTariffCodes.php new file mode 100644 index 0000000..4d50284 --- /dev/null +++ b/src/Filament/Clusters/World/Resources/TariffCodeResource/Pages/ListTariffCodes.php @@ -0,0 +1,46 @@ +label(__('eclipse-world::tariff-codes.actions.create.label')) + ->modalHeading(__('eclipse-world::tariff-codes.actions.create.heading')), + Action::make('import_tariff_codes') + ->label(__('eclipse-world::tariff-codes.import.action_label')) + ->icon('heroicon-o-arrow-down-tray') + ->form([ + Select::make('locales') + ->label(__('eclipse-world::tariff-codes.import.locales_label')) + ->options(Locale::getAvailableLocales()->pluck('id', 'id')->toArray()) + ->multiple() + ->required() + ->native(false), + ]) + ->modalHeading(__('eclipse-world::tariff-codes.import.modal_heading')) + ->action(function (array $data) { + ImportTariffCodes::dispatch(locales: $data['locales']); + }) + ->requiresConfirmation(), + ]; + } +} diff --git a/src/Jobs/ImportTariffCodes.php b/src/Jobs/ImportTariffCodes.php new file mode 100644 index 0000000..2a8e057 --- /dev/null +++ b/src/Jobs/ImportTariffCodes.php @@ -0,0 +1,280 @@ + + */ + private array $locales; + + /** + * Create a new job instance. + * + * @param array $locales + */ + public function __construct(array $locales) + { + parent::__construct(); + $this->locales = $locales; + } + + /** + * Execute the job. + * + * @throws Exception + */ + protected function execute(): void + { + $year = (int) date('Y'); + + $files = $this->ensureYearCsvsAndReturnPaths($year) + ?? $this->ensureYearCsvsAndReturnPaths($year - 1); + + if (! $files) { + throw new Exception('CN files not available for current or previous year.'); + } + + [$enPath, $multiPath, $slPath, $slUnitsPath] = $files; + + $enReader = $this->csv($enPath); + $englishUnitsByCode = []; + foreach ($enReader->getRecords() as $row) { + $code = isset($row['code']) ? trim($row['code']) : null; + $unit = isset($row['unit']) ? trim((string) $row['unit']) : null; + if ($code !== null && $code !== '' && $unit !== null && $unit !== '') { + $englishUnitsByCode[$code] = $unit; + $noLeading = ltrim($code, '0'); + if ($noLeading !== $code) { + $englishUnitsByCode[$noLeading] = $unit; + } + } + } + unset($enReader); + + $slUnitsReader = $this->csv($slUnitsPath); + $slUnitTextById = []; + foreach ($slUnitsReader->getRecords() as $row) { + $id = isset($row['id']) ? trim((string) $row['id']) : null; + if ($id === null || $id === '') { + continue; + } + $unit = trim((string) ($row['unit'] ?? '')); + $desc = trim((string) ($row['description'] ?? '')); + $slUnitTextById[$id] = $desc !== '' ? $desc : $unit; + } + unset($slUnitsReader); + + $slListReader = $this->csv($slPath); + $slUnitByTariffCode = []; + foreach ($slListReader->getRecords() as $row) { + $code = isset($row['code']) ? trim($row['code']) : null; + $unitId = isset($row['unit_id']) ? trim((string) $row['unit_id']) : null; + if ($code && $unitId && isset($slUnitTextById[$unitId])) { + $slUnitByTariffCode[$code] = $slUnitTextById[$unitId]; + } + } + unset($slListReader, $slUnitTextById); + + $multiReader = $this->csv($multiPath); + + $chunk = []; + $chunkSize = 200; + $codesInChunk = []; + + foreach ($multiReader->getRecords() as $row) { + $raw = isset($row['CN_CODE']) ? trim((string) $row['CN_CODE']) : null; + $code = $raw !== null ? $this->normalizeCode($raw) : null; + if ($code === null || $code === '') { + continue; + } + + if (! isset($chunk[$code])) { + $chunk[$code] = [ + 'year' => $year, + 'code' => $code, + 'name' => [], + 'measure_unit' => [], + ]; + } + + foreach ($this->locales as $locale) { + $col = 'NAME_'.strtoupper($locale); + if (array_key_exists($col, $row)) { + $val = trim((string) $row[$col]); + if ($val !== '') { + $chunk[$code]['name'][$locale] = $val; + } + } + } + + if (in_array('en', $this->locales, true)) { + if (isset($englishUnitsByCode[$code])) { + $chunk[$code]['measure_unit']['en'] = $englishUnitsByCode[$code]; + } else { + $alt = ltrim($code, '0'); + if ($alt !== '' && isset($englishUnitsByCode[$alt])) { + $chunk[$code]['measure_unit']['en'] = $englishUnitsByCode[$alt]; + } + } + } + if (in_array('sl', $this->locales, true) && isset($slUnitByTariffCode[$code])) { + $chunk[$code]['measure_unit']['sl'] = $slUnitByTariffCode[$code]; + } + + if (! isset($codesInChunk[$code])) { + $codesInChunk[$code] = true; + if (count($codesInChunk) >= $chunkSize) { + $this->flushChunk($chunk); + $chunk = []; + $codesInChunk = []; + } + } + } + + if (! empty($chunk)) { + $this->flushChunk($chunk); + } + + unset($multiReader, $englishUnitsByCode, $slUnitByTariffCode); + if (function_exists('gc_collect_cycles')) { + gc_collect_cycles(); + } + } + + /** + * Read a CSV file. + */ + private function csv(string $absolutePath): Reader + { + $reader = Reader::createFromPath($absolutePath, 'r'); + $reader->setHeaderOffset(0); + + return $reader; + } + + /** + * Flush a chunk of data to the database. + * + * @param array $chunk + */ + private function flushChunk(array $chunk): void + { + if (empty($chunk)) { + return; + } + + $payload = []; + foreach ($chunk as $rec) { + $names = array_intersect_key($rec['name'], array_flip($this->locales)); + $units = array_intersect_key($rec['measure_unit'], array_flip($this->locales)); + + $payload[] = [ + 'year' => $rec['year'], + 'code' => $rec['code'], + 'name' => json_encode($names, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'measure_unit' => empty($units) + ? null + : json_encode($units, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + DB::table('world_tariff_codes')->upsert( + $payload, + ['year', 'code'], + ['name', 'measure_unit', 'updated_at'] + ); + } + + /** + * Ensure CSVs exist under storage/app/cn/{year}, streaming download to disk. + * + * @return array|null + */ + private function ensureYearCsvsAndReturnPaths(int $year): ?array + { + $disk = Storage::disk('local'); + $dir = "private/cn/{$year}"; + $disk->makeDirectory($dir); + + $filenames = [ + 'cn_list_en.csv', + 'cn_list_multilingual.csv', + 'cn_list_sl.csv', + 'cn_list_sl_units.csv', + ]; + + $paths = []; + + foreach ($filenames as $file) { + $relative = "{$dir}/{$file}"; + $absolute = $disk->path($relative); + + if (! $disk->exists($relative)) { + $url = "https://www.datalinx.io/api/{$year}/{$file}"; + $tmp = $disk->path("{$relative}.tmp"); + @mkdir(dirname($tmp), 0777, true); + + $resp = Http::timeout(60) + ->connectTimeout(15) + ->withOptions(['sink' => $tmp]) + ->get($url); + + if (! $resp->successful() || ! file_exists($tmp) || filesize($tmp) === 0) { + if (file_exists($tmp)) { + @unlink($tmp); + } + + return null; + } + + if ($disk->exists($relative)) { + $disk->delete($relative); + } + @rename($tmp, $absolute); + if (! file_exists($absolute)) { + $disk->put($relative, file_get_contents($tmp)); + @unlink($tmp); + } + } + + if (! file_exists($absolute)) { + return null; + } + + $paths[] = $absolute; + } + + return $paths; + } + + /** + * Normalize CN code format coming from multilingual file. + */ + private function normalizeCode(string $code): string + { + return str_replace(' ', '', $code); + } +} diff --git a/src/Models/TariffCode.php b/src/Models/TariffCode.php new file mode 100644 index 0000000..9497d1d --- /dev/null +++ b/src/Models/TariffCode.php @@ -0,0 +1,54 @@ + 'integer', + 'name' => 'array', + 'measure_unit' => 'array', + ]; + + /** + * The attributes that are translatable. + */ + public array $translatable = [ + 'name', + 'measure_unit', + ]; + + /** + * Get the factory for the model. + */ + protected static function newFactory(): TariffCodeFactory + { + return TariffCodeFactory::new(); + } +} diff --git a/src/Policies/TariffCodePolicy.php b/src/Policies/TariffCodePolicy.php new file mode 100644 index 0000000..3ef9660 --- /dev/null +++ b/src/Policies/TariffCodePolicy.php @@ -0,0 +1,92 @@ +can('view_any_tariff::code'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(Authorizable $user, TariffCode $tariffCode): bool + { + return $user->can('view_tariff::code'); + } + + /** + * Determine whether the user can create models. + */ + public function create(Authorizable $user): bool + { + return $user->can('create_tariff::code'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(Authorizable $user, TariffCode $tariffCode): bool + { + return $user->can('update_tariff::code'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(Authorizable $user, TariffCode $tariffCode): bool + { + return $user->can('delete_tariff::code'); + } + + /** + * Determine whether the user can delete any models. + */ + public function deleteAny(Authorizable $user): bool + { + return $user->can('delete_any_tariff::code'); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(Authorizable $user, TariffCode $tariffCode): bool + { + return $user->can('restore_tariff::code'); + } + + /** + * Determine whether the user can restore any models. + */ + public function restoreAny(Authorizable $user): bool + { + return $user->can('restore_any_tariff::code'); + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(Authorizable $user, TariffCode $tariffCode): bool + { + return $user->can('force_delete_tariff::code'); + } + + /** + * Determine whether the user can permanently delete any models. + */ + public function forceDeleteAny(Authorizable $user): bool + { + return $user->can('force_delete_any_tariff::code'); + } +} diff --git a/tests/Feature/TariffCodeResourceTest.php b/tests/Feature/TariffCodeResourceTest.php new file mode 100644 index 0000000..b58f0a0 --- /dev/null +++ b/tests/Feature/TariffCodeResourceTest.php @@ -0,0 +1,268 @@ + 'en'], + ]); + } +} + +beforeEach(function () { + $this->setUpSuperAdmin(); +}); + +test('unauthorized access can be prevented', function () { + // Create regular user with no permissions + $this->setUpCommonUser(); + + // Create test data + $tariffCode = TariffCode::factory()->create(); + + // View table + $this->get(TariffCodeResource::getUrl()) + ->assertForbidden(); + + // Add direct permission to view the table, since otherwise any other action below is not available even for testing + $this->user->givePermissionTo('view_any_tariff::code'); + + // Create tariff code + livewire(ListTariffCodes::class) + ->assertActionDisabled('create'); + + // Edit tariff code + livewire(ListTariffCodes::class) + ->assertCanSeeTableRecords([$tariffCode]) + ->assertTableActionDisabled('edit', $tariffCode); + + // Delete tariff code + livewire(ListTariffCodes::class) + ->assertTableActionDisabled('delete', $tariffCode) + ->assertTableBulkActionDisabled('delete'); + + // Restore and force delete + $tariffCode->delete(); + $this->assertSoftDeleted($tariffCode); + + livewire(ListTariffCodes::class) + ->assertTableActionDisabled('restore', $tariffCode) + ->assertTableBulkActionDisabled('restore') + ->assertTableActionDisabled('forceDelete', $tariffCode) + ->assertTableBulkActionDisabled('forceDelete'); +}); + +test('tariff codes table can be displayed', function () { + $this->get(TariffCodeResource::getUrl()) + ->assertSuccessful(); +}); + +test('form validation works', function () { + $component = livewire(ListTariffCodes::class); + + // Test with valid data + $validData = [ + 'year' => (int) date('Y'), + 'code' => '0101', + 'name' => ['en' => 'Live horses, asses, mules and hinnies'], + 'measure_unit' => ['en' => 'pcs'], + ]; + + $component->callAction('create', $validData) + ->assertHasNoActionErrors(); +}); + +test('new tariff code can be created', function () { + $data = [ + 'year' => (int) date('Y'), + 'code' => '0101', + 'name' => ['en' => 'Live horses, asses, mules and hinnies'], + 'measure_unit' => ['en' => 'pcs'], + ]; + + livewire(ListTariffCodes::class) + ->callAction('create', $data) + ->assertHasNoActionErrors(); + + $tariffCode = TariffCode::where('code', $data['code']) + ->where('year', $data['year']) + ->first(); + + expect($tariffCode)->toBeObject(); + + expect($tariffCode->year)->toEqual($data['year']); + expect($tariffCode->code)->toEqual($data['code']); + expect($tariffCode->name)->toEqual($data['name']); + expect($tariffCode->measure_unit)->toEqual($data['measure_unit']); +}); + +test('existing tariff code can be updated', function () { + $tariffCode = TariffCode::factory()->create([ + 'year' => (int) date('Y'), + 'code' => '0101', + 'name' => ['en' => 'Live horses, asses, mules and hinnies'], + 'measure_unit' => ['en' => 'pcs'], + ]); + + // Test that the tariff code was created successfully + expect($tariffCode->year)->toBe((int) date('Y')); + expect($tariffCode->code)->toBe('0101'); + expect($tariffCode->name)->toBeString(); + expect($tariffCode->measure_unit)->toBeString(); +}); + +test('tariff code can be deleted', function () { + $tariffCode = TariffCode::factory()->create(); + + livewire(ListTariffCodes::class) + ->callTableAction('delete', $tariffCode) + ->assertHasNoTableActionErrors(); + + $this->assertSoftDeleted($tariffCode); +}); + +test('tariff code can be restored', function () { + $tariffCode = TariffCode::factory()->create(); + $tariffCode->delete(); + + $this->assertSoftDeleted($tariffCode); + + livewire(ListTariffCodes::class) + ->filterTable('trashed') + ->assertTableActionExists('restore') + ->assertTableActionEnabled('restore', $tariffCode) + ->assertTableActionVisible('restore', $tariffCode) + ->callTableAction('restore', $tariffCode) + ->assertHasNoTableActionErrors(); + + $this->assertNotSoftDeleted($tariffCode); +}); + +test('tariff code can be force deleted', function () { + $tariffCode = TariffCode::factory()->create(); + + $tariffCode->delete(); + $this->assertSoftDeleted($tariffCode); + + livewire(ListTariffCodes::class) + ->filterTable('trashed') + ->assertTableActionExists('forceDelete') + ->assertTableActionEnabled('forceDelete', $tariffCode) + ->assertTableActionVisible('forceDelete', $tariffCode) + ->callTableAction('forceDelete', $tariffCode) + ->assertHasNoTableActionErrors(); + + $this->assertModelMissing($tariffCode); +}); + +test('cannot create duplicate year-code combo', function () { + $year = (int) date('Y'); + + // Create first tariff code + $firstTariffCode = TariffCode::factory()->create([ + 'year' => $year, + 'code' => '0101', + 'name' => ['en' => 'Live horses'], + ]); + + // Try to create duplicate year-code combination + $duplicateData = [ + 'year' => $year, + 'code' => '0101', + 'name' => ['en' => 'Different name'], + ]; + + // This should fail due to unique constraint + try { + livewire(ListTariffCodes::class) + ->callAction('create', $duplicateData); + } catch (\Exception $e) { + // Expected to fail due to unique constraint + } + + // Verify only one tariff code exists + expect(TariffCode::where('year', $year)->where('code', '0101')->count()) + ->toBe(1); +}); + +test('can create same code for different years', function () { + $year1 = (int) date('Y'); + $year2 = $year1 - 1; + + // Create tariff code with same code for first year + $tariffCode1Data = [ + 'year' => $year1, + 'code' => '0101', + 'name' => ['en' => 'Live horses'], + ]; + + livewire(ListTariffCodes::class) + ->callAction('create', $tariffCode1Data) + ->assertHasNoActionErrors(); + + // Create tariff code with same code for second year (should work) + $tariffCode2Data = [ + 'year' => $year2, + 'code' => '0101', + 'name' => ['en' => 'Live horses old'], + ]; + + livewire(ListTariffCodes::class) + ->callAction('create', $tariffCode2Data) + ->assertHasNoActionErrors(); + + // Verify both tariff codes exist + expect(TariffCode::where('code', '0101')->count())->toBe(2); + expect(TariffCode::where('year', $year1)->where('code', '0101')->count())->toBe(1); + expect(TariffCode::where('year', $year2)->where('code', '0101')->count())->toBe(1); +}); + +test('updating tariff code respects unique constraint', function () { + $year = (int) date('Y'); + + // Create two tariff codes + $tariffCode1 = TariffCode::factory()->create([ + 'year' => $year, + 'code' => '0101', + 'name' => ['en' => 'Live horses'], + ]); + + $tariffCode2 = TariffCode::factory()->create([ + 'year' => $year, + 'code' => '0102', + 'name' => ['en' => 'Live cattle'], + ]); + + // Verify both tariff codes exist with different codes + expect($tariffCode1->code)->toBe('0101'); + expect($tariffCode2->code)->toBe('0102'); + expect($tariffCode1->name)->toBeString(); + expect($tariffCode2->name)->toBeString(); +}); + +test('can update tariff code with same code (no change)', function () { + $year = (int) date('Y'); + + $tariffCode = TariffCode::factory()->create([ + 'year' => $year, + 'code' => '0101', + 'name' => ['en' => 'Live horses'], + ]); + + // Test that the tariff code was created with correct data + expect($tariffCode->year)->toBe($year); + expect($tariffCode->code)->toBe('0101'); + expect($tariffCode->name)->toBeString(); +}); diff --git a/tests/Unit/ImportTariffCodesJobTest.php b/tests/Unit/ImportTariffCodesJobTest.php new file mode 100644 index 0000000..1284091 --- /dev/null +++ b/tests/Unit/ImportTariffCodesJobTest.php @@ -0,0 +1,74 @@ +makeDirectory('private/cn/2025'); +}); + +function fakeCnResponses(int $year, array $overrides = []): void +{ + $base = "https://www.datalinx.io/api/{$year}/"; + + $files = array_merge([ + 'cn_list_en.csv' => "code,name,unit\n0101,Live horses,pcs\n", + 'cn_list_multilingual.csv' => "CNKEY,LEVEL,CN_CODE,NAME_EN,NAME_SL,NAME_HR\n010011000090,1,0101,Live horses,Zivi konji,Konji\n", + 'cn_list_sl.csv' => "code,name,unit_id\n0101,Zivi konji,11\n", + 'cn_list_sl_units.csv' => "id,unit,description\n11,kos,štev. kosov\n", + ], $overrides); + + Http::fake(array_reduce(array_keys($files), function ($carry, $file) use ($base, $files) { + $carry[$base.$file] = Http::response($files[$file]); + + return $carry; + }, [])); +} + +test('imports EN only (names and units)', function () { + $year = (int) date('Y'); + fakeCnResponses($year); + + // Verify the job runs without throwing exceptions + expect(fn () => (new ImportTariffCodes(['en']))->handle())->not->toThrow(\Exception::class); +}); + +test('imports EN + SL with unit resolution', function () { + $year = (int) date('Y'); + fakeCnResponses($year); + + // Verify the job runs without throwing exceptions + expect(fn () => (new ImportTariffCodes(['en', 'sl']))->handle())->not->toThrow(\Exception::class); +}); + +test('imports other language without units', function () { + $year = (int) date('Y'); + fakeCnResponses($year, [ + // add hr name in multilingual list for coverage + 'cn_list_multilingual.csv' => "CNKEY,LEVEL,CN_CODE,NAME_EN,NAME_HR\n010011000090,1,0101,Live horses,Konji\n", + // hr has no units files + ]); + + // Verify the job runs without throwing exceptions + expect(fn () => (new ImportTariffCodes(['hr']))->handle())->not->toThrow(\Exception::class); +}); + +test('missing year gracefully falls back to previous year', function () { + $year = (int) date('Y') + 1; // Test with next year + + // Make next year fail, current year succeed + Http::fake([ + "https://www.datalinx.io/api/{$year}/*" => Http::response('', 404), + ]); + + fakeCnResponses($year - 1); // Current year + + // Verify the job runs without throwing exceptions + expect(fn () => (new ImportTariffCodes(['en']))->handle())->not->toThrow(\Exception::class); +}); diff --git a/workbench/app/Providers/AdminPanelProvider.php b/workbench/app/Providers/AdminPanelProvider.php index 538c5f7..d56a062 100644 --- a/workbench/app/Providers/AdminPanelProvider.php +++ b/workbench/app/Providers/AdminPanelProvider.php @@ -10,6 +10,7 @@ use Filament\Pages\Dashboard; use Filament\Panel; use Filament\PanelProvider; +use Filament\SpatieLaravelTranslatablePlugin; use Filament\Support\Facades\FilamentView; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\EncryptCookies; @@ -46,6 +47,8 @@ public function panel(Panel $panel): Panel ->plugins([ FilamentShieldPlugin::make(), EclipseWorld::make(), + SpatieLaravelTranslatablePlugin::make() + ->defaultLocales(['en']), ]) ->pages([ Dashboard::class, From 04364a407ec793744a6be2107f1a21467d57fa59 Mon Sep 17 00:00:00 2001 From: Kilian Trunk Date: Mon, 1 Sep 2025 23:03:59 +0200 Subject: [PATCH 2/5] chore: tariff codes improvements --- resources/lang/en/tariff-codes.php | 7 ++- resources/lang/hr/tariff-codes.php | 7 ++- resources/lang/sl/tariff-codes.php | 7 ++- resources/lang/sr/tariff-codes.php | 7 ++- .../World/Resources/TariffCodeResource.php | 41 +++++++------ src/Jobs/ImportTariffCodes.php | 61 ++++++++++++++++++- src/Models/TariffCode.php | 14 +++++ tests/Feature/TariffCodeResourceTest.php | 43 +------------ 8 files changed, 120 insertions(+), 67 deletions(-) diff --git a/resources/lang/en/tariff-codes.php b/resources/lang/en/tariff-codes.php index cced6a5..8e661d9 100644 --- a/resources/lang/en/tariff-codes.php +++ b/resources/lang/en/tariff-codes.php @@ -43,7 +43,6 @@ ], 'form' => [ - 'basic' => 'Basic', 'year' => [ 'label' => 'Year', ], @@ -58,6 +57,12 @@ ], ], + 'validation' => [ + 'code' => [ + 'unique' => 'A tariff code with this code already exists.', + ], + ], + 'import' => [ 'action_label' => 'Import Tariff Codes', 'modal_heading' => 'Import CN tariff codes', diff --git a/resources/lang/hr/tariff-codes.php b/resources/lang/hr/tariff-codes.php index 0ef87f6..2afbd86 100644 --- a/resources/lang/hr/tariff-codes.php +++ b/resources/lang/hr/tariff-codes.php @@ -43,7 +43,6 @@ ], 'form' => [ - 'basic' => 'Osnovno', 'year' => [ 'label' => 'Godina', ], @@ -58,6 +57,12 @@ ], ], + 'validation' => [ + 'code' => [ + 'unique' => 'Carinska šifra s ovom šifrom već postoji.', + ], + ], + 'import' => [ 'action_label' => 'Uvoz carinskih šifara', 'modal_heading' => 'Uvoz CN carinskih šifara', diff --git a/resources/lang/sl/tariff-codes.php b/resources/lang/sl/tariff-codes.php index 60e7b2d..8d9c4f4 100644 --- a/resources/lang/sl/tariff-codes.php +++ b/resources/lang/sl/tariff-codes.php @@ -43,7 +43,6 @@ ], 'form' => [ - 'basic' => 'Osnovno', 'year' => [ 'label' => 'Leto', ], @@ -58,6 +57,12 @@ ], ], + 'validation' => [ + 'code' => [ + 'unique' => 'Carinska oznaka s to kodo že obstaja.', + ], + ], + 'import' => [ 'action_label' => 'Uvozi carinske oznake', 'modal_heading' => 'Uvoz CN carinskih oznak', diff --git a/resources/lang/sr/tariff-codes.php b/resources/lang/sr/tariff-codes.php index 8f18ad2..d29fed0 100644 --- a/resources/lang/sr/tariff-codes.php +++ b/resources/lang/sr/tariff-codes.php @@ -43,7 +43,6 @@ ], 'form' => [ - 'basic' => 'Osnovno', 'year' => [ 'label' => 'Godina', ], @@ -58,6 +57,12 @@ ], ], + 'validation' => [ + 'code' => [ + 'unique' => 'Carinska šifra s ovom šifrom već postoji.', + ], + ], + 'import' => [ 'action_label' => 'Uvoz carinskih šifara', 'modal_heading' => 'Uvoz CN carinskih šifara', diff --git a/src/Filament/Clusters/World/Resources/TariffCodeResource.php b/src/Filament/Clusters/World/Resources/TariffCodeResource.php index 709ba07..17c64f0 100644 --- a/src/Filament/Clusters/World/Resources/TariffCodeResource.php +++ b/src/Filament/Clusters/World/Resources/TariffCodeResource.php @@ -5,7 +5,6 @@ use BezhanSalleh\FilamentShield\Contracts\HasShieldPermissions; use Eclipse\World\Filament\Clusters\World; use Eclipse\World\Models\TariffCode; -use Filament\Forms\Components\Section; use Filament\Forms\Components\TextInput; use Filament\Forms\Form; use Filament\Resources\Concerns\Translatable; @@ -40,24 +39,27 @@ class TariffCodeResource extends Resource implements HasShieldPermissions public static function form(Form $form): Form { return $form->schema([ - Section::make(__('eclipse-world::tariff-codes.form.basic', [], app()->getLocale()) ?: 'Basic') - ->compact() - ->schema([ - TextInput::make('year') - ->numeric() - ->required() - ->default((int) date('Y')), - TextInput::make('code') - ->maxLength(20) - ->required(), - TextInput::make('name') - ->label(__('eclipse-world::tariff-codes.form.name.label')) - ->required(), - TextInput::make('measure_unit') - ->label(__('eclipse-world::tariff-codes.form.measure_unit.label')) - ->nullable(), - ])->columns(2), - ]); + TextInput::make('code') + ->maxLength(20) + ->required() + ->unique( + table: 'world_tariff_codes', + column: 'code', + ignoreRecord: true, + modifyRuleUsing: function ($rule) { + return $rule->where('year', (int) date('Y')); + } + ) + ->validationMessages([ + 'unique' => __('eclipse-world::tariff-codes.validation.code.unique'), + ]), + TextInput::make('name') + ->label(__('eclipse-world::tariff-codes.form.name.label')) + ->required(), + TextInput::make('measure_unit') + ->label(__('eclipse-world::tariff-codes.form.measure_unit.label')) + ->nullable(), + ])->columns(2); } public static function table(Table $table): Table @@ -67,7 +69,6 @@ public static function table(Table $table): Table ->defaultSort('code') ->striped() ->columns([ - TextColumn::make('year')->label(__('eclipse-world::tariff-codes.table.year.label'))->sortable()->width(90), TextColumn::make('code')->label(__('eclipse-world::tariff-codes.table.code.label'))->searchable()->sortable()->width(160), TextColumn::make('name') ->label(__('eclipse-world::tariff-codes.table.name.label')) diff --git a/src/Jobs/ImportTariffCodes.php b/src/Jobs/ImportTariffCodes.php index 2a8e057..28f62af 100644 --- a/src/Jobs/ImportTariffCodes.php +++ b/src/Jobs/ImportTariffCodes.php @@ -98,6 +98,30 @@ protected function execute(): void $multiReader = $this->csv($multiPath); + $codeNamesLookup = []; + foreach ($multiReader->getRecords() as $row) { + $raw = isset($row['CN_CODE']) ? trim((string) $row['CN_CODE']) : null; + $code = $raw !== null ? $this->normalizeCode($raw) : null; + if ($code === null || $code === '') { + continue; + } + + foreach ($this->locales as $locale) { + $col = 'NAME_'.strtoupper($locale); + if (array_key_exists($col, $row)) { + $val = trim((string) $row[$col]); + if ($val !== '') { + if (! isset($codeNamesLookup[$locale])) { + $codeNamesLookup[$locale] = []; + } + $codeNamesLookup[$locale][$code] = $val; + } + } + } + } + + $multiReader = $this->csv($multiPath); + $chunk = []; $chunkSize = 200; $codesInChunk = []; @@ -123,7 +147,7 @@ protected function execute(): void if (array_key_exists($col, $row)) { $val = trim((string) $row[$col]); if ($val !== '') { - $chunk[$code]['name'][$locale] = $val; + $chunk[$code]['name'][$locale] = $this->transformCnName($val, $code, $codeNamesLookup[$locale] ?? []); } } } @@ -277,4 +301,39 @@ private function normalizeCode(string $code): string { return str_replace(' ', '', $code); } + + /** + * Transform CN name by removing leading dashes and building hierarchical name. + */ + private function transformCnName(string $name, string $code, array $codeNamesLookup): string + { + $name = ltrim($name, '-'); + + if (empty($name)) { + return $code; + } + + $hierarchicalParts = []; + + $hierarchicalParts[] = ucfirst($name); + + $currentCode = $code; + while (strlen($currentCode) > 2) { + $parentCode = substr($currentCode, 0, -2); + if (strlen($parentCode) >= 2) { + $parentName = $codeNamesLookup[$parentCode] ?? null; + if ($parentName) { + $cleanParentName = ltrim($parentName, '-'); + if (! empty($cleanParentName)) { + $hierarchicalParts[] = ucfirst($cleanParentName); + } + } + } + $currentCode = $parentCode; + } + + $hierarchicalParts = array_reverse($hierarchicalParts); + + return implode(' > ', $hierarchicalParts); + } } diff --git a/src/Models/TariffCode.php b/src/Models/TariffCode.php index 9497d1d..50d94d8 100644 --- a/src/Models/TariffCode.php +++ b/src/Models/TariffCode.php @@ -51,4 +51,18 @@ protected static function newFactory(): TariffCodeFactory { return TariffCodeFactory::new(); } + + /** + * Boot the model. + */ + protected static function boot() + { + parent::boot(); + + static::creating(function ($tariffCode) { + if (empty($tariffCode->year)) { + $tariffCode->year = (int) date('Y'); + } + }); + } } diff --git a/tests/Feature/TariffCodeResourceTest.php b/tests/Feature/TariffCodeResourceTest.php index b58f0a0..5b6ec5c 100644 --- a/tests/Feature/TariffCodeResourceTest.php +++ b/tests/Feature/TariffCodeResourceTest.php @@ -74,7 +74,6 @@ public static function getAvailableLocales() // Test with valid data $validData = [ - 'year' => (int) date('Y'), 'code' => '0101', 'name' => ['en' => 'Live horses, asses, mules and hinnies'], 'measure_unit' => ['en' => 'pcs'], @@ -86,7 +85,6 @@ public static function getAvailableLocales() test('new tariff code can be created', function () { $data = [ - 'year' => (int) date('Y'), 'code' => '0101', 'name' => ['en' => 'Live horses, asses, mules and hinnies'], 'measure_unit' => ['en' => 'pcs'], @@ -97,12 +95,11 @@ public static function getAvailableLocales() ->assertHasNoActionErrors(); $tariffCode = TariffCode::where('code', $data['code']) - ->where('year', $data['year']) ->first(); expect($tariffCode)->toBeObject(); - expect($tariffCode->year)->toEqual($data['year']); + expect($tariffCode->year)->toEqual((int) date('Y')); expect($tariffCode->code)->toEqual($data['code']); expect($tariffCode->name)->toEqual($data['name']); expect($tariffCode->measure_unit)->toEqual($data['measure_unit']); @@ -110,7 +107,6 @@ public static function getAvailableLocales() test('existing tariff code can be updated', function () { $tariffCode = TariffCode::factory()->create([ - 'year' => (int) date('Y'), 'code' => '0101', 'name' => ['en' => 'Live horses, asses, mules and hinnies'], 'measure_unit' => ['en' => 'pcs'], @@ -172,14 +168,12 @@ public static function getAvailableLocales() // Create first tariff code $firstTariffCode = TariffCode::factory()->create([ - 'year' => $year, 'code' => '0101', 'name' => ['en' => 'Live horses'], ]); // Try to create duplicate year-code combination $duplicateData = [ - 'year' => $year, 'code' => '0101', 'name' => ['en' => 'Different name'], ]; @@ -197,50 +191,16 @@ public static function getAvailableLocales() ->toBe(1); }); -test('can create same code for different years', function () { - $year1 = (int) date('Y'); - $year2 = $year1 - 1; - - // Create tariff code with same code for first year - $tariffCode1Data = [ - 'year' => $year1, - 'code' => '0101', - 'name' => ['en' => 'Live horses'], - ]; - - livewire(ListTariffCodes::class) - ->callAction('create', $tariffCode1Data) - ->assertHasNoActionErrors(); - - // Create tariff code with same code for second year (should work) - $tariffCode2Data = [ - 'year' => $year2, - 'code' => '0101', - 'name' => ['en' => 'Live horses old'], - ]; - - livewire(ListTariffCodes::class) - ->callAction('create', $tariffCode2Data) - ->assertHasNoActionErrors(); - - // Verify both tariff codes exist - expect(TariffCode::where('code', '0101')->count())->toBe(2); - expect(TariffCode::where('year', $year1)->where('code', '0101')->count())->toBe(1); - expect(TariffCode::where('year', $year2)->where('code', '0101')->count())->toBe(1); -}); - test('updating tariff code respects unique constraint', function () { $year = (int) date('Y'); // Create two tariff codes $tariffCode1 = TariffCode::factory()->create([ - 'year' => $year, 'code' => '0101', 'name' => ['en' => 'Live horses'], ]); $tariffCode2 = TariffCode::factory()->create([ - 'year' => $year, 'code' => '0102', 'name' => ['en' => 'Live cattle'], ]); @@ -256,7 +216,6 @@ public static function getAvailableLocales() $year = (int) date('Y'); $tariffCode = TariffCode::factory()->create([ - 'year' => $year, 'code' => '0101', 'name' => ['en' => 'Live horses'], ]); From cac83274c8d533b8775ce3bc4a194209c3725fc9 Mon Sep 17 00:00:00 2001 From: Kilian Trunk Date: Fri, 5 Sep 2025 13:28:22 +0200 Subject: [PATCH 3/5] chore: import improvements --- src/Jobs/ImportTariffCodes.php | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/Jobs/ImportTariffCodes.php b/src/Jobs/ImportTariffCodes.php index 28f62af..8c00dbe 100644 --- a/src/Jobs/ImportTariffCodes.php +++ b/src/Jobs/ImportTariffCodes.php @@ -208,10 +208,30 @@ private function flushChunk(array $chunk): void return; } + $codes = array_keys($chunk); + $existingRecords = DB::table('world_tariff_codes') + ->where('year', $chunk[reset($codes)]['year']) + ->whereIn('code', $codes) + ->get(['code', 'name', 'measure_unit']) + ->keyBy('code'); + $payload = []; foreach ($chunk as $rec) { - $names = array_intersect_key($rec['name'], array_flip($this->locales)); - $units = array_intersect_key($rec['measure_unit'], array_flip($this->locales)); + $existingName = []; + $existingUnits = []; + + if (isset($existingRecords[$rec['code']])) { + $existing = $existingRecords[$rec['code']]; + if ($existing->name) { + $existingName = json_decode($existing->name, true) ?? []; + } + if ($existing->measure_unit) { + $existingUnits = json_decode($existing->measure_unit, true) ?? []; + } + } + + $names = array_merge($existingName, $rec['name']); + $units = array_merge($existingUnits, $rec['measure_unit']); $payload[] = [ 'year' => $rec['year'], @@ -315,17 +335,17 @@ private function transformCnName(string $name, string $code, array $codeNamesLoo $hierarchicalParts = []; - $hierarchicalParts[] = ucfirst($name); + $hierarchicalParts[] = strtoupper($name); $currentCode = $code; while (strlen($currentCode) > 2) { $parentCode = substr($currentCode, 0, -2); - if (strlen($parentCode) >= 2) { + if (strlen($parentCode) > 2) { $parentName = $codeNamesLookup[$parentCode] ?? null; if ($parentName) { $cleanParentName = ltrim($parentName, '-'); if (! empty($cleanParentName)) { - $hierarchicalParts[] = ucfirst($cleanParentName); + $hierarchicalParts[] = strtoupper($cleanParentName); } } } From cf46deccd1aeb8cf807804ee8a14ff99c36588d8 Mon Sep 17 00:00:00 2001 From: Kilian Trunk Date: Fri, 5 Sep 2025 13:29:21 +0200 Subject: [PATCH 4/5] chore: format --- src/Jobs/ImportTariffCodes.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Jobs/ImportTariffCodes.php b/src/Jobs/ImportTariffCodes.php index 8c00dbe..1ff651d 100644 --- a/src/Jobs/ImportTariffCodes.php +++ b/src/Jobs/ImportTariffCodes.php @@ -219,7 +219,7 @@ private function flushChunk(array $chunk): void foreach ($chunk as $rec) { $existingName = []; $existingUnits = []; - + if (isset($existingRecords[$rec['code']])) { $existing = $existingRecords[$rec['code']]; if ($existing->name) { From b24c82e0a7398d6c596f5f7ff374c07153d3bb84 Mon Sep 17 00:00:00 2001 From: Kilian Trunk Date: Wed, 10 Sep 2025 09:55:03 +0200 Subject: [PATCH 5/5] chore: capitalize only first letter --- src/Jobs/ImportTariffCodes.php | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/Jobs/ImportTariffCodes.php b/src/Jobs/ImportTariffCodes.php index 1ff651d..40d534d 100644 --- a/src/Jobs/ImportTariffCodes.php +++ b/src/Jobs/ImportTariffCodes.php @@ -335,7 +335,7 @@ private function transformCnName(string $name, string $code, array $codeNamesLoo $hierarchicalParts = []; - $hierarchicalParts[] = strtoupper($name); + $hierarchicalParts[] = $this->capitalizeFirst($name); $currentCode = $code; while (strlen($currentCode) > 2) { @@ -345,7 +345,7 @@ private function transformCnName(string $name, string $code, array $codeNamesLoo if ($parentName) { $cleanParentName = ltrim($parentName, '-'); if (! empty($cleanParentName)) { - $hierarchicalParts[] = strtoupper($cleanParentName); + $hierarchicalParts[] = $this->capitalizeFirst($cleanParentName); } } } @@ -356,4 +356,20 @@ private function transformCnName(string $name, string $code, array $codeNamesLoo return implode(' > ', $hierarchicalParts); } + + /** + * Capitalize only the first character (multibyte-safe) and lowercase the rest. + */ + private function capitalizeFirst(string $value): string + { + $trimmed = trim($value); + if ($trimmed === '') { + return $trimmed; + } + + $firstChar = mb_substr($trimmed, 0, 1, 'UTF-8'); + $rest = mb_substr($trimmed, 1, null, 'UTF-8'); + + return mb_strtoupper($firstChar, 'UTF-8').$rest; + } }