├── .github └── issue_template.md ├── .styleci.yml ├── LICENSE ├── README.md ├── codesize.xml ├── composer.json ├── config └── imports.php ├── database ├── factories │ ├── ChunkFactory.php │ ├── DataImportFactory.php │ ├── ImportFactory.php │ └── RejectedChunkFactory.php └── migrations │ ├── 2017_01_01_145000_create_data_imports_table.php │ ├── 2017_01_01_145200_create_rejected_imports_table.php │ ├── 2017_01_01_145250_create_rejected_import_chunks_table.php │ ├── 2017_01_01_145500_create_import_chunks_table.php │ └── 2017_01_01_146000_create_structure_for_dataimport.php ├── resources └── views │ └── emails │ └── import.blade.php ├── routes └── api.php ├── src ├── AppServiceProvider.php ├── Attributes │ ├── Attributes.php │ ├── CSV.php │ ├── Column.php │ ├── Params.php │ ├── Sheet.php │ └── Template.php ├── AuthServiceProvider.php ├── Commands │ ├── CancelStuck.php │ └── Purge.php ├── Contracts │ ├── AfterHook.php │ ├── Authenticates.php │ ├── Authorizes.php │ ├── BeforeHook.php │ └── Importable.php ├── EnumServiceProvider.php ├── Enums │ ├── CssClasses.php │ └── Statuses.php ├── Exceptions │ ├── Attributes.php │ ├── Import.php │ ├── Route.php │ └── Template.php ├── Http │ ├── Controllers │ │ └── Import │ │ │ ├── Cancel.php │ │ │ ├── Destroy.php │ │ │ ├── Download.php │ │ │ ├── ExportExcel.php │ │ │ ├── InitTable.php │ │ │ ├── Options.php │ │ │ ├── Rejected.php │ │ │ ├── Restart.php │ │ │ ├── Show.php │ │ │ ├── Store.php │ │ │ ├── TableData.php │ │ │ └── Template.php │ └── Requests │ │ ├── ValidateImport.php │ │ └── ValidateTemplate.php ├── Jobs │ ├── Chunk.php │ ├── Finalize.php │ ├── Import.php │ ├── RejectedExport.php │ └── Sheet.php ├── Models │ ├── Chunk.php │ ├── Import.php │ ├── RejectedChunk.php │ └── RejectedImport.php ├── Notifications │ └── ImportDone.php ├── Policies │ └── Policy.php ├── Services │ ├── ExcelSeeder.php │ ├── Exporters │ │ └── Rejected.php │ ├── ImportTemplate.php │ ├── Importers │ │ ├── Chunk.php │ │ ├── Import.php │ │ └── Sheet.php │ ├── Notifiables.php │ ├── Options.php │ ├── Readers │ │ ├── CSV.php │ │ ├── Reader.php │ │ └── XLSX.php │ ├── Sanitizers │ │ └── Sanitize.php │ ├── Summary.php │ ├── Template.php │ └── Validators │ │ ├── Params.php │ │ ├── Row.php │ │ ├── Structure.php │ │ ├── Template.php │ │ └── Validator.php ├── Tables │ ├── Builders │ │ └── Import.php │ └── Templates │ │ └── imports.json └── Tests │ ├── UserGroupImporter.php │ └── userGroups.json ├── stubs └── Imports │ ├── Importers │ └── ExampleImporter.stub │ ├── Templates │ └── exampleTemplate.stub │ └── Validators │ └── CustomValidator.stub └── tests └── features ├── DataImportTest.php ├── ValidationTest.php ├── templates ├── paramsValidation.json └── userGroups.json └── testFiles ├── content_errors.xlsx ├── invalid_columns.xlsx ├── invalid_sheets.xlsx ├── userGroups.xlsx └── userGroups_import.xlsx /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | 2 | This is a **bug | feature request**. 3 | 4 | 5 | ### Prerequisites 6 | * [ ] Are you running the latest version? 7 | * [ ] Are you reporting to the correct repository? 8 | * [ ] Did you check the documentation? 9 | * [ ] Did you perform a cursory search? 10 | 11 | ### Description 12 | 13 | 14 | ### Steps to Reproduce 15 | 20 | 21 | ### Expected behavior 22 | 23 | 24 | ### Actual behavior 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | risky: true 2 | 3 | preset: laravel 4 | 5 | enabled: 6 | - strict 7 | - unalign_double_arrow 8 | 9 | disabled: 10 | - short_array_syntax 11 | 12 | finder: 13 | exclude: 14 | - "public" 15 | - "resources" 16 | - "tests" 17 | name: 18 | - "*.php" 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 laravel-enso 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Data Import 2 | 3 | [![Codacy Badge](https://app.codacy.com/project/badge/Grade/8c53be4df359406b8ce6bc48f627aee8)](https://www.codacy.com/gh/laravel-enso/data-import?utm_source=github.com&utm_medium=referral&utm_content=laravel-enso/data-import&utm_campaign=Badge_Grade) 4 | [![StyleCI](https://github.styleci.io/repos/89221336/shield?branch=master)](https://github.styleci.io/repos/89221336) 5 | [![License](https://poser.pugx.org/laravel-enso/data-import/license)](https://packagist.org/packages/laravel-enso/data-import) 6 | [![Total Downloads](https://poser.pugx.org/laravel-enso/data-import/downloads)](https://packagist.org/packages/laravel-enso/data-import) 7 | [![Latest Stable Version](https://poser.pugx.org/laravel-enso/data-import/version)](https://packagist.org/packages/laravel-enso/data-import) 8 | 9 | Incredibly powerful, efficient, unlimited number of rows, queues based Excel importer dependency for [Laravel Enso](https://github.com/laravel-enso/Enso). 10 | 11 | [![Watch the demo](https://laravel-enso.github.io/dataimport/screenshots/bulma_006_thumb.png)](https://laravel-enso.github.io/data-import/videos/bulma_demo_01.mp4) 12 | 13 | 14 | click on the photo to view a short demo in compatible browsers 15 | 16 | [![Screenshot](https://laravel-enso.github.io/dataimport/screenshots/bulma_007_thumb.png)](https://laravel-enso.github.io/data-import/screenshots/bulma_007.png) 17 | 18 | 19 | ### Installation, Configuration & Usage 20 | 21 | Be sure to check out the full documentation for this package available at [docs.laravel-enso.com](https://docs.laravel-enso.com/backend/data-import.html) 22 | 23 | ### Contributions 24 | 25 | are welcome. Pull requests are great, but issues are good too. 26 | 27 | ### License 28 | 29 | This package is released under the MIT license. 30 | -------------------------------------------------------------------------------- /codesize.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | custom rules 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel-enso/data-import", 3 | "description": "Excel Importer dependency for Laravel Enso", 4 | "keywords": [ 5 | "laravel-enso", 6 | "data-import", 7 | "data-import", 8 | "excel-import", 9 | "excel-importer" 10 | ], 11 | "homepage": "https://github.com/laravel-enso/data-import", 12 | "type": "library", 13 | "license": "MIT", 14 | "authors": [ 15 | { 16 | "name": "Adrian Ocneanu", 17 | "email": "aocneanu@gmail.com", 18 | "homepage": "https://laravel-enso.com", 19 | "role": "Developer" 20 | }, 21 | { 22 | "name": "Mihai Ocneanu", 23 | "email": "mihai.ocneanu@gmail.com", 24 | "homepage": "https://laravel-enso.com", 25 | "role": "Developer" 26 | } 27 | ], 28 | "require": { 29 | "laravel-enso/core": "^10.0", 30 | "laravel-enso/dynamic-methods": "^3.0", 31 | "laravel-enso/enums": "^2.0", 32 | "laravel-enso/excel": "^3.0", 33 | "laravel-enso/files": "^5.0", 34 | "laravel-enso/helpers": "^3.0", 35 | "laravel-enso/io": "^2.0", 36 | "laravel-enso/migrator": "^2.0", 37 | "laravel-enso/track-who": "^2.0", 38 | "laravel-enso/tables": "^4.0", 39 | "laravel-enso/select": "^4.0", 40 | "openspout/openspout": "^4.0" 41 | }, 42 | "autoload": { 43 | "psr-4": { 44 | "LaravelEnso\\DataImport\\": "src/", 45 | "LaravelEnso\\DataImport\\Database\\Factories\\": "database/factories/" 46 | } 47 | }, 48 | "extra": { 49 | "laravel": { 50 | "providers": [ 51 | "LaravelEnso\\DataImport\\AppServiceProvider", 52 | "LaravelEnso\\DataImport\\EnumServiceProvider" 53 | ], 54 | "aliases": [] 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /config/imports.php: -------------------------------------------------------------------------------- 1 | 'local', 15 | 16 | /* 17 | |-------------------------------------------------------------------------- 18 | | Chunk Size 19 | |-------------------------------------------------------------------------- 20 | | The default row chunk for the import queued jobs. Can be overwritten 21 | | per template. 22 | | 23 | */ 24 | 25 | 'chunkSize' => 1000, 26 | 27 | /* 28 | |-------------------------------------------------------------------------- 29 | | Queues 30 | |-------------------------------------------------------------------------- 31 | | Specifies the queue for each type of job during the import process. 32 | | The splitting process will be the longest and should be set 33 | | to a larger value. 34 | */ 35 | 36 | 'queues' => [ 37 | 'splitting' => 'heavy', 38 | 'processing' => 'light', 39 | 'rejected' => 'heavy', 40 | 'notifications' => 'notifications', 41 | ], 42 | 43 | /* 44 | |-------------------------------------------------------------------------- 45 | | Timeout 46 | |-------------------------------------------------------------------------- 47 | | Sets the default timeout used for chunk splitting jobs & rejected 48 | | summary export. It can be overwritten in the import's template. 49 | | 50 | */ 51 | 52 | 'timeout' => 60 * 5, 53 | 54 | /* 55 | |-------------------------------------------------------------------------- 56 | | Error Column 57 | |-------------------------------------------------------------------------- 58 | | Each import with failed entries will generate a rejected xlsx report 59 | | with the same structure as the import and an extra errors column. 60 | | This flag sets the name of the errors column. 61 | | 62 | */ 63 | 64 | 'errorColumn' => '_errors', 65 | 66 | /* 67 | |-------------------------------------------------------------------------- 68 | | Unknown Import Error Message 69 | |-------------------------------------------------------------------------- 70 | | If the developer misses covering with validations an error scenario 71 | | when that scenario is met the importer will report and unknown 72 | | error. Here you can customize that error message. 73 | */ 74 | 75 | 'unknownError' => 'Undetermined import error', 76 | 77 | /* 78 | |-------------------------------------------------------------------------- 79 | | Notification channels 80 | |-------------------------------------------------------------------------- 81 | | After each import the user will be notified by email. Additionally 82 | | a notification can be broadcasted to the user. 83 | | 84 | */ 85 | 86 | 'notifications' => ['broadcast', 'database'], 87 | 88 | /* 89 | |-------------------------------------------------------------------------- 90 | | Seeder path 91 | |-------------------------------------------------------------------------- 92 | | 93 | | The path for excel files used for the ExcelSeeder class inside the 94 | | 95 | */ 96 | 97 | 'seederPath' => database_path('seeders/xlsx'), 98 | 99 | /* 100 | |-------------------------------------------------------------------------- 101 | | Notifiable Ids 102 | |-------------------------------------------------------------------------- 103 | | You can add here user ids separated by comma if you want to use post 104 | | finalize notifications to other users than the importer. 105 | | 106 | */ 107 | 108 | 'notifiableIds' => env('DATA_IMPORT_NOTIFIABLE_IDS', null), 109 | 110 | /* 111 | |-------------------------------------------------------------------------- 112 | | Retain imports for a number of days 113 | |-------------------------------------------------------------------------- 114 | | The default period in days for retaining imports, used by the 115 | | enso:data-import:purge command. Leave it 0 if you want to 116 | | retain files forever. 117 | */ 118 | 119 | 'retainFor' => (int) env('IMPORT_RETAIN_FOR', 0), 120 | 121 | /* 122 | |-------------------------------------------------------------------------- 123 | | How many hours until cancelling stuck imports 124 | |-------------------------------------------------------------------------- 125 | | The number of hours after the enso:data-import:reset-stuck command resets 126 | | stuck imports to cancel. 127 | | 128 | */ 129 | 130 | 'cancelStuckAfter' => (int) env('IMPORT_CANCEL_STUCK_AFTER', 24), 131 | 132 | /* 133 | |-------------------------------------------------------------------------- 134 | | Configurations 135 | |-------------------------------------------------------------------------- 136 | | Holds your import configuration. 'label' is used for the main page select 137 | | and template is the full path to your import template JSON. 138 | | 139 | */ 140 | 141 | 'configs' => [ 142 | 'userGroups' => [ 143 | 'label' => 'User Groups', 144 | 'template' => 'vendor/laravel-enso/data-import/src/Tests/userGroups.json', 145 | ], 146 | ], 147 | 148 | ]; 149 | -------------------------------------------------------------------------------- /database/factories/ChunkFactory.php: -------------------------------------------------------------------------------- 1 | null, 16 | 'sheet' => null, 17 | 'header' => [], 18 | 'rows' => [], 19 | ]; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /database/factories/DataImportFactory.php: -------------------------------------------------------------------------------- 1 | null, 17 | 'batch' => null, 18 | 'params' => [], 19 | 'successful' => 0, 20 | 'failed' => 0, 21 | 'status' => Statuses::Waiting, 22 | ]; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /database/factories/ImportFactory.php: -------------------------------------------------------------------------------- 1 | null, 17 | 'batch' => null, 18 | 'params' => [], 19 | 'successful' => 0, 20 | 'failed' => 0, 21 | 'status' => Statuses::Waiting, 22 | ]; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /database/factories/RejectedChunkFactory.php: -------------------------------------------------------------------------------- 1 | null, 16 | 'sheet' => null, 17 | 'header' => [], 18 | 'rows' => [], 19 | ]; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /database/migrations/2017_01_01_145000_create_data_imports_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 13 | 14 | $table->bigInteger('file_id')->unsigned()->nullable()->unique(); 15 | $table->foreign('file_id')->references('id')->on('files') 16 | ->onUpdate('restrict')->onDelete('restrict'); 17 | 18 | $table->string('batch')->nullable(); 19 | $table->foreign('batch')->references('id')->on('job_batches') 20 | ->onUpdate('restrict')->onDelete('restrict'); 21 | 22 | $table->string('type')->index(); 23 | 24 | $table->json('params')->nullable(); 25 | 26 | $table->integer('successful'); 27 | $table->integer('failed'); 28 | 29 | $table->tinyInteger('status'); 30 | 31 | $table->integer('created_by')->unsigned()->nullable(); 32 | $table->foreign('created_by')->references('id')->on('users'); 33 | 34 | $table->timestamps(); 35 | }); 36 | } 37 | 38 | public function down() 39 | { 40 | Schema::dropIfExists('data_imports'); 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /database/migrations/2017_01_01_145200_create_rejected_imports_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 13 | 14 | $table->bigInteger('file_id')->unsigned()->nullable()->unique(); 15 | $table->foreign('file_id')->references('id')->on('files') 16 | ->onUpdate('restrict')->onDelete('restrict'); 17 | 18 | $table->integer('import_id')->unsigned(); 19 | $table->foreign('import_id')->references('id')->on('data_imports') 20 | ->onDelete('restrict'); 21 | 22 | $table->timestamps(); 23 | }); 24 | } 25 | 26 | public function down() 27 | { 28 | Schema::dropIfExists('rejected_imports'); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /database/migrations/2017_01_01_145250_create_rejected_import_chunks_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 13 | 14 | $table->integer('import_id')->unsigned(); 15 | $table->foreign('import_id')->references('id')->on('data_imports') 16 | ->onDelete('cascade'); 17 | 18 | $table->string('sheet'); 19 | $table->json('header'); 20 | $table->json('rows'); 21 | 22 | $table->timestamps(); 23 | }); 24 | } 25 | 26 | public function down() 27 | { 28 | Schema::dropIfExists('rejected_import_chunks'); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /database/migrations/2017_01_01_145500_create_import_chunks_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 13 | 14 | $table->integer('import_id')->unsigned(); 15 | $table->foreign('import_id')->references('id')->on('data_imports') 16 | ->onDelete('cascade'); 17 | 18 | $table->string('sheet'); 19 | $table->json('header'); 20 | $table->json('rows'); 21 | 22 | $table->timestamps(); 23 | }); 24 | } 25 | 26 | public function down() 27 | { 28 | Schema::dropIfExists('import_chunks'); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /database/migrations/2017_01_01_146000_create_structure_for_dataimport.php: -------------------------------------------------------------------------------- 1 | 'import.index', 'description' => 'Imports index', 'is_default' => false], 9 | 10 | ['name' => 'import.store', 'description' => 'Upload file for import', 'is_default' => false], 11 | ['name' => 'import.destroy', 'description' => 'Delete import', 'is_default' => false], 12 | ['name' => 'import.download', 'description' => 'Download import', 'is_default' => false], 13 | 14 | ['name' => 'import.initTable', 'description' => 'Init table for imports', 'is_default' => false], 15 | ['name' => 'import.tableData', 'description' => 'Table data for imports', 'is_default' => false], 16 | ['name' => 'import.exportExcel', 'description' => 'Export excel for imports', 'is_default' => false], 17 | 18 | ['name' => 'import.rejected', 'description' => 'Download rejected summary for import', 'is_default' => false], 19 | 20 | ['name' => 'import.template', 'description' => 'Download import template', 'is_default' => false], 21 | 22 | ['name' => 'import.show', 'description' => 'Get import', 'is_default' => false], 23 | 24 | ['name' => 'import.cancel', 'description' => 'Cancel import', 'is_default' => false], 25 | ['name' => 'import.restart', 'description' => 'Restart import', 'is_default' => false], 26 | 27 | ['name' => 'import.options', 'description' => 'Get import options for select', 'is_default' => false], 28 | ]; 29 | 30 | protected array $menu = [ 31 | 'name' => 'Data Import', 'icon' => 'cloud-upload-alt', 'route' => 'import.index', 'order_index' => 800, 'has_children' => false, 32 | ]; 33 | }; 34 | -------------------------------------------------------------------------------- /resources/views/emails/import.blade.php: -------------------------------------------------------------------------------- 1 | @component('mail::message') 2 | {{ __('Hi :name', ['name' => $name]) }}, 3 | 4 | {{ __('The :name import is done', ['name' => $import->type()]) }}: 5 | {{ $import->file->original_name }}. 6 | 7 | @component('mail::table') 8 | | Entries | Count | 9 | |:-----------------------:|:-------------------------:| 10 | | {{ __('Successful') }} | {{ $import->successful }} | 11 | | {{ __('Failed') }} | {{ $import->failed }} | 12 | | {{ __('Total') }} | {{ $import->entries }} | 13 | @endcomponent 14 | 15 | @if($import->rejected) 16 | @component('mail::button', ['url' => $import->rejected->file->temporaryLink()]) 17 | @lang('Download failed report') 18 | @endcomponent 19 | @endif 20 | 21 | {{ __('Thank you') }},
22 | {{ __(config('app.name')) }} 23 | @endcomponent 24 | -------------------------------------------------------------------------------- /routes/api.php: -------------------------------------------------------------------------------- 1 | prefix('api/import')->as('import.') 19 | ->group(function () { 20 | Route::delete('{import}', Destroy::class)->name('destroy'); 21 | Route::post('store', Store::class)->name('store'); 22 | Route::get('download/{import}', Download::class)->name('download'); 23 | 24 | Route::get('initTable', InitTable::class)->name('initTable'); 25 | Route::get('tableData', TableData::class)->name('tableData'); 26 | Route::get('exportExcel', ExportExcel::class)->name('exportExcel'); 27 | 28 | Route::patch('{import}/cancel', Cancel::class)->name('cancel'); 29 | Route::patch('{import}/restart', Restart::class)->name('restart'); 30 | 31 | Route::get('options', Options::class)->name('options'); 32 | 33 | Route::get('{type}', Show::class)->name('show'); 34 | 35 | Route::get('{type}/template', Template::class)->name('template'); 36 | 37 | Route::get('{rejected}/rejected', Rejected::class)->name('rejected'); 38 | }); 39 | -------------------------------------------------------------------------------- /src/AppServiceProvider.php: -------------------------------------------------------------------------------- 1 | load() 20 | ->publishAssets() 21 | ->publishExamples() 22 | ->command(); 23 | } 24 | 25 | private function load(): self 26 | { 27 | $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); 28 | 29 | $this->loadRoutesFrom(__DIR__.'/../routes/api.php'); 30 | 31 | $this->mergeConfigFrom(__DIR__.'/../config/imports.php', 'enso.imports'); 32 | 33 | $this->loadViewsFrom(__DIR__.'/../resources/views', 'laravel-enso/data-import'); 34 | 35 | return $this; 36 | } 37 | 38 | private function publishAssets(): self 39 | { 40 | $this->publishes([ 41 | __DIR__.'/../config' => config_path('enso'), 42 | ], ['data-import-config', 'enso-config']); 43 | 44 | $this->publishes([ 45 | __DIR__.'/../database/factories' => database_path('factories'), 46 | ], ['data-import-factory', 'enso-factories']); 47 | 48 | $this->publishes([ 49 | __DIR__.'/../resources/views' => resource_path('views/vendor/laravel-enso/data-import'), 50 | ], ['data-import-mail', 'enso-mail']); 51 | 52 | return $this; 53 | } 54 | 55 | private function publishExamples(): self 56 | { 57 | $stubPrefix = __DIR__.'/../stubs/'; 58 | 59 | $stubs = Collection::wrap([ 60 | 'Imports/Importers/ExampleImporter', 61 | 'Imports/Templates/exampleTemplate', 62 | 'Imports/Validators/CustomValidator', 63 | ])->reduce(fn ($stubs, $stub) => $stubs 64 | ->put("{$stubPrefix}{$stub}.stub", app_path("{$stub}.php")), new Collection()); 65 | 66 | $this->publishes($stubs->all(), 'data-import-examples'); 67 | 68 | return $this; 69 | } 70 | 71 | private function command(): void 72 | { 73 | $this->commands(Purge::class, CancelStuck::class); 74 | 75 | $this->app->booted(function () { 76 | $schedule = $this->app->make(Schedule::class); 77 | $schedule->command('enso:data-import:purge')->daily(); 78 | $schedule->command('enso:data-import:cancel-stuck')->daily(); 79 | }); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Attributes/Attributes.php: -------------------------------------------------------------------------------- 1 | mandatory()->concat($this->optional()); 19 | } 20 | 21 | public function validateMandatory(Collection $attributes): self 22 | { 23 | $this->mandatory()->diff($attributes) 24 | ->unlessEmpty(fn ($missing) => throw Exception::missing($missing, $this->class())); 25 | 26 | return $this; 27 | } 28 | 29 | public function rejectUnknown(Collection $attributes): self 30 | { 31 | $attributes->diff($this->allowed()) 32 | ->unlessEmpty(fn ($unknown) => throw Exception::unknown($unknown, $this->class())); 33 | 34 | return $this; 35 | } 36 | 37 | public function values($type): Collection 38 | { 39 | return new Collection($this->values[$type] ?? []); 40 | } 41 | 42 | public function dependent($type): Collection 43 | { 44 | return new Collection($this->dependent[$type] ?? []); 45 | } 46 | 47 | public function class(): string 48 | { 49 | $class = (new ReflectionClass(static::class))->getShortName(); 50 | 51 | return strtolower($class); 52 | } 53 | 54 | protected function mandatory(): Collection 55 | { 56 | return new Collection($this->mandatory); 57 | } 58 | 59 | protected function optional(): Collection 60 | { 61 | return new Collection($this->optional); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Attributes/CSV.php: -------------------------------------------------------------------------------- 1 | ['route|options']]; 15 | 16 | protected array $values = ['type' => ['select', 'input', 'checkbox', 'date']]; 17 | } 18 | -------------------------------------------------------------------------------- /src/Attributes/Sheet.php: -------------------------------------------------------------------------------- 1 | Policy::class, 13 | ]; 14 | 15 | public function boot() 16 | { 17 | $this->registerPolicies(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Commands/CancelStuck.php: -------------------------------------------------------------------------------- 1 | update([ 18 | 'status' => Statuses::Cancelled, 19 | 'batch' => null, 20 | ]); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Commands/Purge.php: -------------------------------------------------------------------------------- 1 | notDeletable() 18 | ->update([ 19 | 'status' => Statuses::Cancelled, 20 | 'batch' => null, 21 | ]); 22 | 23 | Import::expired()->deletable()->get()->each->purge(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Contracts/AfterHook.php: -------------------------------------------------------------------------------- 1 | CssClasses::class, 13 | 'importStatuses' => Statuses::class, 14 | ]; 15 | } 16 | -------------------------------------------------------------------------------- /src/Enums/CssClasses.php: -------------------------------------------------------------------------------- 1 | 'is-info', 9 | self::Processing => 'is-warning', 10 | self::Processed => 'is-primary', 11 | self::ExportingRejected => 'is-danger', 12 | self::Finalized => 'is-success', 13 | self::Cancelled => 'is-danger', 14 | ]; 15 | } 16 | -------------------------------------------------------------------------------- /src/Enums/Statuses.php: -------------------------------------------------------------------------------- 1 | 'waiting', 18 | self::Processing => 'processing', 19 | self::Processed => 'processed', 20 | self::ExportingRejected => 'exporting rejected', 21 | self::Finalized => 'finalized', 22 | self::Cancelled => 'cancelled', 23 | ]; 24 | 25 | public static function running(): array 26 | { 27 | return [self::Waiting, self::Processing]; 28 | } 29 | 30 | public static function deletable(): array 31 | { 32 | return [self::Finalized, self::Cancelled]; 33 | } 34 | 35 | public static function isDeletable(int $status): bool 36 | { 37 | return in_array($status, self::deletable()); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Exceptions/Attributes.php: -------------------------------------------------------------------------------- 1 | $attrs->implode('", "'), 'class' => $class], 15 | )); 16 | } 17 | 18 | public static function unknown(Collection $attrs, string $class) 19 | { 20 | return new static(__( 21 | 'The following optional attrs are allowed for :class : ":attrs"', 22 | ['attrs' => $attrs->implode('", "'), 'class' => $class] 23 | )); 24 | } 25 | 26 | public static function invalidParam(Collection $attrs, string $class) 27 | { 28 | return new static(__( 29 | 'The following values are allowed for params types in :class : ":attrs"', 30 | ['attrs' => $attrs->implode('", "'), 'class' => $class] 31 | )); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Exceptions/Import.php: -------------------------------------------------------------------------------- 1 | $file])); 22 | } 23 | 24 | public static function unauthorized() 25 | { 26 | return new static(__('You are not authorized to perform this import')); 27 | } 28 | 29 | public static function cannotBeCancelled() 30 | { 31 | return new static(__('Only in-progress imports can be cancelled')); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Exceptions/Route.php: -------------------------------------------------------------------------------- 1 | $route])); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Exceptions/Template.php: -------------------------------------------------------------------------------- 1 | $attrs] 22 | )); 23 | } 24 | 25 | public static function missingSheetAttributes($attrs) 26 | { 27 | return new static(__( 28 | 'Mandatory Attribute(s) Missing in sheet object: ":attrs"', 29 | ['attrs' => $attrs] 30 | )); 31 | } 32 | 33 | public static function unknownSheetAttributes($attrs) 34 | { 35 | return new static(__( 36 | 'Unknown Optional Attribute(s) in sheet object: ":attr"', 37 | ['attrs' => $attrs] 38 | )); 39 | } 40 | 41 | public static function missingColumnAttributes($attrs) 42 | { 43 | return new static(__( 44 | 'Mandatory Attribute(s) Missing in column object: ":attr"', 45 | ['attrs' => $attrs] 46 | )); 47 | } 48 | 49 | public static function unknownColumnAttributes($attrs) 50 | { 51 | return new static(__( 52 | 'Unknown Attribute(s) found in column object: ":attr"', 53 | ['attrs' => $attrs] 54 | )); 55 | } 56 | 57 | public static function missingImporterClass(Obj $sheet) 58 | { 59 | return new static(__( 60 | 'Importer class ":class" for sheet ":sheet" does not exist', 61 | ['class' => $sheet->get('importerClass'), 'sheet' => $sheet->get('name')] 62 | )); 63 | } 64 | 65 | public static function importerMissingContract(Obj $sheet) 66 | { 67 | return new static(__( 68 | 'Importer class ":class" for sheet ":sheet" must implement the ":contract" contract', 69 | ['class' => $sheet->get('importerClass'), 'contract' => Importable::class] 70 | )); 71 | } 72 | 73 | public static function missingValidatorClass(Obj $sheet) 74 | { 75 | return new static(__( 76 | 'Validator class ":class" for sheet ":sheet" does not exist', 77 | ['class' => $sheet->get('validatorClass'), 'sheet' => $sheet->get('name')] 78 | )); 79 | } 80 | 81 | public static function incorectValidator(Obj $sheet) 82 | { 83 | return new static(__( 84 | 'Validator class ":class" for sheet ":sheet" must extend ":validator" class', 85 | [ 86 | 'class' => $sheet->get('validatorClass'), 87 | 'sheet' => $sheet->get('name'), 88 | 'validator' => Validator::class, 89 | ] 90 | )); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Http/Controllers/Import/Cancel.php: -------------------------------------------------------------------------------- 1 | cancel(); 13 | 14 | return ['message' => __('The import was cancelled successfully')]; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Http/Controllers/Import/Destroy.php: -------------------------------------------------------------------------------- 1 | delete(); 13 | 14 | return ['message' => __('The import record was successfully deleted')]; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Http/Controllers/Import/Download.php: -------------------------------------------------------------------------------- 1 | file->download(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Http/Controllers/Import/ExportExcel.php: -------------------------------------------------------------------------------- 1 | file->download(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Http/Controllers/Import/Restart.php: -------------------------------------------------------------------------------- 1 | restart(); 13 | 14 | return ['message' => __('The import was restarted')]; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Http/Controllers/Import/Show.php: -------------------------------------------------------------------------------- 1 | (new Template($type))->params(false)]; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Http/Controllers/Import/Store.php: -------------------------------------------------------------------------------- 1 | except(['import', 'type']); 16 | 17 | $import = Import::factory()->make([ 18 | 'type' => $request->get('type'), 19 | 'params' => new Obj($params), 20 | ]); 21 | 22 | $rules = $import->template()->paramRules(); 23 | 24 | Validator::make($params, $rules)->validate(); 25 | 26 | return $import->upload($request->file('import')); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Http/Controllers/Import/TableData.php: -------------------------------------------------------------------------------- 1 | inline(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Http/Requests/ValidateImport.php: -------------------------------------------------------------------------------- 1 | 'required|file', 19 | 'type' => 'string|in:'.implode(',', Options::types()), 20 | ]; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Http/Requests/ValidateTemplate.php: -------------------------------------------------------------------------------- 1 | 'required|file', 19 | 'type' => 'string|in:'.implode(',', Options::types()), 20 | ]; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Jobs/Chunk.php: -------------------------------------------------------------------------------- 1 | chunk = $chunk; 23 | } 24 | 25 | public function handle() 26 | { 27 | if (! $this->batch()->cancelled()) { 28 | (new Service($this->chunk))->handle(); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Jobs/Finalize.php: -------------------------------------------------------------------------------- 1 | queue = Config::get('enso.imports.queues.processing'); 23 | } 24 | 25 | public function handle() 26 | { 27 | $this->import->update(['status' => Statuses::Finalized]); 28 | 29 | $this->notify(); 30 | } 31 | 32 | private function notify(): void 33 | { 34 | $queue = Config::get('enso.imports.queues.notifications'); 35 | $notification = (new ImportDone($this->import))->onQueue($queue); 36 | 37 | $this->import->file->createdBy->notify($notification); 38 | 39 | if ($this->import->template()->notifies()) { 40 | Notifiables::get($this->import)->each->notify($notification); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Jobs/Import.php: -------------------------------------------------------------------------------- 1 | queue = Config::get('enso.imports.queues.splitting'); 25 | $this->timeout = $this->import->template()->timeout(); 26 | } 27 | 28 | public function handle() 29 | { 30 | if (! $this->import->cancelled()) { 31 | (new Service($this->import, $this->sheet))->handle(); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Jobs/RejectedExport.php: -------------------------------------------------------------------------------- 1 | queue = Config::get('enso.imports.queues.rejected'); 24 | $this->timeout = (new Template($import->type))->timeout(); 25 | } 26 | 27 | public function handle() 28 | { 29 | if ($this->import->failed > 0) { 30 | (new Rejected($this->import))->handle(); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Jobs/Sheet.php: -------------------------------------------------------------------------------- 1 | batch()->cancelled()) { 27 | (new Service($this->batch(), $this->import, $this->sheet))->handle(); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Models/Chunk.php: -------------------------------------------------------------------------------- 1 | belongsTo(Import::class); 21 | } 22 | 23 | public function template(): Template 24 | { 25 | return $this->import->template(); 26 | } 27 | 28 | public function importer(): Importable 29 | { 30 | return $this->template()->importer($this->sheet); 31 | } 32 | 33 | public function add(array $row): void 34 | { 35 | $rows = $this->rows; 36 | $rows[] = $row; 37 | $this->rows = $rows; 38 | } 39 | 40 | public function count(): int 41 | { 42 | return count($this->rows); 43 | } 44 | 45 | protected function casts(): array 46 | { 47 | return [ 48 | 'header' => 'array', 'rows' => 'array', 49 | ]; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Models/Import.php: -------------------------------------------------------------------------------- 1 | belongsTo(File::class); 52 | } 53 | 54 | public function rejected(): Relation 55 | { 56 | return $this->hasOne(RejectedImport::class); 57 | } 58 | 59 | public function chunks(): Relation 60 | { 61 | return $this->hasMany(Chunk::class, 'import_id'); 62 | } 63 | 64 | public function rejectedChunks(): Relation 65 | { 66 | return $this->hasMany(RejectedChunk::class, 'import_id'); 67 | } 68 | 69 | public function scopeExpired(Builder $query): Builder 70 | { 71 | $retainFor = Config::get('enso.imports.retainFor'); 72 | 73 | if ($retainFor === 0) { 74 | return $query->whereId(0); 75 | } 76 | 77 | $expired = Carbon::today()->subDays($retainFor); 78 | 79 | return $query->where('created_at', '<', $expired); 80 | } 81 | 82 | public function scopeStuck(Builder $query): Builder 83 | { 84 | $cancelStuckAfter = Carbon::today() 85 | ->subHours(Config::get('enso.imports.cancelStuckAfter')); 86 | 87 | return $query->where('created_at', '<', $cancelStuckAfter) 88 | ->whereNotIn('status', [Statuses::Finalized, Statuses::Cancelled]); 89 | } 90 | 91 | public function scopeDeletable(Builder $query): Builder 92 | { 93 | return $query->whereIn('status', Statuses::deletable()); 94 | } 95 | 96 | public function scopeNotDeletable(Builder $query): Builder 97 | { 98 | return $query->whereNotIn('status', Statuses::deletable()); 99 | } 100 | 101 | public function extensions(): array 102 | { 103 | return ['xlsx', 'csv', 'txt']; 104 | } 105 | 106 | public function batch(): ?Batch 107 | { 108 | return $this->batch ? Bus::findBatch($this->batch) : null; 109 | } 110 | 111 | public function getEntriesAttribute() 112 | { 113 | return $this->entries(); 114 | } 115 | 116 | public function entries() 117 | { 118 | return $this->successful + $this->failed; 119 | } 120 | 121 | public function type(): string 122 | { 123 | return Options::label($this->type); 124 | } 125 | 126 | public function operationType(): int 127 | { 128 | return IOTypes::Import; 129 | } 130 | 131 | public function progress(): ?int 132 | { 133 | return $this->batch()?->progress(); 134 | } 135 | 136 | public function broadcastWith(): array 137 | { 138 | $label = Config::get('enso.imports')['configs'][$this->type]['label']; 139 | 140 | return [ 141 | 'type' => Str::lower($label), 142 | 'filename' => $this->file?->original_name, 143 | 'sheet' => $this->batch()?->name, 144 | 'successful' => $this->successful, 145 | 'failed' => $this->failed, 146 | ]; 147 | } 148 | 149 | public function createdAt(): Carbon 150 | { 151 | return $this->created_at; 152 | } 153 | 154 | public function status(): int 155 | { 156 | return $this->running() 157 | ? $this->status 158 | : Statuses::Finalized; 159 | } 160 | 161 | public function waiting(): bool 162 | { 163 | return $this->status === Statuses::Waiting; 164 | } 165 | 166 | public function cancelled(): bool 167 | { 168 | return $this->status === Statuses::Cancelled; 169 | } 170 | 171 | public function processing(): bool 172 | { 173 | return $this->status === Statuses::Processing; 174 | } 175 | 176 | public function finalized(): bool 177 | { 178 | return $this->status === Statuses::Finalized; 179 | } 180 | 181 | public function running(): bool 182 | { 183 | return in_array($this->status, Statuses::running()); 184 | } 185 | 186 | public function template(): Template 187 | { 188 | return $this->template ??= new Template($this->type); 189 | } 190 | 191 | public static function cascadeFileDeletion(File $file): void 192 | { 193 | self::whereFileId($file->id)->first()->delete(); 194 | } 195 | 196 | public function attach(string $savedName, string $filename): array 197 | { 198 | $path = Type::for($this::class)->path($savedName); 199 | $extension = Str::afterLast($filename, '.'); 200 | $args = [$this->template(), Storage::path($path), $filename, $extension]; 201 | 202 | $structure = new Structure(...$args); 203 | 204 | if ($structure->validates()) { 205 | $file = File::attach($this, $savedName, $filename); 206 | $this->file()->associate($file)->save(); 207 | 208 | $this->import(); 209 | } 210 | 211 | return $structure->summary(); 212 | } 213 | 214 | public function upload(UploadedFile $file): array 215 | { 216 | $path = $file->getPathname(); 217 | $filename = $file->getClientOriginalName(); 218 | $extension = $file->getClientOriginalExtension(); 219 | 220 | $args = [$this->template(), $path, $filename, $extension]; 221 | 222 | $structure = new Structure(...$args); 223 | 224 | if ($structure->validates()) { 225 | $this->save(); 226 | 227 | $file = File::upload($this, $file); 228 | $this->file()->associate($file)->save(); 229 | 230 | $this->import(); 231 | } 232 | 233 | return $structure->summary(); 234 | } 235 | 236 | public function forceDelete() 237 | { 238 | if (! Statuses::isDeletable($this->status)) { 239 | $this->update(['status' => Statuses::Cancelled]); 240 | } 241 | 242 | $this->delete(); 243 | } 244 | 245 | public function purge(): void 246 | { 247 | $this->rejected?->delete(); 248 | $file = $this->file; 249 | $this->file()->dissociate()->save(); 250 | $file?->delete(); 251 | } 252 | 253 | public function delete() 254 | { 255 | if (! Statuses::isDeletable($this->status)) { 256 | throw Exception::deleteRunningImport(); 257 | } 258 | 259 | $this->rejected?->delete(); 260 | 261 | $response = parent::delete(); 262 | 263 | $this->file?->delete(); 264 | 265 | return $response; 266 | } 267 | 268 | public function cancel() 269 | { 270 | if (! $this->running()) { 271 | throw Exception::cannotBeCancelled(); 272 | } 273 | 274 | $this->batch()?->cancel(); 275 | 276 | $this->update([ 277 | 'status' => Statuses::Cancelled, 278 | 'batch' => null, 279 | ]); 280 | } 281 | 282 | public function updateProgress(int $successful, int $failed) 283 | { 284 | $this->successful += $successful; 285 | $this->failed += $failed; 286 | $this->save(); 287 | } 288 | 289 | public function import(?string $sheet = null) 290 | { 291 | if ($sheet === null) { 292 | $sheet = $this->template()->sheets()->first()->get('name'); 293 | } 294 | 295 | Job::dispatch($this, $sheet); 296 | } 297 | 298 | public function restart(): void 299 | { 300 | $this->rejected?->delete(); 301 | 302 | $this->update([ 303 | 'successful' => 0, 304 | 'failed' => 0, 305 | 'status' => Statuses::Waiting, 306 | ]); 307 | 308 | $this->import(); 309 | } 310 | 311 | protected function casts(): array 312 | { 313 | return [ 314 | 'status' => 'integer', 'params' => Obj::class, 315 | ]; 316 | } 317 | } 318 | -------------------------------------------------------------------------------- /src/Models/RejectedChunk.php: -------------------------------------------------------------------------------- 1 | belongsTo(Import::class); 19 | } 20 | 21 | public function add(array $row): void 22 | { 23 | $rows = $this->rows; 24 | $rows[] = $row; 25 | $this->rows = $rows; 26 | } 27 | 28 | public function count(): int 29 | { 30 | return count($this->rows); 31 | } 32 | 33 | public function empty(): bool 34 | { 35 | return count($this->rows) === 0; 36 | } 37 | 38 | protected function casts(): array 39 | { 40 | return [ 41 | 'header' => 'array', 'rows' => 'array', 42 | ]; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Models/RejectedImport.php: -------------------------------------------------------------------------------- 1 | belongsTo(Import::class); 20 | } 21 | 22 | public function file(): Relation 23 | { 24 | return $this->belongsTo(File::class); 25 | } 26 | 27 | public static function cascadeFileDeletion(File $file): void 28 | { 29 | self::whereFileId($file->id)->first()?->delete(); 30 | } 31 | 32 | public function delete() 33 | { 34 | $response = parent::delete(); 35 | 36 | $this->file?->delete(); 37 | 38 | return $response; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Notifications/ImportDone.php: -------------------------------------------------------------------------------- 1 | 'success', 30 | 'title' => $this->title(), 31 | 'body' => $this->filename(), 32 | 'icon' => 'file-excel', 33 | ]))->onQueue($this->queue); 34 | } 35 | 36 | public function toMail($notifiable) 37 | { 38 | return (new MailMessage()) 39 | ->subject($this->subject()) 40 | ->markdown('laravel-enso/data-import::emails.import', [ 41 | 'name' => $notifiable->person->appellative 42 | ?? $notifiable->person->name, 43 | 'import' => $this->import, 44 | ]); 45 | } 46 | 47 | public function toArray() 48 | { 49 | return [ 50 | 'body' => "{$this->title()}: {$this->filename()}", 51 | 'icon' => 'file-excel', 52 | 'path' => '/import', 53 | ]; 54 | } 55 | 56 | private function title() 57 | { 58 | return __(':type import done', ['type' => $this->import->type()]); 59 | } 60 | 61 | private function filename() 62 | { 63 | return $this->import->file->original_name; 64 | } 65 | 66 | private function subject() 67 | { 68 | $name = Config::get('app.name'); 69 | 70 | return "[ {$name} ] {$this->title()}"; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Policies/Policy.php: -------------------------------------------------------------------------------- 1 | isSuperior()) { 16 | return true; 17 | } 18 | } 19 | 20 | public function view(User $user, Import $import) 21 | { 22 | return $this->ownsDataImport($user, $import); 23 | } 24 | 25 | public function share(User $user, Import $import) 26 | { 27 | return $this->ownsDataImport($user, $import); 28 | } 29 | 30 | public function destroy(User $user, Import $import) 31 | { 32 | return $this->ownsDataImport($user, $import); 33 | } 34 | 35 | private function ownsDataImport(User $user, Import $import) 36 | { 37 | return $user->id === (int) $import->created_by; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Services/ExcelSeeder.php: -------------------------------------------------------------------------------- 1 | savedName = $this->hashname(); 20 | } 21 | 22 | public function run() 23 | { 24 | File::copy($this->source(), Storage::path($this->path())); 25 | 26 | Import::factory() 27 | ->make(['type' => $this->type(), 'params' => $this->params()]) 28 | ->attach($this->savedName, $this->filename()); 29 | } 30 | 31 | abstract protected function type(): string; 32 | 33 | abstract protected function filename(): string; 34 | 35 | protected function params(): array 36 | { 37 | return []; 38 | } 39 | 40 | protected function hashname(): string 41 | { 42 | $hash = Str::random(40); 43 | 44 | return "{$hash}.xlsx"; 45 | } 46 | 47 | private function source(): string 48 | { 49 | $path = Config::get('enso.imports.seederPath'); 50 | 51 | return "{$path}/{$this->filename()}"; 52 | } 53 | 54 | private function path(): string 55 | { 56 | return Type::for(Import::class)->path($this->savedName); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Services/Exporters/Rejected.php: -------------------------------------------------------------------------------- 1 | rejected = $this->import->rejected()->make(); 28 | $this->savedName = $this->savedName(); 29 | $this->firstChunk = true; 30 | } 31 | 32 | public function handle(): void 33 | { 34 | $this->import->update(['status' => Statuses::ExportingRejected]); 35 | 36 | $this->initWriter(); 37 | 38 | $this->import->rejectedChunks->sortBy('sheet') 39 | ->each(fn ($chunk) => $this->export($chunk)); 40 | 41 | $this->closeWriter() 42 | ->storeRejected() 43 | ->cleanUp(); 44 | } 45 | 46 | private function initWriter(): void 47 | { 48 | $this->xlsx = new Writer(); 49 | 50 | $path = Type::for($this->rejected::class)->path($this->savedName); 51 | 52 | $this->xlsx->openToFile(Storage::path($path)); 53 | } 54 | 55 | private function export(RejectedChunk $chunk): void 56 | { 57 | $this->prepare($chunk); 58 | 59 | Collection::wrap($chunk->rows) 60 | ->each(fn ($row) => $this->xlsx->addRow($this->row($row))); 61 | } 62 | 63 | private function prepare(RejectedChunk $chunk): void 64 | { 65 | if ($this->firstChunk) { 66 | $this->firstChunk = false; 67 | $this->initSheet($chunk); 68 | } elseif ($this->needsNewSheet($chunk->sheet)) { 69 | $this->xlsx->addNewSheetAndMakeItCurrent(); 70 | $this->initSheet($chunk); 71 | } 72 | } 73 | 74 | private function initSheet(RejectedChunk $chunk): void 75 | { 76 | $this->xlsx->getCurrentSheet()->setName($chunk->sheet); 77 | $this->addHeader($chunk); 78 | } 79 | 80 | private function addHeader(RejectedChunk $chunk) 81 | { 82 | $header = $chunk->header; 83 | $header[] = Config::get('enso.imports.errorColumn'); 84 | $this->xlsx->addRow($this->row($header)); 85 | } 86 | 87 | private function closeWriter(): self 88 | { 89 | $this->xlsx->close(); 90 | unset($this->xlsx); 91 | 92 | return $this; 93 | } 94 | 95 | private function storeRejected(): self 96 | { 97 | $args = [ 98 | $this->rejected, $this->savedName, $this->filename(), 99 | $this->import->getAttribute('created_by'), 100 | ]; 101 | 102 | $file = File::attach(...$args); 103 | 104 | $this->rejected->file()->associate($file)->save(); 105 | 106 | return $this; 107 | } 108 | 109 | private function cleanUp(): void 110 | { 111 | $this->import->rejectedChunks()->delete(); 112 | } 113 | 114 | private function filename(): string 115 | { 116 | [$baseName] = explode('.', $this->import->file->original_name); 117 | 118 | return "{$baseName}_rejected.xlsx"; 119 | } 120 | 121 | private function savedName(): string 122 | { 123 | $hash = Str::random(40); 124 | 125 | return "{$hash}.xlsx"; 126 | } 127 | 128 | private function row(array $row): Row 129 | { 130 | return Row::fromValues($row); 131 | } 132 | 133 | private function needsNewSheet(string $sheet): bool 134 | { 135 | return $this->xlsx->getCurrentSheet()->getName() !== $sheet; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/Services/ImportTemplate.php: -------------------------------------------------------------------------------- 1 | template = new Template($this->type); 14 | } 15 | 16 | public function filename(): string 17 | { 18 | return "{$this->type}.xlsx"; 19 | } 20 | 21 | public function heading(string $sheet): array 22 | { 23 | return $this->template->header($sheet)->toArray(); 24 | } 25 | 26 | public function rows(string $sheet): array 27 | { 28 | return [ 29 | $this->template->validations($sheet)->toArray(), 30 | $this->template->descriptions($sheet)->toArray(), 31 | ]; 32 | } 33 | 34 | public function sheets(): array 35 | { 36 | return $this->template->sheets()->pluck('name')->toArray(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Services/Importers/Chunk.php: -------------------------------------------------------------------------------- 1 | import = $this->chunk->import; 33 | $this->importer = $this->chunk->importer(); 34 | $this->rejectedChunk = $this->rejectedChunk(); 35 | $this->output = new ConsoleOutput(); 36 | } 37 | 38 | public function handle(): void 39 | { 40 | $this->authenticate() 41 | ->authorize(); 42 | 43 | Collection::wrap($this->chunk->rows) 44 | ->each(fn ($row) => $this->process($row)); 45 | 46 | $this->dumpRejected() 47 | ->updateProgress(); 48 | 49 | $this->chunk->delete(); 50 | } 51 | 52 | private function authenticate(): self 53 | { 54 | if ($this->importer instanceof Authenticates) { 55 | Auth::setUser($this->import->createdBy); 56 | } 57 | 58 | return $this; 59 | } 60 | 61 | private function authorize(): void 62 | { 63 | $unauthorized = $this->importer instanceof Authorizes 64 | && ! $this->importer->authorizes($this->import); 65 | 66 | if ($unauthorized) { 67 | throw Exception::unauthorized(); 68 | } 69 | } 70 | 71 | private function process(array $row): void 72 | { 73 | $rowObj = $this->row($row); 74 | $validator = new Row($rowObj, $this->chunk); 75 | 76 | if ($validator->passes()) { 77 | $this->import($rowObj); 78 | } else { 79 | $row[] = $validator->errors()->implode(' | '); 80 | $this->rejectedChunk->add($row); 81 | } 82 | } 83 | 84 | private function row(array $row): Obj 85 | { 86 | return new Obj(array_combine($this->chunk->header, $row)); 87 | } 88 | 89 | private function import(Obj $row): void 90 | { 91 | try { 92 | $this->importer->run($row, $this->import); 93 | } catch (Throwable $throwable) { 94 | $row = $row->values()->toArray(); 95 | $row[] = Config::get('enso.imports.unknownError'); 96 | $this->rejectedChunk->add($row); 97 | 98 | $error = App::isProduction() || App::runningInConsole() 99 | ? $throwable->getMessage() 100 | : "{$throwable->getMessage()} {$throwable->getTraceAsString()}"; 101 | 102 | Log::debug($error); 103 | 104 | if (App::runningInConsole()) { 105 | $this->output->writeln("{$throwable->getMessage()}"); 106 | } 107 | } 108 | } 109 | 110 | private function dumpRejected(): self 111 | { 112 | if (! $this->rejectedChunk->empty()) { 113 | $this->rejectedChunk->save(); 114 | } 115 | 116 | return $this; 117 | } 118 | 119 | private function updateProgress(): void 120 | { 121 | $total = $this->chunk->count(); 122 | $failed = $this->rejectedChunk->count(); 123 | 124 | DB::transaction(fn () => Import::lockForUpdate() 125 | ->whereId($this->import->id)->first() 126 | ->updateProgress($total - $failed, $failed)); 127 | } 128 | 129 | private function rejectedChunk(): RejectedChunk 130 | { 131 | return RejectedChunk::factory()->make([ 132 | 'import_id' => $this->import->id, 133 | 'sheet' => $this->chunk->sheet, 134 | 'header' => $this->chunk->header, 135 | ]); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/Services/Importers/Import.php: -------------------------------------------------------------------------------- 1 | template = $import->template(); 27 | } 28 | 29 | public function handle(): void 30 | { 31 | $this->prepare() 32 | ->beforeHook() 33 | ->dispatch(); 34 | } 35 | 36 | private function prepare(): self 37 | { 38 | if ($this->import->waiting()) { 39 | $this->import->update(['status' => Statuses::Processing]); 40 | } 41 | 42 | return $this; 43 | } 44 | 45 | private function beforeHook(): self 46 | { 47 | $importer = $this->template->importer($this->sheet); 48 | 49 | if ($importer instanceof BeforeHook) { 50 | if ($importer instanceof Authenticates) { 51 | Auth::setUser($this->import->createdBy); 52 | } 53 | 54 | $importer->before($this->import); 55 | } 56 | 57 | return $this; 58 | } 59 | 60 | public function dispatch(): self 61 | { 62 | $import = $this->import; 63 | $afterHook = $this->afterHook(); 64 | $nextStep = $this->nextStep(); 65 | 66 | $batch = Bus::batch([new Sheet($this->import, $this->sheet)]) 67 | ->onQueue($this->template->queue()) 68 | ->then(fn () => $import->update(['batch' => null])) 69 | ->then(fn ($batch) => $batch->cancelled() ? null : $afterHook()) 70 | ->then(fn ($batch) => $batch->cancelled() ? null : $nextStep()) 71 | ->name($this->sheet) 72 | ->dispatch(); 73 | 74 | $this->import->update(['batch' => $batch->id]); 75 | 76 | return $this; 77 | } 78 | 79 | private function afterHook(): Closure 80 | { 81 | $importer = $this->template->importer($this->sheet); 82 | 83 | return fn () => $importer instanceof AfterHook 84 | ? $importer->after($this->import) 85 | : null; 86 | } 87 | 88 | public function nextStep(): Closure 89 | { 90 | $import = $this->import; 91 | $sheet = $this->sheet; 92 | $nextSheet = $this->template->nextSheet($sheet); 93 | 94 | if ($nextSheet) { 95 | return fn () => $import->import($nextSheet->get('name')); 96 | } 97 | 98 | return fn () => RejectedExport::withChain([new Finalize($import)]) 99 | ->dispatch($import); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Services/Importers/Sheet.php: -------------------------------------------------------------------------------- 1 | chunkSize = $import->template()->chunkSize($this->sheet); 33 | } 34 | 35 | public function handle() 36 | { 37 | $this->init(); 38 | 39 | while ($this->shouldBatchJobs()) { 40 | $this->prepare() 41 | ->dispatch(); 42 | } 43 | } 44 | 45 | private function init(): void 46 | { 47 | $this->reader = $this->reader(); 48 | $this->iterator = $this->reader->rowIterator($this->sheet); 49 | $this->header = Sanitize::header($this->iterator->current()); 50 | $this->rowLength = $this->header->count(); 51 | $this->iterator->next(); 52 | } 53 | 54 | private function reader() 55 | { 56 | $file = Storage::path($this->import->file->path()); 57 | 58 | return match ($this->import->file->extension()) { 59 | 'txt', 'csv' => new CSV( 60 | $file, 61 | $this->import->template()->delimiter(), 62 | $this->import->template()->enclosure() 63 | ), 64 | 'xlsx' => new XLSX($file), 65 | default => throw new EnsoException('Unsupported import type'), 66 | }; 67 | } 68 | 69 | private function prepare(): self 70 | { 71 | $this->chunk = $this->chunk(); 72 | 73 | while ($this->shouldFillChunk()) { 74 | $this->addRow(); 75 | } 76 | 77 | return $this; 78 | } 79 | 80 | private function addRow(): void 81 | { 82 | $cells = $this->iterator->current()->getCells(); 83 | $row = Sanitize::cells($cells, $this->rowLength); 84 | $this->chunk->add($row); 85 | 86 | $this->iterator->next(); 87 | } 88 | 89 | private function dispatch(): void 90 | { 91 | if (! $this->cancelled()) { 92 | $this->chunk->save(); 93 | $this->batch->add(new Job($this->chunk)); 94 | } 95 | } 96 | 97 | private function shouldBatchJobs(): bool 98 | { 99 | return ! $this->reachedSheetEnd() 100 | && ! $this->cancelled(); 101 | } 102 | 103 | private function shouldFillChunk(): bool 104 | { 105 | return $this->chunk->count() < $this->chunkSize 106 | && ! $this->reachedSheetEnd(); 107 | } 108 | 109 | private function cancelled(): bool 110 | { 111 | return $this->batch->fresh()->cancelled(); 112 | } 113 | 114 | private function reachedSheetEnd(): bool 115 | { 116 | return ! $this->iterator->valid(); 117 | } 118 | 119 | private function chunk(): Chunk 120 | { 121 | return Chunk::factory()->make([ 122 | 'import_id' => $this->import->id, 123 | 'sheet' => $this->sheet, 124 | 'header' => $this->header, 125 | ]); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/Services/Notifiables.php: -------------------------------------------------------------------------------- 1 | where('id', '<>', $import->file->createdBy->id) 18 | ->get(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Services/Options.php: -------------------------------------------------------------------------------- 1 | [ 14 | 'id' => $type, 15 | 'name' => self::label($type), 16 | ]; 17 | 18 | try { 19 | return array_map($map, self::types()); 20 | } catch (Throwable) { 21 | throw Import::configNotReadable(); 22 | } 23 | } 24 | 25 | public static function types(): array 26 | { 27 | return array_keys(self::configs()); 28 | } 29 | 30 | public static function label(string $type): string 31 | { 32 | return self::configs()[$type]['label']; 33 | } 34 | 35 | private static function configs(): array 36 | { 37 | return Config::get('enso.imports.configs'); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Services/Readers/CSV.php: -------------------------------------------------------------------------------- 1 | reader = $this->reader($delimiter, $enclosure); 20 | } 21 | 22 | public function rowIterator(): RowIterator 23 | { 24 | $iterator = $this->sheet()->getRowIterator(); 25 | $iterator->rewind(); 26 | 27 | return $iterator; 28 | } 29 | 30 | private function sheet(): ?Sheet 31 | { 32 | $iterator = $this->sheetIterator(); 33 | 34 | return $iterator->current(); 35 | } 36 | 37 | private function reader(string $delimiter, string $enclosure): CSVReader 38 | { 39 | $options = new Options(); 40 | $options->FIELD_DELIMITER = $delimiter; 41 | $options->FIELD_ENCLOSURE = $enclosure; 42 | 43 | return new CSVReader($options); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Services/Readers/Reader.php: -------------------------------------------------------------------------------- 1 | open = false; 18 | } 19 | 20 | public function __destruct() 21 | { 22 | if ($this->open) { 23 | $this->reader->close(); 24 | $this->open = false; 25 | } 26 | } 27 | 28 | public function sheetIterator(): SheetIteratorInterface 29 | { 30 | $this->ensureIsOpen(); 31 | 32 | $iterator = $this->reader->getSheetIterator(); 33 | $iterator->rewind(); 34 | 35 | return $iterator; 36 | } 37 | 38 | private function ensureIsOpen(): void 39 | { 40 | if (! $this->open) { 41 | $this->open(); 42 | } 43 | } 44 | 45 | private function open(): void 46 | { 47 | try { 48 | $this->reader->open($this->file); 49 | } catch (Exception) { 50 | throw Import::fileNotReadable($this->file); 51 | } 52 | 53 | $this->open = true; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Services/Readers/XLSX.php: -------------------------------------------------------------------------------- 1 | reader = new XLSXReader(); 17 | } 18 | 19 | public function sheets(): Collection 20 | { 21 | $iterator = $this->sheetIterator(); 22 | $sheets = new Collection(); 23 | 24 | while ($iterator->valid()) { 25 | $sheets->push($iterator->current()->getName()); 26 | $iterator->next(); 27 | } 28 | 29 | return Sanitize::sheets($sheets); 30 | } 31 | 32 | public function rowIterator(string $sheet): RowIterator 33 | { 34 | $iterator = $this->sheet($sheet)->getRowIterator(); 35 | $iterator->rewind(); 36 | 37 | return $iterator; 38 | } 39 | 40 | private function sheet(string $name): ?Sheet 41 | { 42 | $iterator = $this->sheetIterator(); 43 | 44 | while (Sanitize::name($iterator->current()->getName()) !== $name) { 45 | $iterator->next(); 46 | } 47 | 48 | return $iterator->current(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Services/Sanitizers/Sanitize.php: -------------------------------------------------------------------------------- 1 | getCells()) 19 | ->map(fn (Cell $cell) => self::name($cell->getValue())); 20 | } 21 | 22 | public static function sheets(Collection $sheets): Collection 23 | { 24 | return $sheets->map(fn ($sheet) => self::name($sheet)); 25 | } 26 | 27 | public static function cells(array $cells, int $length): array 28 | { 29 | return Collection::wrap($cells) 30 | ->map(fn ($cell) => self::cell($cell)) 31 | ->slice(0, $length) 32 | ->pad($length, null) 33 | ->toArray(); 34 | } 35 | 36 | public static function name(string $name): string 37 | { 38 | return Str::of($name)->lower()->snake(); 39 | } 40 | 41 | private static function cell(Cell $cell) 42 | { 43 | $value = $cell instanceof FormulaCell 44 | ? $cell->getComputedValue() 45 | : $cell->getValue(); 46 | 47 | if ($value instanceof DateTime || $value instanceof DateTimeImmutable) { 48 | return Carbon::instance($value)->toDateTimeString(); 49 | } 50 | 51 | if (is_string($value)) { 52 | $value = Str::of($value)->trim(); 53 | $to = 'UTF-8'; 54 | $from = mb_detect_encoding($value, ['auto']); 55 | 56 | if (! $from) { 57 | $value = ''; 58 | } elseif ($from !== $to) { 59 | $value = mb_convert_encoding($value, $to, $from); 60 | } 61 | } 62 | 63 | return $value === '' ? null : $value; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Services/Summary.php: -------------------------------------------------------------------------------- 1 | errors = new Obj(); 15 | } 16 | 17 | public function toArray(): array 18 | { 19 | return $this->errors->toArray(); 20 | } 21 | 22 | public function errors(): Obj 23 | { 24 | return $this->errors; 25 | } 26 | 27 | public function addError(string $type, string $value): void 28 | { 29 | $this->type($this->errors, $type)->push($value); 30 | } 31 | 32 | private function type(Obj $layer, string $type): Collection 33 | { 34 | if (! $layer->has($type)) { 35 | $layer->set($type, new Collection()); 36 | } 37 | 38 | return $layer->get($type); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Services/Template.php: -------------------------------------------------------------------------------- 1 | template = $this->template($type); 26 | $this->chunkSizes = []; 27 | 28 | if ($this->shouldValidate()) { 29 | $this->validate(); 30 | } 31 | } 32 | 33 | public function timeout(): int 34 | { 35 | return $this->template->has('timeout') 36 | ? $this->template->get('timeout') 37 | : (int) Config::get('enso.imports.timeout'); 38 | } 39 | 40 | public function notifies(): bool 41 | { 42 | return $this->template->has('notifies') 43 | && $this->template->get('notifies'); 44 | } 45 | 46 | public function queue(): string 47 | { 48 | return $this->template->has('queue') 49 | ? $this->template->get('queue') 50 | : Config::get('enso.imports.queues.processing'); 51 | } 52 | 53 | public function header(string $sheet): Collection 54 | { 55 | return $this->columns($sheet)->pluck('name'); 56 | } 57 | 58 | public function descriptions(string $sheet): Collection 59 | { 60 | return $this->columns($sheet)->pluck('description'); 61 | } 62 | 63 | public function validations(string $sheet): Collection 64 | { 65 | return $this->columns($sheet)->pluck('validations'); 66 | } 67 | 68 | public function columnRules(string $sheet): array 69 | { 70 | return $this->columnRules ??= $this->columns($sheet) 71 | ->filter(fn ($column) => $column->has('validations')) 72 | ->mapWithKeys(fn ($column) => [ 73 | $column->get('name') => $column->get('validations'), 74 | ])->toArray(); 75 | } 76 | 77 | public function paramRules(): array 78 | { 79 | return $this->paramRules ??= $this->params() 80 | ->filter(fn ($param) => $param->has('validations')) 81 | ->mapWithKeys(fn ($param) => [ 82 | $param->get('name') => $param->get('validations'), 83 | ])->toArray(); 84 | } 85 | 86 | public function chunkSize(string $sheet): int 87 | { 88 | return $this->chunkSizes[$sheet] 89 | ??= $this->sheet($sheet)->has('chunkSize') 90 | ? $this->sheet($sheet)->get('chunkSize') 91 | : (int) Config::get('enso.imports.chunkSize'); 92 | } 93 | 94 | public function importer(string $sheet): Importable 95 | { 96 | $class = $this->sheet($sheet)->get('importerClass'); 97 | 98 | return new $class(); 99 | } 100 | 101 | public function customValidator(string $sheet): ?CustomValidator 102 | { 103 | if ($this->sheet($sheet)->has('validatorClass')) { 104 | $class = $this->sheet($sheet)->get('validatorClass'); 105 | 106 | return new $class(); 107 | } 108 | 109 | return null; 110 | } 111 | 112 | public function params(bool $validations = true): Obj 113 | { 114 | return (new Obj($this->template->get('params', []))) 115 | ->when(! $validations, fn ($params) => $params 116 | ->map->except('validations')) 117 | ->each(fn ($param) => $this->optionallySetOptions($param)); 118 | } 119 | 120 | public function sheets(): Obj 121 | { 122 | return $this->isCSV() 123 | ? new Obj([[ 124 | 'name' => $this->template->get('name'), 125 | 'columns' => $this->template->get('columns'), 126 | 'importerClass' => $this->template->get('importerClass'), 127 | 'chunkSize' => $this->template->get('chunkSize'), 128 | ]]) 129 | : $this->template->get('sheets'); 130 | } 131 | 132 | public function nextSheet(string $name): ?Obj 133 | { 134 | $index = $this->sheets()->search(fn ($sheet) => $sheet->get('name') === $name); 135 | 136 | return $this->sheets()->get($index + 1); 137 | } 138 | 139 | public function delimiter(): string 140 | { 141 | return $this->template->get('fieldDelimiter'); 142 | } 143 | 144 | public function enclosure(): string 145 | { 146 | return $this->template->get('fieldEnclosure'); 147 | } 148 | 149 | public function isCSV(): bool 150 | { 151 | return $this->template->has('fieldDelimiter'); 152 | } 153 | 154 | private function columns(string $sheet): Obj 155 | { 156 | return $this->sheet($sheet)->get('columns'); 157 | } 158 | 159 | private function sheet(string $name): Obj 160 | { 161 | return $this->sheets()->first(fn ($sheet) => $sheet 162 | ->get('name') === $name); 163 | } 164 | 165 | private function validate(): void 166 | { 167 | (new Validator($this->template))->run(); 168 | (new Params($this->template))->run(); 169 | } 170 | 171 | private function shouldValidate(): bool 172 | { 173 | return in_array( 174 | Config::get('enso.imports.validations'), 175 | [App::environment(), 'always'] 176 | ); 177 | } 178 | 179 | private function template(string $type): Obj 180 | { 181 | $template = Config::get("enso.imports.configs.{$type}.template"); 182 | 183 | if (! $template) { 184 | throw Exception::disabled(); 185 | } 186 | 187 | return (new JsonReader(base_path($template)))->obj(); 188 | } 189 | 190 | private function optionallySetOptions($param) 191 | { 192 | $options = $param->get('options'); 193 | 194 | if ($options && class_exists($options)) { 195 | $param->put('options', $options::select()); 196 | } 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/Services/Validators/Params.php: -------------------------------------------------------------------------------- 1 | params = $template->get('params'); 20 | $this->attributes = new Attributes(); 21 | } 22 | 23 | public function run(): void 24 | { 25 | $this->params?->each(fn ($param) => $this->validate($param)); 26 | } 27 | 28 | public function validate(Obj $param): void 29 | { 30 | $this->attributes($param) 31 | ->complementaryAttributes($param) 32 | ->route($param) 33 | ->values($param); 34 | } 35 | 36 | private function attributes(Obj $param): self 37 | { 38 | $this->attributes->validateMandatory($param->keys()) 39 | ->rejectUnknown($param->keys()); 40 | 41 | return $this; 42 | } 43 | 44 | private function complementaryAttributes(Obj $param): self 45 | { 46 | $this->attributes->dependent($param->get('type')) 47 | ->reject(fn ($attr) => Str::of($attr)->explode('|') 48 | ->first(fn ($elem) => $param->has($elem))) 49 | ->unlessEmpty(fn ($missing) => throw Exception::missing($missing, $this->attributes->class())); 50 | 51 | return $this; 52 | } 53 | 54 | private function route(Obj $param): self 55 | { 56 | $route = $param->get('route'); 57 | 58 | if ($route !== null && ! Routes::has($route)) { 59 | throw Route::notFound($route); 60 | } 61 | 62 | return $this; 63 | } 64 | 65 | private function values(Obj $param) 66 | { 67 | $allowed = $this->attributes->values('type'); 68 | $valid = $allowed->contains($param->get('type')); 69 | 70 | if (! $valid) { 71 | throw Exception::invalidParam($allowed, $this->attributes->class()); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Services/Validators/Row.php: -------------------------------------------------------------------------------- 1 | errors = new Collection(); 19 | } 20 | 21 | public function passes(): bool 22 | { 23 | $this->implicit() 24 | ->custom(); 25 | 26 | return $this->errors->isEmpty(); 27 | } 28 | 29 | public function errors(): Collection 30 | { 31 | return $this->errors; 32 | } 33 | 34 | private function implicit(): self 35 | { 36 | $rules = $this->chunk->template()->columnRules($this->chunk->sheet); 37 | $implicit = Validator::make($this->row->all(), $rules); 38 | $this->errors->push(...$implicit->errors()->all()); 39 | 40 | return $this; 41 | } 42 | 43 | private function custom(): void 44 | { 45 | $custom = $this->chunk->template()->customValidator($this->chunk->sheet); 46 | 47 | if ($custom) { 48 | $custom->run($this->row, $this->chunk->import); 49 | $this->errors->push(...$custom->errors()); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Services/Validators/Structure.php: -------------------------------------------------------------------------------- 1 | summary = new Summary(); 24 | $this->reader = $this->reader(); 25 | } 26 | 27 | public function validates(): bool 28 | { 29 | return $this->validatesExtension() 30 | ? $this->validatesStructure() 31 | : false; 32 | } 33 | 34 | private function validatesExtension(): bool 35 | { 36 | $valid = $this->template->isCSV() 37 | && in_array($this->extension, ['csv', 'txt']) 38 | || ! $this->template->isCSV() 39 | && $this->extension === 'xlsx'; 40 | 41 | if (! $valid) { 42 | $required = $this->template->isCSV() 43 | ? '.csv / .txt' 44 | : '.xlsx'; 45 | 46 | $message = 'Required ":required", Provided ":provided"'; 47 | 48 | $this->summary->addError(__('File Extension'), __($message, [ 49 | 'required' => $required, 'provided' => $this->extension, 50 | ])); 51 | } 52 | 53 | return $valid; 54 | } 55 | 56 | private function validatesStructure(): bool 57 | { 58 | if (! $this->template->isCSV()) { 59 | $this->handleSheets(); 60 | } 61 | 62 | if ($this->summary->errors()->isEmpty()) { 63 | $this->template->sheets()->pluck('name')->each(fn ($sheet) => $this 64 | ->handleColumns($sheet)); 65 | } 66 | 67 | return $this->summary->errors()->isEmpty(); 68 | } 69 | 70 | public function summary(): array 71 | { 72 | return [ 73 | 'filename' => $this->filename, 74 | 'errors' => $this->summary->toArray(), 75 | ]; 76 | } 77 | 78 | private function reader(): CSV|XLSX 79 | { 80 | return $this->template->isCSV() 81 | ? new CSV( 82 | $this->path, 83 | $this->template->delimiter(), 84 | $this->template->enclosure() 85 | ) 86 | : new XLSX($this->path); 87 | } 88 | 89 | private function handleSheets(): void 90 | { 91 | $template = $this->template->sheets()->pluck('name'); 92 | $xlsx = $this->reader->sheets(); 93 | 94 | $this->missingSheets($template, $xlsx) 95 | ->extraSheets($template, $xlsx); 96 | } 97 | 98 | private function missingSheets(Collection $template, Collection $xlsx): self 99 | { 100 | $template->diff($xlsx)->each(fn ($name) => $this->summary 101 | ->addError(__('Missing Sheets'), $name)); 102 | 103 | return $this; 104 | } 105 | 106 | private function extraSheets(Collection $template, Collection $xlsx): void 107 | { 108 | $xlsx->diff($template)->each(fn ($name) => $this->summary 109 | ->addError(__('Extra Sheets'), $name)); 110 | } 111 | 112 | private function handleColumns(string $sheet): void 113 | { 114 | $iterator = $this->reader->rowIterator($sheet); 115 | 116 | $header = Sanitize::header($iterator->current()); 117 | $template = $this->template->header($sheet); 118 | 119 | $this->missingColumns($sheet, $header, $template) 120 | ->extraColumns($sheet, $header, $template); 121 | } 122 | 123 | private function missingColumns(string $sheet, Collection $header, Collection $template): self 124 | { 125 | $template->diff($header)->each(fn ($column) => $this->summary 126 | ->addError(__('Missing Columns'), __('Sheet ":sheet", column ":column"', [ 127 | 'sheet' => $sheet, 'column' => $column, 128 | ]))); 129 | 130 | return $this; 131 | } 132 | 133 | private function extraColumns(string $sheet, Collection $header, Collection $template): void 134 | { 135 | $header->diff($template)->each(fn ($column) => $this->summary 136 | ->addError(__('Extra Columns'), __('Sheet ":sheet", column ":column"', [ 137 | 'sheet' => $sheet, 'column' => $column, 138 | ]))); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Services/Validators/Template.php: -------------------------------------------------------------------------------- 1 | root()->sheets()->columns(); 23 | } 24 | 25 | private function root(): self 26 | { 27 | if (! $this->isXLSX()) { 28 | $this->validateCSV($this->template); 29 | 30 | return $this; 31 | } 32 | 33 | (new Attributes())->validateMandatory($this->template->keys()) 34 | ->rejectUnknown($this->template->keys()); 35 | 36 | return $this; 37 | } 38 | 39 | private function sheets(): self 40 | { 41 | if (! $this->isXLSX()) { 42 | return $this; 43 | } 44 | 45 | $this->template->get('sheets') 46 | ->each(fn ($sheet) => $this->validateSheet($sheet)); 47 | 48 | return $this; 49 | } 50 | 51 | private function validateSheet(Obj $sheet): void 52 | { 53 | (new Sheet())->validateMandatory($sheet->keys()) 54 | ->rejectUnknown($sheet->keys()); 55 | 56 | $this->importer($sheet)->validator($sheet); 57 | } 58 | 59 | private function validateCSV(Obj $template): void 60 | { 61 | (new CSV())->validateMandatory($template->keys()) 62 | ->rejectUnknown($template->keys()); 63 | 64 | $this->importer($template)->validator($template); 65 | } 66 | 67 | private function importer(Obj $sheet): self 68 | { 69 | if (! class_exists($sheet->get('importerClass'))) { 70 | throw Exception::missingImporterClass($sheet); 71 | } 72 | 73 | $implements = class_implements($sheet->get('importerClass')); 74 | $underContract = Collection::wrap($implements)->contains(Importable::class); 75 | 76 | if (! $underContract) { 77 | throw Exception::importerMissingContract($sheet); 78 | } 79 | 80 | return $this; 81 | } 82 | 83 | private function validator(Obj $sheet): void 84 | { 85 | if (! $sheet->has('validatorClass')) { 86 | return; 87 | } 88 | 89 | if (! class_exists($sheet->get('validatorClass'))) { 90 | throw Exception::missingValidatorClass($sheet); 91 | } 92 | 93 | if (! is_subclass_of($sheet->get('validatorClass'), Validator::class)) { 94 | throw Exception::incorectValidator($sheet); 95 | } 96 | } 97 | 98 | private function columns(): void 99 | { 100 | $validate = fn ($column) => (new Column()) 101 | ->validateMandatory($column->keys()) 102 | ->rejectUnknown($column->keys()); 103 | 104 | $columns = $this->isXLSX() 105 | ? $this->template->get('sheets')->pluck('columns')->flatten(1) 106 | : $this->template->get('columns'); 107 | 108 | $columns->each($validate); 109 | } 110 | 111 | private function isXLSX(): bool 112 | { 113 | return $this->template->has('sheets'); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Services/Validators/Validator.php: -------------------------------------------------------------------------------- 1 | errors = new Collection(); 16 | } 17 | 18 | abstract public function run(Obj $row, Import $import); 19 | 20 | public function errors(): Collection 21 | { 22 | return $this->errors; 23 | } 24 | 25 | public function fails(): bool 26 | { 27 | return $this->errors->isNotEmpty(); 28 | } 29 | 30 | public function addError(string $error): void 31 | { 32 | $this->errors->push($error); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Tables/Builders/Import.php: -------------------------------------------------------------------------------- 1 | select())) 19 | ->with($this->with()); 20 | } 21 | 22 | public function templatePath(): string 23 | { 24 | return self::TemplatePath; 25 | } 26 | 27 | public function render(array $row, string $action): bool 28 | { 29 | $hasFile = $row['file_id'] !== null; 30 | 31 | return match ($action) { 32 | 'download-rejected' => $row['rejected'] !== null, 33 | 'download' => $hasFile, 34 | 'cancel' => in_array($row['status'], Statuses::running()), 35 | 'restart' => $hasFile && $row['status'] === Statuses::Cancelled, 36 | default => true, 37 | }; 38 | } 39 | 40 | protected function select(): array 41 | { 42 | return [ 43 | 'id', 'type', 'status', 'successful', 'failed', 'created_at', 44 | 'file_id', 'created_by', "{$this->rawTime()} as time", 45 | "{$this->rawDuration()} as duration", 46 | ]; 47 | } 48 | 49 | protected function with(): array 50 | { 51 | return [ 52 | 'file:id,original_name', 'rejected:id,import_id', 53 | 'createdBy' => fn ($user) => $user->with([ 54 | 'person:id,appellative,name', 'avatar:id,user_id', 55 | ]), 56 | ]; 57 | } 58 | 59 | protected function rawDuration(): string 60 | { 61 | return match (DB::getDriverName()) { 62 | 'sqlite' => $this->sqliteDuration(), 63 | 'mysql' => $this->mysqlDuration(), 64 | 'pgsql' => $this->postgresDuration(), 65 | default => 'N/A', 66 | }; 67 | } 68 | 69 | protected function rawTime(): string 70 | { 71 | return DB::getDriverName() === 'pgsql' 72 | ? 'created_at::time' 73 | : 'TIME(created_at)'; 74 | } 75 | 76 | protected function sqliteDuration() 77 | { 78 | $days = 'julianday(updated_at) - julianday(created_at)'; 79 | $seconds = "({$days}) * 86400.0"; 80 | 81 | return "time({$seconds}, 'unixepoch')"; 82 | } 83 | 84 | protected function mysqlDuration() 85 | { 86 | $seconds = 'timestampdiff(second, created_at, updated_at)'; 87 | 88 | return "sec_to_time({$seconds})"; 89 | } 90 | 91 | protected function postgresDuration() 92 | { 93 | $seconds = 'EXTRACT(EPOCH FROM (updated_at::timestamp - created_at::timestamp ))'; 94 | 95 | return "($seconds || ' second')::interval"; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Tables/Templates/imports.json: -------------------------------------------------------------------------------- 1 | { 2 | "routePrefix": "import", 3 | "crtNo": true, 4 | "appends": [ 5 | "entries" 6 | ], 7 | "defaultSortDirection": "desc", 8 | "strip": [ 9 | "created_by" 10 | ], 11 | "buttons": [ 12 | "excel", 13 | { 14 | "name": "cancel", 15 | "type": "row", 16 | "icon": "ban", 17 | "method": "PATCH", 18 | "class": "is-row-button has-text-danger", 19 | "fullRoute": "import.cancel", 20 | "action": "ajax", 21 | "confirmation": true 22 | }, 23 | { 24 | "name": "restart", 25 | "type": "row", 26 | "icon": "sync", 27 | "method": "PATCH", 28 | "class": "is-row-button", 29 | "fullRoute": "import.restart", 30 | "action": "ajax", 31 | "confirmation": true 32 | }, 33 | { 34 | "name": "download-rejected", 35 | "type": "row", 36 | "icon": "cloud-download-alt", 37 | "event": "download-rejected", 38 | "class": "is-row-button has-text-danger" 39 | }, 40 | "download", 41 | "destroy" 42 | ], 43 | "filters": [ 44 | { 45 | "label": "Users", 46 | "data": "data_imports.created_by", 47 | "type": "select", 48 | "value": [], 49 | "selectLabel": "person.name", 50 | "route": "administration.users.options", 51 | "multiple": true 52 | } 53 | ], 54 | "columns": [ 55 | { 56 | "label": "Type", 57 | "name": "type", 58 | "data": "type", 59 | "meta": [ 60 | "method" 61 | ] 62 | }, 63 | { 64 | "label": "File name", 65 | "name": "file.original_name", 66 | "data": "file.original_name", 67 | "meta": [ 68 | "searchable" 69 | ] 70 | }, 71 | { 72 | "label": "Status", 73 | "name": "status", 74 | "data": "data_imports.status", 75 | "enum": "LaravelEnso\\DataImport\\Enums\\Statuses", 76 | "meta": [ 77 | "sortable", 78 | "slot" 79 | ] 80 | }, 81 | { 82 | "label": "Entries", 83 | "name": "entries", 84 | "data": "data_imports.entries", 85 | "meta": [ 86 | "slot" 87 | ] 88 | }, 89 | { 90 | "label": "Successful", 91 | "name": "successful", 92 | "data": "data_imports.successful", 93 | "meta": [ 94 | "slot" 95 | ] 96 | }, 97 | { 98 | "label": "Failed", 99 | "name": "failed", 100 | "data": "data_imports.failed", 101 | "meta": [ 102 | "slot" 103 | ] 104 | }, 105 | { 106 | "label": "Date", 107 | "name": "created_at", 108 | "data": "data_imports.created_at", 109 | "meta": [ 110 | "filterable", 111 | "sortable", 112 | "date" 113 | ] 114 | }, 115 | { 116 | "label": "Time", 117 | "name": "time", 118 | "data": "data_imports.time" 119 | }, 120 | { 121 | "label": "Duration", 122 | "name": "duration", 123 | "data": "duration", 124 | "meta": [ 125 | "sortable" 126 | ] 127 | }, 128 | { 129 | "label": "By", 130 | "name": "createdBy", 131 | "data": "data_imports.createdBy", 132 | "resource": "LaravelEnso\\Users\\Http\\Resources\\User", 133 | "meta": [ 134 | "slot", 135 | "notExportable" 136 | ] 137 | }, 138 | { 139 | "label": "Created By", 140 | "name": "createdBy.person.name", 141 | "data": "data_imports.createdBy", 142 | "meta": [ 143 | "rogue" 144 | ] 145 | } 146 | ] 147 | } -------------------------------------------------------------------------------- /src/Tests/UserGroupImporter.php: -------------------------------------------------------------------------------- 1 | all()); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Tests/userGroups.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeout": 500, 3 | "sheets": [{ 4 | "name": "groups", 5 | "importerClass": "LaravelEnso\\DataImport\\Tests\\UserGroupImporter", 6 | "chunkSize": 1000, 7 | "columns": [ 8 | { 9 | "name": "name", 10 | "description": "user group name", 11 | "validations": "string|required|unique:user_groups,name" 12 | }, 13 | { 14 | "name": "description", 15 | "description": "user group description", 16 | "validations": "string|nullable" 17 | } 18 | ] 19 | }] 20 | } 21 | -------------------------------------------------------------------------------- /stubs/Imports/Importers/ExampleImporter.stub: -------------------------------------------------------------------------------- 1 | addError(string $error) to register errors as many times as you need 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/features/DataImportTest.php: -------------------------------------------------------------------------------- 1 | seed() 35 | ->actingAs(User::first()); 36 | 37 | Config::set(['enso.imports.configs.userGroups' => [ 38 | 'label' => 'User Groupss', 39 | 'template' => Str::of(self::Template)->replace(base_path(), ''), 40 | ]]); 41 | } 42 | 43 | public function tearDown(): void 44 | { 45 | $this->cleanUp(); 46 | parent::tearDown(); 47 | } 48 | 49 | /** @test */ 50 | public function can_import() 51 | { 52 | $this->post(route('import.store', [], false), [ 53 | 'import' => $this->uploadedFile(self::ImportFile), 54 | 'type' => self::ImportType, 55 | ])->assertStatus(200) 56 | ->assertJsonFragment([ 57 | 'errors' => [], 58 | 'filename' => self::ImportTestFile, 59 | ]); 60 | 61 | $this->model = Import::whereHas('file', fn ($file) => $file 62 | ->whereOriginalName(self::ImportTestFile))->first(); 63 | 64 | $this->assertNotNull($this->model); 65 | $this->assertNotNull(UserGroup::whereName('ImportTestName')->first()); 66 | 67 | Storage::assertExists($this->model->file->path()); 68 | } 69 | 70 | /** @test */ 71 | public function generates_rejected() 72 | { 73 | $this->attach(self::ContentErrorsFile); 74 | $this->model->refresh(); 75 | 76 | $this->assertNotNull($this->model->rejected); 77 | $this->assertNotNull($this->model->rejected->file); 78 | Storage::assertExists($this->model->rejected->file->path()); 79 | } 80 | 81 | /** @test */ 82 | public function can_download_rejected() 83 | { 84 | $this->attach(self::ContentErrorsFile); 85 | $this->model->refresh(); 86 | 87 | $resp = $this->get(route('import.rejected', [ 88 | $this->model->rejected->id, 89 | ], false)); 90 | 91 | $resp->assertStatus(200); 92 | } 93 | 94 | /** @test */ 95 | public function download() 96 | { 97 | $this->attach(self::ImportFile); 98 | $this->model->refresh(); 99 | 100 | $this->get(route('import.download', [$this->model->id], false)) 101 | ->assertStatus(200) 102 | ->assertHeader( 103 | 'content-disposition', 104 | 'attachment; filename='.self::ImportTestFile 105 | ); 106 | } 107 | 108 | /** @test */ 109 | public function cant_destroy_while_running() 110 | { 111 | $this->attach(self::ImportFile); 112 | $this->model->update(['status' => Statuses::Processing]); 113 | 114 | $response = $this->delete(route('import.destroy', [$this->model->id], false)); 115 | $response->assertStatus(488); 116 | 117 | $this->model->update(['status' => Statuses::Finalized]); 118 | } 119 | 120 | /** @test */ 121 | public function destroy() 122 | { 123 | $this->attach(self::ImportFile); 124 | $this->model->refresh(); 125 | 126 | Storage::assertExists($this->model->file->path()); 127 | 128 | $this->delete(route('import.destroy', [$this->model->id], false)) 129 | ->assertStatus(200); 130 | 131 | $this->assertNull($this->model->fresh()); 132 | 133 | Storage::assertMissing($this->model->file->path()); 134 | } 135 | 136 | private function attach(string $file) 137 | { 138 | $this->model = Import::factory()->create([ 139 | 'type' => self::ImportType, 140 | ]); 141 | 142 | $folder = Config::get('enso.files.testingFolder'); 143 | $path = "{$folder}/$file"; 144 | 145 | File::copy(self::Path.$file, Storage::path($path)); 146 | 147 | $this->model->attach($file, self::ImportTestFile); 148 | } 149 | 150 | private function uploadedFile($file): UploadedFile 151 | { 152 | return new UploadedFile( 153 | self::Path.$file, 154 | self::ImportTestFile, 155 | null, 156 | null, 157 | true 158 | ); 159 | } 160 | 161 | private function cleanUp() 162 | { 163 | $this->model?->delete(); 164 | 165 | File::delete(self::Path.self::ImportTestFile); 166 | Storage::deleteDirectory(Config::get('enso.files.testingFolder')); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /tests/features/ValidationTest.php: -------------------------------------------------------------------------------- 1 | seed() 28 | ->actingAs(User::first()); 29 | } 30 | 31 | public function tearDown(): void 32 | { 33 | $this->cleanUp(); 34 | parent::tearDown(); 35 | } 36 | 37 | /** @test */ 38 | public function stops_on_invalid_sheets() 39 | { 40 | config(['enso.imports.configs.userGroups' => [ 41 | 'label' => 'User Groups', 42 | 'template' => $this->template('userGroups'), 43 | ]]); 44 | 45 | $this->post(route('import.store', [], false), [ 46 | 'import' => $this->file(self::InvalidSheetsFile), 47 | 'type' => self::ImportType, 48 | ])->assertStatus(200) 49 | ->assertJsonFragment([ 50 | 'errors' => [ 51 | 'Extra Sheets' => ['invalid_sheet'], 52 | 'Missing Sheets' => ['groups'], 53 | ], 54 | 'filename' => self::TestFile, 55 | ]); 56 | 57 | $this->assertNull(Import::whereName(self::TestFile)->first()); 58 | } 59 | 60 | /** @test */ 61 | public function stops_on_invalid_columns() 62 | { 63 | config(['enso.imports.configs.userGroups' => [ 64 | 'label' => 'User Groups', 65 | 'template' => $this->template('userGroups'), 66 | ]]); 67 | 68 | $this->post(route('import.store', [], false), [ 69 | 'import' => $this->file(self::InvalidColumnsFile), 70 | 'type' => self::ImportType, 71 | ])->assertStatus(200) 72 | ->assertJsonFragment([ 73 | 'errors' => [ 74 | 'Extra Columns' => ['Sheet "groups", column "invalid_column"'], 75 | 'Missing Columns' => ['Sheet "groups", column "description"'], 76 | ], 77 | 'filename' => self::TestFile, 78 | ]); 79 | 80 | $this->assertNull(Import::whereName(self::TestFile)->first()); 81 | } 82 | 83 | /** @test */ 84 | public function cannot_import_invalid_params() 85 | { 86 | Config::set(['enso.imports.configs.userGroups' => [ 87 | 'label' => 'User Groups', 88 | 'template' => $this->template('paramsValidation'), 89 | ]]); 90 | 91 | $exception = $this->post(route('import.store', [], false), [ 92 | 'import' => $this->file('invalid_sheets.xlsx'), 93 | 'type' => self::ImportType, 94 | ])->exception; 95 | 96 | $this->assertInstanceOf(ValidationException::class, $exception); 97 | $this->assertArrayHasKey('name', $exception->errors()); 98 | } 99 | 100 | private function file($file) 101 | { 102 | File::copy( 103 | self::Path.$file, 104 | self::Path.self::TestFile 105 | ); 106 | 107 | return new UploadedFile( 108 | self::Path.self::TestFile, 109 | self::TestFile, 110 | null, 111 | null, 112 | true 113 | ); 114 | } 115 | 116 | private function cleanUp() 117 | { 118 | File::delete(self::Path.self::TestFile); 119 | } 120 | 121 | protected function template($template): string 122 | { 123 | return Str::replaceFirst(base_path(), '', __DIR__.DIRECTORY_SEPARATOR.'templates' 124 | .DIRECTORY_SEPARATOR."{$template}.json"); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /tests/features/templates/paramsValidation.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeout": 500, 3 | "params": [ 4 | { 5 | "name": "name", 6 | "validations": "required", 7 | "type": "string" 8 | } 9 | ], 10 | "sheets": [ 11 | { 12 | "name": "groups", 13 | "importerClass": "LaravelEnso\\DataImport\\Tests\\UserGroupImporter", 14 | "chunkSize": 250, 15 | "columns": [ 16 | { 17 | "name": "name", 18 | "validations": "string|required|unique:user_groups,name" 19 | }, 20 | { 21 | "name": "description", 22 | "validations": "string|nullable" 23 | } 24 | ] 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /tests/features/templates/userGroups.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeout": 500, 3 | "sheets": [ 4 | { 5 | "name": "groups", 6 | "importerClass": "LaravelEnso\\DataImport\\Tests\\UserGroupImporter", 7 | "chunkSize": 250, 8 | "columns": [ 9 | { 10 | "name": "name", 11 | "description": "user group name", 12 | "validations": "string|required|unique:user_groups,name" 13 | }, 14 | { 15 | "name": "description", 16 | "description": "user group description", 17 | "validations": "string|nullable" 18 | } 19 | ] 20 | } 21 | ] 22 | } -------------------------------------------------------------------------------- /tests/features/testFiles/content_errors.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laravel-enso/data-import/0283ee4dfb1c06bc49d7c8b99a3babbc8f38b800/tests/features/testFiles/content_errors.xlsx -------------------------------------------------------------------------------- /tests/features/testFiles/invalid_columns.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laravel-enso/data-import/0283ee4dfb1c06bc49d7c8b99a3babbc8f38b800/tests/features/testFiles/invalid_columns.xlsx -------------------------------------------------------------------------------- /tests/features/testFiles/invalid_sheets.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laravel-enso/data-import/0283ee4dfb1c06bc49d7c8b99a3babbc8f38b800/tests/features/testFiles/invalid_sheets.xlsx -------------------------------------------------------------------------------- /tests/features/testFiles/userGroups.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laravel-enso/data-import/0283ee4dfb1c06bc49d7c8b99a3babbc8f38b800/tests/features/testFiles/userGroups.xlsx -------------------------------------------------------------------------------- /tests/features/testFiles/userGroups_import.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laravel-enso/data-import/0283ee4dfb1c06bc49d7c8b99a3babbc8f38b800/tests/features/testFiles/userGroups_import.xlsx --------------------------------------------------------------------------------