Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.71% covered (warning)
79.71%
55 / 69
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
Importer
79.71% covered (warning)
79.71%
55 / 69
33.33% covered (danger)
33.33%
1 / 3
33.09
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 run
80.60% covered (warning)
80.60%
54 / 67
0.00% covered (danger)
0.00%
0 / 1
29.57
 collection
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace App\Services\ImportExport;
4
5use Illuminate\Contracts\Support\Arrayable;
6use Illuminate\Support\Facades\Validator;
7use Illuminate\Support\Str;
8use Maatwebsite\Excel\Concerns\ToCollection;
9use 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 */
21class 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}