Merge branch 'master' into Development
This commit is contained in:
@@ -38,8 +38,10 @@ public static function toDate(?string $raw): ?string
|
||||
// Rebuild date with corrected year
|
||||
$month = (int) $dt->format('m');
|
||||
$day = (int) $dt->format('d');
|
||||
|
||||
return sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||
}
|
||||
|
||||
return $dt->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use App\Models\Account;
|
||||
use App\Models\AccountType;
|
||||
use App\Models\Activity;
|
||||
use App\Models\CaseObject;
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientCase;
|
||||
use App\Models\Contract;
|
||||
@@ -480,6 +481,80 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
||||
}
|
||||
}
|
||||
|
||||
// Case Objects: create or update case objects associated with contracts
|
||||
// Support both 'case_object' and 'case_objects' keys (template may use plural)
|
||||
if (isset($mapped['case_objects']) || isset($mapped['case_object'])) {
|
||||
// Resolve contract_id from various sources
|
||||
$contractIdForObject = null;
|
||||
|
||||
// Get the case object data (support both plural and singular)
|
||||
$caseObjectData = $mapped['case_objects'] ?? $mapped['case_object'] ?? [];
|
||||
|
||||
// First, check if contract_id is directly provided in the mapping
|
||||
if (! empty($caseObjectData['contract_id'])) {
|
||||
$contractIdForObject = $caseObjectData['contract_id'];
|
||||
}
|
||||
// If contract was just created/resolved above, use its id
|
||||
elseif ($contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) {
|
||||
$contractIdForObject = $contractResult['contract']->id;
|
||||
}
|
||||
// If account was processed and has a contract, use that contract
|
||||
elseif ($accountResult && isset($accountResult['contract_id'])) {
|
||||
$contractIdForObject = $accountResult['contract_id'];
|
||||
}
|
||||
|
||||
if ($contractIdForObject) {
|
||||
$caseObjectResult = $this->upsertCaseObject($import, $mapped, $mappings, $contractIdForObject);
|
||||
if ($caseObjectResult['action'] === 'skipped') {
|
||||
$skipped++;
|
||||
$importRow->update(['status' => 'skipped']);
|
||||
ImportEvent::create([
|
||||
'import_id' => $import->id,
|
||||
'user_id' => $user?->getAuthIdentifier(),
|
||||
'import_row_id' => $importRow->id,
|
||||
'event' => 'row_skipped',
|
||||
'level' => 'info',
|
||||
'message' => $caseObjectResult['message'] ?? 'Skipped (no changes).',
|
||||
'context' => $caseObjectResult['context'] ?? null,
|
||||
]);
|
||||
} elseif ($caseObjectResult['action'] === 'inserted' || $caseObjectResult['action'] === 'updated') {
|
||||
$imported++;
|
||||
$importRow->update([
|
||||
'status' => 'imported',
|
||||
'entity_type' => CaseObject::class,
|
||||
'entity_id' => $caseObjectResult['case_object']->id,
|
||||
]);
|
||||
$objectFieldsStr = '';
|
||||
if (! empty($caseObjectResult['applied_fields'] ?? [])) {
|
||||
$objectFieldsStr = $this->formatAppliedFieldMessage('case_object', $caseObjectResult['applied_fields']);
|
||||
}
|
||||
ImportEvent::create([
|
||||
'import_id' => $import->id,
|
||||
'user_id' => $user?->getAuthIdentifier(),
|
||||
'import_row_id' => $importRow->id,
|
||||
'event' => 'row_imported',
|
||||
'level' => 'info',
|
||||
'message' => ucfirst($caseObjectResult['action']).' case object'.($objectFieldsStr ? ' '.$objectFieldsStr : ''),
|
||||
'context' => ['id' => $caseObjectResult['case_object']->id, 'fields' => $caseObjectResult['applied_fields'] ?? []],
|
||||
]);
|
||||
} else {
|
||||
$invalid++;
|
||||
$importRow->update(['status' => 'invalid', 'errors' => ['Unhandled case object result']]);
|
||||
}
|
||||
} else {
|
||||
$invalid++;
|
||||
$importRow->update(['status' => 'invalid', 'errors' => ['Case object requires a valid contract_id']]);
|
||||
ImportEvent::create([
|
||||
'import_id' => $import->id,
|
||||
'user_id' => $user?->getAuthIdentifier(),
|
||||
'import_row_id' => $importRow->id,
|
||||
'event' => 'row_invalid',
|
||||
'level' => 'error',
|
||||
'message' => 'Case object requires a valid contract_id (not resolved).',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Contacts: resolve person via Contract/Account chain, client_case.client_ref, contacts, or identifiers
|
||||
$personIdForRow = null;
|
||||
// Prefer person from contract created/updated above
|
||||
@@ -1447,6 +1522,109 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
|
||||
}
|
||||
}
|
||||
|
||||
private function upsertCaseObject(Import $import, array $mapped, $mappings, int $contractId): array
|
||||
{
|
||||
// Support both 'case_object' and 'case_objects' keys (template may use plural)
|
||||
$obj = $mapped['case_objects'] ?? $mapped['case_object'] ?? [];
|
||||
$reference = $obj['reference'] ?? null;
|
||||
$name = $obj['name'] ?? null;
|
||||
|
||||
// Normalize reference (remove spaces) for consistent matching
|
||||
if (! is_null($reference)) {
|
||||
$reference = preg_replace('/\s+/', '', trim((string) $reference));
|
||||
}
|
||||
|
||||
// At least name or reference must be provided
|
||||
if (! $reference && (! $name || trim($name) === '')) {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'message' => 'Case object requires at least a reference or name',
|
||||
'context' => ['missing' => 'reference and name'],
|
||||
];
|
||||
}
|
||||
|
||||
$existing = null;
|
||||
|
||||
// First, try to find by contract_id and reference (if reference provided)
|
||||
if ($reference) {
|
||||
$existing = CaseObject::query()
|
||||
->where('contract_id', $contractId)
|
||||
->where('reference', $reference)
|
||||
->first();
|
||||
}
|
||||
|
||||
// If not found by reference and name is provided, check for duplicate by name
|
||||
// This prevents creating duplicate case objects with same name for a contract
|
||||
if (! $existing && ! is_null($name) && trim($name) !== '') {
|
||||
$normalizedName = trim($name);
|
||||
$duplicateByName = CaseObject::query()
|
||||
->where('contract_id', $contractId)
|
||||
->where('name', $normalizedName)
|
||||
->first();
|
||||
|
||||
if ($duplicateByName) {
|
||||
// Found existing by name - use it as the existing record
|
||||
$existing = $duplicateByName;
|
||||
}
|
||||
}
|
||||
|
||||
// Build applyable data based on apply_mode
|
||||
$applyInsert = [];
|
||||
$applyUpdate = [];
|
||||
$applyModeByField = [];
|
||||
foreach ($mappings as $map) {
|
||||
$target = (string) ($map->target_field ?? '');
|
||||
// Support both 'case_object.' and 'case_objects.' (template may use plural)
|
||||
if (! str_starts_with($target, 'case_object.') && ! str_starts_with($target, 'case_objects.')) {
|
||||
continue;
|
||||
}
|
||||
// Extract field name - handle both singular and plural prefix
|
||||
$field = str_starts_with($target, 'case_objects.')
|
||||
? substr($target, strlen('case_objects.'))
|
||||
: substr($target, strlen('case_object.'));
|
||||
$applyModeByField[$field] = (string) ($map->apply_mode ?? 'both');
|
||||
}
|
||||
|
||||
foreach ($obj as $field => $value) {
|
||||
$applyMode = $applyModeByField[$field] ?? 'both';
|
||||
if (is_null($value) || (is_string($value) && trim($value) === '')) {
|
||||
continue;
|
||||
}
|
||||
if (in_array($applyMode, ['both', 'insert'], true)) {
|
||||
$applyInsert[$field] = $value;
|
||||
}
|
||||
if (in_array($applyMode, ['both', 'update'], true)) {
|
||||
$applyUpdate[$field] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if ($existing) {
|
||||
// Build non-null changes for case object fields
|
||||
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
|
||||
if (! empty($changes)) {
|
||||
$existing->fill($changes);
|
||||
$existing->save();
|
||||
|
||||
return ['action' => 'updated', 'case_object' => $existing, 'applied_fields' => $changes];
|
||||
} else {
|
||||
return ['action' => 'skipped', 'case_object' => $existing, 'message' => 'No changes needed'];
|
||||
}
|
||||
} else {
|
||||
// Create new case object
|
||||
$data = array_merge([
|
||||
'contract_id' => $contractId,
|
||||
'reference' => $reference,
|
||||
], $applyInsert);
|
||||
|
||||
// Remove any null values
|
||||
$data = array_filter($data, fn ($v) => ! is_null($v));
|
||||
|
||||
$created = CaseObject::create($data);
|
||||
|
||||
return ['action' => 'inserted', 'case_object' => $created, 'applied_fields' => $data];
|
||||
}
|
||||
}
|
||||
|
||||
private function mappingsContainRoot($mappings, string $root): bool
|
||||
{
|
||||
foreach ($mappings as $map) {
|
||||
@@ -1491,6 +1669,17 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
|
||||
return ['action' => 'invalid', 'message' => 'Missing contract.reference'];
|
||||
}
|
||||
|
||||
// Temporary debug output
|
||||
$debugInfo = [
|
||||
'row' => $rowIndex ?? 'unknown',
|
||||
'reference' => $reference,
|
||||
'has_person' => isset($mapped['person']),
|
||||
'person_tax' => $mapped['person']['tax_number'] ?? null,
|
||||
'client_id' => $import->client_id,
|
||||
];
|
||||
|
||||
\Log::info('ImportProcessor: upsertContractChain START', $debugInfo);
|
||||
|
||||
// Determine mapping mode for contract.reference (e.g., keyref)
|
||||
$refMode = $this->mappingMode($mappings, 'contract.reference');
|
||||
|
||||
@@ -1507,6 +1696,21 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
|
||||
->where('contracts.reference', $reference)
|
||||
->select('contracts.*')
|
||||
->first();
|
||||
|
||||
// Debug logging to track contract lookup
|
||||
if ($existing) {
|
||||
\Log::info('ImportProcessor: Found existing contract', [
|
||||
'client_id' => $clientId,
|
||||
'reference' => $reference,
|
||||
'contract_id' => $existing->id,
|
||||
'client_case_id' => $existing->client_case_id,
|
||||
]);
|
||||
} else {
|
||||
\Log::info('ImportProcessor: No existing contract found', [
|
||||
'client_id' => $clientId,
|
||||
'reference' => $reference,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// If not found by client+reference and a specific client_case_id is provided, try that too
|
||||
@@ -1605,6 +1809,7 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
|
||||
// keyref: used as lookup and applied on insert, but not on update
|
||||
if ($mode === 'keyref') {
|
||||
$applyInsert[$field] = $value;
|
||||
|
||||
continue;
|
||||
}
|
||||
if (in_array($mode, ['insert', 'both'], true)) {
|
||||
@@ -2303,14 +2508,19 @@ private function findOrCreateClientCaseId(int $clientId, int $personId, ?string
|
||||
|
||||
return $cc->id;
|
||||
}
|
||||
|
||||
// client_ref was provided but not found, create new case with this client_ref
|
||||
return ClientCase::create(['client_id' => $clientId, 'person_id' => $personId, 'client_ref' => $clientRef])->id;
|
||||
}
|
||||
// Fallback: by (client_id, person_id)
|
||||
|
||||
// No client_ref provided: reuse existing case by (client_id, person_id) if available
|
||||
$cc = ClientCase::where('client_id', $clientId)->where('person_id', $personId)->first();
|
||||
if ($cc) {
|
||||
return $cc->id;
|
||||
}
|
||||
|
||||
return ClientCase::create(['client_id' => $clientId, 'person_id' => $personId, 'client_ref' => $clientRef])->id;
|
||||
// Create new case without client_ref
|
||||
return ClientCase::create(['client_id' => $clientId, 'person_id' => $personId, 'client_ref' => null])->id;
|
||||
}
|
||||
|
||||
private function upsertEmail(int $personId, array $emailData, $mappings): array
|
||||
@@ -2429,10 +2639,16 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
|
||||
private function upsertPhone(int $personId, array $phoneData, $mappings): array
|
||||
{
|
||||
$nu = trim((string) ($phoneData['nu'] ?? ''));
|
||||
// Strip all non-numeric characters from phone number
|
||||
$nu = preg_replace('/[^0-9]/', '', $nu);
|
||||
if ($nu === '') {
|
||||
return ['action' => 'skipped', 'message' => 'No phone value'];
|
||||
}
|
||||
$existing = PersonPhone::where('person_id', $personId)->where('nu', $nu)->first();
|
||||
|
||||
// Find existing phone by normalized number (strip non-numeric from DB values too)
|
||||
$existing = PersonPhone::where('person_id', $personId)
|
||||
->whereRaw("REGEXP_REPLACE(nu, '[^0-9]', '', 'g') = ?", [$nu])
|
||||
->first();
|
||||
$applyInsert = [];
|
||||
$applyUpdate = [];
|
||||
foreach ($mappings as $map) {
|
||||
@@ -2472,6 +2688,12 @@ private function upsertPhone(int $personId, array $phoneData, $mappings): array
|
||||
$data = array_filter($applyInsert, fn ($v) => ! is_null($v));
|
||||
$data['person_id'] = $personId;
|
||||
$data['type_id'] = $data['type_id'] ?? $this->getDefaultPhoneTypeId();
|
||||
// Override nu field with normalized value (digits only)
|
||||
$data['nu'] = $nu;
|
||||
// Set default phone_type to mobile enum if not provided
|
||||
if (! array_key_exists('phone_type', $data) || $data['phone_type'] === null) {
|
||||
$data['phone_type'] = \App\Enums\PersonPhoneType::Mobile;
|
||||
}
|
||||
$created = PersonPhone::create($data);
|
||||
|
||||
return ['action' => 'inserted', 'phone' => $created];
|
||||
|
||||
@@ -1237,7 +1237,9 @@ private function simulateGenericRoot(
|
||||
$entity['country'] = $val('address.country') ?? null;
|
||||
break;
|
||||
case 'phone':
|
||||
$entity['nu'] = $val('phone.nu') ?? null;
|
||||
$rawNu = $val('phone.nu') ?? null;
|
||||
// Strip all non-numeric characters from phone number
|
||||
$entity['nu'] = $rawNu !== null ? preg_replace('/[^0-9]/', '', (string) $rawNu) : null;
|
||||
break;
|
||||
case 'email':
|
||||
$entity['value'] = $val('email.value') ?? null;
|
||||
@@ -1246,6 +1248,11 @@ private function simulateGenericRoot(
|
||||
$entity['title'] = $val('client_case.title') ?? null;
|
||||
$entity['status'] = $val('client_case.status') ?? null;
|
||||
break;
|
||||
case 'case_object':
|
||||
$entity['name'] = $val('case_object.name') ?? null;
|
||||
$entity['description'] = $val('case_object.description') ?? null;
|
||||
$entity['type'] = $val('case_object.type') ?? null;
|
||||
break;
|
||||
}
|
||||
|
||||
if ($verbose) {
|
||||
@@ -1313,7 +1320,8 @@ private function genericIdentityCandidates(string $root, callable $val): array
|
||||
case 'phone':
|
||||
$nu = $val('phone.nu');
|
||||
if ($nu) {
|
||||
$norm = preg_replace('/\D+/', '', (string) $nu) ?? '';
|
||||
// Strip all non-numeric characters from phone number
|
||||
$norm = preg_replace('/[^0-9]/', '', (string) $nu) ?? '';
|
||||
|
||||
return $norm ? ['nu:'.$norm] : [];
|
||||
}
|
||||
@@ -1346,6 +1354,20 @@ private function genericIdentityCandidates(string $root, callable $val): array
|
||||
}
|
||||
|
||||
return [];
|
||||
case 'case_object':
|
||||
$ref = $val('case_object.reference');
|
||||
$name = $val('case_object.name');
|
||||
$ids = [];
|
||||
if ($ref) {
|
||||
// Normalize reference (remove spaces)
|
||||
$normRef = preg_replace('/\s+/', '', trim((string) $ref));
|
||||
$ids[] = 'ref:'.$normRef;
|
||||
}
|
||||
if ($name) {
|
||||
$ids[] = 'name:'.mb_strtolower(trim((string) $name));
|
||||
}
|
||||
|
||||
return $ids;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -1366,7 +1388,8 @@ private function loadExistingGenericIdentities(string $root): array
|
||||
case 'phone':
|
||||
foreach (\App\Models\Person\PersonPhone::query()->pluck('nu') as $p) {
|
||||
if ($p) {
|
||||
$set['nu:'.preg_replace('/\D+/', '', (string) $p)] = true;
|
||||
// Strip all non-numeric characters from phone number
|
||||
$set['nu:'.preg_replace('/[^0-9]/', '', (string) $p)] = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -1391,6 +1414,18 @@ private function loadExistingGenericIdentities(string $root): array
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'case_object':
|
||||
foreach (\App\Models\CaseObject::query()->get(['reference', 'name']) as $rec) {
|
||||
if ($rec->reference) {
|
||||
// Normalize reference (remove spaces)
|
||||
$normRef = preg_replace('/\s+/', '', trim((string) $rec->reference));
|
||||
$set['ref:'.$normRef] = true;
|
||||
}
|
||||
if ($rec->name) {
|
||||
$set['name:'.mb_strtolower(trim((string) $rec->name))] = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// swallow and return what we have
|
||||
@@ -1411,6 +1446,7 @@ private function modelClassForGeneric(string $root): ?string
|
||||
'activity' => \App\Models\Activity::class,
|
||||
'client' => \App\Models\Client::class,
|
||||
'client_case' => \App\Models\ClientCase::class,
|
||||
'case_object' => \App\Models\CaseObject::class,
|
||||
][$root] ?? null;
|
||||
}
|
||||
|
||||
@@ -1563,7 +1599,8 @@ private function simulateGenericRootMulti(
|
||||
} elseif ($root === 'phone') {
|
||||
$nu = $groupVals('phone', 'nu')[$g] ?? null;
|
||||
if ($nu) {
|
||||
$norm = preg_replace('/\D+/', '', (string) $nu) ?? '';
|
||||
// Strip all non-numeric characters from phone number
|
||||
$norm = preg_replace('/[^0-9]/', '', (string) $nu) ?? '';
|
||||
if ($norm) {
|
||||
$identityCandidates = ['nu:'.$norm];
|
||||
}
|
||||
@@ -1615,7 +1652,9 @@ private function simulateGenericRootMulti(
|
||||
if ($root === 'email') {
|
||||
$entity['value'] = $groupVals('email', 'value')[$g] ?? null;
|
||||
} elseif ($root === 'phone') {
|
||||
$entity['nu'] = $groupVals('phone', 'nu')[$g] ?? null;
|
||||
$rawNu = $groupVals('phone', 'nu')[$g] ?? null;
|
||||
// Strip all non-numeric characters from phone number
|
||||
$entity['nu'] = $rawNu !== null ? preg_replace('/[^0-9]/', '', (string) $rawNu) : null;
|
||||
} elseif ($root === 'address') {
|
||||
$entity['address'] = $groupVals('address', 'address')[$g] ?? null;
|
||||
$entity['country'] = $groupVals('address', 'country')[$g] ?? null;
|
||||
|
||||
@@ -24,6 +24,7 @@ protected function normalizeForSms(string $text): string
|
||||
{
|
||||
// Replace NBSP (\xC2\xA0 in UTF-8) and tabs with regular space
|
||||
$text = str_replace(["\u{00A0}", "\t"], ' ', $text);
|
||||
|
||||
// Optionally collapse CRLF to LF (providers typically accept both); keep as-is otherwise
|
||||
return $text;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user