Importer update add support for meta data and multiple inserts for some entities like addresses and phones, updated other things
This commit is contained in:
@@ -1059,7 +1059,7 @@ public function show(ClientCase $clientCase)
|
||||
// Only apply active/inactive filtering IF a segment filter is provided.
|
||||
$contractsQuery = $case->contracts()
|
||||
// Only select lean columns to avoid oversize JSON / headers (include description for UI display)
|
||||
->select(['id', 'uuid', 'reference', 'start_date', 'end_date', 'description', 'active', 'type_id', 'client_case_id', 'created_at'])
|
||||
->select(['id', 'uuid', 'reference', 'start_date', 'end_date', 'description', 'meta', 'active', 'type_id', 'client_case_id', 'created_at'])
|
||||
->with([
|
||||
'type:id,name',
|
||||
// Use closure for account to avoid ambiguous column names with latestOfMany join
|
||||
|
||||
@@ -78,8 +78,7 @@ public function show(Client $client, Request $request)
|
||||
->when($request->input('search'), fn ($que, $search) => $que->whereHas(
|
||||
'person',
|
||||
fn ($q) => $q->where('full_name', 'ilike', '%'.$search.'%')
|
||||
)
|
||||
)
|
||||
))
|
||||
->addSelect([
|
||||
'active_contracts_count' => \DB::query()
|
||||
->from('contracts')
|
||||
@@ -112,6 +111,59 @@ public function show(Client $client, Request $request)
|
||||
]);
|
||||
}
|
||||
|
||||
public function contracts(Client $client, Request $request)
|
||||
{
|
||||
$data = $client->load(['person' => fn ($q) => $q->with(['addresses', 'phones', 'bankAccounts', 'emails'])]);
|
||||
|
||||
$from = $request->input('from');
|
||||
$to = $request->input('to');
|
||||
$search = $request->input('search');
|
||||
|
||||
$contractsQuery = \App\Models\Contract::query()
|
||||
->whereHas('clientCase', function ($q) use ($client) {
|
||||
$q->where('client_id', $client->id);
|
||||
})
|
||||
->with([
|
||||
'clientCase:id,uuid,person_id',
|
||||
'clientCase.person:id,full_name',
|
||||
'segments' => function ($q) {
|
||||
$q->wherePivot('active', true)->select('segments.id', 'segments.name');
|
||||
},
|
||||
'account:id,accounts.contract_id,balance_amount',
|
||||
])
|
||||
->select(['id', 'uuid', 'reference', 'start_date', 'client_case_id'])
|
||||
->whereNull('deleted_at')
|
||||
->when($from || $to, function ($q) use ($from, $to) {
|
||||
if (! empty($from)) {
|
||||
$q->whereDate('start_date', '>=', $from);
|
||||
}
|
||||
if (! empty($to)) {
|
||||
$q->whereDate('start_date', '<=', $to);
|
||||
}
|
||||
})
|
||||
->when($search, function ($q) use ($search) {
|
||||
$q->where(function ($inner) use ($search) {
|
||||
$inner->where('reference', 'ilike', '%'.$search.'%')
|
||||
->orWhereHas('clientCase.person', function ($p) use ($search) {
|
||||
$p->where('full_name', 'ilike', '%'.$search.'%');
|
||||
});
|
||||
});
|
||||
})
|
||||
->orderByDesc('start_date');
|
||||
|
||||
$types = [
|
||||
'address_types' => \App\Models\Person\AddressType::all(),
|
||||
'phone_types' => \App\Models\Person\PhoneType::all(),
|
||||
];
|
||||
|
||||
return Inertia::render('Client/Contracts', [
|
||||
'client' => $data,
|
||||
'contracts' => $contractsQuery->paginate(20)->withQueryString(),
|
||||
'filters' => $request->only(['from', 'to', 'search']),
|
||||
'types' => $types,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
|
||||
|
||||
@@ -385,6 +385,8 @@ public function bulkAddMappings(Request $request, ImportTemplate $template)
|
||||
'default_field' => 'nullable|string', // if provided, used as the field name for all entries
|
||||
'apply_mode' => 'nullable|string|in:insert,update,both,keyref',
|
||||
'transform' => 'nullable|string|in:trim,upper,lower',
|
||||
'options' => 'nullable|array',
|
||||
'group' => 'nullable|string|max:50', // convenience: will be wrapped into options.group
|
||||
])->validate();
|
||||
|
||||
// Accept commas, semicolons, and newlines; strip surrounding quotes/apostrophes and whitespace
|
||||
@@ -408,9 +410,18 @@ public function bulkAddMappings(Request $request, ImportTemplate $template)
|
||||
$entity = $data['entity'] ?? null;
|
||||
$defaultField = $data['default_field'] ?? null; // allows forcing a specific field for all
|
||||
|
||||
// Build options payload once
|
||||
$opts = [];
|
||||
if (isset($data['options']) && is_array($data['options'])) {
|
||||
$opts = $data['options'];
|
||||
}
|
||||
if (! empty($data['group'])) {
|
||||
$opts['group'] = (string) $data['group'];
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
DB::transaction(function () use ($template, $list, $apply, $transform, $entity, $defaultField, $basePosition, &$created, &$updated) {
|
||||
DB::transaction(function () use ($template, $list, $apply, $transform, $entity, $defaultField, $basePosition, $opts, &$created, &$updated) {
|
||||
foreach ($list as $idx => $source) {
|
||||
$targetField = null;
|
||||
if ($defaultField) {
|
||||
@@ -429,7 +440,7 @@ public function bulkAddMappings(Request $request, ImportTemplate $template)
|
||||
'entity' => $entity ?? $existing->entity,
|
||||
'transform' => $transform ?? $existing->transform,
|
||||
'apply_mode' => $apply ?? $existing->apply_mode ?? 'both',
|
||||
'options' => $existing->options,
|
||||
'options' => empty($opts) ? $existing->options : $opts,
|
||||
// keep existing position
|
||||
]);
|
||||
$updated++;
|
||||
@@ -441,7 +452,7 @@ public function bulkAddMappings(Request $request, ImportTemplate $template)
|
||||
'target_field' => $targetField,
|
||||
'transform' => $transform,
|
||||
'apply_mode' => $apply,
|
||||
'options' => null,
|
||||
'options' => empty($opts) ? null : $opts,
|
||||
'position' => $basePosition + $idx + 1,
|
||||
]);
|
||||
$created++;
|
||||
@@ -546,6 +557,10 @@ public function applyToImport(Request $request, ImportTemplate $template, Import
|
||||
|
||||
$rows = $template->mappings()->orderBy('position')->get();
|
||||
foreach ($rows as $row) {
|
||||
$options = $row->options;
|
||||
if (is_array($options) || $options instanceof \JsonSerializable || $options instanceof \stdClass) {
|
||||
$options = json_encode($options);
|
||||
}
|
||||
\DB::table('import_mappings')->insert([
|
||||
'import_id' => $import->id,
|
||||
'entity' => $row->entity,
|
||||
@@ -553,7 +568,7 @@ public function applyToImport(Request $request, ImportTemplate $template, Import
|
||||
'target_field' => $row->target_field,
|
||||
'transform' => $row->transform,
|
||||
'apply_mode' => $row->apply_mode ?? 'both',
|
||||
'options' => $row->options,
|
||||
'options' => $options,
|
||||
'position' => $row->position ?? null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
|
||||
@@ -18,9 +18,15 @@ public function index(Request $request)
|
||||
->whereNull('cancelled_at')
|
||||
->with([
|
||||
'contract' => function ($q) {
|
||||
$q->with(['type:id,name', 'account', 'clientCase.person' => function ($pq) {
|
||||
$pq->with(['addresses', 'phones']);
|
||||
}]);
|
||||
$q->with([
|
||||
'type:id,name',
|
||||
'account',
|
||||
'clientCase.person' => function ($pq) {
|
||||
$pq->with(['addresses', 'phones']);
|
||||
},
|
||||
'clientCase.client:id,uuid,person_id',
|
||||
'clientCase.client.person:id,full_name',
|
||||
]);
|
||||
},
|
||||
])
|
||||
->orderByDesc('assigned_at')
|
||||
@@ -29,6 +35,41 @@ public function index(Request $request)
|
||||
|
||||
return Inertia::render('Phone/Index', [
|
||||
'jobs' => $jobs,
|
||||
'view_mode' => 'assigned',
|
||||
]);
|
||||
}
|
||||
|
||||
public function completedToday(Request $request)
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
|
||||
$start = now()->startOfDay();
|
||||
$end = now()->endOfDay();
|
||||
|
||||
$jobs = FieldJob::query()
|
||||
->where('assigned_user_id', $userId)
|
||||
->whereNull('cancelled_at')
|
||||
->whereBetween('completed_at', [$start, $end])
|
||||
->with([
|
||||
'contract' => function ($q) {
|
||||
$q->with([
|
||||
'type:id,name',
|
||||
'account',
|
||||
'clientCase.person' => function ($pq) {
|
||||
$pq->with(['addresses', 'phones']);
|
||||
},
|
||||
'clientCase.client:id,uuid,person_id',
|
||||
'clientCase.client.person:id,full_name',
|
||||
]);
|
||||
},
|
||||
])
|
||||
->orderByDesc('completed_at')
|
||||
->limit(100)
|
||||
->get();
|
||||
|
||||
return Inertia::render('Phone/Index', [
|
||||
'jobs' => $jobs,
|
||||
'view_mode' => 'completed-today',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -136,6 +177,7 @@ public function showCase(\App\Models\ClientCase $clientCase, Request $request)
|
||||
'account_types' => \App\Models\AccountType::all(),
|
||||
'actions' => \App\Models\Action::with('decisions')->get(),
|
||||
'activities' => $activities,
|
||||
'completed_mode' => (bool) $request->boolean('completed'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ class Contract extends Model
|
||||
'client_case_id',
|
||||
'type_id',
|
||||
'description',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
@@ -35,6 +36,13 @@ class Contract extends Model
|
||||
'type_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'meta' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function type(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\ContractType::class, 'type_id');
|
||||
|
||||
@@ -13,6 +13,8 @@ class ImportEntity extends Model
|
||||
'fields',
|
||||
'field_aliases',
|
||||
'aliases',
|
||||
'supports_multiple',
|
||||
'meta',
|
||||
'rules',
|
||||
'ui',
|
||||
];
|
||||
@@ -21,6 +23,8 @@ class ImportEntity extends Model
|
||||
'fields' => 'array',
|
||||
'field_aliases' => 'array',
|
||||
'aliases' => 'array',
|
||||
'supports_multiple' => 'boolean',
|
||||
'meta' => 'boolean',
|
||||
'rules' => 'array',
|
||||
'ui' => 'array',
|
||||
];
|
||||
|
||||
@@ -62,7 +62,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
||||
->get(['source_column', 'target_field', 'transform', 'apply_mode', 'options']);
|
||||
|
||||
// Load dynamic entity config
|
||||
[$rootAliasMap, $fieldAliasMap, $validRoots] = $this->loadImportEntityConfig();
|
||||
[$rootAliasMap, $fieldAliasMap, $validRoots, $supportsMultiple] = $this->loadImportEntityConfig();
|
||||
// Normalize aliases (plural/legacy roots, field names) before validation
|
||||
$mappings = $this->normalizeMappings($mappings, $rootAliasMap, $fieldAliasMap);
|
||||
// Validate mapping roots early to avoid silent failures due to typos
|
||||
@@ -219,9 +219,10 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
||||
if ($isPg) {
|
||||
// No DB changes were made for this row; nothing to roll back explicitly.
|
||||
}
|
||||
|
||||
continue; // proceed to next CSV row
|
||||
}
|
||||
[$recordType, $mapped] = $this->applyMappings($rawAssoc, $mappings);
|
||||
[$recordType, $mapped] = $this->applyMappings($rawAssoc, $mappings, $supportsMultiple);
|
||||
|
||||
// Determine row-level reactivation intent: precedence row > import > template
|
||||
$rowReactivate = false;
|
||||
@@ -751,9 +752,10 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
||||
|
||||
// Resolve by contact values next
|
||||
if (! $personIdForRow) {
|
||||
$emailVal = trim((string) ($mapped['email']['value'] ?? ''));
|
||||
$phoneNu = trim((string) ($mapped['phone']['nu'] ?? ''));
|
||||
$addrLine = trim((string) ($mapped['address']['address'] ?? ''));
|
||||
// consider first values from multi groups if present
|
||||
$emailVal = trim((string) ($this->firstFromMulti($mapped, 'email', 'value') ?? ''));
|
||||
$phoneNu = trim((string) ($this->firstFromMulti($mapped, 'phone', 'nu') ?? ''));
|
||||
$addrLine = trim((string) ($this->firstFromMulti($mapped, 'address', 'address') ?? ''));
|
||||
|
||||
// Try to resolve by existing contacts first
|
||||
if ($emailVal !== '') {
|
||||
@@ -814,22 +816,57 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
||||
|
||||
$contactChanged = false;
|
||||
if ($personIdForRow) {
|
||||
if (! empty($mapped['email'] ?? [])) {
|
||||
$r = $this->upsertEmail($personIdForRow, $mapped['email'], $mappings);
|
||||
if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) {
|
||||
$contactChanged = true;
|
||||
}
|
||||
}
|
||||
if (! empty($mapped['address'] ?? [])) {
|
||||
$r = $this->upsertAddress($personIdForRow, $mapped['address'], $mappings);
|
||||
if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) {
|
||||
$contactChanged = true;
|
||||
}
|
||||
}
|
||||
if (! empty($mapped['phone'] ?? [])) {
|
||||
$r = $this->upsertPhone($personIdForRow, $mapped['phone'], $mappings);
|
||||
if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) {
|
||||
$contactChanged = true;
|
||||
// Fan-out for multi-supported roots; backward compatible for single hashes
|
||||
foreach (['email' => 'upsertEmail', 'address' => 'upsertAddress', 'phone' => 'upsertPhone'] as $root => $method) {
|
||||
if (isset($mapped[$root]) && is_array($mapped[$root])) {
|
||||
// If it's a grouped map (supports multiple), iterate groups; else treat as single data hash
|
||||
$data = $mapped[$root];
|
||||
$isGrouped = $this->isGroupedMulti($data);
|
||||
if ($isGrouped) {
|
||||
// De-duplicate grouped items within the same row by their unique key per root
|
||||
$keyField = $root === 'email' ? 'value' : ($root === 'phone' ? 'nu' : 'address');
|
||||
$normalizer = function ($v) use ($root) {
|
||||
if ($v === null) {
|
||||
return null;
|
||||
}
|
||||
$s = trim((string) $v);
|
||||
if ($s === '') {
|
||||
return '';
|
||||
}
|
||||
if ($root === 'email') {
|
||||
return mb_strtolower($s);
|
||||
}
|
||||
if ($root === 'phone') {
|
||||
// Keep leading + and digits only for comparison
|
||||
$s = preg_replace('/[^0-9+]/', '', $s) ?? $s;
|
||||
// Collapse multiple + to single leading
|
||||
$s = ltrim($s, '+');
|
||||
|
||||
return '+'.$s;
|
||||
}
|
||||
// address: normalize whitespace and lowercase for comparison
|
||||
$s = preg_replace('/\s+/', ' ', $s) ?? $s;
|
||||
|
||||
return mb_strtolower(trim($s));
|
||||
};
|
||||
$data = $this->dedupeGroupedItems($data, $keyField, $normalizer);
|
||||
foreach ($data as $grp => $payload) {
|
||||
if (empty(array_filter($payload, fn ($v) => ! is_null($v) && trim((string) $v) !== ''))) {
|
||||
continue; // skip empty group
|
||||
}
|
||||
$r = $this->{$method}($personIdForRow, $payload, $mappings);
|
||||
if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) {
|
||||
$contactChanged = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (! empty($data)) {
|
||||
$r = $this->{$method}($personIdForRow, $data, $mappings);
|
||||
if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) {
|
||||
$contactChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -986,7 +1023,7 @@ private function buildRowAssoc(array $row, ?array $header): array
|
||||
return $assoc;
|
||||
}
|
||||
|
||||
private function applyMappings(array $raw, $mappings): array
|
||||
protected function applyMappings(array $raw, $mappings, array $supportsMultiple): array
|
||||
{
|
||||
$recordType = null;
|
||||
$mapped = [];
|
||||
@@ -1025,11 +1062,135 @@ private function applyMappings(array $raw, $mappings): array
|
||||
|
||||
// detect record type from first segment, e.g., "account.balance_amount"
|
||||
$parts = explode('.', $target);
|
||||
if (! $recordType && isset($parts[0])) {
|
||||
$recordType = $parts[0];
|
||||
$rootWithBracket = $parts[0] ?? '';
|
||||
// Support bracket grouping like address[1].city
|
||||
$group = null;
|
||||
$root = $rootWithBracket;
|
||||
if (preg_match('/^(?P<base>[a-zA-Z_][a-zA-Z0-9_]*)(\[(?P<grp>[^\]]+)\])$/', $rootWithBracket, $m)) {
|
||||
$root = $m['base'];
|
||||
$group = $m['grp'];
|
||||
}
|
||||
if (! $recordType && isset($root)) {
|
||||
$recordType = $root;
|
||||
}
|
||||
|
||||
// If this root supports multiple, determine group id: prefer mapping options.group, else bracket, else '1'
|
||||
$supportsMulti = $supportsMultiple[$root] ?? false;
|
||||
// Special handling for meta mappings: contract.meta.key (supports options.key and options.type)
|
||||
if ($root === 'contract' && isset($parts[1]) && str_starts_with($parts[1], 'meta')) {
|
||||
// Path could be meta.key or meta[key]
|
||||
$metaKey = null;
|
||||
$metaType = null;
|
||||
// support dot path contract.meta.someKey
|
||||
if (isset($parts[2])) {
|
||||
$metaKey = $parts[2];
|
||||
} else {
|
||||
// support contract.meta[someKey]
|
||||
if (preg_match('/^meta\[(?P<k>[^\]]+)\]$/', $parts[1], $mm)) {
|
||||
$metaKey = $mm['k'];
|
||||
}
|
||||
}
|
||||
if ($metaKey === null || $metaKey === '') {
|
||||
// fallback: read key from mapping options.key if present
|
||||
$opts = $map->options ?? null;
|
||||
if (is_string($opts)) {
|
||||
$opts = json_decode($opts, true) ?: [];
|
||||
}
|
||||
if (is_array($opts) && ! empty($opts['key'])) {
|
||||
$metaKey = (string) $opts['key'];
|
||||
}
|
||||
if (is_array($opts) && ! empty($opts['type'])) {
|
||||
$metaType = is_string($opts['type']) ? strtolower($opts['type']) : null;
|
||||
}
|
||||
} else {
|
||||
// we still may have options.type
|
||||
$opts = $map->options ?? null;
|
||||
if (is_string($opts)) {
|
||||
$opts = json_decode($opts, true) ?: [];
|
||||
}
|
||||
if (is_array($opts) && ! empty($opts['type'])) {
|
||||
$metaType = is_string($opts['type']) ? strtolower($opts['type']) : null;
|
||||
}
|
||||
}
|
||||
if ($metaKey !== null && $metaKey !== '') {
|
||||
// group-aware bucket for meta entries
|
||||
$groupOpt = $this->mappingOptionGroup($map);
|
||||
$grp = ($groupOpt !== null && $groupOpt !== '') ? (string) $groupOpt : ($group ?? '1');
|
||||
if (! isset($mapped['contract'])) {
|
||||
$mapped['contract'] = [];
|
||||
}
|
||||
if (! isset($mapped['contract']['meta']) || ! is_array($mapped['contract']['meta'])) {
|
||||
$mapped['contract']['meta'] = [];
|
||||
}
|
||||
if (! isset($mapped['contract']['meta'][$grp])) {
|
||||
$mapped['contract']['meta'][$grp] = [];
|
||||
}
|
||||
// Optionally coerce the value based on provided type
|
||||
$coerced = $value;
|
||||
$metaType = in_array($metaType, ['string', 'number', 'date', 'boolean'], true) ? $metaType : null;
|
||||
if ($metaType === 'number') {
|
||||
if (is_string($coerced)) {
|
||||
$norm = $this->normalizeDecimal($coerced);
|
||||
$coerced = is_numeric($norm) ? (float) $norm : $coerced;
|
||||
}
|
||||
} elseif ($metaType === 'boolean') {
|
||||
if (is_string($coerced)) {
|
||||
$lc = strtolower(trim($coerced));
|
||||
if (in_array($lc, ['1', 'true', 'yes', 'y'], true)) {
|
||||
$coerced = true;
|
||||
} elseif (in_array($lc, ['0', 'false', 'no', 'n'], true)) {
|
||||
$coerced = false;
|
||||
}
|
||||
} else {
|
||||
$coerced = (bool) $coerced;
|
||||
}
|
||||
} elseif ($metaType === 'date') {
|
||||
$coerced = is_scalar($coerced) ? $this->normalizeDate((string) $coerced) : null;
|
||||
} else {
|
||||
// string or unspecified: cast scalars to string for consistency
|
||||
if (is_scalar($coerced)) {
|
||||
$coerced = (string) $coerced;
|
||||
}
|
||||
}
|
||||
// Store as structure with title, value and optional type
|
||||
$entry = [
|
||||
'title' => is_string($src) ? $src : (string) $src,
|
||||
'value' => $coerced,
|
||||
];
|
||||
if ($metaType !== null) {
|
||||
$entry['type'] = $metaType;
|
||||
}
|
||||
$mapped['contract']['meta'][$grp][$metaKey] = $entry;
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ($supportsMulti) {
|
||||
$groupOpt = $this->mappingOptionGroup($map);
|
||||
$grp = ($groupOpt !== null && $groupOpt !== '') ? (string) $groupOpt : ($group ?? '1');
|
||||
// rebuild target path to exclude bracket part
|
||||
$field = $parts[1] ?? null;
|
||||
if ($field !== null) {
|
||||
if (! isset($mapped[$root]) || ! is_array($mapped[$root])) {
|
||||
$mapped[$root] = [];
|
||||
}
|
||||
if (! isset($mapped[$root][$grp]) || ! is_array($mapped[$root][$grp])) {
|
||||
$mapped[$root][$grp] = [];
|
||||
}
|
||||
$mapped[$root][$grp][$field] = $value;
|
||||
}
|
||||
} else {
|
||||
// single item root: assign field or root as appropriate
|
||||
$field = $parts[1] ?? null;
|
||||
if ($field !== null) {
|
||||
if (! isset($mapped[$root]) || ! is_array($mapped[$root])) {
|
||||
$mapped[$root] = [];
|
||||
}
|
||||
$mapped[$root][$field] = $value;
|
||||
} else {
|
||||
$mapped[$root] = $value;
|
||||
}
|
||||
}
|
||||
// build nested array by dot notation
|
||||
$this->arraySetDot($mapped, $target, $value);
|
||||
}
|
||||
|
||||
return [$recordType, $mapped];
|
||||
@@ -1190,18 +1351,15 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
|
||||
}
|
||||
|
||||
if ($existing) {
|
||||
if (empty($applyUpdate)) {
|
||||
return ['action' => 'skipped', 'message' => 'No fields marked for update'];
|
||||
}
|
||||
// Only update fields that are set; skip nulls to avoid wiping unintentionally
|
||||
// Build non-null changes for account fields
|
||||
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
|
||||
if (empty($changes)) {
|
||||
return ['action' => 'skipped', 'message' => 'No non-null changes'];
|
||||
}
|
||||
// Track balance change
|
||||
$oldBalance = (float) ($existing->balance_amount ?? 0);
|
||||
$existing->fill($changes);
|
||||
$existing->save();
|
||||
// Note: meta merging for contracts is handled in upsertContractChain, not here
|
||||
if (! empty($changes)) {
|
||||
$existing->fill($changes);
|
||||
$existing->save();
|
||||
}
|
||||
|
||||
// If balance_amount changed and this wasn't caused by a payment (we are in account upsert), log an activity with before/after
|
||||
if (array_key_exists('balance_amount', $changes)) {
|
||||
@@ -1421,18 +1579,71 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
|
||||
}
|
||||
|
||||
if ($existing) {
|
||||
if (empty($applyUpdate)) {
|
||||
// Return existing contract reference even when skipped so callers can treat as resolved
|
||||
return ['action' => 'skipped', 'message' => 'No contract fields marked for update', 'contract' => $existing];
|
||||
}
|
||||
// 1) Prepare contract field changes (non-null)
|
||||
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
|
||||
if (empty($changes)) {
|
||||
return ['action' => 'skipped', 'message' => 'No non-null contract changes', 'contract' => $existing];
|
||||
|
||||
// 2) Prepare meta changes if provided via mapping
|
||||
$metaUpdated = false;
|
||||
$metaAppliedKeys = [];
|
||||
if (! empty($contractData['meta'] ?? null) && is_array($contractData['meta'])) {
|
||||
// Flatten incoming grouped meta to key => {title, value}
|
||||
$incomingMeta = [];
|
||||
foreach ($contractData['meta'] as $grp => $entries) {
|
||||
if (! is_array($entries)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($entries as $k => $v) {
|
||||
// v is expected as [title, value]
|
||||
$incomingMeta[$k] = $v;
|
||||
}
|
||||
}
|
||||
if (! empty($incomingMeta)) {
|
||||
$currentMeta = is_array($existing->meta ?? null) ? $existing->meta : (json_decode((string) $existing->meta, true) ?: []);
|
||||
foreach ($incomingMeta as $k => $entry) {
|
||||
$newVal = is_array($entry) && array_key_exists('value', $entry) ? $entry['value'] : $entry;
|
||||
$newTitle = is_array($entry) && array_key_exists('title', $entry) ? $entry['title'] : null;
|
||||
$newType = is_array($entry) && array_key_exists('type', $entry) ? $entry['type'] : null;
|
||||
$curEntry = $currentMeta[$k] ?? null;
|
||||
$curVal = is_array($curEntry) && array_key_exists('value', $curEntry) ? $curEntry['value'] : $curEntry;
|
||||
$curTitle = is_array($curEntry) && array_key_exists('title', $curEntry) ? $curEntry['title'] : null;
|
||||
$curType = is_array($curEntry) && array_key_exists('type', $curEntry) ? $curEntry['type'] : null;
|
||||
// Update when value differs, or title differs, or type differs
|
||||
$shouldUpdate = ($newVal !== $curVal) || ($newTitle !== null && $newTitle !== $curTitle) || ($newType !== null && $newType !== $curType);
|
||||
if ($shouldUpdate) {
|
||||
if (is_array($entry)) {
|
||||
$currentMeta[$k] = $entry;
|
||||
} else {
|
||||
$currentMeta[$k] = ['title' => (string) $k, 'value' => $newVal];
|
||||
}
|
||||
$metaUpdated = true;
|
||||
$metaAppliedKeys[] = $k;
|
||||
}
|
||||
}
|
||||
if ($metaUpdated) {
|
||||
$existing->meta = $currentMeta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($changes) && ! $metaUpdated) {
|
||||
// Nothing to change
|
||||
return ['action' => 'skipped', 'message' => 'No contract fields or meta changes', 'contract' => $existing];
|
||||
}
|
||||
|
||||
if (! empty($changes)) {
|
||||
$existing->fill($changes);
|
||||
}
|
||||
$existing->fill($changes);
|
||||
$existing->save();
|
||||
|
||||
return ['action' => 'updated', 'contract' => $existing, 'applied_fields' => $changes];
|
||||
// Build applied fields info, include meta keys if any
|
||||
$applied = $changes;
|
||||
if ($metaUpdated && ! empty($metaAppliedKeys)) {
|
||||
foreach ($metaAppliedKeys as $k) {
|
||||
$applied['meta:'.$k] = 'updated';
|
||||
}
|
||||
}
|
||||
|
||||
return ['action' => 'updated', 'contract' => $existing, 'applied_fields' => $applied];
|
||||
} else {
|
||||
if (empty($applyInsert)) {
|
||||
return ['action' => 'skipped', 'message' => 'No contract fields marked for insert'];
|
||||
@@ -1459,6 +1670,21 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
|
||||
// ensure required defaults
|
||||
$data['start_date'] = $data['start_date'] ?? now()->toDateString();
|
||||
$data['type_id'] = $data['type_id'] ?? $this->getDefaultContractTypeId();
|
||||
// Merge meta for create if provided
|
||||
if (! empty($contractData['meta'] ?? null) && is_array($contractData['meta'])) {
|
||||
$incomingMeta = [];
|
||||
foreach ($contractData['meta'] as $grp => $entries) {
|
||||
if (! is_array($entries)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($entries as $k => $v) {
|
||||
$incomingMeta[$k] = $v;
|
||||
}
|
||||
}
|
||||
if (! empty($incomingMeta)) {
|
||||
$data['meta'] = $incomingMeta;
|
||||
}
|
||||
}
|
||||
$created = Contract::create($data);
|
||||
|
||||
return ['action' => 'inserted', 'contract' => $created, 'applied_fields' => $data];
|
||||
@@ -1503,6 +1729,7 @@ private function normalizeDate(?string $raw): ?string
|
||||
if ($ts === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return date('Y-m-d', $ts);
|
||||
}
|
||||
|
||||
@@ -1712,6 +1939,7 @@ private function rowIsEffectivelyEmpty(array $rawAssoc): bool
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1775,7 +2003,15 @@ private function normalizeTargetField(string $target, array $rootAliasMap, array
|
||||
return $target;
|
||||
}
|
||||
$parts = explode('.', $target);
|
||||
$root = $parts[0] ?? '';
|
||||
$rootWithBracket = $parts[0] ?? '';
|
||||
// Extract optional bracket group from root (e.g., address[1]) but preserve it after aliasing
|
||||
$bracket = null;
|
||||
if (preg_match('/^(?P<base>[a-zA-Z_][a-zA-Z0-9_]*)(\[(?P<grp>[^\]]+)\])$/', $rootWithBracket, $m)) {
|
||||
$root = $m['base'];
|
||||
$bracket = $m['grp'];
|
||||
} else {
|
||||
$root = $rootWithBracket;
|
||||
}
|
||||
$field = $parts[1] ?? null;
|
||||
|
||||
// Root aliases (plural to canonical) from DB
|
||||
@@ -1791,18 +2027,21 @@ private function normalizeTargetField(string $target, array $rootAliasMap, array
|
||||
|
||||
// Rebuild
|
||||
if ($field !== null) {
|
||||
return $root.'.'.$field;
|
||||
$rootOut = $bracket !== null ? ($root.'['.$bracket.']') : $root;
|
||||
|
||||
return $rootOut.'.'.$field;
|
||||
}
|
||||
|
||||
return $root;
|
||||
return $bracket !== null ? ($root.'['.$bracket.']') : $root;
|
||||
}
|
||||
|
||||
private function loadImportEntityConfig(): array
|
||||
protected function loadImportEntityConfig(): array
|
||||
{
|
||||
$entities = ImportEntity::all();
|
||||
$rootAliasMap = [];
|
||||
$fieldAliasMap = [];
|
||||
$validRoots = [];
|
||||
$supportsMultiple = [];
|
||||
foreach ($entities as $ent) {
|
||||
$canonical = $ent->canonical_root;
|
||||
$validRoots[] = $canonical;
|
||||
@@ -1817,13 +2056,108 @@ private function loadImportEntityConfig(): array
|
||||
$aliases['__default'] = $aliases['__default'] ?? null;
|
||||
}
|
||||
$fieldAliasMap[$canonical] = $aliases;
|
||||
$supportsMultiple[$canonical] = (bool) ($ent->supports_multiple ?? false);
|
||||
}
|
||||
// sensible defaults when DB empty
|
||||
if (empty($validRoots)) {
|
||||
$validRoots = ['person', 'contract', 'account', 'address', 'phone', 'email', 'client_case'];
|
||||
$supportsMultiple = [
|
||||
'address' => true,
|
||||
'phone' => true,
|
||||
'email' => true,
|
||||
];
|
||||
}
|
||||
|
||||
return [$rootAliasMap, $fieldAliasMap, $validRoots];
|
||||
return [$rootAliasMap, $fieldAliasMap, $validRoots, $supportsMultiple];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mapping options.group if provided.
|
||||
*/
|
||||
private function mappingOptionGroup(object $map): ?string
|
||||
{
|
||||
$raw = $map->options ?? null;
|
||||
if (is_string($raw)) {
|
||||
try {
|
||||
$json = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
return isset($json['group']) ? (string) $json['group'] : null;
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (is_array($raw)) {
|
||||
return isset($raw['group']) ? (string) $raw['group'] : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a mapped root is a grouped multi structure.
|
||||
*/
|
||||
private function isGroupedMulti(mixed $data): bool
|
||||
{
|
||||
if (! is_array($data)) {
|
||||
return false;
|
||||
}
|
||||
// Consider grouped if first element is itself an array
|
||||
foreach ($data as $k => $v) {
|
||||
return is_array($v);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read first value from a multi-group or single map for a given root/field.
|
||||
*/
|
||||
private function firstFromMulti(array $mapped, string $root, string $field): mixed
|
||||
{
|
||||
if (! isset($mapped[$root])) {
|
||||
return null;
|
||||
}
|
||||
$data = $mapped[$root];
|
||||
if ($this->isGroupedMulti($data)) {
|
||||
foreach ($data as $grp => $payload) {
|
||||
if (isset($payload[$field]) && $payload[$field] !== null && trim((string) $payload[$field]) !== '') {
|
||||
return $payload[$field];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $data[$field] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove duplicates from grouped items by comparing a key field across groups after normalization.
|
||||
* Keeps the first occurrence and drops later duplicates. Empty/blank keys are kept only once.
|
||||
*
|
||||
* @param array<string,array> $grouped e.g. ['1' => ['value' => 'a'], '2' => ['value' => 'a']]
|
||||
* @param string $keyField e.g. 'value' for email, 'nu' for phone, 'address' for address
|
||||
* @param callable|null $normalizer function(string|null): string|null normalizes comparison key
|
||||
* @return array<string,array>
|
||||
*/
|
||||
protected function dedupeGroupedItems(array $grouped, string $keyField, ?callable $normalizer = null): array
|
||||
{
|
||||
$seen = [];
|
||||
$out = [];
|
||||
foreach ($grouped as $grp => $payload) {
|
||||
$raw = $payload[$keyField] ?? null;
|
||||
$key = $normalizer ? $normalizer($raw) : (is_null($raw) ? null : trim((string) $raw));
|
||||
$key = $key === '' ? '' : $key; // ensure empty string stays empty
|
||||
$finger = is_null($key) ? '__NULL__' : (string) $key;
|
||||
if (array_key_exists($finger, $seen)) {
|
||||
// duplicate => skip
|
||||
continue;
|
||||
}
|
||||
$seen[$finger] = true;
|
||||
$out[$grp] = $payload;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function findOrCreatePersonId(array $p): ?int
|
||||
|
||||
@@ -32,8 +32,9 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
|
||||
$delimiter = $meta['forced_delimiter'] ?? $meta['detected_delimiter'] ?? ',';
|
||||
$columns = is_array($meta['columns'] ?? null) ? $meta['columns'] : [];
|
||||
|
||||
$targetToSource = $this->buildTargetLookup($import);
|
||||
if (! $targetToSource) {
|
||||
// Build both flat and grouped lookups
|
||||
[$targetToSource, $groupedLookup] = $this->buildTargetLookup($import);
|
||||
if (! $targetToSource && ! $groupedLookup) {
|
||||
return $this->errorPayload('Ni shranjenih mapiranj za ta uvoz.');
|
||||
}
|
||||
|
||||
@@ -47,6 +48,8 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
|
||||
|
||||
// Discover mapped entity roots and then filter by supported list with safe fallbacks
|
||||
$detectedRoots = $this->detectEntityRoots($targetToSource);
|
||||
// Roots that support multiple grouped entries
|
||||
$multiRoots = $this->loadSupportsMultipleRoots();
|
||||
$supported = $this->loadSupportedEntityRoots();
|
||||
$entityRoots = $this->filterEntityRoots($detectedRoots, $supported, $targetToSource);
|
||||
$summaries = $this->initSummaries($entityRoots);
|
||||
@@ -104,6 +107,26 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
|
||||
return null;
|
||||
};
|
||||
|
||||
// Grouped values accessor: returns [group => value] for given root.field
|
||||
$groupVals = function (string $root, string $field) use ($assoc, $groupedLookup, $targetToSource) {
|
||||
$out = [];
|
||||
$key = $root.'.'.$field;
|
||||
if (isset($groupedLookup[$root])) {
|
||||
foreach ($groupedLookup[$root] as $g => $fields) {
|
||||
if (isset($fields[$field])) {
|
||||
$col = $fields[$field];
|
||||
$out[$g] = $assoc[$col] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also include ungrouped flat mapping as default group when no explicit group exists
|
||||
if (isset($targetToSource[$key]) && ! isset($out[''])) {
|
||||
$out[''] = $assoc[$targetToSource[$key]] ?? null;
|
||||
}
|
||||
|
||||
return $out;
|
||||
};
|
||||
|
||||
// Contract
|
||||
if (isset($entityRoots['contract'])) {
|
||||
[$contractEntity, $summaries, $contractCache] = $this->simulateContract($val, $summaries, $contractCache, $val('contract.reference'));
|
||||
@@ -116,6 +139,49 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
|
||||
$contractEntity['action'] = 'reactivate';
|
||||
$contractEntity['reactivation'] = true;
|
||||
}
|
||||
// Attach contract meta preview from mappings (group-aware)
|
||||
$metaGroups = [];
|
||||
// Grouped contract.meta.* via groupedLookup
|
||||
if (isset($groupedLookup['contract'])) {
|
||||
foreach ($groupedLookup['contract'] as $g => $fields) {
|
||||
foreach ($fields as $f => $srcCol) {
|
||||
if (str_starts_with($f, 'meta.')) {
|
||||
$key = substr($f, strlen('meta.'));
|
||||
if ($key !== '') {
|
||||
if (! isset($metaGroups[$g])) {
|
||||
$metaGroups[$g] = [];
|
||||
}
|
||||
$metaGroups[$g][$key] = [
|
||||
'title' => $srcCol,
|
||||
'value' => $assoc[$srcCol] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Flat contract.meta.* (no group): assign to group '1'
|
||||
foreach ($targetToSource as $tf => $srcCol) {
|
||||
if (str_starts_with($tf, 'contract.meta.')) {
|
||||
$key = substr($tf, strlen('contract.meta.'));
|
||||
if ($key !== '') {
|
||||
$g = '1';
|
||||
if (! isset($metaGroups[$g])) {
|
||||
$metaGroups[$g] = [];
|
||||
}
|
||||
// Do not override grouped if already present
|
||||
if (! isset($metaGroups[$g][$key])) {
|
||||
$metaGroups[$g][$key] = [
|
||||
'title' => $srcCol,
|
||||
'value' => $assoc[$srcCol] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (! empty($metaGroups)) {
|
||||
$contractEntity['meta'] = $metaGroups;
|
||||
}
|
||||
$rowEntities['contract'] = $contractEntity + [
|
||||
'action_label' => $translatedActions[$contractEntity['action']] ?? $contractEntity['action'],
|
||||
];
|
||||
@@ -156,22 +222,45 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
|
||||
}
|
||||
$reference = $val($rootKey.'.reference');
|
||||
$identityCandidates = $this->genericIdentityCandidates($rootKey, $val);
|
||||
[$genericEntity, $summaries, $genericCaches, $genericExistingIdentities, $genericSeenIdentities]
|
||||
= $this->simulateGenericRoot(
|
||||
$rootKey,
|
||||
$val,
|
||||
$summaries,
|
||||
$genericCaches,
|
||||
$reference,
|
||||
$identityCandidates,
|
||||
$genericExistingIdentities,
|
||||
$genericSeenIdentities,
|
||||
$verbose,
|
||||
$targetToSource,
|
||||
);
|
||||
$rowEntities[$rootKey] = $genericEntity + [
|
||||
'action_label' => $translatedActions[$genericEntity['action']] ?? $genericEntity['action'],
|
||||
];
|
||||
if (isset($multiRoots[$rootKey]) && $multiRoots[$rootKey] === true) {
|
||||
// Multi-item simulation per group
|
||||
[$items, $summaries, $genericCaches, $genericExistingIdentities, $genericSeenIdentities]
|
||||
= $this->simulateGenericRootMulti(
|
||||
$rootKey,
|
||||
$val,
|
||||
$groupVals,
|
||||
$summaries,
|
||||
$genericCaches,
|
||||
$identityCandidates,
|
||||
$genericExistingIdentities,
|
||||
$genericSeenIdentities,
|
||||
$verbose,
|
||||
$targetToSource
|
||||
);
|
||||
// Add action labels and attach
|
||||
$rowEntities[$rootKey] = array_map(function ($ent) use ($translatedActions) {
|
||||
$ent['action_label'] = $translatedActions[$ent['action']] ?? $ent['action'];
|
||||
|
||||
return $ent;
|
||||
}, $items);
|
||||
} else {
|
||||
[$genericEntity, $summaries, $genericCaches, $genericExistingIdentities, $genericSeenIdentities]
|
||||
= $this->simulateGenericRoot(
|
||||
$rootKey,
|
||||
$val,
|
||||
$summaries,
|
||||
$genericCaches,
|
||||
$reference,
|
||||
$identityCandidates,
|
||||
$genericExistingIdentities,
|
||||
$genericSeenIdentities,
|
||||
$verbose,
|
||||
$targetToSource,
|
||||
);
|
||||
$rowEntities[$rootKey] = $genericEntity + [
|
||||
'action_label' => $translatedActions[$genericEntity['action']] ?? $genericEntity['action'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Attach chain entities (client_case, person) if contract already existed
|
||||
@@ -317,8 +406,22 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
|
||||
// If existing contract: upgrade generic email/phone/address entities (already simulated) to mark as chain if corresponding person attached
|
||||
if ($existingContract && isset($rowEntities['person']['id'])) {
|
||||
foreach (['email', 'phone', 'address'] as $gRoot) {
|
||||
if (isset($rowEntities[$gRoot]) && ! ($rowEntities[$gRoot]['existing_chain'] ?? false)) {
|
||||
$rowEntities[$gRoot]['existing_chain'] = true; // mark for UI toggle
|
||||
if (! isset($rowEntities[$gRoot])) {
|
||||
continue;
|
||||
}
|
||||
$valRef = $rowEntities[$gRoot];
|
||||
// If multi items array, set flag on each item
|
||||
if (is_array($valRef) && isset($valRef[0]) && is_array($valRef[0])) {
|
||||
foreach ($rowEntities[$gRoot] as &$it) {
|
||||
if (! ($it['existing_chain'] ?? false)) {
|
||||
$it['existing_chain'] = true;
|
||||
}
|
||||
}
|
||||
unset($it);
|
||||
} else {
|
||||
if (! ($rowEntities[$gRoot]['existing_chain'] ?? false)) {
|
||||
$rowEntities[$gRoot]['existing_chain'] = true; // mark for UI toggle
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -352,7 +455,21 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
|
||||
if ($verbose) {
|
||||
foreach ($rowEntities as $eroot => &$ent) {
|
||||
$tf = $eroot.'.reference';
|
||||
if (isset($targetToSource[$tf])) {
|
||||
if (! isset($targetToSource[$tf])) {
|
||||
continue;
|
||||
}
|
||||
if (is_array($ent) && isset($ent[0]) && is_array($ent[0])) {
|
||||
foreach ($ent as &$item) {
|
||||
$item['sources'] = $item['sources'] ?? [];
|
||||
if (! isset($item['sources'][$tf])) {
|
||||
$item['sources'][$tf] = [
|
||||
'source_column' => $targetToSource[$tf],
|
||||
'value' => $val($tf),
|
||||
];
|
||||
}
|
||||
}
|
||||
unset($item);
|
||||
} else {
|
||||
$ent['sources'] = $ent['sources'] ?? [];
|
||||
if (! isset($ent['sources'][$tf])) {
|
||||
$ent['sources'][$tf] = [
|
||||
@@ -404,7 +521,6 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
|
||||
foreach (['reference', 'identity_used', 'identity_candidates', 'full_name', 'first_name', 'last_name', 'address', 'country', 'nu', 'value'] as $k) {
|
||||
if (isset($ent[$k]) && $ent[$k]) {
|
||||
$hasData = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Some entities (e.g. payment) do not have 'action'; treat them as non-empty if they have data or status
|
||||
@@ -455,47 +571,88 @@ private function buildTargetLookup(Import $import): array
|
||||
$mappings = \DB::table('import_mappings')
|
||||
->where('import_id', $import->id)
|
||||
->orderBy('position')
|
||||
->get(['source_column', 'target_field']);
|
||||
->get(['source_column', 'target_field', 'options']);
|
||||
|
||||
$lookup = [];
|
||||
$grouped = [];
|
||||
foreach ($mappings as $m) {
|
||||
$target = trim((string) $m->target_field);
|
||||
$source = trim((string) $m->source_column);
|
||||
if ($target === '' || $source === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse grouping and field
|
||||
$group = null;
|
||||
$root = null;
|
||||
$rest = null;
|
||||
$restEffective = null;
|
||||
$opts = [];
|
||||
if (preg_match('/^([a-zA-Z0-9_]+)\\[([^\]]+)\\]\\.([a-zA-Z0-9_]+)$/', $target, $mm)) {
|
||||
$root = $mm[1];
|
||||
$group = (string) $mm[2];
|
||||
$rest = $mm[3];
|
||||
$restEffective = $rest;
|
||||
} else {
|
||||
if (str_contains($target, '.')) {
|
||||
[$root, $rest] = explode('.', $target, 2);
|
||||
}
|
||||
try {
|
||||
$opts = is_array($m->options) ? $m->options : (json_decode((string) $m->options, true) ?: []);
|
||||
} catch (\Throwable) {
|
||||
$opts = [];
|
||||
}
|
||||
if (is_array($opts) && array_key_exists('group', $opts) && $opts['group'] !== '' && $opts['group'] !== null) {
|
||||
$group = (string) $opts['group'];
|
||||
}
|
||||
$restEffective = $rest;
|
||||
// Alias meta with options.key => meta.{key}
|
||||
if ($rest === 'meta' && is_array($opts) && ! empty($opts['key'])) {
|
||||
$restEffective = 'meta'.'.'.(string) $opts['key'];
|
||||
}
|
||||
}
|
||||
|
||||
// Register flat lookups
|
||||
if (! isset($lookup[$target])) {
|
||||
$lookup[$target] = $source;
|
||||
}
|
||||
// If mapping uses *.client_ref, also register *.reference alias for simulation reference purposes
|
||||
if ($rest !== null) {
|
||||
$normRoot = $this->normalizeRoot((string) $root);
|
||||
$tfNorm = $normRoot.'.'.$restEffective;
|
||||
if (! isset($lookup[$tfNorm])) {
|
||||
$lookup[$tfNorm] = $source;
|
||||
}
|
||||
if (str_ends_with((string) $root, 's')) {
|
||||
$sing = substr((string) $root, 0, -1);
|
||||
if ($sing) {
|
||||
$tfSing = $sing.'.'.$restEffective;
|
||||
if (! isset($lookup[$tfSing])) {
|
||||
$lookup[$tfSing] = $source;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (str_ends_with($target, '.client_ref')) {
|
||||
$alias = substr($target, 0, -strlen('.client_ref')).'.reference';
|
||||
if (! isset($lookup[$alias])) {
|
||||
$lookup[$alias] = $source;
|
||||
}
|
||||
}
|
||||
if (str_contains($target, '.')) {
|
||||
[$root, $rest] = explode('.', $target, 2);
|
||||
$norm = $this->normalizeRoot($root);
|
||||
if ($norm !== $root) {
|
||||
$alt = $norm.'.'.$rest;
|
||||
if (! isset($lookup[$alt])) {
|
||||
$lookup[$alt] = $source;
|
||||
}
|
||||
|
||||
// Register grouped lookup per normalized root
|
||||
if ($group !== null && $rest !== null) {
|
||||
$normRoot = $this->normalizeRoot((string) $root);
|
||||
if (! isset($grouped[$normRoot])) {
|
||||
$grouped[$normRoot] = [];
|
||||
}
|
||||
if (str_ends_with($root, 's')) {
|
||||
$sing = substr($root, 0, -1);
|
||||
if ($sing && $sing !== $root) {
|
||||
$alt2 = $sing.'.'.$rest;
|
||||
if (! isset($lookup[$alt2])) {
|
||||
$lookup[$alt2] = $source;
|
||||
}
|
||||
}
|
||||
if (! isset($grouped[$normRoot][$group])) {
|
||||
$grouped[$normRoot][$group] = [];
|
||||
}
|
||||
$grouped[$normRoot][$group][$restEffective] = $source;
|
||||
}
|
||||
}
|
||||
|
||||
return $lookup;
|
||||
return [$lookup, $grouped];
|
||||
}
|
||||
|
||||
private function readFileRows(Import $import, bool $hasHeader, string $delimiter, array $columns, int $limit): array
|
||||
@@ -1173,6 +1330,235 @@ private function loadSupportedEntityRoots(): array
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load which canonical roots support multiple items (grouped) from import_entities.supports_multiple.
|
||||
* Falls back to known defaults if table/column is unavailable.
|
||||
*/
|
||||
private function loadSupportsMultipleRoots(): array
|
||||
{
|
||||
$map = [];
|
||||
try {
|
||||
if (\Schema::hasTable('import_entities') && \Schema::hasColumn('import_entities', 'supports_multiple')) {
|
||||
$rows = \App\Models\ImportEntity::query()->get(['key', 'canonical_root', 'supports_multiple']);
|
||||
foreach ($rows as $r) {
|
||||
$root = $r->canonical_root ?: $r->key;
|
||||
if (! $root) {
|
||||
continue;
|
||||
}
|
||||
$norm = $this->normalizeRoot($root);
|
||||
$map[$norm] = (bool) $r->supports_multiple;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// ignore and fallback
|
||||
}
|
||||
if (empty($map)) {
|
||||
// Conservative defaults: only known contact types are multi
|
||||
$map = [
|
||||
'email' => true,
|
||||
'phone' => true,
|
||||
'address' => true,
|
||||
];
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate generic root that supports multiple items (grouped). Returns array of entities per group.
|
||||
* Mirrors simulateGenericRoot per item while grouping by provided group values.
|
||||
*
|
||||
* @return array{0: array<int, array>, 1: array, 2: array, 3: array, 4: array}
|
||||
*/
|
||||
private function simulateGenericRootMulti(
|
||||
string $root,
|
||||
callable $val,
|
||||
callable $groupVals,
|
||||
array $summaries,
|
||||
array $genericCaches,
|
||||
array $identityCandidatesBase,
|
||||
array $genericExistingIdentities,
|
||||
array $genericSeenIdentities,
|
||||
bool $verbose = false,
|
||||
array $targetToSource = [],
|
||||
): array {
|
||||
// Build per-group entities
|
||||
$items = [];
|
||||
|
||||
// Determine fields per root to preview
|
||||
$previewFields = [];
|
||||
if ($root === 'email') {
|
||||
$previewFields = ['value'];
|
||||
} elseif ($root === 'phone') {
|
||||
$previewFields = ['nu'];
|
||||
} elseif ($root === 'address') {
|
||||
$previewFields = ['address', 'country']; // postal_code not present
|
||||
}
|
||||
|
||||
// Collect groups seen for this root
|
||||
$groups = [];
|
||||
foreach ($previewFields as $pf) {
|
||||
$map = $groupVals($root, $pf);
|
||||
foreach ($map as $g => $v) {
|
||||
$groups[$g] = true;
|
||||
}
|
||||
}
|
||||
// If no groups but flat mapping exists, still simulate single default item
|
||||
if (empty($groups)) {
|
||||
$groups[''] = true;
|
||||
}
|
||||
|
||||
// Ensure summary bucket exists and count total_rows once per row (not per item)
|
||||
if (! isset($summaries[$root])) {
|
||||
$summaries[$root] = [
|
||||
'root' => $root,
|
||||
'total_rows' => 0,
|
||||
'create' => 0,
|
||||
'update' => 0,
|
||||
'missing_ref' => 0,
|
||||
'invalid' => 0,
|
||||
'duplicate' => 0,
|
||||
'duplicate_db' => 0,
|
||||
];
|
||||
}
|
||||
$summaries[$root]['total_rows']++;
|
||||
|
||||
// For each group, produce an item entity similar to simulateGenericRoot
|
||||
foreach (array_keys($groups) as $g) {
|
||||
// Build reference and identity candidates per group
|
||||
$reference = null;
|
||||
if ($root === 'email') {
|
||||
$reference = $groupVals('email', 'value')[$g] ?? null;
|
||||
} elseif ($root === 'phone') {
|
||||
$reference = $groupVals('phone', 'nu')[$g] ?? null;
|
||||
} elseif ($root === 'address') {
|
||||
$reference = $groupVals('address', 'address')[$g] ?? null;
|
||||
}
|
||||
|
||||
$identityCandidates = $identityCandidatesBase;
|
||||
// Override with group-specific identity when present
|
||||
if ($root === 'email') {
|
||||
$v = $groupVals('email', 'value')[$g] ?? null;
|
||||
if ($v) {
|
||||
$identityCandidates = ['value:'.mb_strtolower(trim((string) $v))];
|
||||
}
|
||||
} elseif ($root === 'phone') {
|
||||
$nu = $groupVals('phone', 'nu')[$g] ?? null;
|
||||
if ($nu) {
|
||||
$norm = preg_replace('/\D+/', '', (string) $nu) ?? '';
|
||||
if ($norm) {
|
||||
$identityCandidates = ['nu:'.$norm];
|
||||
}
|
||||
}
|
||||
} elseif ($root === 'address') {
|
||||
$addr = $groupVals('address', 'address')[$g] ?? null;
|
||||
$country = $groupVals('address', 'country')[$g] ?? null;
|
||||
if ($addr || $country) {
|
||||
$key = mb_strtolower(trim((string) ($addr ?? ''))).'|'.mb_strtolower(trim((string) ($country ?? '')));
|
||||
$identityCandidates = ['addr:'.$key];
|
||||
}
|
||||
}
|
||||
|
||||
// Query existing by reference when available
|
||||
$modelClass = $this->modelClassForGeneric($root);
|
||||
$record = null;
|
||||
if ($reference) {
|
||||
if (! isset($genericCaches[$root])) {
|
||||
$genericCaches[$root] = [];
|
||||
}
|
||||
if (array_key_exists($reference, $genericCaches[$root])) {
|
||||
$record = $genericCaches[$root][$reference];
|
||||
} elseif ($modelClass && class_exists($modelClass)) {
|
||||
try {
|
||||
if (\Schema::hasColumn((new $modelClass)->getTable(), 'reference')) {
|
||||
$record = $modelClass::query()->where('reference', $reference)->first(['id', 'reference']);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$record = null;
|
||||
}
|
||||
$genericCaches[$root][$reference] = $record; // may be null
|
||||
}
|
||||
}
|
||||
|
||||
$entity = [
|
||||
'reference' => $reference,
|
||||
'id' => $record?->id,
|
||||
'exists' => (bool) $record,
|
||||
'action' => $reference ? ($record ? 'update' : 'create') : 'skip',
|
||||
'group' => $g,
|
||||
'identity_candidates' => $identityCandidates,
|
||||
];
|
||||
|
||||
// Previews
|
||||
if ($root === 'email') {
|
||||
$entity['value'] = $groupVals('email', 'value')[$g] ?? null;
|
||||
} elseif ($root === 'phone') {
|
||||
$entity['nu'] = $groupVals('phone', 'nu')[$g] ?? null;
|
||||
} elseif ($root === 'address') {
|
||||
$entity['address'] = $groupVals('address', 'address')[$g] ?? null;
|
||||
$entity['country'] = $groupVals('address', 'country')[$g] ?? null;
|
||||
}
|
||||
|
||||
if ($verbose) {
|
||||
$srcs = [];
|
||||
foreach ($targetToSource as $tf => $col) {
|
||||
if (str_starts_with($tf, $root.'.')) {
|
||||
// only include fields that match this group if grouped mapping exists
|
||||
// We can't easily map back to group here without reverse index; include anyway for now
|
||||
$srcs[$tf] = [
|
||||
'source_column' => $col,
|
||||
'value' => $val($tf),
|
||||
];
|
||||
}
|
||||
}
|
||||
if ($srcs) {
|
||||
$entity['sources'] = $entity['sources'] ?? [];
|
||||
$entity['sources'] += $srcs;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $reference) {
|
||||
$summaries[$root]['missing_ref']++;
|
||||
} elseif ($record) {
|
||||
$summaries[$root]['update']++;
|
||||
} else {
|
||||
$summaries[$root]['create']++;
|
||||
}
|
||||
|
||||
// Duplicate detection per root across items in stream
|
||||
foreach ($identityCandidates as $identity) {
|
||||
if ($identity === null || $identity === '') {
|
||||
continue;
|
||||
}
|
||||
if (! isset($genericExistingIdentities[$root])) {
|
||||
$genericExistingIdentities[$root] = $this->loadExistingGenericIdentities($root);
|
||||
}
|
||||
if (isset($genericExistingIdentities[$root][$identity])) {
|
||||
$entity['duplicate_db'] = true;
|
||||
$entity['identity_used'] = $identity;
|
||||
$summaries[$root]['duplicate_db']++;
|
||||
break;
|
||||
}
|
||||
if (! isset($genericSeenIdentities[$root])) {
|
||||
$genericSeenIdentities[$root] = [];
|
||||
}
|
||||
if (isset($genericSeenIdentities[$root][$identity])) {
|
||||
$entity['duplicate'] = true;
|
||||
$entity['identity_used'] = $identity;
|
||||
$summaries[$root]['duplicate']++;
|
||||
break;
|
||||
}
|
||||
$genericSeenIdentities[$root][$identity] = true;
|
||||
$entity['identity_used'] = $identity;
|
||||
break;
|
||||
}
|
||||
|
||||
$items[] = $entity;
|
||||
}
|
||||
|
||||
return [$items, $summaries, $genericCaches, $genericExistingIdentities, $genericSeenIdentities];
|
||||
}
|
||||
|
||||
/* ------------------------------- Localization ------------------------------- */
|
||||
|
||||
private function actionTranslations(): array
|
||||
|
||||
Reference in New Issue
Block a user