This commit is contained in:
Simon Pocrnjič
2025-12-26 22:39:58 +01:00
parent f8623a6071
commit dea7432deb
55 changed files with 7977 additions and 1983 deletions
+6 -52
View File
@@ -2,57 +2,11 @@
namespace App\Services;
class DateNormalizer
/**
* Backward compatibility alias for DateNormalizer.
* Old code references App\Services\DateNormalizer, but actual class is at App\Services\Import\DateNormalizer.
*/
class DateNormalizer extends \App\Services\Import\DateNormalizer
{
/**
* Normalize a raw date string to Y-m-d (ISO) or return null if unparseable.
* Accepted examples: 30.10.2025, 30/10/2025, 30-10-2025, 1/2/25, 2025-10-30
*/
public static function toDate(?string $raw): ?string
{
if ($raw === null) {
return null;
}
$raw = trim($raw);
if ($raw === '') {
return null;
}
// Common European and ISO formats first (day-first, then ISO)
$candidates = [
'd.m.Y', 'd.m.y',
'd/m/Y', 'd/m/y',
'd-m-Y', 'd-m-y',
'Y-m-d', 'Y/m/d', 'Y.m.d',
];
foreach ($candidates as $fmt) {
$dt = \DateTime::createFromFormat($fmt, $raw);
if ($dt instanceof \DateTime) {
$errors = \DateTime::getLastErrors();
if ((int) ($errors['warning_count'] ?? 0) === 0 && (int) ($errors['error_count'] ?? 0) === 0) {
// Adjust two-digit years to reasonable century (00-69 => 2000-2069, 70-99 => 1970-1999)
$year = (int) $dt->format('Y');
if ($year < 100) {
$year += ($year <= 69) ? 2000 : 1900;
// 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');
}
}
}
// Fallback: strtotime (permissive). If fails, return null.
$ts = @strtotime($raw);
if ($ts === false) {
return null;
}
return date('Y-m-d', $ts);
}
// This class extends the actual DateNormalizer for backward compatibility
}
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace App\Services\Import;
use App\Models\ImportEntity;
use App\Services\Import\Contracts\EntityHandlerInterface;
use Illuminate\Support\Facades\Validator;
abstract class BaseEntityHandler implements EntityHandlerInterface
{
protected ?ImportEntity $entityConfig;
public function __construct(?ImportEntity $entityConfig = null)
{
$this->entityConfig = $entityConfig;
}
/**
* Validate mapped data using configuration rules.
*/
public function validate(array $mapped): array
{
$rules = $this->entityConfig?->validation_rules ?? [];
if (empty($rules)) {
return ['valid' => true, 'errors' => []];
}
$validator = Validator::make($mapped, $rules);
if ($validator->fails()) {
return [
'valid' => false,
'errors' => $validator->errors()->all(),
];
}
return ['valid' => true, 'errors' => []];
}
/**
* Get processing options from config.
*/
protected function getOption(string $key, mixed $default = null): mixed
{
return $this->entityConfig?->processing_options[$key] ?? $default;
}
/**
* Determine if a field has changed.
*/
protected function hasChanged($model, string $field, mixed $newValue): bool
{
$current = $model->{$field};
if (is_null($newValue) && is_null($current)) {
return false;
}
return $current != $newValue;
}
/**
* Track which fields were applied/changed.
*/
protected function trackAppliedFields($model, array $payload): array
{
$applied = [];
foreach ($payload as $field => $value) {
if ($this->hasChanged($model, $field, $value)) {
$applied[] = $field;
}
}
return $applied;
}
/**
* Default implementation returns null - override in specific handlers.
*/
public function resolve(array $mapped, array $context = []): mixed
{
return null;
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Services\Import\Contracts;
use App\Models\Import;
interface EntityHandlerInterface
{
/**
* Process a single row for this entity.
*
* @param Import $import The import instance
* @param array $mapped Mapped data for this entity
* @param array $raw Raw row data
* @param array $context Additional context (previous entity results, etc.)
* @return array Result with action, entity instance, applied_fields, etc.
*/
public function process(Import $import, array $mapped, array $raw, array $context = []): array;
/**
* Validate mapped data before processing.
*
* @param array $mapped Mapped data for this entity
* @return array Validation result ['valid' => bool, 'errors' => array]
*/
public function validate(array $mapped): array;
/**
* Get the entity class name this handler manages.
*
* @return string
*/
public function getEntityClass(): string;
/**
* Resolve existing entity by key/reference.
*
* @param array $mapped Mapped data for this entity
* @param array $context Additional context
* @return mixed|null Existing entity instance or null
*/
public function resolve(array $mapped, array $context = []): mixed;
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Services\Import;
class DateNormalizer
{
/**
* Normalize a raw date string to Y-m-d (ISO) or return null if unparseable.
* Accepted examples: 30.10.2025, 30/10/2025, 30-10-2025, 1/2/25, 2025-10-30
*/
public static function toDate(?string $raw): ?string
{
if ($raw === null) {
return null;
}
$raw = trim($raw);
if ($raw === '') {
return null;
}
// Common European and ISO formats first (day-first, then ISO)
$candidates = [
'd.m.Y', 'd.m.y',
'd/m/Y', 'd/m/y',
'd-m-Y', 'd-m-y',
'Y-m-d', 'Y/m/d', 'Y.m.d',
];
foreach ($candidates as $fmt) {
$dt = \DateTime::createFromFormat($fmt, $raw);
if ($dt instanceof \DateTime) {
$errors = \DateTime::getLastErrors();
if ((int) ($errors['warning_count'] ?? 0) === 0 && (int) ($errors['error_count'] ?? 0) === 0) {
// Adjust two-digit years to reasonable century (00-69 => 2000-2069, 70-99 => 1970-1999)
$year = (int) $dt->format('Y');
if ($year < 100) {
$year += ($year <= 69) ? 2000 : 1900;
// 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');
}
}
}
// Fallback: strtotime (permissive). If fails, return null.
$ts = @strtotime($raw);
if ($ts === false) {
return null;
}
return date('Y-m-d', $ts);
}
}
@@ -0,0 +1,394 @@
<?php
namespace App\Services\Import;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\Email;
use App\Models\Import;
use App\Models\Person\Person;
use App\Models\Person\PersonAddress;
use App\Models\Person\PersonPhone;
use Illuminate\Support\Facades\Log;
/**
* EntityResolutionService - Resolves existing entities to prevent duplication.
*
* This service checks for existing entities before creating new ones,
* following the V1 deduplication hierarchy:
* 1. Contract reference ClientCase Person
* 2. ClientCase client_ref Person
* 3. Contact values (email/phone/address) Person
* 4. Person identifiers (tax_number/ssn) Person
*/
class EntityResolutionService
{
/**
* Resolve Person ID from import context (existing entities).
* Returns Person ID if found, null otherwise.
*
* @param Import $import
* @param array $mapped Mapped data from CSV row
* @param array $context Processing context with previously processed entities
* @return int|null Person ID if found, null if should create new
*/
public function resolvePersonFromContext(Import $import, array $mapped, array $context): ?int
{
// 1. Check if Contract already processed in this row
if ($contract = $context['contract']['entity'] ?? null) {
$personId = $this->getPersonFromContract($contract);
if ($personId) {
Log::info('EntityResolutionService: Found Person from processed Contract', [
'person_id' => $personId,
'contract_id' => $contract->id,
]);
return $personId;
}
}
// 2. Check if ClientCase already processed in this row
if ($clientCase = $context['client_case']['entity'] ?? null) {
if ($clientCase->person_id) {
Log::info('EntityResolutionService: Found Person from processed ClientCase', [
'person_id' => $clientCase->person_id,
'client_case_id' => $clientCase->id,
]);
return $clientCase->person_id;
}
}
// 3. Check for existing Contract by reference (before it's processed)
if ($contractRef = $mapped['contract']['reference'] ?? null) {
$personId = $this->getPersonFromContractReference($import->client_id, $contractRef);
if ($personId) {
Log::info('EntityResolutionService: Found Person from existing Contract reference', [
'person_id' => $personId,
'contract_reference' => $contractRef,
]);
return $personId;
}
}
// 4. Check for existing ClientCase by client_ref (before it's processed)
if ($clientRef = $mapped['client_case']['client_ref'] ?? null) {
$personId = $this->getPersonFromClientRef($import->client_id, $clientRef);
if ($personId) {
Log::info('EntityResolutionService: Found Person from existing ClientCase client_ref', [
'person_id' => $personId,
'client_ref' => $clientRef,
]);
return $personId;
}
}
// 5. Check for existing Person by contact values (email/phone/address)
$personId = $this->resolvePersonByContacts($mapped);
if ($personId) {
Log::info('EntityResolutionService: Found Person from contact values', [
'person_id' => $personId,
]);
return $personId;
}
// No existing Person found
return null;
}
/**
* Check if ClientCase exists for this client_ref.
*
* @param int|null $clientId
* @param string $clientRef
* @return bool
*/
public function clientCaseExists(?int $clientId, string $clientRef): bool
{
if (!$clientId || !$clientRef) {
return false;
}
return ClientCase::where('client_id', $clientId)
->where('client_ref', $clientRef)
->exists();
}
/**
* Check if Contract exists for this reference.
*
* @param int|null $clientId
* @param string $reference
* @return bool
*/
public function contractExists(?int $clientId, string $reference): bool
{
if (!$clientId || !$reference) {
return false;
}
return Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->where('client_cases.client_id', $clientId)
->where('contracts.reference', $reference)
->exists();
}
/**
* Get existing ClientCase by client_ref.
*
* @param int|null $clientId
* @param string $clientRef
* @return ClientCase|null
*/
public function getExistingClientCase(?int $clientId, string $clientRef): ?ClientCase
{
if (!$clientId || !$clientRef) {
return null;
}
return ClientCase::where('client_id', $clientId)
->where('client_ref', $clientRef)
->first();
}
/**
* Get existing Contract by reference for this client.
*
* @param int|null $clientId
* @param string $reference
* @return Contract|null
*/
public function getExistingContract(?int $clientId, string $reference): ?Contract
{
if (!$clientId || !$reference) {
return null;
}
return Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->where('client_cases.client_id', $clientId)
->where('contracts.reference', $reference)
->select('contracts.*')
->first();
}
/**
* Get Person ID from a Contract entity.
*
* @param Contract $contract
* @return int|null
*/
protected function getPersonFromContract(Contract $contract): ?int
{
if ($contract->client_case_id) {
return ClientCase::where('id', $contract->client_case_id)
->value('person_id');
}
return null;
}
/**
* Get Person ID from existing Contract by reference.
*
* @param int|null $clientId
* @param string $reference
* @return int|null
*/
protected function getPersonFromContractReference(?int $clientId, string $reference): ?int
{
if (!$clientId) {
return null;
}
$clientCaseId = Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->where('client_cases.client_id', $clientId)
->where('contracts.reference', $reference)
->value('contracts.client_case_id');
if ($clientCaseId) {
return ClientCase::where('id', $clientCaseId)
->value('person_id');
}
return null;
}
/**
* Get Person ID from existing ClientCase by client_ref.
*
* @param int|null $clientId
* @param string $clientRef
* @return int|null
*/
protected function getPersonFromClientRef(?int $clientId, string $clientRef): ?int
{
if (!$clientId) {
return null;
}
return ClientCase::where('client_id', $clientId)
->where('client_ref', $clientRef)
->value('person_id');
}
/**
* Resolve Person by contact values (email, phone, address).
* Checks existing contact records and returns associated Person ID.
*
* @param array $mapped
* @return int|null
*/
protected function resolvePersonByContacts(array $mapped): ?int
{
// Check email (support both single and array formats)
$email = $this->extractContactValue($mapped, 'email', 'value', 'emails');
if ($email) {
$personId = Email::where('value', trim($email))->value('person_id');
if ($personId) {
return $personId;
}
}
// Check phone (support both single and array formats)
$phone = $this->extractContactValue($mapped, 'phone', 'nu', 'person_phones');
if ($phone) {
$personId = PersonPhone::where('nu', trim($phone))->value('person_id');
if ($personId) {
return $personId;
}
}
// Check address (support both single and array formats)
$address = $this->extractContactValue($mapped, 'address', 'address', 'person_addresses');
if ($address) {
$personId = PersonAddress::where('address', trim($address))->value('person_id');
if ($personId) {
return $personId;
}
}
return null;
}
/**
* Extract contact value from mapped data, supporting multiple formats.
*
* @param array $mapped
* @param string $singularKey e.g., 'email', 'phone', 'address'
* @param string $field Field name within the contact data
* @param string $pluralKey e.g., 'emails', 'person_phones', 'person_addresses'
* @return string|null
*/
protected function extractContactValue(array $mapped, string $singularKey, string $field, string $pluralKey): ?string
{
// Try singular key first (e.g., 'email')
if (isset($mapped[$singularKey][$field])) {
return $mapped[$singularKey][$field];
}
// Try plural key (e.g., 'emails')
if (isset($mapped[$pluralKey])) {
// If it's an array of contacts
if (is_array($mapped[$pluralKey])) {
// Try first element if it's an indexed array
if (isset($mapped[$pluralKey][0][$field])) {
return $mapped[$pluralKey][0][$field];
}
// Try direct field access if it's a single hash
if (isset($mapped[$pluralKey][$field])) {
return $mapped[$pluralKey][$field];
}
}
}
return null;
}
/**
* Check if this row should skip Person creation based on existing entities.
* Used by PersonHandler to determine if Person already exists via chain.
*
* @param Import $import
* @param array $mapped
* @param array $context
* @return bool True if Person should be skipped (already exists)
*/
public function shouldSkipPersonCreation(Import $import, array $mapped, array $context): bool
{
// If we can resolve existing Person, we should skip creation
$personId = $this->resolvePersonFromContext($import, $mapped, $context);
return $personId !== null;
}
/**
* Get or create ClientCase for Contract creation.
* Reuses existing ClientCase if found by client_ref.
*
* @param Import $import
* @param array $mapped
* @param array $context
* @return int|null ClientCase ID
*/
public function resolveOrCreateClientCaseForContract(Import $import, array $mapped, array $context): ?int
{
$clientId = $import->client_id;
if (!$clientId) {
return null;
}
// If ClientCase already processed in this row, use it
if ($clientCaseId = $context['client_case']['entity']?->id ?? null) {
return $clientCaseId;
}
// Try to find by client_ref
$clientRef = $mapped['client_case']['client_ref'] ?? $mapped['client_ref'] ?? null;
if ($clientRef) {
$existing = $this->getExistingClientCase($clientId, $clientRef);
if ($existing) {
Log::info('EntityResolutionService: Reusing existing ClientCase for Contract', [
'client_case_id' => $existing->id,
'client_ref' => $clientRef,
]);
return $existing->id;
}
}
// Need to create new ClientCase
// Get Person from context (should be processed before Contract now)
$personId = $context['person']['entity']?->id ?? null;
if (!$personId) {
// Person wasn't in import or wasn't found, try to resolve
$personId = $this->resolvePersonFromContext($import, $mapped, $context);
if (!$personId) {
// Create minimal Person as last resort
$personId = Person::create(['type_id' => 1])->id;
Log::info('EntityResolutionService: Created minimal Person for new ClientCase', [
'person_id' => $personId,
]);
}
}
$clientCase = ClientCase::create([
'client_id' => $clientId,
'person_id' => $personId,
'client_ref' => $clientRef,
]);
Log::info('EntityResolutionService: Created new ClientCase', [
'client_case_id' => $clientCase->id,
'person_id' => $personId,
'client_ref' => $clientRef,
]);
return $clientCase->id;
}
}
@@ -0,0 +1,158 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\Account;
use App\Models\Import;
use App\Services\Import\BaseEntityHandler;
class AccountHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return Account::class;
}
public function resolve(array $mapped, array $context = []): mixed
{
$reference = $mapped['reference'] ?? null;
$contractId = $mapped['contract_id'] ?? $context['contract']?->entity?->id ?? null;
if (! $reference || ! $contractId) {
return null;
}
return Account::where('contract_id', $contractId)
->where('reference', $reference)
->first();
}
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
// Ensure contract context
if (! isset($context['contract'])) {
return [
'action' => 'skipped',
'message' => 'Account requires contract context',
];
}
$contractId = $context['contract']->entity->id;
$mapped['contract_id'] = $contractId;
$existing = $this->resolve($mapped, $context);
if ($existing) {
// Track old balance for activity creation
$oldBalance = (float) ($existing->balance_amount ?? 0);
$payload = $this->buildPayload($mapped, $existing);
$appliedFields = $this->trackAppliedFields($existing, $payload);
if (empty($appliedFields)) {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'No changes detected',
];
}
$existing->fill($payload);
$existing->save();
// Create activity if balance changed and tracking is enabled
if ($this->getOption('track_balance_changes', true) && array_key_exists('balance_amount', $appliedFields)) {
$this->createBalanceChangeActivity($existing, $oldBalance, $import, $context);
}
return [
'action' => 'updated',
'entity' => $existing,
'applied_fields' => $appliedFields,
];
}
// Create new account
$account = new Account;
$payload = $this->buildPayload($mapped, $account);
$account->fill($payload);
$account->save();
return [
'action' => 'inserted',
'entity' => $account,
'applied_fields' => array_keys($payload),
];
}
protected function buildPayload(array $mapped, $model): array
{
$payload = [];
$fieldMap = [
'contract_id' => 'contract_id',
'reference' => 'reference',
'title' => 'title',
'description' => 'description',
'balance_amount' => 'balance_amount',
'currency' => 'currency',
];
foreach ($fieldMap as $source => $target) {
if (array_key_exists($source, $mapped)) {
$payload[$target] = $mapped[$source];
}
}
return $payload;
}
/**
* Create activity when account balance changes.
*/
protected function createBalanceChangeActivity(Account $account, float $oldBalance, Import $import, array $context): void
{
if (! $this->getOption('create_activity_on_balance_change', true)) {
return;
}
try {
$newBalance = (float) ($account->balance_amount ?? 0);
// Skip if balance didn't actually change
if ($newBalance === $oldBalance) {
return;
}
$currency = \App\Models\PaymentSetting::first()?->default_currency ?? 'EUR';
$beforeStr = number_format($oldBalance, 2, ',', '.').' '.$currency;
$afterStr = number_format($newBalance, 2, ',', '.').' '.$currency;
$note = 'Sprememba stanja (Stanje pred: '.$beforeStr.', Stanje po: '.$afterStr.'; Izvor: sprememba)';
// Get client_case_id
$clientCaseId = $account->contract?->client_case_id;
if ($clientCaseId) {
// Use action_id from import meta if available
$metaActionId = (int) ($import->meta['action_id'] ?? 0);
if ($metaActionId > 0) {
\App\Models\Activity::create([
'due_date' => null,
'amount' => null,
'note' => $note,
'action_id' => $metaActionId,
'decision_id' => $import->meta['decision_id'] ?? null,
'client_case_id' => $clientCaseId,
'contract_id' => $account->contract_id,
]);
}
}
} catch (\Throwable $e) {
\Log::warning('Failed to create balance change activity', [
'account_id' => $account->id,
'error' => $e->getMessage(),
]);
}
}
}
@@ -0,0 +1,171 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\Activity;
use App\Models\Import;
use App\Services\Import\DateNormalizer;
use App\Services\Import\BaseEntityHandler;
class ActivityHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return Activity::class;
}
/**
* Override validate to skip validation if note is empty.
* Handles both single values and arrays.
*/
public function validate(array $mapped): array
{
$note = $mapped['note'] ?? null;
// If array, check if all values are empty
if (is_array($note)) {
$hasValue = false;
foreach ($note as $n) {
if (!empty($n) && trim((string)$n) !== '') {
$hasValue = true;
break;
}
}
if (!$hasValue) {
return ['valid' => true, 'errors' => []];
}
// Skip parent validation for arrays - we'll validate in process()
return ['valid' => true, 'errors' => []];
}
// Single value - check if empty
if (empty($note) || trim((string)$note) === '') {
return ['valid' => true, 'errors' => []];
}
return parent::validate($mapped);
}
public function resolve(array $mapped, array $context = []): mixed
{
// Activities typically don't have a unique reference for deduplication
// Override this method if you have specific deduplication logic
return null;
}
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
// Handle multiple activities if note is an array
$notes = $mapped['note'] ?? null;
// If single value, convert to array for uniform processing
if (!is_array($notes)) {
$notes = [$notes];
}
$results = [];
$insertedCount = 0;
$skippedCount = 0;
// Get context IDs once
$clientCaseId = $mapped['client_case_id'] ?? $context['contract']['entity']?->client_case_id ?? null;
$contractId = $mapped['contract_id'] ?? $context['contract']['entity']?->id ?? null;
foreach ($notes as $note) {
// Skip if note is empty
if (empty($note) || trim((string)$note) === '') {
$skippedCount++;
continue;
}
// Require at least client_case_id or contract_id based on options
$requireCase = $this->getOption('require_client_case', false);
$requireContract = $this->getOption('require_contract', false);
if ($requireCase && ! $clientCaseId) {
$skippedCount++;
continue;
}
if ($requireContract && ! $contractId) {
$skippedCount++;
continue;
}
// Build activity payload for this note
$payload = ['note' => $note];
$payload['client_case_id'] = $clientCaseId;
$payload['contract_id'] = $contractId;
// Set action_id and decision_id from template meta if not in mapped data
if (!isset($mapped['action_id'])) {
$payload['action_id'] = $import->template->meta['activity_action_id'] ?? $this->getDefaultActionId();
} else {
$payload['action_id'] = $mapped['action_id'];
}
if (!isset($mapped['decision_id']) && isset($import->template->meta['activity_decision_id'])) {
$payload['decision_id'] = $import->template->meta['activity_decision_id'];
}
// Create activity
$activity = new \App\Models\Activity;
$activity->fill($payload);
$activity->save();
$results[] = $activity;
$insertedCount++;
}
if ($insertedCount === 0 && $skippedCount > 0) {
return [
'action' => 'skipped',
'message' => 'All activities empty or missing requirements',
];
}
return [
'action' => 'inserted',
'entity' => $results[0] ?? null,
'entities' => $results,
'applied_fields' => ['note', 'client_case_id', 'contract_id', 'action_id'],
'count' => $insertedCount,
];
}
protected function buildPayload(array $mapped, $model): array
{
$payload = [];
// Map activity fields
if (isset($mapped['due_date'])) {
$payload['due_date'] = DateNormalizer::toDate((string) $mapped['due_date']);
}
if (isset($mapped['amount'])) {
$payload['amount'] = is_string($mapped['amount']) ? (float) str_replace(',', '.', $mapped['amount']) : (float) $mapped['amount'];
}
if (isset($mapped['note'])) {
$payload['note'] = $mapped['note'];
}
if (isset($mapped['action_id'])) {
$payload['action_id'] = (int) $mapped['action_id'];
}
if (isset($mapped['decision_id'])) {
$payload['decision_id'] = (int) $mapped['decision_id'];
}
return $payload;
}
/**
* Get default action ID (use minimum ID from actions table).
*/
private function getDefaultActionId(): int
{
return (int) (\App\Models\Action::min('id') ?? 1);
}
}
@@ -0,0 +1,144 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\Import;
use App\Models\Person\PersonAddress;
use App\Services\Import\BaseEntityHandler;
class AddressHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return PersonAddress::class;
}
/**
* Override validate to skip validation if address is empty.
* Handles both single values and arrays.
*/
public function validate(array $mapped): array
{
$address = $mapped['address'] ?? null;
// If array, check if all values are empty
if (is_array($address)) {
$hasValue = false;
foreach ($address as $addr) {
if (!empty($addr) && trim((string)$addr) !== '') {
$hasValue = true;
break;
}
}
if (!$hasValue) {
return ['valid' => true, 'errors' => []];
}
// Skip parent validation for arrays - we'll validate in process()
return ['valid' => true, 'errors' => []];
}
// Single value - check if empty
if (empty($address) || trim((string)$address) === '') {
return ['valid' => true, 'errors' => []];
}
return parent::validate($mapped);
}
public function resolve(array $mapped, array $context = []): mixed
{
$address = $mapped['address'] ?? null;
$personId = $mapped['person_id']
?? ($context['person']['entity']->id ?? null)
?? ($context['person']?->entity?->id ?? null);
if (! $address || ! $personId) {
return null;
}
// Find existing address by exact match for this person
return PersonAddress::where('person_id', $personId)
->where('address', $address)
->first();
}
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
// Handle multiple addresses if address is an array
$addresses = $mapped['address'] ?? null;
// If single value, convert to array for uniform processing
if (!is_array($addresses)) {
$addresses = [$addresses];
}
$results = [];
$insertedCount = 0;
$skippedCount = 0;
foreach ($addresses as $address) {
// Skip if address is empty or blank
if (empty($address) || trim((string)$address) === '') {
$skippedCount++;
continue;
}
// Resolve person_id from context
$personId = $mapped['person_id'] ?? $context['person']['entity']?->id ?? null;
if (! $personId) {
$skippedCount++;
continue;
}
$existing = $this->resolveAddress($address, $personId);
// Check for duplicates if configured
if ($this->getOption('deduplicate', true) && $existing) {
$skippedCount++;
continue;
}
// Create new address
$payload = $this->buildPayloadForAddress($address);
$payload['person_id'] = $personId;
$addressEntity = new \App\Models\Person\PersonAddress;
$addressEntity->fill($payload);
$addressEntity->save();
$results[] = $addressEntity;
$insertedCount++;
}
if ($insertedCount === 0 && $skippedCount > 0) {
return [
'action' => 'skipped',
'message' => 'All addresses empty or duplicates',
];
}
return [
'action' => 'inserted',
'entity' => $results[0] ?? null,
'entities' => $results,
'applied_fields' => ['address', 'person_id'],
'count' => $insertedCount,
];
}
protected function resolveAddress(string $address, int $personId): mixed
{
return \App\Models\Person\PersonAddress::where('person_id', $personId)
->where('address', $address)
->first();
}
protected function buildPayloadForAddress(string $address): array
{
return [
'address' => $address,
'type_id' => 1, // Default to permanent address
];
}
}
@@ -0,0 +1,96 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\CaseObject;
use App\Models\Import;
use App\Services\Import\BaseEntityHandler;
class CaseObjectHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return CaseObject::class;
}
public function resolve(array $mapped, array $context = []): mixed
{
$reference = $mapped['reference'] ?? null;
$name = $mapped['name'] ?? null;
if (! $reference && ! $name) {
return null;
}
// Try to find by reference first
if ($reference) {
$object = CaseObject::where('reference', $reference)->first();
if ($object) {
return $object;
}
}
// Fall back to name if reference not found
if ($name) {
return CaseObject::where('name', $name)->first();
}
return null;
}
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
$existing = $this->resolve($mapped, $context);
if ($existing) {
// Update existing object
$payload = $this->buildPayload($mapped, $existing);
$appliedFields = $this->trackAppliedFields($existing, $payload);
if (empty($appliedFields)) {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'No changes detected',
];
}
$existing->fill($payload);
$existing->save();
return [
'action' => 'updated',
'entity' => $existing,
'applied_fields' => $appliedFields,
];
}
// Create new case object
$payload = $this->buildPayload($mapped, new CaseObject);
$caseObject = new CaseObject;
$caseObject->fill($payload);
$caseObject->save();
return [
'action' => 'inserted',
'entity' => $caseObject,
'applied_fields' => array_keys($payload),
];
}
protected function buildPayload(array $mapped, $model): array
{
$payload = [];
$fields = ['reference', 'name', 'description', 'type', 'contract_id'];
foreach ($fields as $field) {
if (array_key_exists($field, $mapped)) {
$payload[$field] = $mapped[$field];
}
}
return $payload;
}
}
@@ -0,0 +1,163 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\ClientCase;
use App\Models\Import;
use App\Services\Import\BaseEntityHandler;
use App\Services\Import\EntityResolutionService;
use Illuminate\Support\Facades\Log;
class ClientCaseHandler extends BaseEntityHandler
{
protected EntityResolutionService $resolutionService;
public function __construct($entityConfig = null)
{
parent::__construct($entityConfig);
$this->resolutionService = new EntityResolutionService();
}
public function getEntityClass(): string
{
return ClientCase::class;
}
public function resolve(array $mapped, array $context = []): mixed
{
$clientRef = $mapped['client_ref'] ?? null;
$clientId = $context['import']?->client_id ?? null;
if (! $clientRef || ! $clientId) {
return null;
}
// Find existing case by client_ref for this client
return ClientCase::where('client_id', $clientId)
->where('client_ref', $clientRef)
->first();
}
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
$clientId = $import->client_id ?? null;
if (! $clientId) {
return [
'action' => 'skipped',
'message' => 'ClientCase requires client_id',
];
}
// PHASE 5: Use Person from context (already processed due to reversed priorities)
// Priority order: explicit person_id > context person > resolved person
$personId = $mapped['person_id'] ?? $context['person']['entity']?->id ?? null;
// If no Person in context, try to resolve using EntityResolutionService
if (!$personId) {
$personId = $this->resolutionService->resolvePersonFromContext($import, $mapped, $context);
if ($personId) {
Log::info('ClientCaseHandler: Resolved Person via EntityResolutionService', [
'person_id' => $personId,
]);
} else {
Log::warning('ClientCaseHandler: No Person found in context or via resolution', [
'has_person_context' => isset($context['person']),
'has_mapped_person_id' => isset($mapped['person_id']),
]);
}
} else {
Log::info('ClientCaseHandler: Using Person from context/mapping', [
'person_id' => $personId,
'source' => $mapped['person_id'] ? 'mapped' : 'context',
]);
}
$existing = $this->resolve($mapped, $context);
if ($existing) {
// Update if configured
$mode = $this->getOption('update_mode', 'update');
if ($mode === 'skip') {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'ClientCase already exists (skip mode)',
];
}
$payload = $this->buildPayload($mapped, $existing);
// Update person_id if provided and different
if ($personId && $existing->person_id !== $personId) {
$payload['person_id'] = $personId;
}
$appliedFields = $this->trackAppliedFields($existing, $payload);
if (empty($appliedFields)) {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'No changes detected',
];
}
$existing->fill($payload);
$existing->save();
Log::info('ClientCaseHandler: Updated existing ClientCase', [
'client_case_id' => $existing->id,
'person_id' => $existing->person_id,
'applied_fields' => $appliedFields,
]);
return [
'action' => 'updated',
'entity' => $existing,
'applied_fields' => $appliedFields,
];
}
// Create new client case
$payload = $this->buildPayload($mapped, new ClientCase);
$payload['client_id'] = $clientId;
if ($personId) {
$payload['person_id'] = $personId;
}
$clientCase = new ClientCase;
$clientCase->fill($payload);
$clientCase->save();
Log::info('ClientCaseHandler: Created new ClientCase', [
'client_case_id' => $clientCase->id,
'person_id' => $clientCase->person_id,
'client_ref' => $clientCase->client_ref,
]);
return [
'action' => 'inserted',
'entity' => $clientCase,
'applied_fields' => array_keys($payload),
];
}
protected function buildPayload(array $mapped, $model): array
{
$payload = [];
$fields = ['client_ref'];
foreach ($fields as $field) {
if (array_key_exists($field, $mapped)) {
$payload[$field] = $mapped[$field];
}
}
return $payload;
}
}
@@ -0,0 +1,343 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\Import;
use App\Services\Import\BaseEntityHandler;
use App\Services\Import\EntityResolutionService;
use Illuminate\Support\Facades\Log;
class ContractHandler extends BaseEntityHandler
{
protected EntityResolutionService $resolutionService;
public function __construct($entityConfig = null)
{
parent::__construct($entityConfig);
$this->resolutionService = new EntityResolutionService();
}
public function getEntityClass(): string
{
return Contract::class;
}
public function resolve(array $mapped, array $context = []): mixed
{
$reference = $mapped['reference'] ?? null;
if (! $reference) {
return null;
}
$query = Contract::query();
// Scope by client if available
if ($clientId = $context['import']->client_id) {
$query->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->where('client_cases.client_id', $clientId)
->select('contracts.*');
}
return $query->where('contracts.reference', $reference)->first();
}
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
// PHASE 4: Check for existing Contract early to prevent duplicate creation
$reference = $mapped['reference'] ?? null;
if ($reference) {
$existingContract = $this->resolutionService->getExistingContract(
$import->client_id,
$reference
);
if ($existingContract) {
Log::info('ContractHandler: Found existing Contract by reference', [
'contract_id' => $existingContract->id,
'reference' => $reference,
]);
$mode = $this->getOption('update_mode', 'update');
if ($mode === 'skip') {
return [
'action' => 'skipped',
'entity' => $existingContract,
'message' => 'Contract already exists (skip mode)',
];
}
// Update existing contract
$payload = $this->buildPayload($mapped, $existingContract);
$payload = $this->mergeJsonFields($payload, $existingContract);
$appliedFields = $this->trackAppliedFields($existingContract, $payload);
if (empty($appliedFields)) {
return [
'action' => 'skipped',
'entity' => $existingContract,
'message' => 'No changes detected',
];
}
$existingContract->fill($payload);
$existingContract->save();
return [
'action' => 'updated',
'entity' => $existingContract,
'applied_fields' => $appliedFields,
];
}
}
$existing = $this->resolve($mapped, $context);
// Check for reactivation request
$reactivate = $this->shouldReactivate($context);
// Handle reactivation if entity is soft-deleted or inactive
if ($existing && $reactivate && $this->needsReactivation($existing)) {
$reactivated = $this->attemptReactivation($existing, $context);
if ($reactivated) {
return [
'action' => 'reactivated',
'entity' => $existing,
'message' => 'Contract reactivated',
];
}
}
// Determine if we should update or skip based on mode
$mode = $this->getOption('update_mode', 'update');
if ($existing) {
if ($mode === 'skip') {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'Contract already exists (skip mode)',
];
}
// Update
$payload = $this->buildPayload($mapped, $existing);
// Merge JSON fields instead of overwriting
$payload = $this->mergeJsonFields($payload, $existing);
$appliedFields = $this->trackAppliedFields($existing, $payload);
if (empty($appliedFields)) {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'No changes detected',
];
}
$existing->fill($payload);
$existing->save();
return [
'action' => 'updated',
'entity' => $existing,
'applied_fields' => $appliedFields,
];
}
// Create new contract
$contract = new Contract;
$payload = $this->buildPayload($mapped, $contract);
// Get client_case_id from context or mapped data
$clientCaseId = $mapped['client_case_id']
?? $context['client_case']?->entity?->id
?? null;
// If no client_case_id, try to create/find one automatically (using EntityResolutionService)
if (!$clientCaseId) {
// Add mapped data to context for EntityResolutionService
$context['mapped'] = $mapped;
$clientCaseId = $this->findOrCreateClientCaseId($context);
}
if (!$clientCaseId) {
return [
'action' => 'invalid',
'message' => 'Contract requires client_case_id (import must have client_id)',
];
}
$payload['client_case_id'] = $clientCaseId;
// Ensure required defaults
if (!isset($payload['type_id'])) {
$payload['type_id'] = $this->getDefaultContractTypeId();
}
if (!isset($payload['start_date'])) {
$payload['start_date'] = now()->toDateString();
}
$contract->fill($payload);
$contract->save();
return [
'action' => 'inserted',
'entity' => $contract,
'applied_fields' => array_keys($payload),
];
}
protected function buildPayload(array $mapped, $model): array
{
$payload = [];
// Map fields according to contract schema
$fieldMap = [
'reference' => 'reference',
'title' => 'title',
'description' => 'description',
'amount' => 'amount',
'currency' => 'currency',
'start_date' => 'start_date',
'end_date' => 'end_date',
'active' => 'active',
'type_id' => 'type_id',
'client_case_id' => 'client_case_id',
];
foreach ($fieldMap as $source => $target) {
if (array_key_exists($source, $mapped)) {
$payload[$target] = $mapped[$source];
}
}
return $payload;
}
private function getDefaultContractTypeId(): int
{
return (int) (\App\Models\ContractType::min('id') ?? 1);
}
/**
* Check if reactivation should be attempted.
*/
protected function shouldReactivate(array $context): bool
{
// Row-level reactivate column takes precedence
if (isset($context['raw']['reactivate'])) {
return filter_var($context['raw']['reactivate'], FILTER_VALIDATE_BOOLEAN);
}
// Then import-level
if (isset($context['import']->reactivate)) {
return (bool) $context['import']->reactivate;
}
// Finally template-level
if (isset($context['import']->template?->reactivate)) {
return (bool) $context['import']->template->reactivate;
}
return false;
}
/**
* Check if entity needs reactivation.
*/
protected function needsReactivation($entity): bool
{
return $entity->active == 0 || $entity->deleted_at !== null;
}
/**
* Attempt to reactivate soft-deleted or inactive contract.
*/
protected function attemptReactivation(Contract $contract, array $context): bool
{
if (! $this->getOption('supports_reactivation', false)) {
return false;
}
try {
if ($contract->trashed()) {
$contract->restore();
}
$contract->update(['active' => 1]);
return true;
} catch (\Throwable $e) {
\Log::error('Contract reactivation failed', [
'contract_id' => $contract->id,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Merge JSON fields instead of overwriting.
*/
protected function mergeJsonFields(array $payload, $existing): array
{
$mergeFields = $this->getOption('merge_json_fields', []);
foreach ($mergeFields as $field) {
if (isset($payload[$field]) && isset($existing->{$field})) {
$existingData = is_array($existing->{$field}) ? $existing->{$field} : [];
$newData = is_array($payload[$field]) ? $payload[$field] : [];
$payload[$field] = array_merge($existingData, $newData);
}
}
return $payload;
}
/**
* Find or create a ClientCase for this contract (using EntityResolutionService).
*/
protected function findOrCreateClientCaseId(array $context): ?int
{
$import = $context['import'] ?? null;
$mapped = $context['mapped'] ?? [];
$clientId = $import?->client_id ?? null;
if (!$clientId) {
return null;
}
// PHASE 4: Use EntityResolutionService to resolve or create ClientCase
// This will reuse existing ClientCase when possible
$clientCaseId = $this->resolutionService->resolveOrCreateClientCaseForContract(
$import,
$mapped,
$context
);
if ($clientCaseId) {
Log::info('ContractHandler: Resolved/Created ClientCase for Contract', [
'client_case_id' => $clientCaseId,
]);
}
return $clientCaseId;
}
/**
* Generate a unique client_ref.
*/
protected function generateClientRef(int $clientId): string
{
$timestamp = now()->format('ymdHis');
$random = substr(md5(uniqid()), 0, 4);
return "C{$clientId}-{$timestamp}-{$random}";
}
}
@@ -0,0 +1,117 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\Email;
use App\Models\Import;
use App\Services\Import\BaseEntityHandler;
class EmailHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return Email::class;
}
/**
* Override validate to skip validation if email is empty.
*/
public function validate(array $mapped): array
{
$email = $mapped['value'] ?? null;
if (empty($email) || trim((string)$email) === '') {
return ['valid' => true, 'errors' => []];
}
return parent::validate($mapped);
}
public function resolve(array $mapped, array $context = []): mixed
{
$value = $mapped['value'] ?? null;
if (! $value) {
return null;
}
return Email::where('value', strtolower(trim($value)))->first();
}
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
// Skip if email is empty or blank
$email = $mapped['value'] ?? null;
if (empty($email) || trim((string)$email) === '') {
return [
'action' => 'skipped',
'message' => 'Email is empty',
];
}
// Resolve person_id from context
$personId = $mapped['person_id'] ?? $context['person']['entity']?->id ?? null;
if (! $personId) {
return [
'action' => 'skipped',
'message' => 'Email requires person_id',
];
}
$existing = $this->resolve($mapped, $context);
// Check for duplicates if configured
if ($this->getOption('deduplicate', true) && $existing) {
// Update person_id if different
if ($existing->person_id !== $personId) {
$existing->person_id = $personId;
$existing->save();
return [
'action' => 'updated',
'entity' => $existing,
'applied_fields' => ['person_id'],
];
}
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'Email already exists',
];
}
// Create new email
$payload = $this->buildPayload($mapped, new Email);
$payload['person_id'] = $personId;
$email = new Email;
$email->fill($payload);
$email->save();
return [
'action' => 'inserted',
'entity' => $email,
'applied_fields' => array_keys($payload),
];
}
protected function buildPayload(array $mapped, $model): array
{
$payload = [];
if (isset($mapped['value'])) {
$payload['value'] = strtolower(trim($mapped['value']));
}
if (isset($mapped['is_primary'])) {
$payload['is_primary'] = (bool) $mapped['is_primary'];
}
if (isset($mapped['label'])) {
$payload['label'] = $mapped['label'];
}
return $payload;
}
}
@@ -0,0 +1,224 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\Account;
use App\Models\Booking;
use App\Models\Import;
use App\Models\Payment;
use App\Models\PaymentSetting;
use App\Services\Import\DateNormalizer;
use App\Services\Import\BaseEntityHandler;
use Illuminate\Support\Facades\Log;
class PaymentHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return Payment::class;
}
/**
* Override validate to skip validation if amount is empty.
*/
public function validate(array $mapped): array
{
$amount = $mapped['amount'] ?? null;
if (empty($amount) || !is_numeric($amount)) {
return ['valid' => true, 'errors' => []];
}
return parent::validate($mapped);
}
public function resolve(array $mapped, array $context = []): mixed
{
$accountId = $mapped['account_id'] ?? $context['account']?->entity?->id ?? null;
$reference = $mapped['reference'] ?? null;
if (! $accountId || ! $reference) {
return null;
}
return Payment::where('account_id', $accountId)
->where('reference', $reference)
->first();
}
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
// Skip if amount is empty or invalid
$amount = $mapped['amount'] ?? null;
if (empty($amount) || !is_numeric($amount)) {
return [
'action' => 'skipped',
'message' => 'Payment amount is empty or invalid',
];
}
// Resolve account - either from mapped data or context
$accountId = $mapped['account_id'] ?? $context['account']?->entity?->id ?? null;
if (! $accountId) {
return [
'action' => 'skipped',
'message' => 'Payment requires an account',
];
}
// Check for duplicates if configured
if ($this->getOption('deduplicate_by', [])) {
$existing = $this->resolve($mapped, ['account' => (object) ['entity' => (object) ['id' => $accountId]]]);
if ($existing) {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'Payment already exists (duplicate by reference)',
];
}
}
// Build payment payload
$payload = $this->buildPayload($mapped, new Payment);
$payload['account_id'] = $accountId;
$payload['created_by'] = $context['user']?->getAuthIdentifier();
// Get account balance before payment
$account = Account::find($accountId);
$balanceBefore = $account ? (float) ($account->balance_amount ?? 0) : 0;
// Create payment
$payment = new Payment;
$payment->fill($payload);
$payment->balance_before = $balanceBefore;
try {
$payment->save();
} catch (\Throwable $e) {
// Handle unique constraint violations gracefully
if (str_contains($e->getMessage(), 'payments_account_id_reference_unique')) {
return [
'action' => 'skipped',
'message' => 'Payment duplicate detected (database constraint)',
];
}
throw $e;
}
// Create booking if configured
if ($this->getOption('create_booking', true) && isset($payment->amount)) {
try {
Booking::create([
'account_id' => $accountId,
'payment_id' => $payment->id,
'amount_cents' => (int) round(((float) $payment->amount) * 100),
'type' => 'credit',
'description' => $payment->reference ? ('Plačilo '.$payment->reference) : 'Plačilo',
'booked_at' => $payment->paid_at ?? now(),
]);
} catch (\Throwable $e) {
Log::warning('Failed to create booking for payment', [
'payment_id' => $payment->id,
'error' => $e->getMessage(),
]);
}
}
// Create activity if configured
if ($this->getOption('create_activity', false)) {
$this->createPaymentActivity($payment, $account, $balanceBefore);
}
return [
'action' => 'inserted',
'entity' => $payment,
'applied_fields' => array_keys($payload),
];
}
protected function buildPayload(array $mapped, $model): array
{
$payload = [];
// Map payment fields
if (isset($mapped['reference'])) {
$payload['reference'] = is_string($mapped['reference']) ? trim($mapped['reference']) : $mapped['reference'];
}
// Handle amount - support both amount and amount_cents
if (array_key_exists('amount', $mapped)) {
$payload['amount'] = is_string($mapped['amount']) ? (float) str_replace(',', '.', $mapped['amount']) : (float) $mapped['amount'];
} elseif (array_key_exists('amount_cents', $mapped)) {
$payload['amount'] = ((int) $mapped['amount_cents']) / 100.0;
}
// Payment date - support both paid_at and payment_date
$dateValue = $mapped['paid_at'] ?? $mapped['payment_date'] ?? null;
if ($dateValue) {
$payload['paid_at'] = DateNormalizer::toDate((string) $dateValue);
}
$payload['currency'] = $mapped['currency'] ?? 'EUR';
// Handle meta
$meta = [];
if (is_array($mapped['meta'] ?? null)) {
$meta = $mapped['meta'];
}
if (! empty($mapped['payment_nu'])) {
$meta['payment_nu'] = trim((string) $mapped['payment_nu']);
}
if (! empty($meta)) {
$payload['meta'] = $meta;
}
return $payload;
}
protected function createPaymentActivity(Payment $payment, ?Account $account, float $balanceBefore): void
{
try {
$settings = PaymentSetting::first();
if (! $settings || ! ($settings->create_activity_on_payment ?? false)) {
return;
}
$amountCents = (int) round(((float) $payment->amount) * 100);
$note = $settings->activity_note_template ?? 'Prejeto plačilo';
$note = str_replace(
['{amount}', '{currency}'],
[number_format($amountCents / 100, 2, ',', '.'), $payment->currency ?? 'EUR'],
$note
);
// Get updated balance
$account?->refresh();
$balanceAfter = $account ? (float) ($account->balance_amount ?? 0) : 0;
$beforeStr = number_format($balanceBefore, 2, ',', '.').' '.($payment->currency ?? 'EUR');
$afterStr = number_format($balanceAfter, 2, ',', '.').' '.($payment->currency ?? 'EUR');
$note .= " (Stanje pred: {$beforeStr}, Stanje po: {$afterStr}; Izvor: plačilo)";
// Resolve client_case_id
$account?->loadMissing('contract');
$clientCaseId = $account?->contract?->client_case_id;
if ($clientCaseId) {
$activity = \App\Models\Activity::create([
'due_date' => null,
'amount' => $amountCents / 100,
'note' => $note,
'action_id' => $settings->default_action_id,
'decision_id' => $settings->default_decision_id,
'client_case_id' => $clientCaseId,
'contract_id' => $account->contract_id,
]);
$payment->update(['activity_id' => $activity->id]);
}
} catch (\Throwable $e) {
Log::warning('Failed to create activity for payment', [
'payment_id' => $payment->id,
'error' => $e->getMessage(),
]);
}
}
}
@@ -0,0 +1,187 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\Import;
use App\Models\Person\Person;
use App\Models\Person\PersonGroup;
use App\Models\Person\PersonType;
use App\Services\Import\DateNormalizer;
use App\Services\Import\BaseEntityHandler;
use App\Services\Import\EntityResolutionService;
use Illuminate\Support\Facades\Log;
class PersonHandler extends BaseEntityHandler
{
protected EntityResolutionService $resolutionService;
public function __construct($entityConfig = null)
{
parent::__construct($entityConfig);
$this->resolutionService = new EntityResolutionService();
}
public function getEntityClass(): string
{
return Person::class;
}
public function resolve(array $mapped, array $context = []): mixed
{
// PHASE 3: Use EntityResolutionService to check chain-based deduplication
// This prevents creating duplicate Persons when Contract/ClientCase already exists
$import = $context['import'] ?? null;
if ($import) {
$personId = $this->resolutionService->resolvePersonFromContext($import, $mapped, $context);
if ($personId) {
$person = Person::find($personId);
if ($person) {
Log::info('PersonHandler: Resolved existing Person via chain', [
'person_id' => $personId,
'resolution_method' => 'EntityResolutionService',
]);
return $person;
}
}
}
// Fall back to configured deduplication fields (tax_number, SSN)
$dedupeBy = $this->getOption('deduplicate_by', ['tax_number', 'social_security_number']);
foreach ($dedupeBy as $field) {
if (! empty($mapped[$field])) {
$person = Person::where($field, $mapped[$field])->first();
if ($person) {
Log::info('PersonHandler: Resolved existing Person by identifier', [
'person_id' => $person->id,
'field' => $field,
]);
return $person;
}
}
}
return null;
}
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
// Add import to context for EntityResolutionService
$context['import'] = $import;
$existing = $this->resolve($mapped, $context);
if ($existing) {
// Update if configured
$mode = $this->getOption('update_mode', 'update');
if ($mode === 'skip') {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'Person already exists (skip mode)',
];
}
$payload = $this->buildPayload($mapped, $existing);
$appliedFields = $this->trackAppliedFields($existing, $payload);
if (empty($appliedFields)) {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'No changes detected',
];
}
$existing->fill($payload);
$existing->save();
return [
'action' => 'updated',
'entity' => $existing,
'applied_fields' => $appliedFields,
];
}
// Create new person
Log::info('PersonHandler: Creating new Person (no existing entity found)', [
'has_tax_number' => !empty($mapped['tax_number']),
'has_ssn' => !empty($mapped['social_security_number']),
'has_contract' => isset($context['contract']),
'has_client_case' => isset($context['client_case']),
]);
$person = new Person;
$payload = $this->buildPayload($mapped, $person);
// Ensure required foreign keys have defaults
if (!isset($payload['group_id'])) {
$payload['group_id'] = $this->getDefaultPersonGroupId();
}
if (!isset($payload['type_id'])) {
$payload['type_id'] = $this->getDefaultPersonTypeId();
}
$person->fill($payload);
$person->save();
Log::info('PersonHandler: Created new Person', [
'person_id' => $person->id,
]);
return [
'action' => 'inserted',
'entity' => $person,
'applied_fields' => array_keys($payload),
];
}
protected function buildPayload(array $mapped, $model): array
{
$payload = [];
$fieldMap = [
'first_name' => 'first_name',
'last_name' => 'last_name',
'full_name' => 'full_name',
'gender' => 'gender',
'birthday' => 'birthday',
'tax_number' => 'tax_number',
'social_security_number' => 'social_security_number',
'description' => 'description',
'group_id' => 'group_id',
'type_id' => 'type_id',
];
foreach ($fieldMap as $source => $target) {
if (array_key_exists($source, $mapped)) {
$value = $mapped[$source];
// Normalize date fields
if ($source === 'birthday' && $value) {
$value = DateNormalizer::toDate((string) $value);
}
$payload[$target] = $value;
}
}
return $payload;
}
private function getDefaultPersonGroupId(): int
{
return (int) (PersonGroup::min('id') ?? 1);
}
private function getDefaultPersonTypeId(): int
{
return (int) (PersonType::min('id') ?? 1);
}
}
@@ -0,0 +1,153 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\Import;
use App\Models\Person\PersonPhone;
use App\Services\Import\BaseEntityHandler;
class PhoneHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return PersonPhone::class;
}
/**
* Override validate to skip validation if phone is empty.
* Handles both single values and arrays.
*/
public function validate(array $mapped): array
{
$phone = $mapped['nu'] ?? null;
// If array, check if all values are empty/invalid
if (is_array($phone)) {
$hasValue = false;
foreach ($phone as $ph) {
if (!empty($ph) && trim((string)$ph) !== '' && $ph !== '0') {
$hasValue = true;
break;
}
}
if (!$hasValue) {
return ['valid' => true, 'errors' => []];
}
// Skip parent validation for arrays - we'll validate in process()
return ['valid' => true, 'errors' => []];
}
// Single value - check if empty or invalid
if (empty($phone) || trim((string)$phone) === '' || $phone === '0') {
return ['valid' => true, 'errors' => []];
}
return parent::validate($mapped);
}
public function resolve(array $mapped, array $context = []): mixed
{
$nu = $mapped['nu'] ?? null;
$personId = $mapped['person_id']
?? ($context['person']['entity']->id ?? null)
?? ($context['person']?->entity?->id ?? null);
if (! $nu || ! $personId) {
return null;
}
// Normalize phone number for comparison
$normalizedNu = $this->normalizePhoneNumber($nu);
// Find existing phone by normalized number for this person
return PersonPhone::where('person_id', $personId)
->where('nu', $normalizedNu)
->first();
}
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
// Handle multiple phones if nu is an array
$phones = $mapped['nu'] ?? null;
// If single value, convert to array for uniform processing
if (!is_array($phones)) {
$phones = [$phones];
}
$results = [];
$insertedCount = 0;
$skippedCount = 0;
foreach ($phones as $phone) {
// Skip if phone number is empty or blank or '0'
if (empty($phone) || trim((string)$phone) === '' || $phone === '0') {
$skippedCount++;
continue;
}
// Resolve person_id from context
$personId = $mapped['person_id'] ?? $context['person']['entity']?->id ?? null;
if (! $personId) {
$skippedCount++;
continue;
}
// Normalize phone number
$normalizedPhone = $this->normalizePhoneNumber($phone);
$existing = $this->resolvePhone($normalizedPhone, $personId);
// Check for duplicates if configured
if ($this->getOption('deduplicate', true) && $existing) {
$skippedCount++;
continue;
}
// Create new phone
$payload = [
'nu' => $normalizedPhone,
'person_id' => $personId,
'type_id' => 1, // Default to mobile
];
$phoneEntity = new PersonPhone;
$phoneEntity->fill($payload);
$phoneEntity->save();
$results[] = $phoneEntity;
$insertedCount++;
}
if ($insertedCount === 0 && $skippedCount > 0) {
return [
'action' => 'skipped',
'message' => 'All phones empty, invalid or duplicates',
];
}
return [
'action' => 'inserted',
'entity' => $results[0] ?? null,
'entities' => $results,
'applied_fields' => ['nu', 'person_id'],
'count' => $insertedCount,
];
}
protected function resolvePhone(string $normalizedPhone, int $personId): mixed
{
return PersonPhone::where('person_id', $personId)
->where('nu', $normalizedPhone)
->first();
}
/**
* Normalize phone number by removing spaces, dashes, and parentheses.
*/
protected function normalizePhoneNumber(string $phone): string
{
return preg_replace('/[\s\-\(\)]/', '', $phone);
}
}
+759
View File
@@ -0,0 +1,759 @@
<?php
namespace App\Services\Import;
use App\Models\Import;
use App\Models\ImportEntity;
use App\Models\ImportEvent;
use App\Models\ImportRow;
use App\Services\Import\Contracts\EntityHandlerInterface;
use App\Services\Import\DateNormalizer;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
/**
* ImportServiceV2 - Generic, database-driven import processor.
*
* Refactored from ImportProcessor to use entity handlers and config from import_entities table.
*
* PHASE 6: EntityResolutionService is integrated via handler constructors.
* Each handler (PersonHandler, ContractHandler, ClientCaseHandler) instantiates
* the service and uses it to prevent duplicate Person creation.
*/
class ImportServiceV2
{
protected array $handlers = [];
protected array $entityConfigs = [];
protected array $templateMeta = [];
protected bool $paymentsImport = false;
protected bool $historyImport = false;
protected ?string $contractKeyMode = null;
/**
* Process an import using v2 architecture.
*/
public function process(Import $import, ?Authenticatable $user = null): array
{
$started = now();
$total = 0;
$skipped = 0;
$imported = 0;
$invalid = 0;
try {
// Load template meta flags
$this->loadTemplateMeta($import);
// Load entity configurations and handlers
$this->loadEntityConfigurations();
// Only CSV/TSV supported for now
if (! in_array($import->source_type, ['csv', 'txt'])) {
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'event' => 'processing_skipped',
'level' => 'warning',
'message' => 'Only CSV/TXT supported in v2 processor.',
]);
$import->update(['status' => 'completed', 'finished_at' => now()]);
return compact('total', 'imported', 'skipped', 'invalid');
}
$import->update(['status' => 'processing', 'started_at' => $started]);
$filePath = $import->path;
if (! Storage::disk($import->disk ?? 'local')->exists($filePath)) {
throw new \RuntimeException("File not found: {$filePath}");
}
$fullPath = Storage::disk($import->disk ?? 'local')->path($filePath);
$fh = fopen($fullPath, 'r');
if (! $fh) {
throw new \RuntimeException("Could not open file: {$filePath}");
}
$meta = $import->meta ?? [];
$hasHeader = (bool) ($meta['has_header'] ?? true);
$delimiter = $meta['forced_delimiter'] ?? $meta['detected_delimiter'] ?? ',';
$mappings = $this->loadMappings($import);
$header = null;
$rowNum = 0;
// Read header if present
if ($hasHeader) {
$header = fgetcsv($fh, 0, $delimiter);
$rowNum++;
}
$isPg = DB::connection()->getDriverName() === 'pgsql';
while (($row = fgetcsv($fh, 0, $delimiter)) !== false) {
$rowNum++;
$total++;
try {
$rawAssoc = $this->buildRowAssoc($row, $header);
// Skip empty rows
if ($this->rowIsEffectivelyEmpty($rawAssoc)) {
$skipped++;
continue;
}
$mapped = $this->applyMappings($rawAssoc, $mappings);
$rawSha1 = sha1(json_encode($rawAssoc));
$importRow = ImportRow::create([
'import_id' => $import->id,
'row_number' => $rowNum,
'record_type' => $this->determineRecordType($mapped),
'raw_data' => $rawAssoc,
'mapped_data' => $mapped,
'status' => 'valid',
'raw_sha1' => $rawSha1,
]);
// 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, $rowNum, $entityDetails, $hasWarnings, $rawAssoc);
} elseif ($results['status'] === 'skipped') {
$skipped++;
$importRow->update(['status' => 'skipped']);
$this->createRowSkippedEvent($import, $user, $rowNum, $entityDetails, $rawAssoc);
} else {
$invalid++;
$importRow->update([
'status' => 'invalid',
'errors' => $results['errors'] ?? ['Processing failed'],
]);
$this->createRowFailedEvent(
$import,
$user,
$rowNum,
$results['errors'] ?? ['Processing failed'],
$entityDetails,
$rawAssoc
);
}
} catch (\Throwable $e) {
$invalid++;
$this->handleRowException($import, $user, $rowNum, $e);
}
}
fclose($fh);
$this->finalizeImport($import, $user, $total, $imported, $skipped, $invalid);
} catch (\Throwable $e) {
$this->handleFatalException($import, $user, $e);
throw $e;
}
return compact('total', 'imported', 'skipped', 'invalid');
}
/**
* Load entity configurations from database.
*/
protected function loadEntityConfigurations(): void
{
$entities = ImportEntity::where('is_active', true)
->orderBy('priority', 'desc')
->get();
foreach ($entities as $entity) {
$this->entityConfigs[$entity->canonical_root] = $entity;
// Instantiate handler if specified
if ($entity->handler_class && class_exists($entity->handler_class)) {
$this->handlers[$entity->canonical_root] = new $entity->handler_class($entity);
}
}
}
/**
* Load mappings for import.
*/
protected function loadMappings(Import $import)
{
return DB::table('import_mappings')
->where('import_id', $import->id)
->orderBy('position')
->get();
}
/**
* Build associative array from row.
*/
protected function buildRowAssoc(array $row, ?array $header): array
{
if ($header) {
$result = [];
foreach ($header as $idx => $col) {
$result[$col] = $row[$idx] ?? null;
}
return $result;
}
return array_combine(range(0, count($row) - 1), $row);
}
/**
* Check if row is effectively empty.
*/
protected function rowIsEffectivelyEmpty(array $raw): bool
{
foreach ($raw as $val) {
if (! is_null($val) && trim((string) $val) !== '') {
return false;
}
}
return true;
}
/**
* Apply mappings to raw data.
*/
protected function applyMappings(array $raw, $mappings): array
{
$mapped = [];
// Group mappings by target field to handle concatenation
$groupedMappings = [];
foreach ($mappings as $mapping) {
$targetField = $mapping->target_field;
if (!isset($groupedMappings[$targetField])) {
$groupedMappings[$targetField] = [];
}
$groupedMappings[$targetField][] = $mapping;
}
foreach ($groupedMappings as $targetField => $fieldMappings) {
// Group by group number from options
$valuesByGroup = [];
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 group from options
$options = $mapping->options ? json_decode($mapping->options, true) : [];
$group = $options['group'] ?? null;
// Group values by their group number
if ($group !== null) {
// Same group = concatenate
if (!isset($valuesByGroup[$group])) {
$valuesByGroup[$group] = [];
}
$valuesByGroup[$group][] = $value;
} else {
// No group = each gets its own group
$valuesByGroup[] = [$value];
}
}
// Now set the values
foreach ($valuesByGroup as $values) {
if (count($values) === 1) {
// Single value - set directly
$this->setNestedValue($mapped, $targetField, $values[0]);
} else {
// Multiple values in same group - concatenate with newline
$concatenated = implode("\n", array_filter($values, fn($v) => !empty($v) && trim((string)$v) !== ''));
if (!empty($concatenated)) {
$this->setNestedValue($mapped, $targetField, $concatenated);
}
}
}
}
return $mapped;
}
/**
* Apply transform to value.
*/
protected function applyTransform(mixed $value, string $transform): mixed
{
return match (strtolower($transform)) {
'trim' => is_string($value) ? trim($value) : $value,
'upper' => is_string($value) ? strtoupper($value) : $value,
'lower' => is_string($value) ? strtolower($value) : $value,
'date' => $this->normalizeDate($value),
default => $value,
};
}
/**
* Normalize date value.
*/
protected function normalizeDate(mixed $value): ?string
{
if (empty($value)) {
return null;
}
try {
return DateNormalizer::toDate((string) $value);
} catch (\Throwable $e) {
return null;
}
}
/**
* Set nested value in array using dot notation.
* If the key already exists, convert to array and append the new value.
*/
protected function setNestedValue(array &$array, string $key, mixed $value): void
{
$keys = explode('.', $key);
$current = &$array;
foreach ($keys as $i => $k) {
if ($i === count($keys) - 1) {
// If key already exists, convert to array and append
if (isset($current[$k])) {
// Convert existing single value to array if needed
if (!is_array($current[$k])) {
$current[$k] = [$current[$k]];
}
// Append new value
$current[$k][] = $value;
} else {
// Set as single value
$current[$k] = $value;
}
} else {
if (! isset($current[$k]) || ! is_array($current[$k])) {
$current[$k] = [];
}
$current = &$current[$k];
}
}
}
/**
* Determine record type from mapped data.
*/
protected function determineRecordType(array $mapped): string
{
if (isset($mapped['payment'])) {
return 'payment';
}
if (isset($mapped['activity'])) {
return 'activity';
}
if (isset($mapped['contract'])) {
return 'contract';
}
if (isset($mapped['account'])) {
return 'account';
}
return 'contact';
}
/**
* Process a single row through all entity handlers.
*/
protected function processRow(Import $import, array $mapped, array $raw, array $context): array
{
$entityResults = [];
$lastEntityType = null;
$lastEntityId = null;
$hasErrors = false;
// Process entities in configured priority order
foreach ($this->entityConfigs as $root => $config) {
// Check if this entity exists in mapped data (support aliases)
$mappedKey = $this->findMappedKey($mapped, $root, $config);
if (!$mappedKey || !isset($mapped[$mappedKey])) {
continue;
}
$handler = $this->handlers[$root] ?? null;
if (! $handler) {
continue;
}
try {
// Validate before processing
$validation = $handler->validate($mapped[$mappedKey]);
if (! $validation['valid']) {
$entityResults[$root] = [
'action' => 'invalid',
'errors' => $validation['errors'],
'level' => 'error',
];
$hasErrors = true;
// Don't stop processing, continue to other entities to collect all errors
continue;
}
// Pass previous results as context
$result = $handler->process($import, $mapped[$mappedKey], $raw, array_merge($context, $entityResults));
$entityResults[$root] = $result;
// Track last successful entity for row status
if (in_array($result['action'] ?? null, ['inserted', 'updated'])) {
$lastEntityType = $handler->getEntityClass();
$lastEntityId = $result['entity']?->id ?? null;
}
} catch (\Throwable $e) {
$hasErrors = true;
Log::error("Handler failed for entity {$root}", [
'import_id' => $import->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
$entityResults[$root] = [
'action' => 'failed',
'level' => 'error',
'errors' => [$e->getMessage()],
'exception' => [
'message' => $e->getMessage(),
'file' => basename($e->getFile()),
'line' => $e->getLine(),
'class' => get_class($e),
],
];
// Continue to process other entities to collect all errors
continue;
}
}
// If we had errors, return invalid status
if ($hasErrors) {
$allErrors = [];
foreach ($entityResults as $root => $result) {
if (isset($result['errors'])) {
$allErrors[] = "{$root}: " . implode(', ', $result['errors']);
}
}
return [
'status' => 'invalid',
'errors' => $allErrors,
'results' => $entityResults,
];
}
return [
'status' => $lastEntityId ? 'imported' : 'skipped',
'entity_type' => $lastEntityType,
'entity_id' => $lastEntityId,
'results' => $entityResults,
];
}
/**
* Find the key in mapped data that corresponds to this canonical root.
*/
protected function findMappedKey(array $mapped, string $canonicalRoot, $config): ?string
{
// First check canonical_root itself
if (isset($mapped[$canonicalRoot])) {
return $canonicalRoot;
}
// Then check key (e.g., 'contracts', 'person_addresses')
if (isset($mapped[$config->key])) {
return $config->key;
}
// Then check aliases
$aliases = $config->aliases ?? [];
foreach ($aliases as $alias) {
if (isset($mapped[$alias])) {
return $alias;
}
}
return null;
}
/**
* Load template meta flags for special processing modes.
*/
protected function loadTemplateMeta(Import $import): void
{
$this->templateMeta = optional($import->template)->meta ?? [];
$this->paymentsImport = (bool) ($this->templateMeta['payments_import'] ?? false);
$this->historyImport = (bool) ($this->templateMeta['history_import'] ?? false);
$this->contractKeyMode = $this->templateMeta['contract_key_mode'] ?? null;
}
/**
* Collect entity details from processing results.
*/
protected function collectEntityDetails(array $results): array
{
$entityDetails = [];
$hasErrors = false;
$hasWarnings = false;
if (isset($results['results']) && is_array($results['results'])) {
foreach ($results['results'] as $entityKey => $result) {
$action = $result['action'] ?? 'unknown';
$message = $result['message'] ?? null;
$count = $result['count'] ?? 1;
$detail = [
'entity' => $entityKey,
'action' => $action,
'count' => $count,
];
if ($message) {
$detail['message'] = $message;
}
if ($action === 'invalid' || isset($result['errors'])) {
$detail['level'] = 'error';
$detail['errors'] = $result['errors'] ?? [];
$hasErrors = true;
} elseif ($action === 'skipped') {
$detail['level'] = 'warning';
$hasWarnings = true;
} else {
$detail['level'] = 'info';
}
if (isset($result['exception'])) {
$detail['exception'] = $result['exception'];
$hasErrors = true;
}
$entityDetails[] = $detail;
}
}
return [
'details' => $entityDetails,
'hasErrors' => $hasErrors,
'hasWarnings' => $hasWarnings,
];
}
/**
* Create a success event for a processed row.
*/
protected function createRowProcessedEvent(
Import $import,
?Authenticatable $user,
int $rowNum,
array $entityDetails,
bool $hasWarnings,
array $rawData = []
): void {
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'event' => 'row_processed',
'level' => $hasWarnings ? 'warning' : 'info',
'message' => "Row {$rowNum} processed successfully",
'context' => [
'row' => $rowNum,
'entity_details' => $entityDetails,
'raw_data' => $rawData,
],
]);
}
/**
* Create a skip event for a skipped row.
*/
protected function createRowSkippedEvent(
Import $import,
?Authenticatable $user,
int $rowNum,
array $entityDetails,
array $rawData = []
): void {
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'event' => 'row_skipped',
'level' => 'warning',
'message' => "Row {$rowNum} skipped",
'context' => [
'row' => $rowNum,
'entity_details' => $entityDetails,
'raw_data' => $rawData,
],
]);
}
/**
* Create a failure event for a failed row.
*/
protected function createRowFailedEvent(
Import $import,
?Authenticatable $user,
int $rowNum,
array $errors,
array $entityDetails,
array $rawData = []
): void {
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'event' => 'row_failed',
'level' => 'error',
'message' => "Row {$rowNum} failed: " . implode(', ', $errors),
'context' => [
'row' => $rowNum,
'errors' => $errors,
'entity_details' => $entityDetails,
'raw_data' => $rawData,
],
]);
}
/**
* Handle row processing exception.
*/
protected function handleRowException(
Import $import,
?Authenticatable $user,
int $rowNum,
\Throwable $e
): void {
Log::error('ImportServiceV2 row processing failed', [
'import_id' => $import->id,
'row' => $rowNum,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'event' => 'row_failed',
'level' => 'error',
'message' => "Row {$rowNum} exception: {$e->getMessage()}",
'context' => [
'row' => $rowNum,
'exception' => [
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
],
],
]);
}
/**
* Finalize import with completion event.
*/
protected function finalizeImport(
Import $import,
?Authenticatable $user,
int $total,
int $imported,
int $skipped,
int $invalid
): void {
$import->update([
'status' => 'completed',
'finished_at' => now(),
'total_rows' => $total,
'imported_rows' => $imported,
'valid_rows' => $imported,
'invalid_rows' => $invalid,
]);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'event' => 'processing_completed',
'level' => 'info',
'message' => "Processed {$total} rows: {$imported} imported, {$skipped} skipped, {$invalid} invalid",
]);
}
/**
* Handle fatal processing exception.
*/
protected function handleFatalException(
Import $import,
?Authenticatable $user,
\Throwable $e
): void {
Log::error('ImportServiceV2 processing failed', [
'import_id' => $import->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
$import->update(['status' => 'failed', 'finished_at' => now()]);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'event' => 'processing_failed',
'level' => 'error',
'message' => $e->getMessage(),
]);
}
}
@@ -0,0 +1,786 @@
<?php
namespace App\Services\Import;
use App\Models\Import;
use App\Models\ImportEntity;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
/**
* ImportSimulationServiceV2 - Simulates imports using V2 handler architecture.
*
* Processes rows using entity handlers without persisting any data to the database.
* Returns preview data showing what would be created/updated for each row.
*
* Deduplication: Uses EntityResolutionService through handlers to accurately simulate
* Person resolution from Contract/ClientCase chains, matching production behavior.
*/
class ImportSimulationServiceV2
{
protected array $handlers = [];
protected array $entityConfigs = [];
/**
* Simulate an import and return preview data.
*
* @param Import $import Import record with mappings
* @param int $limit Maximum number of rows to simulate (default: 100)
* @param bool $verbose Include detailed information (default: false)
* @return array Simulation results with row previews and statistics
*/
public function simulate(Import $import, int $limit = 100, bool $verbose = false): array
{
try {
// Load entity configurations and handlers
$this->loadEntityConfigurations();
// Only CSV/TXT supported
if (! in_array($import->source_type, ['csv', 'txt'])) {
return $this->errorPayload('Podprti so samo CSV/TXT formati.');
}
$filePath = $import->path;
if (! Storage::disk($import->disk ?? 'local')->exists($filePath)) {
return $this->errorPayload("Datoteka ni najdena: {$filePath}");
}
$fullPath = Storage::disk($import->disk ?? 'local')->path($filePath);
$fh = fopen($fullPath, 'r');
if (! $fh) {
return $this->errorPayload("Datoteke ni mogoče odpreti: {$filePath}");
}
$meta = $import->meta ?? [];
$hasHeader = (bool) ($meta['has_header'] ?? true);
$delimiter = $meta['forced_delimiter'] ?? $meta['detected_delimiter'] ?? ',';
$mappings = $this->loadMappings($import);
if (empty($mappings)) {
fclose($fh);
return $this->errorPayload('Ni shranjenih mapiranj za ta uvoz.');
}
$header = null;
$rowNum = 0;
// Read header if present
if ($hasHeader) {
$header = fgetcsv($fh, 0, $delimiter);
$rowNum++;
}
$simRows = [];
$summaries = $this->initSummaries();
$rowCount = 0;
while (($row = fgetcsv($fh, 0, $delimiter)) !== false && $rowCount < $limit) {
$rowNum++;
$rowCount++;
try {
$rawAssoc = $this->buildRowAssoc($row, $header);
// Skip empty rows
if ($this->rowIsEffectivelyEmpty($rawAssoc)) {
continue;
}
$mapped = $this->applyMappings($rawAssoc, $mappings);
// Group mapped data by entity (from "entity.field" to nested structure)
$groupedMapped = $this->groupMappedDataByEntity($mapped);
\Log::info('ImportSimulation: Grouped entities', [
'row' => $rowNum,
'entity_keys' => array_keys($groupedMapped),
'config_roots' => array_keys($this->entityConfigs),
]);
// Simulate processing for this row
// Context must include 'import' for EntityResolutionService to work
$context = [
'import' => $import,
'simulation' => true,
];
$rowResult = $this->simulateRow($import, $groupedMapped, $rawAssoc, $context, $verbose);
// Update summaries - handle both single and array results
foreach ($rowResult['entities'] ?? [] as $entityKey => $entityDataOrArray) {
// Extract entity root from key (e.g., 'person', 'contract', etc.)
$root = explode('.', $entityKey)[0];
// Handle array of results (grouped entities)
if (is_array($entityDataOrArray) && isset($entityDataOrArray[0])) {
foreach ($entityDataOrArray as $entityData) {
$action = $entityData['action'] ?? 'skip';
if (!isset($summaries[$root])) {
$summaries[$root] = ['create' => 0, 'update' => 0, 'skip' => 0, 'invalid' => 0];
}
$summaries[$root][$action] = ($summaries[$root][$action] ?? 0) + 1;
}
} else {
// Single result
$action = $entityDataOrArray['action'] ?? 'skip';
if (!isset($summaries[$root])) {
$summaries[$root] = ['create' => 0, 'update' => 0, 'skip' => 0, 'invalid' => 0];
}
$summaries[$root][$action] = ($summaries[$root][$action] ?? 0) + 1;
}
}
$simRows[] = [
'row_number' => $rowNum,
'raw_data' => $verbose ? $rawAssoc : null,
'entities' => $rowResult['entities'],
'warnings' => $rowResult['warnings'] ?? [],
'errors' => $rowResult['errors'] ?? [],
];
} catch (\Throwable $e) {
$simRows[] = [
'row_number' => $rowNum,
'raw_data' => $verbose ? ($rawAssoc ?? null) : null,
'entities' => [],
'warnings' => [],
'errors' => [$e->getMessage()],
];
}
}
fclose($fh);
return [
'success' => true,
'total_simulated' => $rowCount,
'limit' => $limit,
'summaries' => $summaries,
'rows' => $simRows,
'meta' => [
'has_header' => $hasHeader,
'delimiter' => $delimiter,
'mappings_count' => count($mappings),
],
];
} catch (\Throwable $e) {
return $this->errorPayload('Napaka pri simulaciji: '.$e->getMessage());
}
}
/**
* Simulate processing a single row without database writes.
*
* Updated to match ImportServiceV2 logic:
* - Process entities in priority order from entity configs
* - Accumulate entity results in context for chain resolution
* - Pass proper context to handlers for EntityResolutionService
*/
protected function simulateRow(Import $import, array $mapped, array $raw, array $context, bool $verbose): array
{
$entities = [];
$warnings = [];
$errors = [];
$entityResults = [];
// Process entities in configured priority order (like ImportServiceV2)
foreach ($this->entityConfigs as $root => $config) {
// Check if this entity exists in mapped data
$mappedKey = $this->findMappedKey($mapped, $root, $config);
if (!$mappedKey || !isset($mapped[$mappedKey])) {
continue;
}
$handler = $this->handlers[$root] ?? null;
if (!$handler) {
continue;
}
try {
// Check if this is an array of entities (grouped)
$entityDataArray = is_array($mapped[$mappedKey]) && isset($mapped[$mappedKey][0])
? $mapped[$mappedKey]
: [$mapped[$mappedKey]];
$results = [];
foreach ($entityDataArray as $entityData) {
// Validate
$validation = $handler->validate($entityData);
if (!$validation['valid']) {
$results[] = [
'action' => 'invalid',
'data' => $entityData,
'errors' => $validation['errors'],
];
continue;
}
// Skip empty/invalid data that handlers would skip during real import
// Phone: skip if nu is 0, empty, or #N/A
if ($root === 'phone') {
$nu = $entityData['nu'] ?? null;
if (empty($nu) || $nu === '0' || $nu === '#N/A' || trim((string)$nu) === '') {
continue; // Skip this phone entirely
}
}
// Address: skip if address is empty or #N/A
if ($root === 'address') {
$address = $entityData['address'] ?? null;
if (empty($address) || $address === '#N/A' || trim((string)$address) === '') {
continue; // Skip this address entirely
}
}
// Email: skip if value is 0, empty, or #N/A
if ($root === 'email') {
$email = $entityData['value'] ?? null;
if (empty($email) || $email === '0' || $email === '#N/A' || trim((string)$email) === '') {
continue; // Skip this email entirely
}
}
// DEBUG: Log context for grouped entities
if (in_array($root, ['phone', 'address'])) {
Log::info("ImportSimulation: Resolving grouped entity", [
'entity' => $root,
'data' => $entityData,
'has_person_in_context' => isset($entityResults['person']),
'person_id' => $entityResults['person']['entity']->id ?? null,
'context_keys' => array_keys(array_merge($context, $entityResults)),
]);
}
// Resolve existing entity (uses EntityResolutionService internally)
// Pass accumulated entityResults as context for chain resolution
$existingEntity = $handler->resolve($entityData, array_merge($context, $entityResults));
if ($existingEntity) {
// Would update existing
$results[] = [
'action' => 'update',
'reference' => $this->getEntityReference($existingEntity, $root),
'existing_id' => $existingEntity->id ?? null,
'data' => $entityData,
'existing_data' => $verbose ? $this->extractExistingData($existingEntity) : null,
'changes' => $verbose ? $this->detectChanges($existingEntity, $entityData) : null,
];
// Add to entityResults for subsequent handlers
$entityResults[$root] = [
'entity' => $existingEntity,
'action' => 'updated',
];
} else {
// Would create new
$results[] = [
'action' => 'create',
'data' => $entityData,
];
// Simulate entity creation for context (no actual ID)
$entityResults[$root] = [
'entity' => (object) $entityData,
'action' => 'inserted',
];
}
}
// Store results (single or array)
$entities[$mappedKey] = (count($results) === 1) ? $results[0] : $results;
} catch (\Throwable $e) {
$entities[$mappedKey] = [
'action' => 'error',
'errors' => [$e->getMessage()],
];
$errors[] = "{$root}: {$e->getMessage()}";
}
}
return compact('entities', 'warnings', 'errors');
}
/**
* Find the mapped key for an entity (supports aliases and common variations).
*/
protected function findMappedKey(array $mapped, string $canonicalRoot, $config): ?string
{
// Check canonical root exactly
if (isset($mapped[$canonicalRoot])) {
return $canonicalRoot;
}
// Build comprehensive list of variations
$variations = [$canonicalRoot];
// Generate plural variations (handle -y endings correctly)
if (str_ends_with($canonicalRoot, 'y') && !str_ends_with($canonicalRoot, 'ay') && !str_ends_with($canonicalRoot, 'ey')) {
// activity -> activities
$variations[] = substr($canonicalRoot, 0, -1) . 'ies';
} else {
// address -> addresses
$variations[] = $canonicalRoot . 's';
}
// Add singular form (remove trailing s or ies)
if (str_ends_with($canonicalRoot, 'ies')) {
$variations[] = substr($canonicalRoot, 0, -3) . 'y'; // activities -> activity
} else {
$variations[] = rtrim($canonicalRoot, 's'); // addresses -> address
}
// Add person_ prefixed versions
$variations[] = 'person_' . $canonicalRoot;
// person_activity -> person_activities
if (str_ends_with($canonicalRoot, 'y') && !str_ends_with($canonicalRoot, 'ay') && !str_ends_with($canonicalRoot, 'ey')) {
$variations[] = 'person_' . substr($canonicalRoot, 0, -1) . 'ies';
} else {
$variations[] = 'person_' . $canonicalRoot . 's';
}
// Special handling: if canonical is 'address', also check 'person_addresses'
if ($canonicalRoot === 'address') {
$variations[] = 'person_addresses';
}
// Special handling: if canonical is 'phone', also check 'person_phones'
if ($canonicalRoot === 'phone') {
$variations[] = 'person_phones';
}
// Reverse: if canonical has 'person_', also check without it
if (str_starts_with($canonicalRoot, 'person_')) {
$withoutPerson = str_replace('person_', '', $canonicalRoot);
$variations[] = $withoutPerson;
// Handle plural variations
if (str_ends_with($withoutPerson, 'y') && !str_ends_with($withoutPerson, 'ay') && !str_ends_with($withoutPerson, 'ey')) {
$variations[] = substr($withoutPerson, 0, -1) . 'ies';
} else {
$variations[] = rtrim($withoutPerson, 's');
$variations[] = $withoutPerson . 's';
}
}
$variations = array_unique($variations);
foreach ($variations as $variation) {
if (isset($mapped[$variation])) {
\Log::debug("ImportSimulation: Matched entity", [
'canonical_root' => $canonicalRoot,
'matched_key' => $variation,
]);
return $variation;
}
}
// Check aliases if configured
if (isset($config->options['aliases'])) {
$aliases = is_array($config->options['aliases']) ? $config->options['aliases'] : [];
foreach ($aliases as $alias) {
if (isset($mapped[$alias])) {
return $alias;
}
}
}
\Log::debug("ImportSimulation: No match found for entity", [
'canonical_root' => $canonicalRoot,
'tried_variations' => array_slice($variations, 0, 5),
'available_keys' => array_keys($mapped),
]);
return null;
}
/**
* Group mapped data by entity from "entity.field" format to nested structure.
* Handles both single values and arrays (for grouped entities like multiple addresses).
*
* Special handling:
* - activity.note arrays are kept together (single activity with multiple notes)
* - Other array values create separate entity instances (e.g., multiple addresses)
*
* Input: ['person.first_name' => 'John', 'person.last_name' => 'Doe', 'email.value' => ['a@b.com', 'c@d.com']]
* Output: ['person' => ['first_name' => 'John', 'last_name' => 'Doe'], 'email' => [['value' => 'a@b.com'], ['value' => 'c@d.com']]]
*/
protected function groupMappedDataByEntity(array $mapped): array
{
$grouped = [];
foreach ($mapped as $key => $value) {
if (!str_contains($key, '.')) {
continue;
}
[$entity, $field] = explode('.', $key, 2);
// Handle array values
if (is_array($value)) {
if (!isset($grouped[$entity])) {
$grouped[$entity] = [];
}
// Special case: activity.note should be kept as array in single instance
if ($entity === 'activity' || $entity === 'activities') {
if (!isset($grouped[$entity][0])) {
$grouped[$entity][0] = [];
}
$grouped[$entity][0][$field] = $value; // Keep as array
} else {
// Create separate entity instances for each array value
foreach ($value as $idx => $val) {
if (!isset($grouped[$entity][$idx])) {
$grouped[$entity][$idx] = [];
}
$grouped[$entity][$idx][$field] = $val;
}
}
} else {
// Single value
if (!isset($grouped[$entity])) {
$grouped[$entity] = [];
}
// If entity is already an array (from previous grouped field), add to all instances
if (isset($grouped[$entity][0]) && is_array($grouped[$entity][0])) {
foreach ($grouped[$entity] as &$instance) {
$instance[$field] = $value;
}
unset($instance);
} else {
// Simple associative array
$grouped[$entity][$field] = $value;
}
}
}
return $grouped;
}
/**
* Determine if entity data is grouped (array of instances).
*/
protected function isGroupedEntity($data): bool
{
if (!is_array($data)) {
return false;
}
// Check if numeric array (multiple instances)
$keys = array_keys($data);
return isset($keys[0]) && is_int($keys[0]);
}
/**
* Extract existing entity data as array.
*/
protected function extractExistingData($entity): array
{
if (method_exists($entity, 'toArray')) {
return $entity->toArray();
}
return (array) $entity;
}
/**
* Detect changes between existing entity and new data.
*/
protected function detectChanges($existingEntity, array $newData): array
{
$changes = [];
foreach ($newData as $key => $newValue) {
$oldValue = $existingEntity->{$key} ?? null;
// Convert to comparable formats
if ($oldValue instanceof \Carbon\Carbon) {
$oldValue = $oldValue->format('Y-m-d');
}
if ($oldValue != $newValue && ! ($oldValue === null && $newValue === '')) {
$changes[$key] = [
'old' => $oldValue,
'new' => $newValue,
];
}
}
return $changes;
}
/**
* Get a reference string for an entity.
*/
protected function getEntityReference($entity, string $root): string
{
if (isset($entity->reference)) {
return (string) $entity->reference;
}
if (isset($entity->value)) {
return (string) $entity->value;
}
if (isset($entity->title)) {
return (string) $entity->title;
}
if (isset($entity->id)) {
return "{$root}#{$entity->id}";
}
return 'N/A';
}
/**
* Load entity configurations from database.
*/
protected function loadEntityConfigurations(): void
{
$entities = ImportEntity::where('is_active', true)
->orderBy('priority', 'desc')
->get();
foreach ($entities as $entity) {
$this->entityConfigs[$entity->canonical_root] = $entity;
// Instantiate handler if configured
if ($entity->handler_class && class_exists($entity->handler_class)) {
$this->handlers[$entity->canonical_root] = app($entity->handler_class, ['entity' => $entity]);
}
}
}
/**
* Get handler for entity root.
*/
protected function getHandler(string $root)
{
return $this->handlers[$root] ?? null;
}
/**
* Load mappings from import_mappings table.
* Uses target_field in "entity.field" format.
* Supports multiple sources mapping to same target (for groups).
*/
protected function loadMappings(Import $import): array
{
$rows = \DB::table('import_mappings')
->where('import_id', $import->id)
->orderBy('position')
->get(['source_column', 'target_field', 'transform', 'apply_mode', 'options']);
$mappings = [];
foreach ($rows as $row) {
$source = trim((string) $row->source_column);
$target = trim((string) $row->target_field);
if ($source === '' || $target === '') {
continue;
}
// Use unique key combining source and target to avoid overwriting
$key = $source . '→' . $target;
// target_field is in "entity.field" format
$mappings[$key] = [
'source' => $source,
'target' => $target,
'transform' => $row->transform ?? null,
'apply_mode' => $row->apply_mode ?? 'both',
'options' => $row->options ? json_decode($row->options, true) : [],
];
}
return $mappings;
}
/**
* Build associative array from row data.
*/
protected function buildRowAssoc(array $row, ?array $header): array
{
if ($header) {
return array_combine($header, array_pad($row, count($header), null));
}
// Use numeric indices if no header
return array_combine(
array_map(fn ($i) => "col_{$i}", array_keys($row)),
$row
);
}
/**
* Check if row is effectively empty.
*/
protected function rowIsEffectivelyEmpty(array $assoc): bool
{
foreach ($assoc as $value) {
if ($value !== null && $value !== '') {
return false;
}
}
return true;
}
/**
* Apply mappings to raw row data.
* Returns array keyed by "entity.field".
*
* Updated to match ImportServiceV2:
* - Supports group option for concatenating multiple sources
* - Uses setNestedValue for proper array handling
*/
protected function applyMappings(array $raw, array $mappings): array
{
$mapped = [];
// Group mappings by target field to handle concatenation (same as ImportServiceV2)
$groupedMappings = [];
foreach ($mappings as $mapping) {
$target = $mapping['target'];
if (!isset($groupedMappings[$target])) {
$groupedMappings[$target] = [];
}
$groupedMappings[$target][] = $mapping;
}
foreach ($groupedMappings as $targetField => $fieldMappings) {
// Group by group number from options
$valuesByGroup = [];
foreach ($fieldMappings as $mapping) {
$source = $mapping['source'];
if (!isset($raw[$source])) {
continue;
}
$value = $raw[$source];
// Apply transform if specified
if (!empty($mapping['transform'])) {
$value = $this->applyTransform($value, $mapping['transform']);
}
// Get group from options
$options = $mapping['options'] ?? [];
$group = $options['group'] ?? null;
// Group values by their group number (same logic as ImportServiceV2)
if ($group !== null) {
// Same group = concatenate
if (!isset($valuesByGroup[$group])) {
$valuesByGroup[$group] = [];
}
$valuesByGroup[$group][] = $value;
} else {
// No group = each gets its own group
$valuesByGroup[] = [$value];
}
}
// Now set the values (same logic as ImportServiceV2)
foreach ($valuesByGroup as $values) {
if (count($values) === 1) {
// Single value - set directly
$this->setNestedValue($mapped, $targetField, $values[0]);
} else {
// Multiple values in same group - concatenate with newline
$concatenated = implode("\n", array_filter($values, fn($v) => !empty($v) && trim((string)$v) !== ''));
if (!empty($concatenated)) {
$this->setNestedValue($mapped, $targetField, $concatenated);
}
}
}
}
return $mapped;
}
/**
* Set nested value in array using dot notation.
* If the key already exists, convert to array and append the new value.
*
* Same logic as ImportServiceV2.
*/
protected function setNestedValue(array &$array, string $key, mixed $value): void
{
$keys = explode('.', $key);
$current = &$array;
foreach ($keys as $i => $k) {
if ($i === count($keys) - 1) {
// If key already exists, convert to array and append
if (isset($current[$k])) {
// Convert existing single value to array if needed
if (!is_array($current[$k])) {
$current[$k] = [$current[$k]];
}
// Append new value
$current[$k][] = $value;
} else {
// Set as single value
$current[$k] = $value;
}
} else {
if (!isset($current[$k]) || !is_array($current[$k])) {
$current[$k] = [];
}
$current = &$current[$k];
}
}
}
/**
* Apply transform to a value.
*/
protected function applyTransform(mixed $value, string $transform): mixed
{
return match ($transform) {
'trim' => trim((string) $value),
'upper' => strtoupper((string) $value),
'lower' => strtolower((string) $value),
'decimal' => (float) str_replace(',', '.', (string) $value),
default => $value,
};
}
/**
* Initialize summary counters.
*/
protected function initSummaries(): array
{
$summaries = [];
foreach (array_keys($this->entityConfigs) as $root) {
$summaries[$root] = [
'create' => 0,
'update' => 0,
'skip' => 0,
'invalid' => 0,
];
}
return $summaries;
}
/**
* Create error payload.
*/
protected function errorPayload(string $message): array
{
return [
'success' => false,
'error' => $message,
'total_simulated' => 0,
'summaries' => [],
'rows' => [],
];
}
}
+347
View File
@@ -0,0 +1,347 @@
# Import System V2 Architecture
## Overview
ImportServiceV2 is a refactored, database-driven import processing system that replaces the monolithic ImportProcessor.php with a modular, maintainable architecture.
## Key Features
- **Database-driven configuration**: Entity processing rules, validation, and handlers configured in `import_entities` table
- **Pluggable handlers**: Each entity type has its own handler class implementing `EntityHandlerInterface`
- **Queue support**: Large imports can be processed asynchronously via `ProcessLargeImportJob`
- **Validation**: Entity-level validation rules stored in database
- **Priority-based processing**: Entities processed in configured priority order
- **Extensible**: Easy to add new entity types without modifying core service
## Directory Structure
```
app/Services/Import/
├── Contracts/
│ └── EntityHandlerInterface.php # Handler contract
├── Handlers/
│ ├── ContractHandler.php # Contract entity handler
│ ├── AccountHandler.php # Account entity handler
│ ├── PaymentHandler.php # Payment handler (to be implemented)
│ ├── ActivityHandler.php # Activity handler (to be implemented)
│ └── ... # Additional handlers
├── BaseEntityHandler.php # Base handler with common logic
└── ImportServiceV2.php # Main import service
```
## Database Schema
### import_entities Table
| Column | Type | Description |
|--------|------|-------------|
| id | bigint | Primary key |
| key | string | UI key (plural, e.g., "contracts") |
| canonical_root | string | Canonical root for processor (singular, e.g., "contract") |
| label | string | Human-readable label |
| fields | json | Array of field names |
| field_aliases | json | Field alias mappings |
| aliases | json | Root aliases |
| supports_multiple | boolean | Whether entity supports multiple items per row |
| meta | boolean | Whether entity is metadata |
| rules | json | Suggestion rules |
| ui | json | UI configuration |
| handler_class | string | Fully qualified handler class name |
| validation_rules | json | Laravel validation rules |
| processing_options | json | Handler-specific options |
| is_active | boolean | Whether entity is enabled |
| priority | integer | Processing priority (higher = first) |
| created_at | timestamp | Creation timestamp |
| updated_at | timestamp | Update timestamp |
## Handler Interface
All entity handlers must implement `EntityHandlerInterface`:
```php
interface EntityHandlerInterface
{
public function process(Import $import, array $mapped, array $raw, array $context = []): array;
public function validate(array $mapped): array;
public function getEntityClass(): string;
public function resolve(array $mapped, array $context = []): mixed;
}
```
### Handler Methods
- **process()**: Main processing method, returns result with action (inserted/updated/skipped) and entity
- **validate()**: Validates mapped data before processing
- **getEntityClass()**: Returns the model class name this handler manages
- **resolve()**: Resolves existing entity by key/reference
## Creating a New Handler
1. Create handler class extending `BaseEntityHandler`:
```php
<?php
namespace App\Services\Import\Handlers;
use App\Models\YourEntity;
use App\Models\Import;
use App\Services\Import\BaseEntityHandler;
class YourEntityHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return YourEntity::class;
}
public function resolve(array $mapped, array $context = []): mixed
{
// Implement entity resolution logic
return YourEntity::where('key', $mapped['key'])->first();
}
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
$existing = $this->resolve($mapped, $context);
if ($existing) {
// Update logic
$payload = $this->buildPayload($mapped, $existing);
$appliedFields = $this->trackAppliedFields($existing, $payload);
if (empty($appliedFields)) {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'No changes detected',
];
}
$existing->fill($payload);
$existing->save();
return [
'action' => 'updated',
'entity' => $existing,
'applied_fields' => $appliedFields,
];
}
// Create logic
$entity = new YourEntity;
$payload = $this->buildPayload($mapped, $entity);
$entity->fill($payload);
$entity->save();
return [
'action' => 'inserted',
'entity' => $entity,
'applied_fields' => array_keys($payload),
];
}
protected function buildPayload(array $mapped, $model): array
{
// Map fields to model attributes
return [
'field1' => $mapped['field1'] ?? null,
'field2' => $mapped['field2'] ?? null,
];
}
}
```
2. Add configuration to `import_entities` table:
```php
ImportEntity::create([
'key' => 'your_entities',
'canonical_root' => 'your_entity',
'label' => 'Your Entities',
'fields' => ['field1', 'field2'],
'handler_class' => \App\Services\Import\Handlers\YourEntityHandler::class,
'validation_rules' => [
'field1' => 'required|string',
'field2' => 'nullable|integer',
],
'processing_options' => [
'update_mode' => 'update',
],
'is_active' => true,
'priority' => 100,
]);
```
## Usage
### Synchronous Processing
```php
use App\Services\Import\ImportServiceV2;
$service = app(ImportServiceV2::class);
$results = $service->process($import, $user);
```
### Queue Processing (Large Imports)
```php
use App\Jobs\ProcessLargeImportJob;
ProcessLargeImportJob::dispatch($import, $user->id);
```
## Processing Options
Handler-specific options stored in `processing_options` JSON column:
### Contract Handler
- `update_mode`: 'update' | 'skip' | 'error'
- `create_missing`: boolean
### Account Handler
- `update_mode`: 'update' | 'skip'
- `require_contract`: boolean
### Payment Handler (planned)
- `deduplicate_by`: array of fields
- `create_booking`: boolean
- `create_activity`: boolean
## Migration Path
### Phase 1: Setup (Current)
- ✅ Create directory structure
- ✅ Add v2 columns to import_entities
- ✅ Create base interfaces and classes
- ✅ Implement ContractHandler and AccountHandler
- ✅ Create ProcessLargeImportJob
- ✅ Create seeder for entity configurations
### Phase 2: Implementation
- [ ] Implement remaining handlers (Payment, Activity, Person, Contacts)
- [ ] Add comprehensive tests
- [ ] Update controllers to use ImportServiceV2
- [ ] Add feature flag to toggle between v1 and v2
### Phase 3: Migration
- [ ] Run both systems in parallel
- [ ] Compare results and fix discrepancies
- [ ] Migrate all imports to v2
- [ ] Remove ImportProcessor.php (v1)
## Testing
```bash
# Run migrations
php artisan migrate
# Seed entity configurations
php artisan db:seed --class=ImportEntitiesV2Seeder
# Run tests
php artisan test --filter=ImportServiceV2
```
## Benefits Over V1
1. **Maintainability**: Each entity has its own handler, easier to understand and modify
2. **Testability**: Handlers can be tested independently
3. **Extensibility**: New entities added without touching core service
4. **Configuration**: Business rules in database, no code deployment needed
5. **Queue Support**: Built-in queue support for large imports
6. **Validation**: Entity-level validation separate from processing logic
7. **Priority Control**: Process entities in configurable order
8. **Reusability**: Handlers can be reused across different import scenarios
## Simulation Service
ImportSimulationServiceV2 provides a way to preview what an import would do without persisting any data to the database. This is useful for:
- Validating mappings before processing
- Previewing create/update actions
- Detecting errors before running actual import
- Testing handler logic
### Usage
```php
use App\Services\Import\ImportSimulationServiceV2;
$service = app(ImportSimulationServiceV2::class);
// Simulate first 100 rows (default)
$result = $service->simulate($import);
// Simulate 50 rows with verbose output
$result = $service->simulate($import, limit: 50, verbose: true);
// Result structure:
// [
// 'success' => true,
// 'total_simulated' => 50,
// 'limit' => 50,
// 'summaries' => [
// 'contract' => ['create' => 10, 'update' => 5, 'skip' => 0, 'invalid' => 1],
// 'account' => ['create' => 20, 'update' => 3, 'skip' => 0, 'invalid' => 0],
// ],
// 'rows' => [
// [
// 'row_number' => 2,
// 'entities' => [
// 'contract' => [
// 'action' => 'update',
// 'reference' => 'CNT-001',
// 'existing_id' => 123,
// 'data' => ['reference', 'title', 'amount'],
// 'changes' => ['title' => ['old' => 'Old', 'new' => 'New']],
// ],
// ],
// 'warnings' => [],
// 'errors' => [],
// ],
// ],
// 'meta' => [
// 'has_header' => true,
// 'delimiter' => ',',
// 'mappings_count' => 8,
// ],
// ]
```
### CLI Command
```bash
# Simulate import with ID 123
php artisan import:simulate-v2 123
# Simulate with custom limit
php artisan import:simulate-v2 123 --limit=50
# Verbose mode shows field-level changes
php artisan import:simulate-v2 123 --verbose
```
### Action Types
- **create**: Entity doesn't exist, would be created
- **update**: Entity exists, would be updated
- **skip**: Entity exists but update_mode is 'skip'
- **invalid**: Validation failed
- **error**: Processing error occurred
### Comparison with V1 Simulation
| Feature | ImportSimulationService (V1) | ImportSimulationServiceV2 |
|---------|------------------------------|---------------------------|
| Handler-based | ❌ Hardcoded logic | ✅ Uses V2 handlers |
| Configuration | ❌ In code | ✅ From database |
| Validation | ❌ Manual | ✅ Handler validation |
| Extensibility | ❌ Modify service | ✅ Add handlers |
| Change detection | ✅ Yes | ✅ Yes |
| Priority ordering | ❌ Fixed | ✅ Configurable |
| Error handling | ✅ Basic | ✅ Comprehensive |
## Original ImportProcessor.php
The original file remains at `app/Services/ImportProcessor.php` and can be used as reference for implementing remaining handlers.