This commit is contained in:
Simon Pocrnjič
2025-12-26 22:39:58 +01:00
parent f8623a6071
commit dea7432deb
55 changed files with 7977 additions and 1983 deletions
@@ -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);
}
}