Teren-app/app/Services/Import/ImportSimulationServiceV2.php
Simon Pocrnjič 36b63a180d fixed import
2025-12-28 13:55:09 +01:00

833 lines
31 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
try {
$existingEntity = $handler->resolve($entityData, array_merge($context, $entityResults));
} catch (\Throwable $resolutionError) {
// In simulation mode, resolution may fail due to simulated entities
// Just treat as new entity
\Log::debug("ImportSimulation: Resolution failed (treating as new)", [
'entity' => $root,
'error' => $resolutionError->getMessage(),
]);
$existingEntity = null;
}
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)
// Mark as simulated so resolution service knows not to use model methods
$simulatedEntity = (object) $entityData;
$simulatedEntity->_simulated = true;
$entityResults[$root] = [
'entity' => $simulatedEntity,
'action' => 'inserted',
'_simulated' => true,
];
}
}
// 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)) {
// Special case: activity.note should be kept as array in single instance
if ($entity === 'activity' || $entity === 'activities') {
if (!isset($grouped[$entity])) {
$grouped[$entity] = [];
}
$grouped[$entity][$field] = $value; // Keep as array
} else {
// For other entities, only create multiple instances if:
// 1. Entity doesn't exist yet, OR
// 2. Entity has no other fields yet (is empty array)
if (!isset($grouped[$entity])) {
$grouped[$entity] = [];
}
// If entity already has string-keyed fields, just set the array as field value
// Otherwise, create separate instances
$hasStringKeys = !empty($grouped[$entity]) && isset(array_keys($grouped[$entity])[0]) && is_string(array_keys($grouped[$entity])[0]);
if ($hasStringKeys) {
// Entity has fields already - don't split, keep array as-is
$grouped[$entity][$field] = $value;
} 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] = [];
}
// Check if entity is already an array of instances (from previous grouped field)
if (!empty($grouped[$entity]) && is_int(array_key_first($grouped[$entity]))) {
// Entity has multiple instances - add field to all instances
foreach ($grouped[$entity] as &$instance) {
if (is_array($instance)) {
$instance[$field] = $value;
}
}
unset($instance);
} else {
// Simple associative array - add field
$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
* - Returns flat array with "entity.field" keys (no nesting)
*/
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 - KEEP FLAT, DON'T NEST
foreach ($valuesByGroup as $values) {
if (count($values) === 1) {
// Single value - add to array if key exists, otherwise set directly
if (isset($mapped[$targetField])) {
// Convert to array and append
if (!is_array($mapped[$targetField])) {
$mapped[$targetField] = [$mapped[$targetField]];
}
$mapped[$targetField][] = $values[0];
} else {
$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)) {
if (isset($mapped[$targetField])) {
// Convert to array and append
if (!is_array($mapped[$targetField])) {
$mapped[$targetField] = [$mapped[$targetField]];
}
$mapped[$targetField][] = $concatenated;
} else {
$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' => [],
];
}
}