changes
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user