Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
79.71% |
55 / 69 |
|
33.33% |
1 / 3 |
CRAP | |
0.00% |
0 / 1 |
| Importer | |
79.71% |
55 / 69 |
|
33.33% |
1 / 3 |
33.09 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| run | |
80.60% |
54 / 67 |
|
0.00% |
0 / 1 |
29.57 | |||
| collection | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace App\Services\ImportExport; |
| 4 | |
| 5 | use Illuminate\Contracts\Support\Arrayable; |
| 6 | use Illuminate\Support\Facades\Validator; |
| 7 | use Illuminate\Support\Str; |
| 8 | use Maatwebsite\Excel\Concerns\ToCollection; |
| 9 | use Maatwebsite\Excel\Concerns\WithHeadingRow; |
| 10 | |
| 11 | /** |
| 12 | * Generic import driven by a Definition. |
| 13 | * |
| 14 | * $result = (new Importer($def))->run($rows, dryRun: true); |
| 15 | * // -> ['processed' => int, 'created' => int, 'updated' => int, |
| 16 | * // 'skipped' => int, 'errors' => [ ['row'=>2, 'messages'=>[...]], ... ]] |
| 17 | * |
| 18 | * maatwebsite reads the sheet with a heading row; headings are slugged to |
| 19 | * match column keys ("Customer Name" -> "customer_name"). |
| 20 | */ |
| 21 | class Importer implements ToCollection, WithHeadingRow |
| 22 | { |
| 23 | public array $result = [ |
| 24 | 'processed' => 0, 'created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => [], |
| 25 | ]; |
| 26 | |
| 27 | private bool $dryRun = true; |
| 28 | |
| 29 | public function __construct(private readonly Definition $definition) {} |
| 30 | |
| 31 | /** Synchronous import safety cap — bigger files should be split for now. */ |
| 32 | public const MAX_ROWS = 5000; |
| 33 | |
| 34 | public function run(iterable $rows, bool $dryRun = true): array |
| 35 | { |
| 36 | $this->dryRun = $dryRun; |
| 37 | $this->result = ['processed' => 0, 'created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => []]; |
| 38 | |
| 39 | $rows = is_countable($rows) ? $rows : iterator_to_array($rows); |
| 40 | if (count($rows) > self::MAX_ROWS) { |
| 41 | $this->result['errors'][] = [ |
| 42 | 'row' => 0, |
| 43 | 'messages' => ['This file has ' . count($rows) . ' rows. Please split it into files of ' . self::MAX_ROWS . ' rows or fewer.'], |
| 44 | ]; |
| 45 | |
| 46 | return $this->result; |
| 47 | } |
| 48 | |
| 49 | $cols = $this->definition->resolvedColumns(); |
| 50 | $headerMap = []; |
| 51 | foreach ($cols as $key => $col) { |
| 52 | $headerMap[Str::slug($col['label'], '_')] = $key; |
| 53 | $headerMap[$key] = $key; |
| 54 | } |
| 55 | |
| 56 | $modelClass = $this->definition->model(); |
| 57 | $unique = $this->definition->uniqueBy(); |
| 58 | |
| 59 | foreach ($rows as $i => $raw) { |
| 60 | $line = $i + 2; // + heading row + 1-index |
| 61 | $raw = $raw instanceof Arrayable ? $raw->toArray() : (array) $raw; |
| 62 | $row = []; |
| 63 | foreach ($raw as $h => $v) { |
| 64 | $mapped = $headerMap[$h] ?? null; |
| 65 | if ($mapped) { |
| 66 | $row[$mapped] = is_string($v) ? trim($v) : $v; |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | if (count(array_filter($row, fn ($v) => $v !== null && $v !== '')) === 0) { |
| 71 | continue; // blank line |
| 72 | } |
| 73 | |
| 74 | $this->result['processed']++; |
| 75 | |
| 76 | // validate cells |
| 77 | $rules = []; |
| 78 | foreach ($cols as $key => $col) { |
| 79 | $r = []; |
| 80 | if ($col['required']) { |
| 81 | $r[] = 'required'; |
| 82 | } |
| 83 | if ($col['rules']) { |
| 84 | $r[] = $col['rules']; |
| 85 | } |
| 86 | if ($r) { |
| 87 | $rules[$key] = implode('|', $r); |
| 88 | } |
| 89 | } |
| 90 | $validator = Validator::make($row, $rules); |
| 91 | if ($validator->fails()) { |
| 92 | $this->result['errors'][] = ['row' => $line, 'messages' => $validator->errors()->all()]; |
| 93 | $this->result['skipped']++; |
| 94 | |
| 95 | continue; |
| 96 | } |
| 97 | |
| 98 | // build attributes (relation resolvers via 'import' closures) |
| 99 | $attributes = $this->definition->defaults(); |
| 100 | foreach ($cols as $key => $col) { |
| 101 | if (! array_key_exists($key, $row)) { |
| 102 | continue; |
| 103 | } |
| 104 | $value = $row[$key]; |
| 105 | if (isset($col['import'])) { |
| 106 | $value = $col['import']($value, $row); |
| 107 | if ($value === Definition::SKIP_COLUMN) { |
| 108 | continue; |
| 109 | } |
| 110 | } |
| 111 | $attributes[$key] = $value; |
| 112 | } |
| 113 | |
| 114 | try { |
| 115 | $model = null; |
| 116 | if ($unique && collect($unique)->every(fn ($u) => ! empty($row[$u] ?? null))) { |
| 117 | $model = $modelClass::query() |
| 118 | ->where(collect($unique)->mapWithKeys(fn ($u) => [$u => $row[$u]])->all()) |
| 119 | ->first(); |
| 120 | } |
| 121 | |
| 122 | $isUpdate = (bool) $model; |
| 123 | $model ??= new $modelClass; |
| 124 | $model->fill($attributes); |
| 125 | $this->definition->beforeSave($model, $row); |
| 126 | |
| 127 | if (! $this->dryRun) { |
| 128 | $model->save(); |
| 129 | } |
| 130 | |
| 131 | $this->result[$isUpdate ? 'updated' : 'created']++; |
| 132 | } catch (\Throwable $e) { |
| 133 | $this->result['errors'][] = ['row' => $line, 'messages' => [$e->getMessage()]]; |
| 134 | $this->result['skipped']++; |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | return $this->result; |
| 139 | } |
| 140 | |
| 141 | /** maatwebsite entry point — collect() gives us the parsed rows. */ |
| 142 | public function collection($rows): void |
| 143 | { |
| 144 | $this->run($rows, $this->dryRun); |
| 145 | } |
| 146 | } |