fixed import
This commit is contained in:
parent
84b75143df
commit
36b63a180d
83
app/Services/Import/DecimalNormalizer.php
Normal file
83
app/Services/Import/DecimalNormalizer.php
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Import;
|
||||||
|
|
||||||
|
class DecimalNormalizer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Normalize a raw decimal string to a standard format (period as decimal separator).
|
||||||
|
* Handles European format (comma as decimal) and American format (period as decimal).
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* - "958,31" => "958.31"
|
||||||
|
* - "1.234,56" => "1234.56"
|
||||||
|
* - "1,234.56" => "1234.56"
|
||||||
|
* - "1234" => "1234"
|
||||||
|
*
|
||||||
|
* Based on ImportProcessor::normalizeDecimal()
|
||||||
|
*/
|
||||||
|
public static function normalize(?string $raw): ?string
|
||||||
|
{
|
||||||
|
if ($raw === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep digits, comma, dot, and minus to detect separators
|
||||||
|
$s = preg_replace('/[^0-9,\.-]/', '', $raw) ?? '';
|
||||||
|
$s = trim($s);
|
||||||
|
|
||||||
|
if ($s === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastComma = strrpos($s, ',');
|
||||||
|
$lastDot = strrpos($s, '.');
|
||||||
|
|
||||||
|
// Determine decimal separator by last occurrence
|
||||||
|
$decimalSep = null;
|
||||||
|
if ($lastComma !== false || $lastDot !== false) {
|
||||||
|
if ($lastComma === false) {
|
||||||
|
$decimalSep = '.';
|
||||||
|
} elseif ($lastDot === false) {
|
||||||
|
$decimalSep = ',';
|
||||||
|
} else {
|
||||||
|
$decimalSep = $lastComma > $lastDot ? ',' : '.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all thousand separators and unify decimal to '.'
|
||||||
|
if ($decimalSep === ',') {
|
||||||
|
// Remove all dots (thousand separators)
|
||||||
|
$s = str_replace('.', '', $s);
|
||||||
|
// Replace last comma with dot
|
||||||
|
$pos = strrpos($s, ',');
|
||||||
|
if ($pos !== false) {
|
||||||
|
$s[$pos] = '.';
|
||||||
|
}
|
||||||
|
// Remove any remaining commas (unlikely)
|
||||||
|
$s = str_replace(',', '', $s);
|
||||||
|
} elseif ($decimalSep === '.') {
|
||||||
|
// Remove all commas (thousand separators)
|
||||||
|
$s = str_replace(',', '', $s);
|
||||||
|
// Dot is already decimal separator
|
||||||
|
} else {
|
||||||
|
// No decimal separator: remove commas/dots entirely
|
||||||
|
$s = str_replace([',', '.'], '', $s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle negative numbers
|
||||||
|
$s = ltrim($s, '+');
|
||||||
|
$neg = false;
|
||||||
|
if (str_starts_with($s, '-')) {
|
||||||
|
$neg = true;
|
||||||
|
$s = ltrim($s, '-');
|
||||||
|
}
|
||||||
|
// Remove any stray minus signs
|
||||||
|
$s = str_replace('-', '', $s);
|
||||||
|
if ($neg) {
|
||||||
|
$s = '-' . $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -370,9 +370,14 @@ public function resolveOrCreateClientCaseForContract(Import $import, array $mapp
|
||||||
|
|
||||||
if (!$personId) {
|
if (!$personId) {
|
||||||
// Create minimal Person as last resort
|
// Create minimal Person as last resort
|
||||||
$personId = Person::create(['type_id' => 1])->id;
|
$defaultGroupId = (int) (\App\Models\Person\PersonGroup::min('id') ?? 1);
|
||||||
|
$personId = Person::create([
|
||||||
|
'type_id' => 1,
|
||||||
|
'group_id' => $defaultGroupId,
|
||||||
|
])->id;
|
||||||
Log::info('EntityResolutionService: Created minimal Person for new ClientCase', [
|
Log::info('EntityResolutionService: Created minimal Person for new ClientCase', [
|
||||||
'person_id' => $personId,
|
'person_id' => $personId,
|
||||||
|
'group_id' => $defaultGroupId,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
use App\Models\Account;
|
use App\Models\Account;
|
||||||
use App\Models\Import;
|
use App\Models\Import;
|
||||||
use App\Services\Import\BaseEntityHandler;
|
use App\Services\Import\BaseEntityHandler;
|
||||||
|
use App\Services\Import\DecimalNormalizer;
|
||||||
|
|
||||||
class AccountHandler extends BaseEntityHandler
|
class AccountHandler extends BaseEntityHandler
|
||||||
{
|
{
|
||||||
|
|
@ -13,10 +14,38 @@ public function getEntityClass(): string
|
||||||
return Account::class;
|
return Account::class;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override validate to handle contract_id and reference from context.
|
||||||
|
* Both contract_id and reference are populated in process() (reference defaults to contract reference).
|
||||||
|
*/
|
||||||
|
public function validate(array $mapped): array
|
||||||
|
{
|
||||||
|
// Remove contract_id and reference from validation - both will be populated in process()
|
||||||
|
// Reference defaults to contract.reference if not set (matching v1 behavior)
|
||||||
|
$rules = $this->entityConfig?->validation_rules ?? [];
|
||||||
|
|
||||||
|
unset($rules['contract_id'], $rules['reference']);
|
||||||
|
|
||||||
|
if (empty($rules)) {
|
||||||
|
return ['valid' => true, 'errors' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$validator = \Illuminate\Support\Facades\Validator::make($mapped, $rules);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return [
|
||||||
|
'valid' => false,
|
||||||
|
'errors' => $validator->errors()->all(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['valid' => true, 'errors' => []];
|
||||||
|
}
|
||||||
|
|
||||||
public function resolve(array $mapped, array $context = []): mixed
|
public function resolve(array $mapped, array $context = []): mixed
|
||||||
{
|
{
|
||||||
$reference = $mapped['reference'] ?? null;
|
$reference = $mapped['reference'] ?? null;
|
||||||
$contractId = $mapped['contract_id'] ?? $context['contract']?->entity?->id ?? null;
|
$contractId = $mapped['contract_id'] ?? $context['contract']['entity']->id ?? null;
|
||||||
|
|
||||||
if (! $reference || ! $contractId) {
|
if (! $reference || ! $contractId) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -37,7 +66,15 @@ public function process(Import $import, array $mapped, array $raw, array $contex
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$contractId = $context['contract']->entity->id;
|
// Fallback: if account.reference is empty, use contract.reference (matching v1 behavior)
|
||||||
|
if (empty($mapped['reference'])) {
|
||||||
|
$contractReference = $context['contract']['entity']->reference ?? null;
|
||||||
|
if ($contractReference) {
|
||||||
|
$mapped['reference'] = preg_replace('/\s+/', '', trim((string) $contractReference));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$contractId = $context['contract']['entity']->id;
|
||||||
$mapped['contract_id'] = $contractId;
|
$mapped['contract_id'] = $contractId;
|
||||||
|
|
||||||
$existing = $this->resolve($mapped, $context);
|
$existing = $this->resolve($mapped, $context);
|
||||||
|
|
@ -75,6 +112,12 @@ public function process(Import $import, array $mapped, array $raw, array $contex
|
||||||
// Create new account
|
// Create new account
|
||||||
$account = new Account;
|
$account = new Account;
|
||||||
$payload = $this->buildPayload($mapped, $account);
|
$payload = $this->buildPayload($mapped, $account);
|
||||||
|
|
||||||
|
// Ensure required defaults for new accounts
|
||||||
|
if (!isset($payload['type_id'])) {
|
||||||
|
$payload['type_id'] = $this->getDefaultAccountTypeId();
|
||||||
|
}
|
||||||
|
|
||||||
$account->fill($payload);
|
$account->fill($payload);
|
||||||
$account->save();
|
$account->save();
|
||||||
|
|
||||||
|
|
@ -100,7 +143,14 @@ protected function buildPayload(array $mapped, $model): array
|
||||||
|
|
||||||
foreach ($fieldMap as $source => $target) {
|
foreach ($fieldMap as $source => $target) {
|
||||||
if (array_key_exists($source, $mapped)) {
|
if (array_key_exists($source, $mapped)) {
|
||||||
$payload[$target] = $mapped[$source];
|
$value = $mapped[$source];
|
||||||
|
|
||||||
|
// Normalize decimal fields (convert comma to period)
|
||||||
|
if (in_array($source, ['balance_amount', 'initial_amount']) && is_string($value)) {
|
||||||
|
$value = DecimalNormalizer::normalize($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload[$target] = $value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,4 +205,12 @@ protected function createBalanceChangeActivity(Account $account, float $oldBalan
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default account type ID.
|
||||||
|
*/
|
||||||
|
protected function getDefaultAccountTypeId(): int
|
||||||
|
{
|
||||||
|
return (int) (\App\Models\AccountType::min('id') ?? 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -217,6 +217,22 @@ protected function buildPayload(array $mapped, $model): array
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle meta field - merge grouped meta into flat structure
|
||||||
|
if (!empty($mapped['meta']) && is_array($mapped['meta'])) {
|
||||||
|
$metaData = [];
|
||||||
|
foreach ($mapped['meta'] as $grp => $entries) {
|
||||||
|
if (!is_array($entries)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($entries as $k => $v) {
|
||||||
|
$metaData[$k] = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($metaData)) {
|
||||||
|
$payload['meta'] = $metaData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $payload;
|
return $payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@ public function getEntityClass(): string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Override validate to skip validation if email is empty.
|
* Override validate to skip validation if email is empty or invalid.
|
||||||
|
* Invalid emails should be skipped, not cause transaction rollback.
|
||||||
*/
|
*/
|
||||||
public function validate(array $mapped): array
|
public function validate(array $mapped): array
|
||||||
{
|
{
|
||||||
|
|
@ -23,6 +24,12 @@ public function validate(array $mapped): array
|
||||||
return ['valid' => true, 'errors' => []];
|
return ['valid' => true, 'errors' => []];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate email format - if invalid, mark as valid to skip instead of failing
|
||||||
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
// Return valid=true but we'll skip it in process()
|
||||||
|
return ['valid' => true, 'errors' => []];
|
||||||
|
}
|
||||||
|
|
||||||
return parent::validate($mapped);
|
return parent::validate($mapped);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,6 +55,14 @@ public function process(Import $import, array $mapped, array $raw, array $contex
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip if email format is invalid
|
||||||
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
return [
|
||||||
|
'action' => 'skipped',
|
||||||
|
'message' => 'Invalid email format',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve person_id from context
|
// Resolve person_id from context
|
||||||
$personId = $mapped['person_id'] ?? $context['person']['entity']?->id ?? null;
|
$personId = $mapped['person_id'] ?? $context['person']['entity']?->id ?? null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,20 @@ public function process(Import $import, array $mapped, array $raw, array $contex
|
||||||
$payload['type_id'] = $this->getDefaultPersonTypeId();
|
$payload['type_id'] = $this->getDefaultPersonTypeId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Log::debug('PersonHandler: Payload before fill', [
|
||||||
|
'payload' => $payload,
|
||||||
|
'has_group_id' => isset($payload['group_id']),
|
||||||
|
'group_id_value' => $payload['group_id'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
$person->fill($payload);
|
$person->fill($payload);
|
||||||
|
|
||||||
|
Log::debug('PersonHandler: Person attributes after fill', [
|
||||||
|
'attributes' => $person->getAttributes(),
|
||||||
|
'has_group_id' => isset($person->group_id),
|
||||||
|
'group_id_value' => $person->group_id ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
$person->save();
|
$person->save();
|
||||||
|
|
||||||
Log::info('PersonHandler: Created new Person', [
|
Log::info('PersonHandler: Created new Person', [
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
use App\Models\ImportRow;
|
use App\Models\ImportRow;
|
||||||
use App\Services\Import\Contracts\EntityHandlerInterface;
|
use App\Services\Import\Contracts\EntityHandlerInterface;
|
||||||
use App\Services\Import\DateNormalizer;
|
use App\Services\Import\DateNormalizer;
|
||||||
|
use App\Services\Import\DecimalNormalizer;
|
||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
@ -75,6 +76,9 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
||||||
throw new \RuntimeException("File not found: {$filePath}");
|
throw new \RuntimeException("File not found: {$filePath}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this is a retry (import_rows already exist)
|
||||||
|
$isRetry = ImportRow::where('import_id', $import->id)->exists();
|
||||||
|
|
||||||
$fullPath = Storage::disk($import->disk ?? 'local')->path($filePath);
|
$fullPath = Storage::disk($import->disk ?? 'local')->path($filePath);
|
||||||
$fh = fopen($fullPath, 'r');
|
$fh = fopen($fullPath, 'r');
|
||||||
|
|
||||||
|
|
@ -98,7 +102,82 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
||||||
|
|
||||||
$isPg = DB::connection()->getDriverName() === 'pgsql';
|
$isPg = DB::connection()->getDriverName() === 'pgsql';
|
||||||
|
|
||||||
while (($row = fgetcsv($fh, 0, $delimiter)) !== false) {
|
// If retry mode, only process failed/invalid rows
|
||||||
|
if ($isRetry) {
|
||||||
|
$failedRows = ImportRow::where('import_id', $import->id)
|
||||||
|
->whereIn('status', ['invalid', 'failed'])
|
||||||
|
->orderBy('row_number')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($failedRows as $importRow) {
|
||||||
|
$total++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$rawAssoc = $importRow->raw_data;
|
||||||
|
$mapped = $importRow->mapped_data;
|
||||||
|
|
||||||
|
// Process entities in priority order within a transaction
|
||||||
|
$context = ['import' => $import, 'user' => $user, 'import_row' => $importRow];
|
||||||
|
|
||||||
|
DB::beginTransaction();
|
||||||
|
try {
|
||||||
|
$results = $this->processRow($import, $mapped, $rawAssoc, $context);
|
||||||
|
|
||||||
|
// If processing succeeded, commit the transaction
|
||||||
|
if ($results['status'] === 'imported' || $results['status'] === 'skipped') {
|
||||||
|
DB::commit();
|
||||||
|
} else {
|
||||||
|
DB::rollBack();
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
DB::rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect entity details from results
|
||||||
|
$entityData = $this->collectEntityDetails($results);
|
||||||
|
$entityDetails = $entityData['details'];
|
||||||
|
$hasErrors = $entityData['hasErrors'];
|
||||||
|
$hasWarnings = $entityData['hasWarnings'];
|
||||||
|
|
||||||
|
// Handle different result statuses
|
||||||
|
if ($results['status'] === 'imported') {
|
||||||
|
$imported++;
|
||||||
|
$importRow->update([
|
||||||
|
'status' => 'imported',
|
||||||
|
'entity_type' => $results['entity_type'] ?? null,
|
||||||
|
'entity_id' => $results['entity_id'] ?? null,
|
||||||
|
]);
|
||||||
|
$this->createRowProcessedEvent($import, $user, $importRow->row_number, $entityDetails, $hasWarnings, $rawAssoc);
|
||||||
|
} elseif ($results['status'] === 'skipped') {
|
||||||
|
$skipped++;
|
||||||
|
$importRow->update(['status' => 'skipped']);
|
||||||
|
$this->createRowSkippedEvent($import, $user, $importRow->row_number, $entityDetails, $rawAssoc);
|
||||||
|
} else {
|
||||||
|
$invalid++;
|
||||||
|
$importRow->update([
|
||||||
|
'status' => 'invalid',
|
||||||
|
'errors' => $results['errors'] ?? ['Processing failed'],
|
||||||
|
]);
|
||||||
|
$this->createRowFailedEvent(
|
||||||
|
$import,
|
||||||
|
$user,
|
||||||
|
$importRow->row_number,
|
||||||
|
$results['errors'] ?? ['Processing failed'],
|
||||||
|
$entityDetails,
|
||||||
|
$rawAssoc
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$invalid++;
|
||||||
|
$this->handleRowException($import, $user, $importRow->row_number, $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($fh);
|
||||||
|
} else {
|
||||||
|
// Normal mode: process all rows from CSV
|
||||||
|
while (($row = fgetcsv($fh, 0, $delimiter)) !== false) {
|
||||||
$rowNum++;
|
$rowNum++;
|
||||||
$total++;
|
$total++;
|
||||||
|
|
||||||
|
|
@ -180,9 +259,10 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
||||||
$invalid++;
|
$invalid++;
|
||||||
$this->handleRowException($import, $user, $rowNum, $e);
|
$this->handleRowException($import, $user, $rowNum, $e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fclose($fh);
|
fclose($fh);
|
||||||
|
}
|
||||||
|
|
||||||
$this->finalizeImport($import, $user, $total, $imported, $skipped, $invalid);
|
$this->finalizeImport($import, $user, $total, $imported, $skipped, $invalid);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|
@ -272,6 +352,12 @@ protected function applyMappings(array $raw, $mappings): array
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($groupedMappings as $targetField => $fieldMappings) {
|
foreach ($groupedMappings as $targetField => $fieldMappings) {
|
||||||
|
// Special handling for meta fields: contracts.meta or other_entity.meta
|
||||||
|
if (str_ends_with($targetField, '.meta')) {
|
||||||
|
$this->applyMetaMappings($mapped, $targetField, $fieldMappings, $raw);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Group by group number from options
|
// Group by group number from options
|
||||||
$valuesByGroup = [];
|
$valuesByGroup = [];
|
||||||
|
|
||||||
|
|
@ -323,6 +409,89 @@ protected function applyMappings(array $raw, $mappings): array
|
||||||
|
|
||||||
return $mapped;
|
return $mapped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply meta mappings with special structure: entity.meta[group][key] = {title, value, type}
|
||||||
|
*/
|
||||||
|
protected function applyMetaMappings(array &$mapped, string $targetField, array $fieldMappings, array $raw): void
|
||||||
|
{
|
||||||
|
// Extract entity from target field: contracts.meta -> contracts
|
||||||
|
$entity = str_replace('.meta', '', $targetField);
|
||||||
|
|
||||||
|
foreach ($fieldMappings as $mapping) {
|
||||||
|
$sourceCol = $mapping->source_column;
|
||||||
|
|
||||||
|
if (!isset($raw[$sourceCol])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $raw[$sourceCol];
|
||||||
|
|
||||||
|
// Apply transform
|
||||||
|
if ($mapping->transform) {
|
||||||
|
$value = $this->applyTransform($value, $mapping->transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get options
|
||||||
|
$options = $mapping->options ? json_decode($mapping->options, true) : [];
|
||||||
|
$metaKey = $options['key'] ?? null;
|
||||||
|
$metaType = $options['type'] ?? 'string';
|
||||||
|
$group = $options['group'] ?? '1';
|
||||||
|
|
||||||
|
if (!$metaKey) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coerce value based on type
|
||||||
|
$coerced = $value;
|
||||||
|
if ($metaType === 'number') {
|
||||||
|
if (is_string($coerced)) {
|
||||||
|
$norm = DecimalNormalizer::normalize($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
|
||||||
|
if (is_scalar($coerced)) {
|
||||||
|
$coerced = (string) $coerced;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize structure if needed
|
||||||
|
if (!isset($mapped[$entity])) {
|
||||||
|
$mapped[$entity] = [];
|
||||||
|
}
|
||||||
|
if (!isset($mapped[$entity]['meta']) || !is_array($mapped[$entity]['meta'])) {
|
||||||
|
$mapped[$entity]['meta'] = [];
|
||||||
|
}
|
||||||
|
if (!isset($mapped[$entity]['meta'][$group])) {
|
||||||
|
$mapped[$entity]['meta'][$group] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store as structure with title, value and type
|
||||||
|
$entry = [
|
||||||
|
'title' => $sourceCol,
|
||||||
|
'value' => $coerced,
|
||||||
|
];
|
||||||
|
if ($metaType) {
|
||||||
|
$entry['type'] = $metaType;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mapped[$entity]['meta'][$group][$metaKey] = $entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply transform to value.
|
* Apply transform to value.
|
||||||
|
|
@ -457,6 +626,19 @@ protected function processRow(Import $import, array $mapped, array $raw, array $
|
||||||
$lastEntityType = $handler->getEntityClass();
|
$lastEntityType = $handler->getEntityClass();
|
||||||
$lastEntityId = $result['entity']?->id ?? null;
|
$lastEntityId = $result['entity']?->id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Post-contract actions (segment attachment, activity creation)
|
||||||
|
if ($root === 'contract' && in_array($result['action'] ?? null, ['inserted', 'updated', 'reactivated'])) {
|
||||||
|
try {
|
||||||
|
$this->postContractActions($import, $result['entity'], $context);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('Post-contract action failed', [
|
||||||
|
'import_id' => $import->id,
|
||||||
|
'contract_id' => $result['entity']->id ?? null,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$hasErrors = true;
|
$hasErrors = true;
|
||||||
|
|
||||||
|
|
@ -714,8 +896,13 @@ protected function finalizeImport(
|
||||||
int $skipped,
|
int $skipped,
|
||||||
int $invalid
|
int $invalid
|
||||||
): void {
|
): void {
|
||||||
|
// If there are any invalid rows, mark import as failed, not completed
|
||||||
|
$status = $invalid > 0 ? 'failed' : 'completed';
|
||||||
|
$eventLevel = $invalid > 0 ? 'error' : 'info';
|
||||||
|
$eventName = $invalid > 0 ? 'processing_failed' : 'processing_completed';
|
||||||
|
|
||||||
$import->update([
|
$import->update([
|
||||||
'status' => 'completed',
|
'status' => $status,
|
||||||
'finished_at' => now(),
|
'finished_at' => now(),
|
||||||
'total_rows' => $total,
|
'total_rows' => $total,
|
||||||
'imported_rows' => $imported,
|
'imported_rows' => $imported,
|
||||||
|
|
@ -726,8 +913,8 @@ protected function finalizeImport(
|
||||||
ImportEvent::create([
|
ImportEvent::create([
|
||||||
'import_id' => $import->id,
|
'import_id' => $import->id,
|
||||||
'user_id' => $user?->getAuthIdentifier(),
|
'user_id' => $user?->getAuthIdentifier(),
|
||||||
'event' => 'processing_completed',
|
'event' => $eventName,
|
||||||
'level' => 'info',
|
'level' => $eventLevel,
|
||||||
'message' => "Processed {$total} rows: {$imported} imported, {$skipped} skipped, {$invalid} invalid",
|
'message' => "Processed {$total} rows: {$imported} imported, {$skipped} skipped, {$invalid} invalid",
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -756,4 +943,77 @@ protected function handleFatalException(
|
||||||
'message' => $e->getMessage(),
|
'message' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-contract actions: attach segment, create activity with decision.
|
||||||
|
* Matches ImportProcessor::postContractActions() behavior.
|
||||||
|
*/
|
||||||
|
protected function postContractActions(Import $import, $contract, array $context = []): void
|
||||||
|
{
|
||||||
|
$meta = $import->meta ?? [];
|
||||||
|
$segmentId = (int) ($meta['segment_id'] ?? 0);
|
||||||
|
$decisionId = (int) ($meta['decision_id'] ?? 0);
|
||||||
|
$templateName = (string) ($meta['template_name'] ?? optional($import->template)->name ?? '');
|
||||||
|
$actionId = (int) ($meta['action_id'] ?? 0);
|
||||||
|
|
||||||
|
// Attach segment to contract as the main (active) segment if provided
|
||||||
|
if ($segmentId > 0) {
|
||||||
|
// Ensure the segment exists on the client case and is active
|
||||||
|
$ccSeg = \DB::table('client_case_segment')
|
||||||
|
->where('client_case_id', $contract->client_case_id)
|
||||||
|
->where('segment_id', $segmentId)
|
||||||
|
->first();
|
||||||
|
if (! $ccSeg) {
|
||||||
|
\DB::table('client_case_segment')->insert([
|
||||||
|
'client_case_id' => $contract->client_case_id,
|
||||||
|
'segment_id' => $segmentId,
|
||||||
|
'active' => true,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
} elseif (! $ccSeg->active) {
|
||||||
|
\DB::table('client_case_segment')
|
||||||
|
->where('id', $ccSeg->id)
|
||||||
|
->update(['active' => true, 'updated_at' => now()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate all other segments for this contract to make this the main one
|
||||||
|
\DB::table('contract_segment')
|
||||||
|
->where('contract_id', $contract->id)
|
||||||
|
->where('segment_id', '!=', $segmentId)
|
||||||
|
->update(['active' => false, 'updated_at' => now()]);
|
||||||
|
|
||||||
|
// Upsert the selected segment as active for this contract
|
||||||
|
$pivot = \DB::table('contract_segment')
|
||||||
|
->where('contract_id', $contract->id)
|
||||||
|
->where('segment_id', $segmentId)
|
||||||
|
->first();
|
||||||
|
if ($pivot) {
|
||||||
|
if (! $pivot->active) {
|
||||||
|
\DB::table('contract_segment')
|
||||||
|
->where('id', $pivot->id)
|
||||||
|
->update(['active' => true, 'updated_at' => now()]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
\DB::table('contract_segment')->insert([
|
||||||
|
'contract_id' => $contract->id,
|
||||||
|
'segment_id' => $segmentId,
|
||||||
|
'active' => true,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create activity if decision provided
|
||||||
|
if ($decisionId > 0) {
|
||||||
|
\App\Models\Activity::create([
|
||||||
|
'decision_id' => $decisionId,
|
||||||
|
'action_id' => $actionId > 0 ? $actionId : null,
|
||||||
|
'contract_id' => $contract->id,
|
||||||
|
'client_case_id' => $contract->client_case_id,
|
||||||
|
'note' => trim('Imported via template'.($templateName ? ': '.$templateName : '')),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -258,7 +258,17 @@ protected function simulateRow(Import $import, array $mapped, array $raw, array
|
||||||
|
|
||||||
// Resolve existing entity (uses EntityResolutionService internally)
|
// Resolve existing entity (uses EntityResolutionService internally)
|
||||||
// Pass accumulated entityResults as context for chain resolution
|
// Pass accumulated entityResults as context for chain resolution
|
||||||
$existingEntity = $handler->resolve($entityData, array_merge($context, $entityResults));
|
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) {
|
if ($existingEntity) {
|
||||||
// Would update existing
|
// Would update existing
|
||||||
|
|
@ -284,9 +294,14 @@ protected function simulateRow(Import $import, array $mapped, array $raw, array
|
||||||
];
|
];
|
||||||
|
|
||||||
// Simulate entity creation for context (no actual ID)
|
// 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] = [
|
$entityResults[$root] = [
|
||||||
'entity' => (object) $entityData,
|
'entity' => $simulatedEntity,
|
||||||
'action' => 'inserted',
|
'action' => 'inserted',
|
||||||
|
'_simulated' => true,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -420,23 +435,35 @@ protected function groupMappedDataByEntity(array $mapped): array
|
||||||
|
|
||||||
// Handle array values
|
// Handle array values
|
||||||
if (is_array($value)) {
|
if (is_array($value)) {
|
||||||
if (!isset($grouped[$entity])) {
|
|
||||||
$grouped[$entity] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Special case: activity.note should be kept as array in single instance
|
// Special case: activity.note should be kept as array in single instance
|
||||||
if ($entity === 'activity' || $entity === 'activities') {
|
if ($entity === 'activity' || $entity === 'activities') {
|
||||||
if (!isset($grouped[$entity][0])) {
|
if (!isset($grouped[$entity])) {
|
||||||
$grouped[$entity][0] = [];
|
$grouped[$entity] = [];
|
||||||
}
|
}
|
||||||
$grouped[$entity][0][$field] = $value; // Keep as array
|
$grouped[$entity][$field] = $value; // Keep as array
|
||||||
} else {
|
} else {
|
||||||
// Create separate entity instances for each array value
|
// For other entities, only create multiple instances if:
|
||||||
foreach ($value as $idx => $val) {
|
// 1. Entity doesn't exist yet, OR
|
||||||
if (!isset($grouped[$entity][$idx])) {
|
// 2. Entity has no other fields yet (is empty array)
|
||||||
$grouped[$entity][$idx] = [];
|
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;
|
||||||
}
|
}
|
||||||
$grouped[$entity][$idx][$field] = $val;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -445,14 +472,17 @@ protected function groupMappedDataByEntity(array $mapped): array
|
||||||
$grouped[$entity] = [];
|
$grouped[$entity] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// If entity is already an array (from previous grouped field), add to all instances
|
// Check if entity is already an array of instances (from previous grouped field)
|
||||||
if (isset($grouped[$entity][0]) && is_array($grouped[$entity][0])) {
|
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) {
|
foreach ($grouped[$entity] as &$instance) {
|
||||||
$instance[$field] = $value;
|
if (is_array($instance)) {
|
||||||
|
$instance[$field] = $value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
unset($instance);
|
unset($instance);
|
||||||
} else {
|
} else {
|
||||||
// Simple associative array
|
// Simple associative array - add field
|
||||||
$grouped[$entity][$field] = $value;
|
$grouped[$entity][$field] = $value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -634,7 +664,7 @@ protected function rowIsEffectivelyEmpty(array $assoc): bool
|
||||||
*
|
*
|
||||||
* Updated to match ImportServiceV2:
|
* Updated to match ImportServiceV2:
|
||||||
* - Supports group option for concatenating multiple sources
|
* - Supports group option for concatenating multiple sources
|
||||||
* - Uses setNestedValue for proper array handling
|
* - Returns flat array with "entity.field" keys (no nesting)
|
||||||
*/
|
*/
|
||||||
protected function applyMappings(array $raw, array $mappings): array
|
protected function applyMappings(array $raw, array $mappings): array
|
||||||
{
|
{
|
||||||
|
|
@ -685,16 +715,32 @@ protected function applyMappings(array $raw, array $mappings): array
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now set the values (same logic as ImportServiceV2)
|
// Now set the values - KEEP FLAT, DON'T NEST
|
||||||
foreach ($valuesByGroup as $values) {
|
foreach ($valuesByGroup as $values) {
|
||||||
if (count($values) === 1) {
|
if (count($values) === 1) {
|
||||||
// Single value - set directly
|
// Single value - add to array if key exists, otherwise set directly
|
||||||
$this->setNestedValue($mapped, $targetField, $values[0]);
|
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 {
|
} else {
|
||||||
// Multiple values in same group - concatenate with newline
|
// Multiple values in same group - concatenate with newline
|
||||||
$concatenated = implode("\n", array_filter($values, fn($v) => !empty($v) && trim((string)$v) !== ''));
|
$concatenated = implode("\n", array_filter($values, fn($v) => !empty($v) && trim((string)$v) !== ''));
|
||||||
if (!empty($concatenated)) {
|
if (!empty($concatenated)) {
|
||||||
$this->setNestedValue($mapped, $targetField, $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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
mark-import-failed.php
Normal file
18
mark-import-failed.php
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
require __DIR__ . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
$app = require_once __DIR__ . '/bootstrap/app.php';
|
||||||
|
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||||
|
|
||||||
|
$uuid = '68c7b8f8-fdf0-4575-9cbc-3ab2b3544d8f';
|
||||||
|
$import = \App\Models\Import::where('uuid', $uuid)->first();
|
||||||
|
|
||||||
|
if ($import) {
|
||||||
|
$import->status = 'failed';
|
||||||
|
$import->save();
|
||||||
|
echo "Import {$uuid} marked as failed\n";
|
||||||
|
echo "Current status: {$import->status}\n";
|
||||||
|
} else {
|
||||||
|
echo "Import not found\n";
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user