Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.57% covered (success)
95.57%
151 / 158
50.00% covered (danger)
50.00%
5 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
DataImportService
95.54% covered (success)
95.54%
150 / 157
50.00% covered (danger)
50.00%
5 / 10
53
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
 analyzeFile
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
2
 executeImport
97.83% covered (success)
97.83%
45 / 46
0.00% covered (danger)
0.00%
0 / 1
8
 processImportRow
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
9
 resolveReader
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 buildExistingLookup
80.00% covered (warning)
80.00%
12 / 15
0.00% covered (danger)
0.00%
0 / 1
6.29
 applyDeduplication
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
11
 generateSuggestedMapping
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
6.01
 normalizeString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 verifyImportPermission
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.01
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\DataExchange\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\DataExchange\Domain\Model\ImportMappingConfig;
12use App\Core\DataExchange\Domain\Model\ImportResultDto;
13use App\Core\DataExchange\Domain\Service\UiTypeImportValidator;
14use App\Core\DataExchange\Infrastructure\Reader\CsvTabularStreamReader;
15use App\Core\DataExchange\Infrastructure\Reader\TabularStreamReaderInterface;
16use App\Core\DataExchange\Infrastructure\Reader\XlsxTabularStreamReader;
17use App\Core\Engine\Application\Service\UniversalCrudService;
18use App\Core\Engine\Domain\Exception\PermissionDeniedException;
19use App\Core\Engine\Domain\Model\FieldMetadata;
20use App\Core\Engine\Domain\Model\PermissionContext;
21use InvalidArgumentException;
22use PDO;
23use RuntimeException;
24use Throwable;
25
26/**
27 * Application service orchestrating the multi-step data import workflow.
28 *
29 * @package App\Core\DataExchange\Application\Service
30 */
31final class DataImportService implements DataImportServiceInterface
32{
33    private readonly UiTypeImportValidator $validator;
34
35    /**
36     * DataImportService constructor.
37     *
38     * @param UniversalCrudService $crudService Core engine universal CRUD service.
39     * @param PDO                  $pdo         Database connection.
40     * @param string               $tablePrefix Database table prefix.
41     */
42    public function __construct(
43        private readonly UniversalCrudService $crudService,
44        private readonly PDO $pdo,
45        private readonly string $tablePrefix = 'a_'
46    ) {
47        $this->validator = new UiTypeImportValidator();
48    }
49
50    /**
51     * Analyzes an uploaded file, extracts header metadata, sample rows, and suggests field mappings.
52     *
53     * @param string $filePath   Absolute path to uploaded spreadsheet file.
54     * @param string $moduleName Target module machine name.
55     * @return array{
56     *     file_headers: list<string>,
57     *     sample_rows: list<array<string, string>>,
58     *     module_fields: list<array{key: string, label: string, uitype: string, is_mandatory: bool, is_unique: bool}>,
59     *     suggested_mapping: array<string, string>
60     * }
61     */
62    public function analyzeFile(string $filePath, string $moduleName): array
63    {
64        $reader = $this->resolveReader($filePath);
65        $fileHeaders = $reader->getHeaders($filePath);
66        $sampleRows = $reader->getSampleRows($filePath, 3);
67
68        $metaRepo = $this->crudService->getMetadataRepository();
69        $module = $metaRepo->findModule($moduleName);
70        $allFields = $metaRepo->findFields($module->id);
71
72        $importableFields = array_values(
73            array_filter($allFields, static fn(FieldMetadata $f): bool => !$f->isSystem || $f->isTitleField())
74        );
75
76        $suggestedMapping = $this->generateSuggestedMapping($fileHeaders, $importableFields);
77
78        $fieldsPayload = array_map(
79            static fn(FieldMetadata $f): array => [
80                'key' => $f->fieldKey,
81                'label' => $f->label,
82                'uitype' => $f->uitypeName,
83                'is_mandatory' => $f->isMandatory,
84                'is_unique' => $f->isUnique,
85            ],
86            $importableFields
87        );
88
89        return [
90            'file_headers' => $fileHeaders,
91            'sample_rows' => $sampleRows,
92            'module_fields' => $fieldsPayload,
93            'suggested_mapping' => $suggestedMapping,
94        ];
95    }
96
97    /**
98     * Executes data import streaming with strict UiType validation and deduplication strategies.
99     *
100     * @param string                  $moduleName  Target module machine name.
101     * @param string                  $filePath    Path to source tabular file.
102     * @param ImportMappingConfig     $config      Import configuration and column mapping rules.
103     * @param PermissionContext       $context     User security context.
104     * @param (callable(int, int):void)|null $onProgress Optional progress callback ($processed, $totalEstimate).
105     * @return ImportResultDto Summary metrics and row error details.
106     */
107    public function executeImport(
108        string $moduleName,
109        string $filePath,
110        ImportMappingConfig $config,
111        PermissionContext $context,
112        ?callable $onProgress = null
113    ): ImportResultDto {
114        $metaRepo = $this->crudService->getMetadataRepository();
115        $module = $metaRepo->findModule($moduleName);
116
117        $this->verifyImportPermission($module->id, $context);
118
119        $fieldsByKey = [];
120        foreach ($metaRepo->findFields($module->id) as $field) {
121            $fieldsByKey[$field->fieldKey] = $field;
122        }
123
124        $reader = $this->resolveReader($filePath);
125        $existingLookup = $this->buildExistingLookup($module->tableName, $config);
126
127        $totalRows = 0;
128        $importedRows = 0;
129        $updatedRows = 0;
130        $skippedRows = 0;
131        $failedRows = 0;
132        $errors = [];
133
134        foreach ($reader->iterateRows($filePath) as $rowNumber => $fileRow) {
135            $totalRows++;
136            $processed = $this->processImportRow($fileRow, $config, $fieldsByKey);
137
138            if (!empty($processed['errors'])) {
139                $failedRows++;
140                $errors[] = [
141                    'row' => $rowNumber,
142                    'message' => implode('; ', $processed['errors']),
143                ];
144                continue;
145            }
146
147            $dedupResult = $this->applyDeduplication(
148                $moduleName,
149                $processed['data'],
150                $existingLookup,
151                $config,
152                $context
153            );
154
155            if ($dedupResult === 'skipped') {
156                $skippedRows++;
157            } elseif ($dedupResult === 'updated') {
158                $updatedRows++;
159            } else {
160                $importedRows++;
161            }
162
163            if ($onProgress !== null && $totalRows % 25 === 0) {
164                $onProgress($totalRows, 0);
165            }
166        }
167
168        return new ImportResultDto(
169            totalRows: $totalRows,
170            importedRows: $importedRows,
171            updatedRows: $updatedRows,
172            skippedRows: $skippedRows,
173            failedRows: $failedRows,
174            errors: $errors
175        );
176    }
177
178    /**
179     * Maps and validates single tabular file row against module field contracts.
180     *
181     * @param array<string, mixed>         $fileRow
182     * @param ImportMappingConfig          $config
183     * @param array<string, FieldMetadata> $fieldsByKey
184     * @return array{data: array<string, mixed>, errors: list<string>}
185     */
186    private function processImportRow(
187        array $fileRow,
188        ImportMappingConfig $config,
189        array $fieldsByKey
190    ): array {
191        $recordData = [];
192        $rowErrors = [];
193
194        foreach ($config->columnMapping as $fileCol => $targetKey) {
195            if ($targetKey === '' || !isset($fieldsByKey[$targetKey])) {
196                continue;
197            }
198
199            $fieldMeta = $fieldsByKey[$targetKey];
200            $rawVal = $fileRow[$fileCol] ?? '';
201
202            $normResult = $this->validator->validateAndNormalize($rawVal, $fieldMeta);
203            if (!$normResult['valid']) {
204                $rowErrors[] = sprintf('[%s]: %s', $fieldMeta->label, (string) $normResult['error']);
205            } else {
206                $recordData[$targetKey] = $normResult['value'];
207            }
208        }
209
210        foreach ($fieldsByKey as $key => $field) {
211            if ($field->isMandatory && (!isset($recordData[$key]) || $recordData[$key] === '')) {
212                $rowErrors[] = sprintf('Required field [%s] is missing.', $field->label);
213            }
214        }
215
216        return ['data' => $recordData, 'errors' => $rowErrors];
217    }
218
219    /**
220     * Resolves appropriate tabular stream reader based on file extension.
221     */
222    private function resolveReader(string $filePath): TabularStreamReaderInterface
223    {
224        $ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
225        return match ($ext) {
226            'xlsx' => new XlsxTabularStreamReader(),
227            'csv', 'txt' => new CsvTabularStreamReader(),
228            default => throw new InvalidArgumentException("Unsupported spreadsheet format: .{$ext}"),
229        };
230    }
231
232    /**
233     * Pre-indexes existing records for deduplication check.
234     *
235     * @return array<string, int> Value -> Primary Key mapping.
236     */
237    private function buildExistingLookup(string $tableName, ImportMappingConfig $config): array
238    {
239        if ($config->deduplicationStrategy === 'insert_all' || $config->uniqueIdentifierField === null) {
240            return [];
241        }
242
243        $field = (string) preg_replace('/\W/', '', (string) $config->uniqueIdentifierField);
244        $fullTable = str_starts_with($tableName, $this->tablePrefix)
245            ? $tableName
246            : $this->tablePrefix . $tableName;
247
248        $sql = "SELECT id, {$field} FROM {$fullTable} WHERE {$field} IS NOT NULL AND {$field} != ''";
249        try {
250            $stmt = $this->pdo->query($sql);
251            $lookup = [];
252            while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
253                $key = strtolower(trim((string) $row[$field]));
254                $lookup[$key] = (int) $row['id'];
255            }
256            return $lookup;
257        } catch (Throwable) {
258            return [];
259        }
260    }
261
262    /**
263     * Applies deduplication strategy and writes record to database.
264     *
265     * @param array<string, mixed> $recordData
266     * @param array<string, int>   $existingLookup
267     */
268    private function applyDeduplication(
269        string $moduleName,
270        array $recordData,
271        array &$existingLookup,
272        ImportMappingConfig $config,
273        PermissionContext $context
274    ): string {
275        $uniqueKey = '';
276        if ($config->uniqueIdentifierField !== null && isset($recordData[$config->uniqueIdentifierField])) {
277            $uniqueKey = strtolower(trim((string) $recordData[$config->uniqueIdentifierField]));
278        }
279
280        $existingId = ($uniqueKey !== '' && isset($existingLookup[$uniqueKey]))
281            ? $existingLookup[$uniqueKey]
282            : null;
283
284        if ($existingId !== null) {
285            if ($config->deduplicationStrategy === 'skip_duplicates') {
286                return 'skipped';
287            }
288
289            if ($config->deduplicationStrategy === 'update_duplicates') {
290                if (!$config->isDryRun) {
291                    $this->crudService->update($moduleName, $existingId, $recordData, $context);
292                }
293                return 'updated';
294            }
295        }
296
297        if (!$config->isDryRun) {
298            $newId = $this->crudService->create($moduleName, $recordData, $context);
299            if ($uniqueKey !== '') {
300                $existingLookup[$uniqueKey] = $newId;
301            }
302        }
303
304        return 'imported';
305    }
306
307    /**
308     * Generates heuristic column mapping matching file headers to module field keys/labels.
309     *
310     * @param list<string>        $fileHeaders
311     * @param list<FieldMetadata> $fields
312     * @return array<string, string>
313     */
314    private function generateSuggestedMapping(array $fileHeaders, array $fields): array
315    {
316        $mapping = [];
317        $normalizedFields = [];
318
319        foreach ($fields as $field) {
320            $normalizedFields[$field->fieldKey] = $this->normalizeString($field->fieldKey);
321            $normalizedFields[$field->fieldKey . '_lbl'] = $this->normalizeString($field->label);
322        }
323
324        foreach ($fileHeaders as $header) {
325            $normHeader = $this->normalizeString($header);
326            $bestMatch = '';
327
328            foreach ($fields as $field) {
329                if ($normHeader === $normalizedFields[$field->fieldKey]) {
330                    $bestMatch = $field->fieldKey;
331                    break;
332                }
333                if ($normHeader === $normalizedFields[$field->fieldKey . '_lbl']) {
334                    $bestMatch = $field->fieldKey;
335                    break;
336                }
337            }
338
339            $mapping[$header] = $bestMatch;
340        }
341
342        return $mapping;
343    }
344
345    /**
346     * Normalizes a string for heuristic column comparison.
347     */
348    private function normalizeString(string $val): string
349    {
350        return strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $val) ?? '');
351    }
352
353    /**
354     * Verifies if user has permission to import data into the target module.
355     */
356    private function verifyImportPermission(int $moduleId, PermissionContext $context): void
357    {
358        if ($context->isSuperuser) {
359            return;
360        }
361
362        if ($context->actorProfileId === null) {
363            throw new PermissionDeniedException('User does not have import permission for this module.');
364        }
365
366        $sql = "SELECT can_import FROM {$this->tablePrefix}core_profile_modules " .
367            'WHERE profile_id = :profile_id AND module_id = :module_id LIMIT 1';
368        $stmt = $this->pdo->prepare($sql);
369        $stmt->execute([
370            ':profile_id' => $context->actorProfileId,
371            ':module_id' => $moduleId,
372        ]);
373
374        $canImport = $stmt->fetchColumn();
375        if ($canImport !== false && (int) $canImport === 0) {
376            throw new PermissionDeniedException('User does not have import permission for this module.');
377        }
378    }
379}