Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
95.57% |
151 / 158 |
|
50.00% |
5 / 10 |
CRAP | |
0.00% |
0 / 1 |
| DataImportService | |
95.54% |
150 / 157 |
|
50.00% |
5 / 10 |
53 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| analyzeFile | |
100.00% |
26 / 26 |
|
100.00% |
1 / 1 |
2 | |||
| executeImport | |
97.83% |
45 / 46 |
|
0.00% |
0 / 1 |
8 | |||
| processImportRow | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
9 | |||
| resolveReader | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
4.07 | |||
| buildExistingLookup | |
80.00% |
12 / 15 |
|
0.00% |
0 / 1 |
6.29 | |||
| applyDeduplication | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
11 | |||
| generateSuggestedMapping | |
93.75% |
15 / 16 |
|
0.00% |
0 / 1 |
6.01 | |||
| normalizeString | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| verifyImportPermission | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
5.01 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Core\DataExchange\Application\Service; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Core\DataExchange\Domain\Model\ImportMappingConfig; |
| 12 | use App\Core\DataExchange\Domain\Model\ImportResultDto; |
| 13 | use App\Core\DataExchange\Domain\Service\UiTypeImportValidator; |
| 14 | use App\Core\DataExchange\Infrastructure\Reader\CsvTabularStreamReader; |
| 15 | use App\Core\DataExchange\Infrastructure\Reader\TabularStreamReaderInterface; |
| 16 | use App\Core\DataExchange\Infrastructure\Reader\XlsxTabularStreamReader; |
| 17 | use App\Core\Engine\Application\Service\UniversalCrudService; |
| 18 | use App\Core\Engine\Domain\Exception\PermissionDeniedException; |
| 19 | use App\Core\Engine\Domain\Model\FieldMetadata; |
| 20 | use App\Core\Engine\Domain\Model\PermissionContext; |
| 21 | use InvalidArgumentException; |
| 22 | use PDO; |
| 23 | use RuntimeException; |
| 24 | use Throwable; |
| 25 | |
| 26 | /** |
| 27 | * Application service orchestrating the multi-step data import workflow. |
| 28 | * |
| 29 | * @package App\Core\DataExchange\Application\Service |
| 30 | */ |
| 31 | final 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 | } |