├── .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 | [](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 | [](https://github.styleci.io/repos/89221336)
5 | [](https://packagist.org/packages/laravel-enso/data-import)
6 | [](https://packagist.org/packages/laravel-enso/data-import)
7 | [](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 | [](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 | [](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
--------------------------------------------------------------------------------