787 lines
29 KiB
PHP
787 lines
29 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Import;
|
|
|
|
use App\Models\Import;
|
|
use App\Models\ImportEntity;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
/**
|
|
* ImportSimulationServiceV2 - Simulates imports using V2 handler architecture.
|
|
*
|
|
* Processes rows using entity handlers without persisting any data to the database.
|
|
* Returns preview data showing what would be created/updated for each row.
|
|
*
|
|
* Deduplication: Uses EntityResolutionService through handlers to accurately simulate
|
|
* Person resolution from Contract/ClientCase chains, matching production behavior.
|
|
*/
|
|
class ImportSimulationServiceV2
|
|
{
|
|
protected array $handlers = [];
|
|
|
|
protected array $entityConfigs = [];
|
|
|
|
/**
|
|
* Simulate an import and return preview data.
|
|
*
|
|
* @param Import $import Import record with mappings
|
|
* @param int $limit Maximum number of rows to simulate (default: 100)
|
|
* @param bool $verbose Include detailed information (default: false)
|
|
* @return array Simulation results with row previews and statistics
|
|
*/
|
|
public function simulate(Import $import, int $limit = 100, bool $verbose = false): array
|
|
{
|
|
try {
|
|
// Load entity configurations and handlers
|
|
$this->loadEntityConfigurations();
|
|
|
|
// Only CSV/TXT supported
|
|
if (! in_array($import->source_type, ['csv', 'txt'])) {
|
|
return $this->errorPayload('Podprti so samo CSV/TXT formati.');
|
|
}
|
|
|
|
$filePath = $import->path;
|
|
if (! Storage::disk($import->disk ?? 'local')->exists($filePath)) {
|
|
return $this->errorPayload("Datoteka ni najdena: {$filePath}");
|
|
}
|
|
|
|
$fullPath = Storage::disk($import->disk ?? 'local')->path($filePath);
|
|
$fh = fopen($fullPath, 'r');
|
|
|
|
if (! $fh) {
|
|
return $this->errorPayload("Datoteke ni mogoče odpreti: {$filePath}");
|
|
}
|
|
|
|
$meta = $import->meta ?? [];
|
|
$hasHeader = (bool) ($meta['has_header'] ?? true);
|
|
$delimiter = $meta['forced_delimiter'] ?? $meta['detected_delimiter'] ?? ',';
|
|
|
|
$mappings = $this->loadMappings($import);
|
|
if (empty($mappings)) {
|
|
fclose($fh);
|
|
|
|
return $this->errorPayload('Ni shranjenih mapiranj za ta uvoz.');
|
|
}
|
|
|
|
$header = null;
|
|
$rowNum = 0;
|
|
|
|
// Read header if present
|
|
if ($hasHeader) {
|
|
$header = fgetcsv($fh, 0, $delimiter);
|
|
$rowNum++;
|
|
}
|
|
|
|
$simRows = [];
|
|
$summaries = $this->initSummaries();
|
|
$rowCount = 0;
|
|
|
|
while (($row = fgetcsv($fh, 0, $delimiter)) !== false && $rowCount < $limit) {
|
|
$rowNum++;
|
|
$rowCount++;
|
|
|
|
try {
|
|
$rawAssoc = $this->buildRowAssoc($row, $header);
|
|
|
|
// Skip empty rows
|
|
if ($this->rowIsEffectivelyEmpty($rawAssoc)) {
|
|
continue;
|
|
}
|
|
|
|
$mapped = $this->applyMappings($rawAssoc, $mappings);
|
|
|
|
// Group mapped data by entity (from "entity.field" to nested structure)
|
|
$groupedMapped = $this->groupMappedDataByEntity($mapped);
|
|
|
|
\Log::info('ImportSimulation: Grouped entities', [
|
|
'row' => $rowNum,
|
|
'entity_keys' => array_keys($groupedMapped),
|
|
'config_roots' => array_keys($this->entityConfigs),
|
|
]);
|
|
|
|
// Simulate processing for this row
|
|
// Context must include 'import' for EntityResolutionService to work
|
|
$context = [
|
|
'import' => $import,
|
|
'simulation' => true,
|
|
];
|
|
|
|
$rowResult = $this->simulateRow($import, $groupedMapped, $rawAssoc, $context, $verbose);
|
|
|
|
// Update summaries - handle both single and array results
|
|
foreach ($rowResult['entities'] ?? [] as $entityKey => $entityDataOrArray) {
|
|
// Extract entity root from key (e.g., 'person', 'contract', etc.)
|
|
$root = explode('.', $entityKey)[0];
|
|
|
|
// Handle array of results (grouped entities)
|
|
if (is_array($entityDataOrArray) && isset($entityDataOrArray[0])) {
|
|
foreach ($entityDataOrArray as $entityData) {
|
|
$action = $entityData['action'] ?? 'skip';
|
|
if (!isset($summaries[$root])) {
|
|
$summaries[$root] = ['create' => 0, 'update' => 0, 'skip' => 0, 'invalid' => 0];
|
|
}
|
|
$summaries[$root][$action] = ($summaries[$root][$action] ?? 0) + 1;
|
|
}
|
|
} else {
|
|
// Single result
|
|
$action = $entityDataOrArray['action'] ?? 'skip';
|
|
if (!isset($summaries[$root])) {
|
|
$summaries[$root] = ['create' => 0, 'update' => 0, 'skip' => 0, 'invalid' => 0];
|
|
}
|
|
$summaries[$root][$action] = ($summaries[$root][$action] ?? 0) + 1;
|
|
}
|
|
}
|
|
|
|
$simRows[] = [
|
|
'row_number' => $rowNum,
|
|
'raw_data' => $verbose ? $rawAssoc : null,
|
|
'entities' => $rowResult['entities'],
|
|
'warnings' => $rowResult['warnings'] ?? [],
|
|
'errors' => $rowResult['errors'] ?? [],
|
|
];
|
|
} catch (\Throwable $e) {
|
|
$simRows[] = [
|
|
'row_number' => $rowNum,
|
|
'raw_data' => $verbose ? ($rawAssoc ?? null) : null,
|
|
'entities' => [],
|
|
'warnings' => [],
|
|
'errors' => [$e->getMessage()],
|
|
];
|
|
}
|
|
}
|
|
|
|
fclose($fh);
|
|
|
|
return [
|
|
'success' => true,
|
|
'total_simulated' => $rowCount,
|
|
'limit' => $limit,
|
|
'summaries' => $summaries,
|
|
'rows' => $simRows,
|
|
'meta' => [
|
|
'has_header' => $hasHeader,
|
|
'delimiter' => $delimiter,
|
|
'mappings_count' => count($mappings),
|
|
],
|
|
];
|
|
} catch (\Throwable $e) {
|
|
return $this->errorPayload('Napaka pri simulaciji: '.$e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Simulate processing a single row without database writes.
|
|
*
|
|
* Updated to match ImportServiceV2 logic:
|
|
* - Process entities in priority order from entity configs
|
|
* - Accumulate entity results in context for chain resolution
|
|
* - Pass proper context to handlers for EntityResolutionService
|
|
*/
|
|
protected function simulateRow(Import $import, array $mapped, array $raw, array $context, bool $verbose): array
|
|
{
|
|
$entities = [];
|
|
$warnings = [];
|
|
$errors = [];
|
|
$entityResults = [];
|
|
|
|
// Process entities in configured priority order (like ImportServiceV2)
|
|
foreach ($this->entityConfigs as $root => $config) {
|
|
// Check if this entity exists in mapped data
|
|
$mappedKey = $this->findMappedKey($mapped, $root, $config);
|
|
|
|
if (!$mappedKey || !isset($mapped[$mappedKey])) {
|
|
continue;
|
|
}
|
|
|
|
$handler = $this->handlers[$root] ?? null;
|
|
|
|
if (!$handler) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
// Check if this is an array of entities (grouped)
|
|
$entityDataArray = is_array($mapped[$mappedKey]) && isset($mapped[$mappedKey][0])
|
|
? $mapped[$mappedKey]
|
|
: [$mapped[$mappedKey]];
|
|
|
|
$results = [];
|
|
|
|
foreach ($entityDataArray as $entityData) {
|
|
// Validate
|
|
$validation = $handler->validate($entityData);
|
|
if (!$validation['valid']) {
|
|
$results[] = [
|
|
'action' => 'invalid',
|
|
'data' => $entityData,
|
|
'errors' => $validation['errors'],
|
|
];
|
|
continue;
|
|
}
|
|
|
|
// Skip empty/invalid data that handlers would skip during real import
|
|
// Phone: skip if nu is 0, empty, or #N/A
|
|
if ($root === 'phone') {
|
|
$nu = $entityData['nu'] ?? null;
|
|
if (empty($nu) || $nu === '0' || $nu === '#N/A' || trim((string)$nu) === '') {
|
|
continue; // Skip this phone entirely
|
|
}
|
|
}
|
|
|
|
// Address: skip if address is empty or #N/A
|
|
if ($root === 'address') {
|
|
$address = $entityData['address'] ?? null;
|
|
if (empty($address) || $address === '#N/A' || trim((string)$address) === '') {
|
|
continue; // Skip this address entirely
|
|
}
|
|
}
|
|
|
|
// Email: skip if value is 0, empty, or #N/A
|
|
if ($root === 'email') {
|
|
$email = $entityData['value'] ?? null;
|
|
if (empty($email) || $email === '0' || $email === '#N/A' || trim((string)$email) === '') {
|
|
continue; // Skip this email entirely
|
|
}
|
|
}
|
|
|
|
// DEBUG: Log context for grouped entities
|
|
if (in_array($root, ['phone', 'address'])) {
|
|
Log::info("ImportSimulation: Resolving grouped entity", [
|
|
'entity' => $root,
|
|
'data' => $entityData,
|
|
'has_person_in_context' => isset($entityResults['person']),
|
|
'person_id' => $entityResults['person']['entity']->id ?? null,
|
|
'context_keys' => array_keys(array_merge($context, $entityResults)),
|
|
]);
|
|
}
|
|
|
|
// Resolve existing entity (uses EntityResolutionService internally)
|
|
// Pass accumulated entityResults as context for chain resolution
|
|
$existingEntity = $handler->resolve($entityData, array_merge($context, $entityResults));
|
|
|
|
if ($existingEntity) {
|
|
// Would update existing
|
|
$results[] = [
|
|
'action' => 'update',
|
|
'reference' => $this->getEntityReference($existingEntity, $root),
|
|
'existing_id' => $existingEntity->id ?? null,
|
|
'data' => $entityData,
|
|
'existing_data' => $verbose ? $this->extractExistingData($existingEntity) : null,
|
|
'changes' => $verbose ? $this->detectChanges($existingEntity, $entityData) : null,
|
|
];
|
|
|
|
// Add to entityResults for subsequent handlers
|
|
$entityResults[$root] = [
|
|
'entity' => $existingEntity,
|
|
'action' => 'updated',
|
|
];
|
|
} else {
|
|
// Would create new
|
|
$results[] = [
|
|
'action' => 'create',
|
|
'data' => $entityData,
|
|
];
|
|
|
|
// Simulate entity creation for context (no actual ID)
|
|
$entityResults[$root] = [
|
|
'entity' => (object) $entityData,
|
|
'action' => 'inserted',
|
|
];
|
|
}
|
|
}
|
|
|
|
// Store results (single or array)
|
|
$entities[$mappedKey] = (count($results) === 1) ? $results[0] : $results;
|
|
} catch (\Throwable $e) {
|
|
$entities[$mappedKey] = [
|
|
'action' => 'error',
|
|
'errors' => [$e->getMessage()],
|
|
];
|
|
$errors[] = "{$root}: {$e->getMessage()}";
|
|
}
|
|
}
|
|
|
|
return compact('entities', 'warnings', 'errors');
|
|
}
|
|
|
|
/**
|
|
* Find the mapped key for an entity (supports aliases and common variations).
|
|
*/
|
|
protected function findMappedKey(array $mapped, string $canonicalRoot, $config): ?string
|
|
{
|
|
// Check canonical root exactly
|
|
if (isset($mapped[$canonicalRoot])) {
|
|
return $canonicalRoot;
|
|
}
|
|
|
|
// Build comprehensive list of variations
|
|
$variations = [$canonicalRoot];
|
|
|
|
// Generate plural variations (handle -y endings correctly)
|
|
if (str_ends_with($canonicalRoot, 'y') && !str_ends_with($canonicalRoot, 'ay') && !str_ends_with($canonicalRoot, 'ey')) {
|
|
// activity -> activities
|
|
$variations[] = substr($canonicalRoot, 0, -1) . 'ies';
|
|
} else {
|
|
// address -> addresses
|
|
$variations[] = $canonicalRoot . 's';
|
|
}
|
|
|
|
// Add singular form (remove trailing s or ies)
|
|
if (str_ends_with($canonicalRoot, 'ies')) {
|
|
$variations[] = substr($canonicalRoot, 0, -3) . 'y'; // activities -> activity
|
|
} else {
|
|
$variations[] = rtrim($canonicalRoot, 's'); // addresses -> address
|
|
}
|
|
|
|
// Add person_ prefixed versions
|
|
$variations[] = 'person_' . $canonicalRoot;
|
|
|
|
// person_activity -> person_activities
|
|
if (str_ends_with($canonicalRoot, 'y') && !str_ends_with($canonicalRoot, 'ay') && !str_ends_with($canonicalRoot, 'ey')) {
|
|
$variations[] = 'person_' . substr($canonicalRoot, 0, -1) . 'ies';
|
|
} else {
|
|
$variations[] = 'person_' . $canonicalRoot . 's';
|
|
}
|
|
|
|
// Special handling: if canonical is 'address', also check 'person_addresses'
|
|
if ($canonicalRoot === 'address') {
|
|
$variations[] = 'person_addresses';
|
|
}
|
|
// Special handling: if canonical is 'phone', also check 'person_phones'
|
|
if ($canonicalRoot === 'phone') {
|
|
$variations[] = 'person_phones';
|
|
}
|
|
// Reverse: if canonical has 'person_', also check without it
|
|
if (str_starts_with($canonicalRoot, 'person_')) {
|
|
$withoutPerson = str_replace('person_', '', $canonicalRoot);
|
|
$variations[] = $withoutPerson;
|
|
// Handle plural variations
|
|
if (str_ends_with($withoutPerson, 'y') && !str_ends_with($withoutPerson, 'ay') && !str_ends_with($withoutPerson, 'ey')) {
|
|
$variations[] = substr($withoutPerson, 0, -1) . 'ies';
|
|
} else {
|
|
$variations[] = rtrim($withoutPerson, 's');
|
|
$variations[] = $withoutPerson . 's';
|
|
}
|
|
}
|
|
|
|
$variations = array_unique($variations);
|
|
|
|
foreach ($variations as $variation) {
|
|
if (isset($mapped[$variation])) {
|
|
\Log::debug("ImportSimulation: Matched entity", [
|
|
'canonical_root' => $canonicalRoot,
|
|
'matched_key' => $variation,
|
|
]);
|
|
return $variation;
|
|
}
|
|
}
|
|
|
|
// Check aliases if configured
|
|
if (isset($config->options['aliases'])) {
|
|
$aliases = is_array($config->options['aliases']) ? $config->options['aliases'] : [];
|
|
foreach ($aliases as $alias) {
|
|
if (isset($mapped[$alias])) {
|
|
return $alias;
|
|
}
|
|
}
|
|
}
|
|
|
|
\Log::debug("ImportSimulation: No match found for entity", [
|
|
'canonical_root' => $canonicalRoot,
|
|
'tried_variations' => array_slice($variations, 0, 5),
|
|
'available_keys' => array_keys($mapped),
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Group mapped data by entity from "entity.field" format to nested structure.
|
|
* Handles both single values and arrays (for grouped entities like multiple addresses).
|
|
*
|
|
* Special handling:
|
|
* - activity.note arrays are kept together (single activity with multiple notes)
|
|
* - Other array values create separate entity instances (e.g., multiple addresses)
|
|
*
|
|
* Input: ['person.first_name' => 'John', 'person.last_name' => 'Doe', 'email.value' => ['a@b.com', 'c@d.com']]
|
|
* Output: ['person' => ['first_name' => 'John', 'last_name' => 'Doe'], 'email' => [['value' => 'a@b.com'], ['value' => 'c@d.com']]]
|
|
*/
|
|
protected function groupMappedDataByEntity(array $mapped): array
|
|
{
|
|
$grouped = [];
|
|
|
|
foreach ($mapped as $key => $value) {
|
|
if (!str_contains($key, '.')) {
|
|
continue;
|
|
}
|
|
|
|
[$entity, $field] = explode('.', $key, 2);
|
|
|
|
// Handle array values
|
|
if (is_array($value)) {
|
|
if (!isset($grouped[$entity])) {
|
|
$grouped[$entity] = [];
|
|
}
|
|
|
|
// Special case: activity.note should be kept as array in single instance
|
|
if ($entity === 'activity' || $entity === 'activities') {
|
|
if (!isset($grouped[$entity][0])) {
|
|
$grouped[$entity][0] = [];
|
|
}
|
|
$grouped[$entity][0][$field] = $value; // Keep as array
|
|
} else {
|
|
// Create separate entity instances for each array value
|
|
foreach ($value as $idx => $val) {
|
|
if (!isset($grouped[$entity][$idx])) {
|
|
$grouped[$entity][$idx] = [];
|
|
}
|
|
$grouped[$entity][$idx][$field] = $val;
|
|
}
|
|
}
|
|
} else {
|
|
// Single value
|
|
if (!isset($grouped[$entity])) {
|
|
$grouped[$entity] = [];
|
|
}
|
|
|
|
// If entity is already an array (from previous grouped field), add to all instances
|
|
if (isset($grouped[$entity][0]) && is_array($grouped[$entity][0])) {
|
|
foreach ($grouped[$entity] as &$instance) {
|
|
$instance[$field] = $value;
|
|
}
|
|
unset($instance);
|
|
} else {
|
|
// Simple associative array
|
|
$grouped[$entity][$field] = $value;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $grouped;
|
|
}
|
|
|
|
/**
|
|
* Determine if entity data is grouped (array of instances).
|
|
*/
|
|
protected function isGroupedEntity($data): bool
|
|
{
|
|
if (!is_array($data)) {
|
|
return false;
|
|
}
|
|
|
|
// Check if numeric array (multiple instances)
|
|
$keys = array_keys($data);
|
|
return isset($keys[0]) && is_int($keys[0]);
|
|
}
|
|
|
|
/**
|
|
* Extract existing entity data as array.
|
|
*/
|
|
protected function extractExistingData($entity): array
|
|
{
|
|
if (method_exists($entity, 'toArray')) {
|
|
return $entity->toArray();
|
|
}
|
|
|
|
return (array) $entity;
|
|
}
|
|
|
|
/**
|
|
* Detect changes between existing entity and new data.
|
|
*/
|
|
protected function detectChanges($existingEntity, array $newData): array
|
|
{
|
|
$changes = [];
|
|
|
|
foreach ($newData as $key => $newValue) {
|
|
$oldValue = $existingEntity->{$key} ?? null;
|
|
|
|
// Convert to comparable formats
|
|
if ($oldValue instanceof \Carbon\Carbon) {
|
|
$oldValue = $oldValue->format('Y-m-d');
|
|
}
|
|
|
|
if ($oldValue != $newValue && ! ($oldValue === null && $newValue === '')) {
|
|
$changes[$key] = [
|
|
'old' => $oldValue,
|
|
'new' => $newValue,
|
|
];
|
|
}
|
|
}
|
|
|
|
return $changes;
|
|
}
|
|
|
|
/**
|
|
* Get a reference string for an entity.
|
|
*/
|
|
protected function getEntityReference($entity, string $root): string
|
|
{
|
|
if (isset($entity->reference)) {
|
|
return (string) $entity->reference;
|
|
}
|
|
if (isset($entity->value)) {
|
|
return (string) $entity->value;
|
|
}
|
|
if (isset($entity->title)) {
|
|
return (string) $entity->title;
|
|
}
|
|
if (isset($entity->id)) {
|
|
return "{$root}#{$entity->id}";
|
|
}
|
|
|
|
return 'N/A';
|
|
}
|
|
|
|
/**
|
|
* Load entity configurations from database.
|
|
*/
|
|
protected function loadEntityConfigurations(): void
|
|
{
|
|
$entities = ImportEntity::where('is_active', true)
|
|
->orderBy('priority', 'desc')
|
|
->get();
|
|
|
|
foreach ($entities as $entity) {
|
|
$this->entityConfigs[$entity->canonical_root] = $entity;
|
|
|
|
// Instantiate handler if configured
|
|
if ($entity->handler_class && class_exists($entity->handler_class)) {
|
|
$this->handlers[$entity->canonical_root] = app($entity->handler_class, ['entity' => $entity]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get handler for entity root.
|
|
*/
|
|
protected function getHandler(string $root)
|
|
{
|
|
return $this->handlers[$root] ?? null;
|
|
}
|
|
|
|
/**
|
|
* Load mappings from import_mappings table.
|
|
* Uses target_field in "entity.field" format.
|
|
* Supports multiple sources mapping to same target (for groups).
|
|
*/
|
|
protected function loadMappings(Import $import): array
|
|
{
|
|
$rows = \DB::table('import_mappings')
|
|
->where('import_id', $import->id)
|
|
->orderBy('position')
|
|
->get(['source_column', 'target_field', 'transform', 'apply_mode', 'options']);
|
|
|
|
$mappings = [];
|
|
foreach ($rows as $row) {
|
|
$source = trim((string) $row->source_column);
|
|
$target = trim((string) $row->target_field);
|
|
|
|
if ($source === '' || $target === '') {
|
|
continue;
|
|
}
|
|
|
|
// Use unique key combining source and target to avoid overwriting
|
|
$key = $source . '→' . $target;
|
|
|
|
// target_field is in "entity.field" format
|
|
$mappings[$key] = [
|
|
'source' => $source,
|
|
'target' => $target,
|
|
'transform' => $row->transform ?? null,
|
|
'apply_mode' => $row->apply_mode ?? 'both',
|
|
'options' => $row->options ? json_decode($row->options, true) : [],
|
|
];
|
|
}
|
|
|
|
return $mappings;
|
|
}
|
|
|
|
/**
|
|
* Build associative array from row data.
|
|
*/
|
|
protected function buildRowAssoc(array $row, ?array $header): array
|
|
{
|
|
if ($header) {
|
|
return array_combine($header, array_pad($row, count($header), null));
|
|
}
|
|
|
|
// Use numeric indices if no header
|
|
return array_combine(
|
|
array_map(fn ($i) => "col_{$i}", array_keys($row)),
|
|
$row
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if row is effectively empty.
|
|
*/
|
|
protected function rowIsEffectivelyEmpty(array $assoc): bool
|
|
{
|
|
foreach ($assoc as $value) {
|
|
if ($value !== null && $value !== '') {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Apply mappings to raw row data.
|
|
* Returns array keyed by "entity.field".
|
|
*
|
|
* Updated to match ImportServiceV2:
|
|
* - Supports group option for concatenating multiple sources
|
|
* - Uses setNestedValue for proper array handling
|
|
*/
|
|
protected function applyMappings(array $raw, array $mappings): array
|
|
{
|
|
$mapped = [];
|
|
|
|
// Group mappings by target field to handle concatenation (same as ImportServiceV2)
|
|
$groupedMappings = [];
|
|
foreach ($mappings as $mapping) {
|
|
$target = $mapping['target'];
|
|
if (!isset($groupedMappings[$target])) {
|
|
$groupedMappings[$target] = [];
|
|
}
|
|
$groupedMappings[$target][] = $mapping;
|
|
}
|
|
|
|
foreach ($groupedMappings as $targetField => $fieldMappings) {
|
|
// Group by group number from options
|
|
$valuesByGroup = [];
|
|
|
|
foreach ($fieldMappings as $mapping) {
|
|
$source = $mapping['source'];
|
|
|
|
if (!isset($raw[$source])) {
|
|
continue;
|
|
}
|
|
|
|
$value = $raw[$source];
|
|
|
|
// Apply transform if specified
|
|
if (!empty($mapping['transform'])) {
|
|
$value = $this->applyTransform($value, $mapping['transform']);
|
|
}
|
|
|
|
// Get group from options
|
|
$options = $mapping['options'] ?? [];
|
|
$group = $options['group'] ?? null;
|
|
|
|
// Group values by their group number (same logic as ImportServiceV2)
|
|
if ($group !== null) {
|
|
// Same group = concatenate
|
|
if (!isset($valuesByGroup[$group])) {
|
|
$valuesByGroup[$group] = [];
|
|
}
|
|
$valuesByGroup[$group][] = $value;
|
|
} else {
|
|
// No group = each gets its own group
|
|
$valuesByGroup[] = [$value];
|
|
}
|
|
}
|
|
|
|
// Now set the values (same logic as ImportServiceV2)
|
|
foreach ($valuesByGroup as $values) {
|
|
if (count($values) === 1) {
|
|
// Single value - set directly
|
|
$this->setNestedValue($mapped, $targetField, $values[0]);
|
|
} else {
|
|
// Multiple values in same group - concatenate with newline
|
|
$concatenated = implode("\n", array_filter($values, fn($v) => !empty($v) && trim((string)$v) !== ''));
|
|
if (!empty($concatenated)) {
|
|
$this->setNestedValue($mapped, $targetField, $concatenated);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return $mapped;
|
|
}
|
|
|
|
/**
|
|
* Set nested value in array using dot notation.
|
|
* If the key already exists, convert to array and append the new value.
|
|
*
|
|
* Same logic as ImportServiceV2.
|
|
*/
|
|
protected function setNestedValue(array &$array, string $key, mixed $value): void
|
|
{
|
|
$keys = explode('.', $key);
|
|
$current = &$array;
|
|
|
|
foreach ($keys as $i => $k) {
|
|
if ($i === count($keys) - 1) {
|
|
// If key already exists, convert to array and append
|
|
if (isset($current[$k])) {
|
|
// Convert existing single value to array if needed
|
|
if (!is_array($current[$k])) {
|
|
$current[$k] = [$current[$k]];
|
|
}
|
|
// Append new value
|
|
$current[$k][] = $value;
|
|
} else {
|
|
// Set as single value
|
|
$current[$k] = $value;
|
|
}
|
|
} else {
|
|
if (!isset($current[$k]) || !is_array($current[$k])) {
|
|
$current[$k] = [];
|
|
}
|
|
$current = &$current[$k];
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply transform to a value.
|
|
*/
|
|
protected function applyTransform(mixed $value, string $transform): mixed
|
|
{
|
|
return match ($transform) {
|
|
'trim' => trim((string) $value),
|
|
'upper' => strtoupper((string) $value),
|
|
'lower' => strtolower((string) $value),
|
|
'decimal' => (float) str_replace(',', '.', (string) $value),
|
|
default => $value,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Initialize summary counters.
|
|
*/
|
|
protected function initSummaries(): array
|
|
{
|
|
$summaries = [];
|
|
|
|
foreach (array_keys($this->entityConfigs) as $root) {
|
|
$summaries[$root] = [
|
|
'create' => 0,
|
|
'update' => 0,
|
|
'skip' => 0,
|
|
'invalid' => 0,
|
|
];
|
|
}
|
|
|
|
return $summaries;
|
|
}
|
|
|
|
/**
|
|
* Create error payload.
|
|
*/
|
|
protected function errorPayload(string $message): array
|
|
{
|
|
return [
|
|
'success' => false,
|
|
'error' => $message,
|
|
'total_simulated' => 0,
|
|
'summaries' => [],
|
|
'rows' => [],
|
|
];
|
|
}
|
|
}
|