changes
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user