23 Commits

Author SHA1 Message Date
Simon Pocrnjič 8031501d25 Changes to import where on reactivation if start_date not set import mapping it should fill field with current date, same for end_date but it sets it to null 2026-01-02 14:49:05 +01:00
Simon Pocrnjič adc2a64687 Fixed some field job problem where field operator could still see archived contracts 2025-12-21 21:00:49 +01:00
Simon Pocrnjič 11206fb4f7 UTF8 fixed 2025-12-18 20:48:11 +01:00
Simon Pocrnjič 39a597f6eb added #N/A check 2025-12-18 20:22:14 +01:00
Simon Pocrnjič 5d4498ac5a fixed address import 2025-12-18 19:40:27 +01:00
Simon Pocrnjič 622f53e401 removed something 2025-12-18 18:25:15 +01:00
Simon Pocrnjič 96473fd60b dwdwf 2025-12-17 22:21:36 +01:00
Simon Pocrnjič 5ddca35389 test 2025-12-17 22:17:00 +01:00
Simon Pocrnjič 94ad0c0772 test 2025-12-17 22:14:43 +01:00
Simon Pocrnjič 2140181a76 sdwsd 2025-12-17 21:50:48 +01:00
Simon Pocrnjič 06fa443b3e trimming additional spaces example TEST 2 now to TEST 2 2025-12-17 21:22:17 +01:00
Simon Pocrnjič 6c45063e47 fixed naslove 2025-12-17 21:12:53 +01:00
Simon Pocrnjič b8c9b51f29 test 2025-12-17 20:51:31 +01:00
Simon Pocrnjič a4db37adfa Fix error reporting 2025-12-17 20:49:11 +01:00
Simon Pocrnjič 76f76f73b4 error handling importer 2025-12-17 20:45:02 +01:00
Simon Pocrnjič d69f4dd6f6 On template creation for entity activities action and decision or not required anymore 2025-12-16 20:18:23 +01:00
Simon Pocrnjič a596177a68 changes to import added activity entity 2025-12-16 19:35:51 +01:00
Simon Pocrnjič aa40ebed5c Again contract format problem in excel export fixed now! 2025-12-10 21:36:17 +01:00
Simon Pocrnjič 79de54eef0 Contract reference text format inside exported excel 2025-12-10 21:15:53 +01:00
Simon Pocrnjič 53941c054e add maatwebsite/excel to composer.json as required package 2025-12-10 21:05:22 +01:00
Simon Pocrnjič 1a7d2793b0 removed csrf from blade 2025-12-10 20:52:31 +01:00
Simon Pocrnjič fa54cf48f3 Segment view contract export option 2025-12-10 20:41:10 +01:00
Simon Pocrnjič d2287ef963 Changed SMS package so it allows separate SMS for each person contract. 2025-12-07 09:45:19 +01:00
27 changed files with 2831 additions and 468 deletions
+170
View File
@@ -0,0 +1,170 @@
<?php
namespace App\Exports;
use App\Models\Contract;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Maatwebsite\Excel\Concerns\FromQuery;
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
use Maatwebsite\Excel\Concerns\WithCustomValueBinder;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;
use PhpOffice\PhpSpreadsheet\Cell\Cell;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Cell\DefaultValueBinder;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
class SegmentContractsExport extends DefaultValueBinder implements FromQuery, ShouldAutoSize, WithColumnFormatting, WithCustomValueBinder, WithHeadings, WithMapping
{
public const DATE_EXCEL_FORMAT = 'dd"."mm"."yyyy';
public const TEXT_EXCEL_FORMAT = NumberFormat::FORMAT_TEXT;
/**
* @var array<string, string>
*/
private array $columnLetterMap = [];
/**
* @var array<string, array{label: string}>
*/
public const COLUMN_METADATA = [
'reference' => ['label' => 'Pogodba'],
'client_case' => ['label' => 'Primer'],
'client' => ['label' => 'Stranka'],
'type' => ['label' => 'Vrsta'],
'start_date' => ['label' => 'Začetek'],
'end_date' => ['label' => 'Konec'],
'account' => ['label' => 'Stanje'],
];
/**
* @param array<int, string> $columns
*/
public function __construct(private Builder $query, private array $columns) {}
/**
* @return array<int, string>
*/
public static function allowedColumns(): array
{
return array_keys(self::COLUMN_METADATA);
}
public static function columnLabel(string $column): string
{
return self::COLUMN_METADATA[$column]['label'] ?? $column;
}
public function query(): Builder
{
return $this->query;
}
/**
* @return array<int, mixed>
*/
public function map($row): array
{
return array_map(fn (string $column) => $this->resolveValue($row, $column), $this->columns);
}
/**
* @return array<int, string>
*/
public function headings(): array
{
return array_map(fn (string $column) => self::columnLabel($column), $this->columns);
}
/**
* @return array<string, string>
*/
public function columnFormats(): array
{
$formats = [];
foreach ($this->getColumnLetterMap() as $letter => $column) {
if ($column === 'reference') {
$formats[$letter] = self::TEXT_EXCEL_FORMAT;
continue;
}
if (in_array($column, ['start_date', 'end_date'], true)) {
$formats[$letter] = self::DATE_EXCEL_FORMAT;
}
}
return $formats;
}
private function resolveValue(Contract $contract, string $column): mixed
{
return match ($column) {
'reference' => $contract->reference,
'client_case' => optional($contract->clientCase?->person)->full_name,
'client' => optional($contract->clientCase?->client?->person)->full_name,
'type' => optional($contract->type)->name,
'start_date' => $this->formatDate($contract->start_date),
'end_date' => $this->formatDate($contract->end_date),
'account' => optional($contract->account)->balance_amount,
default => null,
};
}
private function formatDate(mixed $value): ?float
{
$carbon = Carbon::make($value);
if (! $carbon) {
return null;
}
return ExcelDate::dateTimeToExcel($carbon->copy()->startOfDay());
}
private function columnLetter(int $index): string
{
$index++;
$letter = '';
while ($index > 0) {
$remainder = ($index - 1) % 26;
$letter = chr(65 + $remainder).$letter;
$index = intdiv($index - 1, 26);
}
return $letter;
}
public function bindValue(Cell $cell, $value): bool
{
$columnKey = $this->getColumnLetterMap()[$cell->getColumn()] ?? null;
if ($columnKey === 'reference') {
$cell->setValueExplicit((string) $value, DataType::TYPE_STRING);
return true;
}
return parent::bindValue($cell, $value);
}
/**
* @return array<string, string>
*/
private function getColumnLetterMap(): array
{
if ($this->columnLetterMap === []) {
foreach ($this->columns as $index => $column) {
$this->columnLetterMap[$this->columnLetter($index)] = $column;
}
}
return $this->columnLetterMap;
}
}
@@ -223,6 +223,8 @@ public function store(StorePackageRequest $request): RedirectResponse
'created_by' => optional($request->user())->id, 'created_by' => optional($request->user())->id,
]); ]);
dd($data['items']);
$items = collect($data['items']) $items = collect($data['items'])
->map(function (array $row) { ->map(function (array $row) {
return new PackageItem([ return new PackageItem([
@@ -457,12 +459,12 @@ public function storeFromContracts(StorePackageFromContractsRequest $request, Ph
continue; continue;
} }
$key = $phone->id ? 'id:'.$phone->id : 'num:'.$phone->nu; $key = $phone->id ? 'id:'.$phone->id : 'num:'.$phone->nu;
if ($seen->contains($key)) { /*if ($seen->contains($key)) {
// skip duplicates across multiple contracts/persons // skip duplicates across multiple contracts/persons
$skipped++; $skipped++;
continue; continue;
} }*/
$seen->push($key); $seen->push($key);
$items[] = [ $items[] = [
'number' => (string) $phone->nu, 'number' => (string) $phone->nu,
+108 -155
View File
@@ -252,11 +252,14 @@ public function storeActivity(ClientCase $clientCase, Request $request)
'action_id' => 'exists:\App\Models\Action,id', 'action_id' => 'exists:\App\Models\Action,id',
'decision_id' => 'exists:\App\Models\Decision,id', 'decision_id' => 'exists:\App\Models\Decision,id',
'contract_uuid' => 'nullable|uuid', 'contract_uuid' => 'nullable|uuid',
'phone_view' => 'nullable|boolean',
'send_auto_mail' => 'sometimes|boolean', 'send_auto_mail' => 'sometimes|boolean',
'attachment_document_ids' => 'sometimes|array', 'attachment_document_ids' => 'sometimes|array',
'attachment_document_ids.*' => 'integer', 'attachment_document_ids.*' => 'integer',
]); ]);
$isPhoneView = $attributes['phone_view'] ?? false;
// Map contract_uuid to contract_id within the same client case, if provided // Map contract_uuid to contract_id within the same client case, if provided
$contractId = null; $contractId = null;
if (! empty($attributes['contract_uuid'])) { if (! empty($attributes['contract_uuid'])) {
@@ -279,10 +282,23 @@ public function storeActivity(ClientCase $clientCase, Request $request)
'decision_id' => $attributes['decision_id'], 'decision_id' => $attributes['decision_id'],
'contract_id' => $contractId, 'contract_id' => $contractId,
]); ]);
/*foreach ($activity->decision->events as $e) {
$class = '\\App\\Events\\' . $e->name; if ($isPhoneView && $contractId) {
event(new $class($clientCase)); $fieldJob = $contract->fieldJobs()
}*/ ->whereNull('completed_at')
->whereNull('cancelled_at')
->where('assigned_user_id', \Auth::id())
->orderByDesc('id')
->first();
if ($fieldJob) {
$fieldJob->update([
'added_activity' => true,
'last_activity' => $row->created_at,
]);
}
}
logger()->info('Activity successfully inserted', $attributes); logger()->info('Activity successfully inserted', $attributes);
@@ -297,8 +313,8 @@ public function storeActivity(ClientCase $clientCase, Request $request)
->values(); ->values();
$validAttachmentIds = collect(); $validAttachmentIds = collect();
if ($attachmentIds->isNotEmpty() && $contractId) { if ($attachmentIds->isNotEmpty() && $contractId) {
$validAttachmentIds = \App\Models\Document::query() $validAttachmentIds = Document::query()
->where('documentable_type', \App\Models\Contract::class) ->where('documentable_type', Contract::class)
->where('documentable_id', $contractId) ->where('documentable_id', $contractId)
->whereIn('id', $attachmentIds) ->whereIn('id', $attachmentIds)
->pluck('id'); ->pluck('id');
@@ -1458,178 +1474,115 @@ public function archiveContract(ClientCase $clientCase, string $uuid, Request $r
{ {
$contract = Contract::query()->where('uuid', $uuid)->firstOrFail(); $contract = Contract::query()->where('uuid', $uuid)->firstOrFail();
if ($contract->client_case_id !== $clientCase->id) { if ($contract->client_case_id !== $clientCase->id) {
\Log::warning('Contract not found uuid: {uuid}', ['uuid' => $uuid]);
abort(404); abort(404);
} }
$reactivateRequested = (bool) $request->boolean('reactivate');
// Determine applicable settings based on intent (archive vs reactivate) $attr = $request->validate([
if ($reactivateRequested) { 'reactivate' => 'boolean',
$latestReactivate = \App\Models\ArchiveSetting::query() ]);
->where('enabled', true)
->where('reactivate', true) $reactivate = $attr['reactivate'] ?? false;
->whereIn('strategy', ['immediate', 'manual'])
->orderByDesc('id') $setting = \App\Models\ArchiveSetting::query()
->first(); ->where('enabled', true)
if (! $latestReactivate) { ->whereIn('strategy', ['immediate', 'manual'])
return back()->with('warning', __('contracts.reactivate_not_allowed')); ->where('reactivate', $reactivate)
} ->orderByDesc('id')
$settings = collect([$latestReactivate]); ->first();
$hasReactivateRule = true;
} else { if (! $setting->exists()) {
$settings = \App\Models\ArchiveSetting::query() \Log::warning('No archive settings found!');
->where('enabled', true)
->whereIn('strategy', ['immediate', 'manual']) return back()->with('warning', 'No settings found');
->where(function ($q) { // exclude reactivate-only rules from archive run
$q->whereNull('reactivate')->orWhere('reactivate', false);
})
->get();
if ($settings->isEmpty()) {
return back()->with('warning', __('contracts.no_archive_settings'));
}
$hasReactivateRule = false;
} }
// Service archive executor
$executor = app(\App\Services\Archiving\ArchiveExecutor::class); $executor = app(\App\Services\Archiving\ArchiveExecutor::class);
$result = null;
$context = [ $context = [
'contract_id' => $contract->id, 'contract_id' => $contract->id,
'client_case_id' => $clientCase->id, 'client_case_id' => $clientCase->id,
'account_id' => $contract->account->id ?? null,
]; ];
if ($contract->account) {
$context['account_id'] = $contract->account->id; try {
$result = $executor->executeSetting($setting, $context, \Auth::id());
} catch (Exception $e) {
\Log::error('There was an error executing ArchiveExecutor::executeSetting {msg}', ['msg' => $e->getMessage()]);
return back()->with('warning', 'Something went wrong!');
} }
$overall = []; try {
$hadAnyEffect = false; \DB::transaction(function () use ($contract, $clientCase, $setting, $reactivate) {
foreach ($settings as $setting) { // Create an Activity record logging this archive if an action or decision is tied to any setting
if ($setting->action_id && $setting->decision_id) {
$res = $executor->executeSetting($setting, $context, optional($request->user())->id);
foreach ($res as $table => $count) {
$overall[$table] = ($overall[$table] ?? 0) + $count;
if ($count > 0) {
$hadAnyEffect = true;
}
}
}
if ($reactivateRequested && $hasReactivateRule) {
// Reactivation path: ensure contract becomes active and soft-delete cleared.
if ($contract->active == 0 || $contract->deleted_at) {
$contract->forceFill(['active' => 1, 'deleted_at' => null])->save();
$overall['contracts_reactivated'] = ($overall['contracts_reactivated'] ?? 0) + 1;
$hadAnyEffect = true;
}
} else {
// Ensure the contract itself is archived even if rule conditions would have excluded it
if (! empty($contract->getAttributes()) && $contract->active) {
if (! array_key_exists('contracts', $overall)) {
$contract->update(['active' => 0]);
$overall['contracts'] = ($overall['contracts'] ?? 0) + 1;
} else {
$contract->refresh();
}
$hadAnyEffect = true;
}
}
// Create an Activity record logging this archive if an action or decision is tied to any setting
if ($hadAnyEffect) {
$activitySetting = $settings->first(fn ($s) => ! is_null($s->action_id) || ! is_null($s->decision_id));
if ($activitySetting) {
try {
if ($reactivateRequested) {
$note = 'Ponovna aktivacija pogodba '.$contract->reference;
} else {
$noteKey = 'contracts.archived_activity_note';
$note = __($noteKey, ['reference' => $contract->reference]);
if ($note === $noteKey) {
$note = \Illuminate\Support\Facades\Lang::get($noteKey, ['reference' => $contract->reference], 'sl');
}
}
$activityData = [ $activityData = [
'client_case_id' => $clientCase->id, 'client_case_id' => $clientCase->id,
'action_id' => $activitySetting->action_id, 'action_id' => $setting->action_id,
'decision_id' => $activitySetting->decision_id, 'decision_id' => $setting->decision_id,
'note' => $note, 'note' => ($reactivate)
'active' => 1, ? "Ponovno aktivirana pogodba $contract->reference"
'user_id' => optional($request->user())->id, : "Arhivirana pogodba $contract->reference",
]; ];
if ($reactivateRequested) {
// Attach the contract_id when reactivated as per requirement try {
$activityData['contract_id'] = $contract->id; \App\Models\Activity::create($activityData);
} catch (Exception $e) {
\Log::warning('Activity could not be created!');
} }
\App\Models\Activity::create($activityData);
} catch (\Throwable $e) {
logger()->warning('Failed to create archive/reactivate activity', [
'error' => $e->getMessage(),
'contract_id' => $contract->id,
'setting_id' => optional($activitySetting)->id,
'reactivate' => $reactivateRequested,
]);
} }
}
} // If any archive setting specifies a segment_id, move the contract to that segment (archive bucket)
// If any archive setting specifies a segment_id, move the contract to that segment (archive bucket) if ($setting->segment_id) {
$segmentSetting = $settings->first(fn ($s) => ! is_null($s->segment_id)); // for reactivation this is the single reactivation setting if segment specified $segmentId = $setting->segment_id;
if ($segmentSetting && $segmentSetting->segment_id) {
try { $contract->segments()
$segmentId = $segmentSetting->segment_id; ->allRelatedIds()
\DB::transaction(function () use ($contract, $segmentId, $clientCase) { ->map(fn (int $val, int|string $key) => $contract->segments()->updateExistingPivot($val, [
// Ensure the segment is attached to the client case (activate if previously inactive) 'active' => false,
$casePivot = \DB::table('client_case_segment') 'updated_at' => now(),
->where('client_case_id', $clientCase->id) ])
->where('segment_id', $segmentId) );
->first();
if (! $casePivot) { if ($contract->attachedSegments()->find($segmentId)->pluck('id')->isNotEmpty()) {
\DB::table('client_case_segment')->insert([ $contract->attachedSegments()->updateExistingPivot($segmentId, [
'client_case_id' => $clientCase->id,
'segment_id' => $segmentId,
'active' => true, 'active' => true,
'created_at' => now(),
'updated_at' => now(), 'updated_at' => now(),
]); ]);
} elseif (! $casePivot->active) {
\DB::table('client_case_segment')
->where('id', $casePivot->id)
->update(['active' => true, 'updated_at' => now()]);
}
// Deactivate all current active contract segments
\DB::table('contract_segment')
->where('contract_id', $contract->id)
->where('active', true)
->update(['active' => false, 'updated_at' => now()]);
// Attach or activate the archive segment for this contract
$existing = \DB::table('contract_segment')
->where('contract_id', $contract->id)
->where('segment_id', $segmentId)
->first();
if ($existing) {
\DB::table('contract_segment')
->where('id', $existing->id)
->update(['active' => true, 'updated_at' => now()]);
} else { } else {
\DB::table('contract_segment')->insert([ $contract->segments()->attach(
'contract_id' => $contract->id, $segmentId,
'segment_id' => $segmentId, [
'active' => true, 'active' => true,
'created_at' => now(), 'created_at' => now(),
'updated_at' => now(), 'updated_at' => now(),
]); ]
);
} }
}); }
} catch (\Throwable $e) {
logger()->warning('Failed to move contract to archive segment', [ $contract->fieldJobs()
'error' => $e->getMessage(), ->whereNull('completed_at')
'contract_id' => $contract->id, ->whereNull('cancelled_at')
'segment_id' => $segmentSetting->segment_id, ->update([
'setting_id' => $segmentSetting->id, 'cancelled_at' => date('Y-m-d'),
]); 'updated_at' => now(),
} ]);
});
} catch (Exception $e) {
\Log::warning('Something went wrong with inserting / updating archive setting partials!');
return back()->with('warning', 'Something went wrong!');
} }
$message = $reactivateRequested ? __('contracts.reactivated') : __('contracts.archived'); return back()->with('success', $reactivate
? __('contracts.reactivated')
return back()->with('success', $message); : __('contracts.archived')
);
} }
/** /**
@@ -58,6 +58,13 @@ public function index()
'fields' => ['reference', 'balance_amount', 'contract_id', 'contract_reference', 'type_id', 'active', 'description'], 'fields' => ['reference', 'balance_amount', 'contract_id', 'contract_reference', 'type_id', 'active', 'description'],
'ui' => ['order' => 6], 'ui' => ['order' => 6],
], ],
[
'key' => 'activities',
'canonical_root' => 'activity',
'label' => 'Activities',
'fields' => ['note', 'due_date', 'amount', 'action_id', 'decision_id', 'contract_id', 'client_case_id', 'user_id'],
'ui' => ['order' => 7],
],
]); ]);
} else { } else {
// Ensure fields are arrays for frontend consumption // Ensure fields are arrays for frontend consumption
@@ -111,10 +111,10 @@ public function store(Request $request)
'is_active' => 'boolean', 'is_active' => 'boolean',
'reactivate' => 'boolean', 'reactivate' => 'boolean',
'entities' => 'nullable|array', 'entities' => 'nullable|array',
'entities.*' => 'string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments', 'entities.*' => 'string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities',
'mappings' => 'array', 'mappings' => 'array',
'mappings.*.source_column' => 'required|string', 'mappings.*.source_column' => 'required|string',
'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments', 'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities',
'mappings.*.target_field' => 'nullable|string', 'mappings.*.target_field' => 'nullable|string',
'mappings.*.transform' => 'nullable|string|max:50', 'mappings.*.transform' => 'nullable|string|max:50',
'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both,keyref', 'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both,keyref',
@@ -124,7 +124,11 @@ public function store(Request $request)
'meta.segment_id' => 'nullable|integer|exists:segments,id', 'meta.segment_id' => 'nullable|integer|exists:segments,id',
'meta.decision_id' => 'nullable|integer|exists:decisions,id', 'meta.decision_id' => 'nullable|integer|exists:decisions,id',
'meta.action_id' => 'nullable|integer|exists:actions,id', 'meta.action_id' => 'nullable|integer|exists:actions,id',
'meta.activity_action_id' => 'nullable|integer|exists:actions,id',
'meta.activity_decision_id' => 'nullable|integer|exists:decisions,id',
'meta.activity_created_at' => 'nullable|date',
'meta.payments_import' => 'nullable|boolean', 'meta.payments_import' => 'nullable|boolean',
'meta.history_import' => 'nullable|boolean',
'meta.contract_key_mode' => 'nullable|string|in:reference', 'meta.contract_key_mode' => 'nullable|string|in:reference',
])->validate(); ])->validate();
@@ -142,7 +146,28 @@ public function store(Request $request)
$template = null; $template = null;
DB::transaction(function () use (&$template, $request, $data) { DB::transaction(function () use (&$template, $request, $data) {
$paymentsImport = (bool) (data_get($data, 'meta.payments_import') ?? false); $paymentsImport = (bool) (data_get($data, 'meta.payments_import') ?? false);
$historyImport = (bool) (data_get($data, 'meta.history_import') ?? false);
$entities = $data['entities'] ?? []; $entities = $data['entities'] ?? [];
if ($historyImport) {
$paymentsImport = false; // history import cannot be combined with payments mode
$allowedHistoryEntities = ['person', 'person_addresses', 'person_phones', 'contracts', 'activities', 'client_cases'];
$entities = array_values(array_intersect($entities, $allowedHistoryEntities));
// If contracts are present, ensure accounts are included implicitly for reference consistency
if (in_array('contracts', $entities, true) && ! in_array('accounts', $entities, true)) {
$entities[] = 'accounts';
}
// Reject mappings that target disallowed entities for history import
$disallowedMappings = collect($data['mappings'] ?? [])->filter(function ($m) use ($allowedHistoryEntities) {
if (empty($m['entity'])) {
return false;
}
return ! in_array($m['entity'], $allowedHistoryEntities, true);
});
if ($disallowedMappings->isNotEmpty()) {
abort(422, 'History import only allows entities: person, person_addresses, person_phones, contracts, activities, client_cases. Remove other mapping entities.');
}
}
if ($paymentsImport) { if ($paymentsImport) {
$entities = ['contracts', 'accounts', 'payments']; $entities = ['contracts', 'accounts', 'payments'];
} }
@@ -162,7 +187,11 @@ public function store(Request $request)
'segment_id' => data_get($data, 'meta.segment_id'), 'segment_id' => data_get($data, 'meta.segment_id'),
'decision_id' => data_get($data, 'meta.decision_id'), 'decision_id' => data_get($data, 'meta.decision_id'),
'action_id' => data_get($data, 'meta.action_id'), 'action_id' => data_get($data, 'meta.action_id'),
'activity_action_id' => data_get($data, 'meta.activity_action_id'),
'activity_decision_id' => data_get($data, 'meta.activity_decision_id'),
'activity_created_at' => data_get($data, 'meta.activity_created_at'),
'payments_import' => $paymentsImport ?: null, 'payments_import' => $paymentsImport ?: null,
'history_import' => $historyImport ?: null,
'contract_key_mode' => data_get($data, 'meta.contract_key_mode'), 'contract_key_mode' => data_get($data, 'meta.contract_key_mode'),
], fn ($v) => ! is_null($v) && $v !== ''), ], fn ($v) => ! is_null($v) && $v !== ''),
]); ]);
@@ -244,7 +273,7 @@ public function addMapping(Request $request, ImportTemplate $template)
} }
$data = validator($raw, [ $data = validator($raw, [
'source_column' => 'required|string', 'source_column' => 'required|string',
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments', 'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities',
'target_field' => 'nullable|string', 'target_field' => 'nullable|string',
'transform' => 'nullable|string|in:trim,upper,lower', 'transform' => 'nullable|string|in:trim,upper,lower',
'apply_mode' => 'nullable|string|in:insert,update,both,keyref', 'apply_mode' => 'nullable|string|in:insert,update,both,keyref',
@@ -314,7 +343,11 @@ public function update(Request $request, ImportTemplate $template)
'meta.segment_id' => 'nullable|integer|exists:segments,id', 'meta.segment_id' => 'nullable|integer|exists:segments,id',
'meta.decision_id' => 'nullable|integer|exists:decisions,id', 'meta.decision_id' => 'nullable|integer|exists:decisions,id',
'meta.action_id' => 'nullable|integer|exists:actions,id', 'meta.action_id' => 'nullable|integer|exists:actions,id',
'meta.activity_action_id' => 'nullable|integer|exists:actions,id',
'meta.activity_decision_id' => 'nullable|integer|exists:decisions,id',
'meta.activity_created_at' => 'nullable|date',
'meta.payments_import' => 'nullable|boolean', 'meta.payments_import' => 'nullable|boolean',
'meta.history_import' => 'nullable|boolean',
'meta.contract_key_mode' => 'nullable|string|in:reference', 'meta.contract_key_mode' => 'nullable|string|in:reference',
])->validate(); ])->validate();
@@ -342,6 +375,11 @@ public function update(Request $request, ImportTemplate $template)
unset($newMeta[$k]); unset($newMeta[$k]);
} }
} }
foreach (['activity_action_id', 'activity_decision_id', 'activity_created_at'] as $k) {
if (array_key_exists($k, $newMeta) && ($newMeta[$k] === '' || is_null($newMeta[$k]))) {
unset($newMeta[$k]);
}
}
} }
// Finalize meta (ensure payments entities forced if enabled) // Finalize meta (ensure payments entities forced if enabled)
@@ -349,6 +387,20 @@ public function update(Request $request, ImportTemplate $template)
if (! empty($finalMeta['payments_import'])) { if (! empty($finalMeta['payments_import'])) {
$finalMeta['entities'] = ['contracts', 'accounts', 'payments']; $finalMeta['entities'] = ['contracts', 'accounts', 'payments'];
} }
if (! empty($finalMeta['history_import'])) {
$finalMeta['payments_import'] = false;
$allowedHistoryEntities = ['person', 'person_addresses', 'person_phones', 'contracts', 'activities', 'client_cases'];
$finalMeta['entities'] = array_values(array_intersect($finalMeta['entities'] ?? [], $allowedHistoryEntities));
if (in_array('contracts', $finalMeta['entities'] ?? [], true) && ! in_array('accounts', $finalMeta['entities'] ?? [], true)) {
$finalMeta['entities'][] = 'accounts';
}
}
if (in_array('activities', $finalMeta['entities'] ?? [], true)) {
if (empty($finalMeta['activity_action_id']) || empty($finalMeta['activity_decision_id'])) {
return back()->withErrors(['meta.activity_action_id' => 'Activities import requires selecting both a default action and decision.'])->withInput();
}
}
$update = [ $update = [
'name' => $data['name'], 'name' => $data['name'],
@@ -381,7 +433,7 @@ public function bulkAddMappings(Request $request, ImportTemplate $template)
} }
$data = validator($raw, [ $data = validator($raw, [
'sources' => 'required|string', // comma and/or newline separated 'sources' => 'required|string', // comma and/or newline separated
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments', 'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities',
'default_field' => 'nullable|string', // if provided, used as the field name for all entries 'default_field' => 'nullable|string', // if provided, used as the field name for all entries
'apply_mode' => 'nullable|string|in:insert,update,both,keyref', 'apply_mode' => 'nullable|string|in:insert,update,both,keyref',
'transform' => 'nullable|string|in:trim,upper,lower', 'transform' => 'nullable|string|in:trim,upper,lower',
@@ -583,6 +635,9 @@ public function applyToImport(Request $request, ImportTemplate $template, Import
'segment_id' => $tplMeta['segment_id'] ?? null, 'segment_id' => $tplMeta['segment_id'] ?? null,
'decision_id' => $tplMeta['decision_id'] ?? null, 'decision_id' => $tplMeta['decision_id'] ?? null,
'action_id' => $tplMeta['action_id'] ?? null, 'action_id' => $tplMeta['action_id'] ?? null,
'activity_action_id' => $tplMeta['activity_action_id'] ?? null,
'activity_decision_id' => $tplMeta['activity_decision_id'] ?? null,
'activity_created_at' => $tplMeta['activity_created_at'] ?? null,
'template_name' => $template->name, 'template_name' => $template->name,
], fn ($v) => ! is_null($v) && $v !== '')); ], fn ($v) => ! is_null($v) && $v !== ''));
+56 -144
View File
@@ -76,169 +76,81 @@ public function completedToday(Request $request)
public function showCase(\App\Models\ClientCase $clientCase, Request $request) public function showCase(\App\Models\ClientCase $clientCase, Request $request)
{ {
$userId = $request->user()->id; $userId = $request->user()->id;
$completedMode = (bool) $request->boolean('completed'); $completedMode = $request->boolean('completed');
// Eager load client case with person details // Eager load case with person details
$case = \App\Models\ClientCase::query() $case = $clientCase->load('person.addresses', 'person.phones', 'person.emails', 'person.bankAccounts');
->with(['person' => fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts'])])
->findOrFail($clientCase->id);
// Determine contracts of this case relevant to the current user // Query contracts based on field jobs
// - Normal mode: contracts assigned to me and still active (not completed/cancelled) $contractsQuery = FieldJob::query()
// - Completed mode (?completed=1): contracts where my field job was completed today ->where('assigned_user_id', $userId)
if ($completedMode) { ->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id))
$start = now()->startOfDay(); ->when($completedMode,
$end = now()->endOfDay(); fn ($q) => $q->whereNull('cancelled_at')->whereBetween('completed_at', [now()->startOfDay(), now()->endOfDay()]),
$contractIds = FieldJob::query() fn ($q) => $q->whereNull('completed_at')->whereNull('cancelled_at')
->where('assigned_user_id', $userId) );
->whereNull('cancelled_at')
->whereBetween('completed_at', [$start, $end])
->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id))
->pluck('contract_id')
->unique()
->values();
} else {
$contractIds = FieldJob::query()
->where('assigned_user_id', $userId)
->whereNull('completed_at')
->whereNull('cancelled_at')
->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id))
->pluck('contract_id')
->unique()
->values();
}
// Get contracts with relationships
$contracts = \App\Models\Contract::query() $contracts = \App\Models\Contract::query()
->where('client_case_id', $case->id) ->where('client_case_id', $case->id)
->whereIn('id', $contractIds) ->whereIn('id', $contractsQuery->pluck('contract_id')->unique())
->with(['type:id,name', 'account']) ->with(['type:id,name', 'account', 'latestObject'])
->orderByDesc('created_at') ->orderByDesc('created_at')
->get(); ->get();
// Attach latest object (if any) to each contract as last_object for display // Build merged documents
if ($contracts->isNotEmpty()) { $documents = $case->documents()
$byId = $contracts->keyBy('id');
$latestObjects = \App\Models\CaseObject::query()
->whereIn('contract_id', $byId->keys())
->whereNull('deleted_at')
->select('id', 'reference', 'name', 'description', 'type', 'contract_id', 'created_at')
->orderByDesc('created_at')
->get()
->groupBy('contract_id')
->map(function ($group) {
return $group->first();
});
foreach ($latestObjects as $cid => $obj) {
if (isset($byId[$cid])) {
$byId[$cid]->setAttribute('last_object', $obj);
}
}
}
// Build merged documents: case documents + documents of assigned contracts
$contractRefMap = [];
foreach ($contracts as $c) {
$contractRefMap[$c->id] = $c->reference;
}
$contractDocs = \App\Models\Document::query()
->where('documentable_type', \App\Models\Contract::class)
->whereIn('documentable_id', $contractIds)
->orderByDesc('created_at') ->orderByDesc('created_at')
->get() ->get()
->map(function ($d) use ($contractRefMap) { ->map(fn ($d) => array_merge($d->toArray(), [
$arr = $d->toArray(); 'documentable_type' => \App\Models\ClientCase::class,
$arr['contract_reference'] = $contractRefMap[$d->documentable_id] ?? null; 'client_case_uuid' => $case->uuid,
$arr['documentable_type'] = \App\Models\Contract::class; ]))
$arr['contract_uuid'] = optional(\App\Models\Contract::withTrashed()->find($d->documentable_id))->uuid; ->concat(
\App\Models\Document::query()
return $arr; ->where('documentable_type', \App\Models\Contract::class)
}); ->whereIn('documentable_id', $contracts->pluck('id'))
->with('documentable:id,uuid,reference')
$caseDocs = $case->documents()->orderByDesc('created_at')->get()->map(function ($d) use ($case) { ->orderByDesc('created_at')
$arr = $d->toArray(); ->get()
$arr['documentable_type'] = \App\Models\ClientCase::class; ->map(fn ($d) => array_merge($d->toArray(), [
$arr['client_case_uuid'] = $case->uuid; 'contract_reference' => $d->documentable?->reference,
'contract_uuid' => $d->documentable?->uuid,
return $arr; ]))
});
$documents = $caseDocs->concat($contractDocs)->sortByDesc('created_at')->values();
// Provide minimal types for PersonInfoGrid
$types = [
'address_types' => \App\Models\Person\AddressType::all(),
'phone_types' => \App\Models\Person\PhoneType::all(),
];
// Case activities (compact for phone): latest 20 with relations
$activities = $case->activities()
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name'])
->orderByDesc('created_at')
->limit(20)
->get()
->map(function ($a) {
$a->setAttribute('user_name', optional($a->user)->name);
return $a;
});
// Determine segment filters from FieldJobSettings for this case/user context
$settingIds = FieldJob::query()
->where('assigned_user_id', $userId)
->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id))
->when(
$completedMode,
function ($q) {
$q->whereNull('cancelled_at')
->whereBetween('completed_at', [now()->startOfDay(), now()->endOfDay()]);
},
function ($q) {
$q->whereNull('completed_at')->whereNull('cancelled_at');
}
) )
->pluck('field_job_setting_id') ->sortByDesc('created_at')
->filter()
->unique()
->values(); ->values();
$segmentIds = collect(); // Get segment IDs for filtering actions
if ($settingIds->isNotEmpty()) { $segmentIds = \App\Models\FieldJobSetting::query()
$segmentIds = \App\Models\FieldJobSetting::query() ->whereIn('id', $contractsQuery->pluck('field_job_setting_id')->filter()->unique())
->whereIn('id', $settingIds) ->pluck('segment_id')
->pluck('segment_id') ->filter()
->filter() ->unique();
->unique()
->values();
}
// Filter actions and their decisions by the derived segment ids (decisions.segment_id)
$actions = \App\Models\Action::query()
->when($segmentIds->isNotEmpty(), function ($q) use ($segmentIds) {
// Filter actions by their segment_id matching the FieldJobSetting segment(s)
$q->whereIn('segment_id', $segmentIds);
})
->with([
'decisions' => function ($q) {
$q->select('decisions.id', 'decisions.name', 'decisions.color_tag', 'decisions.auto_mail', 'decisions.email_template_id');
},
'decisions.emailTemplate' => function ($q) {
$q->select('id', 'name', 'entity_types', 'allow_attachments');
},
])
->get(['id', 'name', 'color_tag', 'segment_id']);
return Inertia::render('Phone/Case/Index', [ return Inertia::render('Phone/Case/Index', [
'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts']))->firstOrFail(), 'client' => $case->client->load('person.addresses', 'person.phones', 'person.emails', 'person.bankAccounts'),
'client_case' => $case, 'client_case' => $case,
'contracts' => $contracts, 'contracts' => $contracts,
'documents' => $documents, 'documents' => $documents,
'types' => $types, 'types' => [
'address_types' => \App\Models\Person\AddressType::all(),
'phone_types' => \App\Models\Person\PhoneType::all(),
],
'account_types' => \App\Models\AccountType::all(), 'account_types' => \App\Models\AccountType::all(),
// Provide decisions (filtered by segment) with linked email template metadata (entity_types, allow_attachments) 'actions' => \App\Models\Action::query()
'actions' => $actions, ->when($segmentIds->isNotEmpty(), fn ($q) => $q->whereIn('segment_id', $segmentIds))
'activities' => $activities, ->with([
'decisions:id,name,color_tag,auto_mail,email_template_id',
'decisions.emailTemplate:id,name,entity_types,allow_attachments',
])
->get(['id', 'name', 'color_tag', 'segment_id']),
'activities' => $case->activities()
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name'])
->orderByDesc('created_at')
->limit(20)
->get()
->map(fn ($a) => $a->setAttribute('user_name', $a->user?->name)),
'completed_mode' => $completedMode, 'completed_mode' => $completedMode,
]); ]);
} }
+135 -52
View File
@@ -2,12 +2,20 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Exports\SegmentContractsExport;
use App\Http\Requests\ExportSegmentContractsRequest;
use App\Http\Requests\StoreSegmentRequest; use App\Http\Requests\StoreSegmentRequest;
use App\Http\Requests\UpdateSegmentRequest; use App\Http\Requests\UpdateSegmentRequest;
use App\Models\Client;
use App\Models\Contract;
use App\Models\Segment; use App\Models\Segment;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Inertia; use Inertia\Inertia;
use Maatwebsite\Excel\Facades\Excel;
class SegmentController extends Controller class SegmentController extends Controller
{ {
@@ -44,64 +52,20 @@ public function index()
]); ]);
} }
public function show(\App\Models\Segment $segment) public function show(Segment $segment)
{ {
// Retrieve contracts that are active in this segment, eager-loading required relations
$search = request('search'); $search = request('search');
$clientFilter = request('client') ?? request('client_id'); // support either ?client=<uuid|id> or ?client_id=<id> $clientFilter = request('client') ?? request('client_id');
$contractsQuery = \App\Models\Contract::query() $perPage = request()->integer('perPage', request()->integer('per_page', 15));
->whereHas('segments', function ($q) use ($segment) { $perPage = max(1, min(200, $perPage));
$q->where('segments.id', $segment->id)
->where('contract_segment.active', '=', 1);
})
->with([
'clientCase.person',
'clientCase.client.person',
'type',
'account',
])
->latest('id');
// Optional filter by client (accepts numeric id or client uuid) $contracts = $this->buildContractsQuery($segment, $search, $clientFilter)
if (! empty($clientFilter)) { ->paginate($perPage)
$contractsQuery->whereHas('clientCase.client', function ($q) use ($clientFilter) {
if (is_numeric($clientFilter)) {
$q->where('clients.id', (int) $clientFilter);
} else {
$q->where('clients.uuid', $clientFilter);
}
});
}
if (! empty($search)) {
$contractsQuery->where(function ($qq) use ($search) {
$qq->where('contracts.reference', 'ilike', '%'.$search.'%')
->orWhereHas('clientCase.person', function ($p) use ($search) {
$p->where('full_name', 'ilike', '%'.$search.'%');
})
->orWhereHas('clientCase.client.person', function ($p) use ($search) {
$p->where('full_name', 'ilike', '%'.$search.'%');
});
});
}
$contracts = $contractsQuery
->paginate(15)
->withQueryString(); ->withQueryString();
// Mirror client onto the contract to simplify frontend access (c.client.person.full_name) $contracts = $this->hydrateClientShortcut($contracts);
$items = collect($contracts->items());
$items->each(function ($contract) {
if ($contract->relationLoaded('clientCase') && $contract->clientCase) {
$contract->setRelation('client', $contract->clientCase->client);
}
});
if (method_exists($contracts, 'setCollection')) {
$contracts->setCollection($items);
}
// Build a full client list for this segment (not limited to current page) for the dropdown $clients = Client::query()
$clients = \App\Models\Client::query()
->whereHas('clientCases.contracts.segments', function ($q) use ($segment) { ->whereHas('clientCases.contracts.segments', function ($q) use ($segment) {
$q->where('segments.id', $segment->id) $q->where('segments.id', $segment->id)
->where('contract_segment.active', '=', 1); ->where('contract_segment.active', '=', 1);
@@ -124,6 +88,69 @@ public function show(\App\Models\Segment $segment)
]); ]);
} }
public function export(ExportSegmentContractsRequest $request, Segment $segment)
{
$data = $request->validated();
$client = $this->resolveClient($data['client'] ?? null);
$columns = array_values(array_unique($data['columns']));
$query = $this->buildContractsQuery(
$segment,
$data['search'] ?? null,
$data['client'] ?? null
);
if (($data['scope'] ?? ExportSegmentContractsRequest::SCOPE_ALL) === ExportSegmentContractsRequest::SCOPE_CURRENT) {
$page = max(1, (int) ($data['page'] ?? 1));
$perPage = max(1, min(200, (int) ($data['per_page'] ?? 15)));
$query->forPage($page, $perPage);
}
$filename = $this->buildExportFilename($segment, $client);
return Excel::download(new SegmentContractsExport($query, $columns), $filename);
}
private function resolveClient(?string $identifier): ?Client
{
if (empty($identifier)) {
return null;
}
$query = Client::query()->with(['person:id,full_name']);
if (Str::isUuid($identifier)) {
$query->where('uuid', $identifier);
} elseif (is_numeric($identifier)) {
$query->where('id', (int) $identifier);
} else {
$query->where('uuid', $identifier);
}
return $query->first();
}
private function buildExportFilename(Segment $segment, ?Client $client): string
{
$datePrefix = now()->format('dmy');
$segmentName = $this->slugify($segment->name ?? 'segment');
$base = sprintf('%s_%s-Pogodbe', $datePrefix, $segmentName);
if ($client && $client->person?->full_name) {
$clientName = $this->slugify($client->person->full_name);
return sprintf('%s_%s.xlsx', $base, $clientName);
}
return sprintf('%s.xlsx', $base);
}
private function slugify(string $value): string
{
$slug = trim(preg_replace('/[^a-zA-Z0-9]+/', '-', $value), '-');
return $slug !== '' ? $slug : 'data';
}
public function settings(Request $request) public function settings(Request $request)
{ {
return Inertia::render('Settings/Segments/Index', [ return Inertia::render('Settings/Segments/Index', [
@@ -155,4 +182,60 @@ public function update(UpdateSegmentRequest $request, Segment $segment)
return to_route('settings.segments')->with('success', 'Segment updated'); return to_route('settings.segments')->with('success', 'Segment updated');
} }
private function buildContractsQuery(Segment $segment, ?string $search, ?string $clientFilter): Builder
{
$query = Contract::query()
->whereHas('segments', function ($q) use ($segment) {
$q->where('segments.id', $segment->id)
->where('contract_segment.active', '=', 1);
})
->with([
'clientCase.person',
'clientCase.client.person',
'type',
'account',
])
->latest('id');
if (! empty($clientFilter)) {
$query->whereHas('clientCase.client', function ($q) use ($clientFilter) {
if (is_numeric($clientFilter)) {
$q->where('clients.id', (int) $clientFilter);
} else {
$q->where('clients.uuid', $clientFilter);
}
});
}
if (! empty($search)) {
$query->where(function ($qq) use ($search) {
$qq->where('contracts.reference', 'ilike', '%'.$search.'%')
->orWhereHas('clientCase.person', function ($p) use ($search) {
$p->where('full_name', 'ilike', '%'.$search.'%');
})
->orWhereHas('clientCase.client.person', function ($p) use ($search) {
$p->where('full_name', 'ilike', '%'.$search.'%');
});
});
}
return $query;
}
private function hydrateClientShortcut(LengthAwarePaginator $contracts): LengthAwarePaginator
{
$items = collect($contracts->items());
$items->each(function (Contract $contract) {
if ($contract->relationLoaded('clientCase') && $contract->clientCase) {
$contract->setRelation('client', $contract->clientCase->client);
}
});
if (method_exists($contracts, 'setCollection')) {
$contracts->setCollection($items);
}
return $contracts;
}
} }
@@ -0,0 +1,42 @@
<?php
namespace App\Http\Requests;
use App\Exports\SegmentContractsExport;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ExportSegmentContractsRequest extends FormRequest
{
public const SCOPE_CURRENT = 'current';
public const SCOPE_ALL = 'all';
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
$columnRule = Rule::in(SegmentContractsExport::allowedColumns());
return [
'scope' => ['required', Rule::in([self::SCOPE_CURRENT, self::SCOPE_ALL])],
'columns' => ['required', 'array', 'min:1'],
'columns.*' => ['string', $columnRule],
'search' => ['nullable', 'string', 'max:255'],
'client' => ['nullable', 'string', 'max:64'],
'page' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:200'],
];
}
protected function prepareForValidation(): void
{
$this->merge([
'client' => $this->input('client') ?? $this->input('client_id'),
'per_page' => $this->input('per_page') ?? $this->input('perPage'),
]);
}
}
+17
View File
@@ -96,6 +96,11 @@ public function segments(): BelongsToMany
->wherePivot('active', true); ->wherePivot('active', true);
} }
public function attachedSegments(): BelongsToMany
{
return $this->belongsToMany(\App\Models\Segment::class);
}
public function account(): HasOne public function account(): HasOne
{ {
// Use latestOfMany to always surface newest account snapshot if multiple exist. // Use latestOfMany to always surface newest account snapshot if multiple exist.
@@ -114,6 +119,18 @@ public function documents(): MorphMany
return $this->morphMany(\App\Models\Document::class, 'documentable'); return $this->morphMany(\App\Models\Document::class, 'documentable');
} }
public function fieldJobs(): HasMany
{
return $this->hasMany(\App\Models\FieldJob::class);
}
public function latestObject(): HasOne
{
return $this->hasOne(\App\Models\CaseObject::class)
->whereNull('deleted_at')
->latest();
}
protected static function booted(): void protected static function booted(): void
{ {
static::created(function (Contract $contract): void { static::created(function (Contract $contract): void {
+6 -1
View File
@@ -24,6 +24,8 @@ class FieldJob extends Model
'priority', 'priority',
'notes', 'notes',
'address_snapshot ', 'address_snapshot ',
'last_activity',
'added_activity'
]; ];
protected $casts = [ protected $casts = [
@@ -31,6 +33,8 @@ class FieldJob extends Model
'completed_at' => 'datetime', 'completed_at' => 'datetime',
'cancelled_at' => 'datetime', 'cancelled_at' => 'datetime',
'priority' => 'boolean', 'priority' => 'boolean',
'last_activity' => 'datetime',
'added_activity' => 'boolean',
'address_snapshot ' => 'array', 'address_snapshot ' => 'array',
]; ];
@@ -90,7 +94,8 @@ public function user(): BelongsTo
public function contract(): BelongsTo public function contract(): BelongsTo
{ {
return $this->belongsTo(Contract::class, 'contract_id'); return $this->belongsTo(Contract::class, 'contract_id')
->where('active', true);
} }
/** /**
+1 -1
View File
@@ -190,7 +190,7 @@ protected static function joinNameParts(?string $first, ?string $second): ?strin
protected static function normalizeSegment(?string $value): ?string protected static function normalizeSegment(?string $value): ?string
{ {
if (blank($value)) { if (empty($value)) {
return null; return null;
} }
+3 -1
View File
@@ -69,6 +69,8 @@ public function executeSetting(ArchiveSetting $setting, ?array $context = null,
$entities = $flat; $entities = $flat;
} }
// dd($entities);
foreach ($entities as $entityDef) { foreach ($entities as $entityDef) {
$rawTable = $entityDef['table'] ?? null; $rawTable = $entityDef['table'] ?? null;
if (! $rawTable) { if (! $rawTable) {
@@ -97,7 +99,7 @@ public function executeSetting(ArchiveSetting $setting, ?array $context = null,
// Process in batches to avoid locking large tables // Process in batches to avoid locking large tables
while (true) { while (true) {
$query = DB::table($table)->whereNull('deleted_at'); $query = DB::table($table)->whereNull('deleted_at');
if (Schema::hasColumn($table, 'active')) { if (Schema::hasColumn($table, 'active') && ! $reactivate) {
$query->where('active', 1); $query->where('active', 1);
} }
// Apply context filters or chain derived filters // Apply context filters or chain derived filters
+641 -46
View File
@@ -25,11 +25,21 @@
use App\Models\Person\PersonType; use App\Models\Person\PersonType;
use App\Models\Person\PhoneType; use App\Models\Person\PhoneType;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\QueryException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
class ImportProcessor class ImportProcessor
{ {
/**
* Track contracts that already existed and were matched during history imports.
*
* @var array<int,bool>
*/
private array $historyFoundContractIds = [];
/** /**
* Process an import and apply basic dedup checks. * Process an import and apply basic dedup checks.
* Returns summary counts. * Returns summary counts.
@@ -42,6 +52,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
$imported = 0; $imported = 0;
$invalid = 0; $invalid = 0;
$fh = null; $fh = null;
$this->historyFoundContractIds = [];
// Only CSV/TSV supported in this pass // Only CSV/TSV supported in this pass
if (! in_array($import->source_type, ['csv', 'txt'])) { if (! in_array($import->source_type, ['csv', 'txt'])) {
@@ -73,6 +84,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
// Template meta flags // Template meta flags
$tplMeta = optional($import->template)->meta ?? []; $tplMeta = optional($import->template)->meta ?? [];
$paymentsImport = (bool) ($tplMeta['payments_import'] ?? false); $paymentsImport = (bool) ($tplMeta['payments_import'] ?? false);
$historyImport = (bool) ($tplMeta['history_import'] ?? false);
$contractKeyMode = $tplMeta['contract_key_mode'] ?? null; $contractKeyMode = $tplMeta['contract_key_mode'] ?? null;
// Prefer explicitly chosen delimiter, then template meta, else detected // Prefer explicitly chosen delimiter, then template meta, else detected
$delimiter = $import->meta['forced_delimiter'] $delimiter = $import->meta['forced_delimiter']
@@ -171,10 +183,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
} }
// Preflight: warn if any mapped source columns are not present in the header (exact match) // Preflight: warn if any mapped source columns are not present in the header (exact match)
$headerSet = []; $headerSet = [];
foreach ($header as $h) { // Regex validation removed per request; rely on basic length/placeholder checks only
$headerSet[$h] = true;
}
$missingSources = [];
foreach ($mappings as $map) { foreach ($mappings as $map) {
$src = (string) ($map->source_column ?? ''); $src = (string) ($map->source_column ?? '');
if ($src !== '' && ! array_key_exists($src, $headerSet)) { if ($src !== '' && ! array_key_exists($src, $headerSet)) {
@@ -207,7 +216,28 @@ public function process(Import $import, ?Authenticatable $user = null): array
if ($isPg) { if ($isPg) {
// Establish a savepoint so a failing row does not poison the whole transaction // Establish a savepoint so a failing row does not poison the whole transaction
DB::statement('SAVEPOINT import_row_'.$rowNum); try {
DB::statement('SAVEPOINT import_row_'.$rowNum);
} catch (\Throwable $se) {
Log::error('Import savepoint_failed', [
'import_id' => $import->id,
'row_number' => $rowNum,
'exception' => $this->exceptionContext($se),
]);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'event' => 'savepoint_failed',
'level' => 'error',
'message' => 'Failed to create savepoint; transaction already aborted.',
'context' => [
'row_number' => $rowNum,
'exception' => $this->exceptionContext($se),
],
]);
throw $se; // abort import so root cause surfaces
}
} }
// Scope variables per row so they aren't reused after exception // Scope variables per row so they aren't reused after exception
@@ -299,34 +329,51 @@ public function process(Import $import, ?Authenticatable $user = null): array
$contractResult = null; $contractResult = null;
} }
} else { } else {
$contractResult = $this->upsertContractChain($import, $mapped, $mappings); $contractResult = $this->upsertContractChain($import, $mapped, $mappings, $historyImport);
// If contract was resolved/updated/inserted and reactivation requested but not needed (already active), we just continue normal flow. // If contract was resolved/updated/inserted and reactivation requested but not needed (already active), we just continue normal flow.
if ($contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) { if ($contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) {
$found = $contractResult['contract']; // Do not attempt reactivation on freshly inserted contracts
if ($found->active == 0 || $found->deleted_at) { if (($contractResult['action'] ?? null) === 'inserted') {
$reactivationApplied = $this->attemptContractReactivation($found, $user); // Newly created contracts are already active; skip reactivation path
if ($reactivationApplied['reactivated']) { } else {
$reactivatedThisRow = true; $found = $contractResult['contract'];
$importRow->update([ if ($found->active == 0 || $found->deleted_at) {
'status' => 'imported', $reactivationApplied = $this->attemptContractReactivation($found, $user);
'entity_type' => Contract::class, if ($reactivationApplied['reactivated']) {
'entity_id' => $found->id, $reactivatedThisRow = true;
]); $importRow->update([
ImportEvent::create([ 'status' => 'imported',
'import_id' => $import->id, 'entity_type' => Contract::class,
'user_id' => $user?->getAuthIdentifier(), 'entity_id' => $found->id,
'import_row_id' => $importRow->id, ]);
'event' => 'contract_reactivated', ImportEvent::create([
'level' => 'info', 'import_id' => $import->id,
'message' => 'Contract reactivated via import (post-upsert).', 'user_id' => $user?->getAuthIdentifier(),
'context' => ['contract_id' => $found->id], 'import_row_id' => $importRow->id,
]); 'event' => 'contract_reactivated',
// Do not continue; allow post actions + account handling. 'level' => 'info',
'message' => 'Contract reactivated via import (post-upsert).',
'context' => ['contract_id' => $found->id],
]);
// Do not continue; allow post actions + account handling.
}
} }
} }
} }
} }
if ($contractResult['action'] === 'skipped') { if ($contractResult['action'] === 'skipped_history') {
// History import: keep existing contract for downstream relations but do not update or attach segments/actions
$skipped++;
$importRow->update(['status' => 'skipped']);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'row_skipped',
'level' => 'info',
'message' => $contractResult['message'] ?? 'Existing contract reused (history import).',
]);
} elseif ($contractResult['action'] === 'skipped') {
// Even if no contract fields were updated, we may still need to apply template meta // Even if no contract fields were updated, we may still need to apply template meta
// like attaching a segment or creating an activity. Do that if we have the contract. // like attaching a segment or creating an activity. Do that if we have the contract.
if (isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) { if (isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) {
@@ -437,12 +484,18 @@ public function process(Import $import, ?Authenticatable $user = null): array
// Accounts // Accounts
$accountResult = null; $accountResult = null;
if ($historyImport && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract && ! isset($mapped['account'])) {
$autoAcc = $this->ensureHistoryAccount($contractResult['contract'], $mapped);
if ($autoAcc) {
$accountResult = ['action' => 'inserted', 'account' => $autoAcc, 'contract' => $contractResult['contract'], 'contract_id' => $contractResult['contract']->id];
}
}
if (isset($mapped['account'])) { if (isset($mapped['account'])) {
// If a contract was just created or resolved above, pass its id to account mapping for this row // If a contract was just created or resolved above, pass its id to account mapping for this row
if ($contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) { if ($contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) {
$mapped['account']['contract_id'] = $contractResult['contract']->id; $mapped['account']['contract_id'] = $contractResult['contract']->id;
} }
$accountResult = $this->upsertAccount($import, $mapped, $mappings); $accountResult = $this->upsertAccount($import, $mapped, $mappings, $historyImport);
if ($accountResult['action'] === 'skipped') { if ($accountResult['action'] === 'skipped') {
$skipped++; $skipped++;
$importRow->update(['status' => 'skipped']); $importRow->update(['status' => 'skipped']);
@@ -555,6 +608,55 @@ public function process(Import $import, ?Authenticatable $user = null): array
} }
} }
// Activities: create or update activities linked to contracts/cases
if (isset($mapped['activity'])) {
$activityResult = $this->upsertActivity($import, $mapped, $mappings, $contractResult ?? null, $accountResult ?? null);
if ($activityResult['action'] === 'skipped') {
$skipped++;
$importRow->update(['status' => 'skipped']);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'row_skipped',
'level' => 'info',
'message' => $activityResult['message'] ?? 'Skipped (no changes).',
'context' => $activityResult['context'] ?? null,
]);
} elseif (in_array($activityResult['action'], ['inserted', 'updated'], true)) {
$imported++;
$importRow->update([
'status' => 'imported',
'entity_type' => Activity::class,
'entity_id' => $activityResult['activity']->id,
]);
$activityFieldsStr = '';
if (! empty($activityResult['applied_fields'] ?? [])) {
$activityFieldsStr = $this->formatAppliedFieldMessage('activity', $activityResult['applied_fields']);
}
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'row_imported',
'level' => 'info',
'message' => ucfirst($activityResult['action']).' activity'.($activityFieldsStr ? ' '.$activityFieldsStr : ''),
'context' => ['id' => $activityResult['activity']->id, 'fields' => $activityResult['applied_fields'] ?? []],
]);
} else {
$invalid++;
$importRow->update(['status' => 'invalid', 'errors' => [$activityResult['message'] ?? 'Activity processing failed']]);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'row_invalid',
'level' => 'error',
'message' => $activityResult['message'] ?? 'Activity processing failed',
]);
}
}
// Contacts: resolve person via Contract/Account chain, client_case.client_ref, contacts, or identifiers // Contacts: resolve person via Contract/Account chain, client_case.client_ref, contacts, or identifiers
$personIdForRow = null; $personIdForRow = null;
// Prefer person from contract created/updated above // Prefer person from contract created/updated above
@@ -986,13 +1088,38 @@ public function process(Import $import, ?Authenticatable $user = null): array
} }
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
$rollbackFailed = false;
$rollbackError = null;
if ($isPg) { if ($isPg) {
// Roll back only this row's work // Roll back only this row's work
try { try {
DB::statement('ROLLBACK TO SAVEPOINT import_row_'.$rowNum); DB::statement('ROLLBACK TO SAVEPOINT import_row_'.$rowNum);
} catch (\Throwable $ignored) { /* noop */ } catch (\Throwable $ignored) {
$rollbackFailed = true;
$rollbackError = $ignored;
} }
} }
if ($rollbackFailed) {
Log::error('Import row_rollback_failed', [
'import_id' => $import->id,
'row_number' => $rowNum,
'exception' => $this->exceptionContext($rollbackError ?? $e),
]);
// Abort the whole import if we cannot rollback to the row savepoint (transaction is poisoned)
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'event' => 'row_rollback_failed',
'level' => 'error',
'message' => 'Rollback to savepoint failed; aborting import.',
'context' => [
'row_number' => $rowNum,
'exception' => $this->exceptionContext($rollbackError ?? $e),
],
]);
throw $rollbackError ?? $e;
}
// Ensure importRow exists for logging if failure happened before its creation // Ensure importRow exists for logging if failure happened before its creation
if (! $importRow) { if (! $importRow) {
try { try {
@@ -1019,6 +1146,12 @@ public function process(Import $import, ?Authenticatable $user = null): array
} }
$failedRows[] = $rowNum; $failedRows[] = $rowNum;
$invalid++; $invalid++;
Log::error('Import row_exception', [
'import_id' => $import->id,
'row_number' => $rowNum,
'exception' => $this->exceptionContext($e),
'raw_preview' => isset($rawAssoc) ? $this->buildRawDataPreview($rawAssoc) : [],
]);
try { try {
ImportEvent::create([ ImportEvent::create([
'import_id' => $import->id, 'import_id' => $import->id,
@@ -1036,6 +1169,12 @@ public function process(Import $import, ?Authenticatable $user = null): array
], ],
]); ]);
} catch (\Throwable $evtErr) { } catch (\Throwable $evtErr) {
Log::error('Import row_exception_event_failed', [
'import_id' => $import->id,
'row_number' => $rowNum,
'exception' => $this->exceptionContext($evtErr),
'original_exception' => $this->exceptionContext($e),
]);
// Swallow secondary failure to ensure loop continues // Swallow secondary failure to ensure loop continues
} }
@@ -1060,6 +1199,29 @@ public function process(Import $import, ?Authenticatable $user = null): array
]); ]);
} }
$meta = $import->meta ?? [];
if ($historyImport) {
if (! empty($this->historyFoundContractIds)) {
$found = Contract::query()
->with(['clientCase.person'])
->whereIn('id', array_keys($this->historyFoundContractIds))
->get()
->map(function (Contract $c) {
return [
'contract_uuid' => $c->uuid ?? null,
'reference' => $c->reference,
'case_uuid' => $c->clientCase?->uuid,
'full_name' => $c->clientCase?->person?->full_name,
];
})
->values()
->all();
$meta['history_found_contracts'] = $found;
} else {
$meta['history_found_contracts'] = [];
}
}
$import->update([ $import->update([
'status' => 'completed', 'status' => 'completed',
'finished_at' => now(), 'finished_at' => now(),
@@ -1067,6 +1229,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
'imported_rows' => $imported, 'imported_rows' => $imported,
'invalid_rows' => $invalid, 'invalid_rows' => $invalid,
'valid_rows' => $total - $invalid, 'valid_rows' => $total - $invalid,
'meta' => $meta,
]); ]);
DB::commit(); DB::commit();
@@ -1084,12 +1247,17 @@ public function process(Import $import, ?Authenticatable $user = null): array
// Mark failed and log after rollback (so no partial writes persist) // Mark failed and log after rollback (so no partial writes persist)
$import->refresh(); $import->refresh();
$import->update(['status' => 'failed', 'failed_at' => now()]); $import->update(['status' => 'failed', 'failed_at' => now()]);
Log::error('Import processing_failed', [
'import_id' => $import->id,
'exception' => $this->exceptionContext($e),
]);
ImportEvent::create([ ImportEvent::create([
'import_id' => $import->id, 'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(), 'user_id' => $user?->getAuthIdentifier(),
'event' => 'processing_failed', 'event' => 'processing_failed',
'level' => 'error', 'level' => 'error',
'message' => $e->getMessage(), 'message' => $this->safeErrorMessage($e->getMessage()),
'context' => $this->exceptionContext($e),
]); ]);
return ['ok' => false, 'status' => 'failed', 'error' => $e->getMessage()]; return ['ok' => false, 'status' => 'failed', 'error' => $e->getMessage()];
@@ -1119,6 +1287,21 @@ protected function applyMappings(array $raw, $mappings, array $supportsMultiple)
{ {
$recordType = null; $recordType = null;
$mapped = []; $mapped = [];
$appendValue = function ($existing, $label, $value) {
// Skip empty new values
if (is_null($value) || (is_string($value) && trim($value) === '')) {
return $existing;
}
$stringVal = is_scalar($value) ? (string) $value : json_encode($value);
$existingStr = is_null($existing) ? '' : (is_scalar($existing) ? (string) $existing : json_encode($existing));
if ($existingStr === '') {
return $label.': '.$stringVal;
}
return $existingStr.', '.$label.': '.$stringVal;
};
$fieldFirstLabel = [];
$rootFirstLabel = [];
foreach ($mappings as $map) { foreach ($mappings as $map) {
$src = $map->source_column; $src = $map->source_column;
$target = $map->target_field; $target = $map->target_field;
@@ -1278,9 +1461,34 @@ protected function applyMappings(array $raw, $mappings, array $supportsMultiple)
if (! isset($mapped[$root]) || ! is_array($mapped[$root])) { if (! isset($mapped[$root]) || ! is_array($mapped[$root])) {
$mapped[$root] = []; $mapped[$root] = [];
} }
$mapped[$root][$field] = $value; $canConcat = $this->fieldAllowsConcatenation($root, $field);
if ($canConcat) {
$key = $root.'.'.$field;
if (! array_key_exists($field, $mapped[$root]) || is_null($mapped[$root][$field]) || $mapped[$root][$field] === '') {
$mapped[$root][$field] = $value;
$fieldFirstLabel[$key] = (string) $src;
} else {
$existing = $mapped[$root][$field];
$firstLabel = $fieldFirstLabel[$key] ?? null;
$existingStr = $firstLabel ? ($firstLabel.': '.(is_scalar($existing) ? (string) $existing : json_encode($existing))) : (is_scalar($existing) ? (string) $existing : json_encode($existing));
$mapped[$root][$field] = $appendValue($existingStr, (string) $src, $value);
}
} else {
// For typed fields (dates/numbers), keep the first non-empty value to avoid coercion errors
if (! array_key_exists($field, $mapped[$root]) || is_null($mapped[$root][$field]) || $mapped[$root][$field] === '') {
$mapped[$root][$field] = $value;
}
}
} else { } else {
$mapped[$root] = $value; if (! array_key_exists($root, $mapped) || is_null($mapped[$root]) || $mapped[$root] === '') {
$mapped[$root] = $value;
$rootFirstLabel[$root] = (string) $src;
} else {
$firstLabel = $rootFirstLabel[$root] ?? null;
$existing = $mapped[$root];
$existingStr = $firstLabel ? ($firstLabel.': '.(is_scalar($existing) ? (string) $existing : json_encode($existing))) : (is_scalar($existing) ? (string) $existing : json_encode($existing));
$mapped[$root] = $appendValue($existingStr, (string) $src, $value);
}
} }
} }
} }
@@ -1288,6 +1496,28 @@ protected function applyMappings(array $raw, $mappings, array $supportsMultiple)
return [$recordType, $mapped]; return [$recordType, $mapped];
} }
/**
* Decide whether multiple mapped source columns should be concatenated for a given target field.
* For date/time and numeric-like fields we avoid concatenation to prevent invalid type coercion.
*/
private function fieldAllowsConcatenation(?string $root, ?string $field): bool
{
if ($field === null) {
return true;
}
$f = strtolower($field);
// Date / datetime indicators
if ($f === 'birthday' || str_contains($f, 'date') || str_ends_with($f, '_at')) {
return false;
}
// Common numeric fields
if (in_array($f, ['amount', 'amount_cents', 'quantity', 'balance_amount'], true)) {
return false;
}
return true;
}
private function arraySetDot(array &$arr, string $path, $value): void private function arraySetDot(array &$arr, string $path, $value): void
{ {
$keys = explode('.', $path); $keys = explode('.', $path);
@@ -1301,7 +1531,7 @@ private function arraySetDot(array &$arr, string $path, $value): void
$ref = $value; $ref = $value;
} }
private function upsertAccount(Import $import, array $mapped, $mappings): array private function upsertAccount(Import $import, array $mapped, $mappings, bool $historyImport = false): array
{ {
$clientId = $import->client_id; // may be null, used for contract lookup/creation $clientId = $import->client_id; // may be null, used for contract lookup/creation
$acc = $mapped['account'] ?? []; $acc = $mapped['account'] ?? [];
@@ -1447,6 +1677,10 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
} }
} }
if ($historyImport && $existing) {
return ['action' => 'skipped', 'account' => $existing, 'contract_id' => $contractId, 'message' => 'History import does not update accounts'];
}
if ($existing) { if ($existing) {
// Build non-null changes for account fields // Build non-null changes for account fields
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v)); $changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
@@ -1505,6 +1739,11 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
&& ($initMode === null || in_array($initMode, ['insert', 'both'], true))) { && ($initMode === null || in_array($initMode, ['insert', 'both'], true))) {
$applyInsert['initial_amount'] = $applyInsert['balance_amount']; $applyInsert['initial_amount'] = $applyInsert['balance_amount'];
} }
if ($historyImport) {
// Force zero amounts for history imports regardless of mapped amounts
$applyInsert['balance_amount'] = 0;
$applyInsert['initial_amount'] = 0;
}
if (empty($applyInsert)) { if (empty($applyInsert)) {
return ['action' => 'skipped', 'message' => 'No fields marked for insert']; return ['action' => 'skipped', 'message' => 'No fields marked for insert'];
} }
@@ -1516,12 +1755,44 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
if (! array_key_exists('active', $data)) { if (! array_key_exists('active', $data)) {
$data['active'] = 1; $data['active'] = 1;
} }
if ($historyImport) {
$data['balance_amount'] = 0;
$data['initial_amount'] = 0;
}
$created = Account::create($data); $created = Account::create($data);
return ['action' => 'inserted', 'account' => $created, 'contract_id' => $contractId, 'applied_fields' => $data]; return ['action' => 'inserted', 'account' => $created, 'contract_id' => $contractId, 'applied_fields' => $data];
} }
} }
private function ensureHistoryAccount(Contract $contract, array $mapped): ?Account
{
$existing = Account::query()
->where('contract_id', $contract->id)
->where('active', 1)
->first();
if ($existing) {
return $existing;
}
$reference = $mapped['account']['reference'] ?? $mapped['contract']['reference'] ?? null;
if (is_string($reference)) {
$reference = preg_replace('/\s+/', '', trim($reference));
}
if (! $reference || $reference === '') {
$reference = 'HIST-'.$contract->id;
}
return Account::create([
'contract_id' => $contract->id,
'reference' => $reference,
'type_id' => $this->getDefaultAccountTypeId(),
'active' => 1,
'balance_amount' => 0,
'initial_amount' => 0,
]);
}
private function upsertCaseObject(Import $import, array $mapped, $mappings, int $contractId): array private function upsertCaseObject(Import $import, array $mapped, $mappings, int $contractId): array
{ {
// Support both 'case_object' and 'case_objects' keys (template may use plural) // Support both 'case_object' and 'case_objects' keys (template may use plural)
@@ -1625,6 +1896,210 @@ private function upsertCaseObject(Import $import, array $mapped, $mappings, int
} }
} }
private function upsertActivity(Import $import, array $mapped, $mappings, ?array $contractResult, ?array $accountResult): array
{
$activity = $mapped['activity'] ?? [];
// Default contract/client_case from freshly created or updated contract when present
if ($contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) {
$activity['contract_id'] = $activity['contract_id'] ?? $contractResult['contract']->id;
$activity['client_case_id'] = $activity['client_case_id'] ?? $contractResult['contract']->client_case_id;
}
$contractId = $activity['contract_id'] ?? null;
if (! $contractId && $contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) {
$contractId = $contractResult['contract']->id;
} elseif (! $contractId && $accountResult && isset($accountResult['contract_id'])) {
$contractId = $accountResult['contract_id'];
} elseif (! $contractId && $accountResult && isset($accountResult['contract']) && $accountResult['contract'] instanceof Contract) {
$contractId = $accountResult['contract']->id;
}
if (! $contractId && $import->client_id && ! empty($mapped['contract']['reference'] ?? null)) {
$ref = preg_replace('/\s+/', '', trim((string) $mapped['contract']['reference']));
if ($ref !== '') {
$contractId = Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->where('client_cases.client_id', $import->client_id)
->where('contracts.reference', $ref)
->value('contracts.id');
}
}
$clientCaseId = $activity['client_case_id'] ?? null;
if (! $clientCaseId && $contractId) {
$clientCaseId = Contract::where('id', $contractId)->value('client_case_id');
}
if (! $clientCaseId && $contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) {
$clientCaseId = $contractResult['contract']->client_case_id;
}
if (! $clientCaseId && $accountResult && isset($accountResult['contract']) && $accountResult['contract'] instanceof Contract) {
$clientCaseId = $accountResult['contract']->client_case_id;
}
if (! $clientCaseId && $accountResult && isset($accountResult['contract_id'])) {
$clientCaseId = Contract::where('id', $accountResult['contract_id'])->value('client_case_id');
}
if (! $clientCaseId && $import->client_id && ! empty($mapped['client_case']['client_ref'] ?? null)) {
$clientCaseId = ClientCase::where('client_id', $import->client_id)
->where('client_ref', $mapped['client_case']['client_ref'])
->value('id');
}
if (! $clientCaseId) {
return [
'action' => 'invalid',
'message' => 'Activity requires a client_case_id or resolvable contract.',
'context' => [
'contract_id' => $contractId,
'contract_reference' => $mapped['contract']['reference'] ?? null,
],
];
}
// Collect apply_mode settings for activity fields
$applyModeByField = [];
foreach ($mappings as $map) {
$target = (string) ($map->target_field ?? '');
if (! str_starts_with($target, 'activity.')) {
continue;
}
$field = substr($target, strlen('activity.'));
$applyModeByField[$field] = (string) ($map->apply_mode ?? 'both');
}
$applyInsert = [];
$applyUpdate = [];
foreach ($activity as $field => $value) {
$applyMode = $applyModeByField[$field] ?? 'both';
$normalized = $value;
if ($field === 'due_date') {
$normalized = is_scalar($value) ? $this->normalizeDate((string) $value) : null;
} elseif ($field === 'created_at') {
$normalized = is_scalar($value) ? $this->normalizeDateTime((string) $value) : null;
} elseif ($field === 'amount') {
if (is_string($value)) {
$normalized = $this->normalizeDecimal($value);
}
$normalized = is_null($normalized) ? null : (float) $normalized;
} elseif (in_array($field, ['action_id', 'decision_id', 'user_id'], true)) {
$normalized = is_null($value) ? null : (int) $value;
} elseif (is_string($normalized)) {
// Clean invalid UTF-8 sequences from string fields
$normalized = mb_convert_encoding($normalized, 'UTF-8', 'UTF-8');
$normalized = trim($normalized);
}
if (in_array($applyMode, ['both', 'insert'], true)) {
$applyInsert[$field] = $normalized;
}
if (in_array($applyMode, ['both', 'update'], true)) {
$applyUpdate[$field] = $normalized;
}
}
$settings = \App\Models\PaymentSetting::query()->first();
$tplMeta = optional($import->template)->meta ?? [];
$defaultActionId = $applyInsert['action_id']
?? $applyUpdate['action_id']
?? ($import->meta['activity_action_id'] ?? null)
?? ($tplMeta['activity_action_id'] ?? null)
?? ($import->meta['action_id'] ?? null)
?? ($tplMeta['action_id'] ?? null)
?? ($settings?->default_action_id ?? null);
$defaultDecisionId = $applyInsert['decision_id']
?? $applyUpdate['decision_id']
?? ($import->meta['activity_decision_id'] ?? null)
?? ($tplMeta['activity_decision_id'] ?? null)
?? ($import->meta['decision_id'] ?? null)
?? ($tplMeta['decision_id'] ?? null);
if (! $defaultActionId) {
return [
'action' => 'invalid',
'message' => 'Activity requires action_id (provide via mapping or import meta).',
];
}
$applyInsert['action_id'] = $applyInsert['action_id'] ?? $defaultActionId;
$applyUpdate['action_id'] = $applyUpdate['action_id'] ?? $defaultActionId;
if ($defaultDecisionId) {
$applyInsert['decision_id'] = $applyInsert['decision_id'] ?? $defaultDecisionId;
$applyUpdate['decision_id'] = $applyUpdate['decision_id'] ?? $defaultDecisionId;
}
// Default created_at for inserted activities (from mapped field or template/import meta)
if (empty($applyInsert['created_at'])) {
$defaultCreated = $this->normalizeDateTime($applyInsert['created_at'] ?? $import->meta['activity_created_at'] ?? $tplMeta['activity_created_at'] ?? null);
if ($defaultCreated) {
$applyInsert['created_at'] = $defaultCreated;
if (empty($applyInsert['updated_at'])) {
$applyInsert['updated_at'] = $defaultCreated;
}
}
}
$applyInsert['client_case_id'] = $clientCaseId;
$applyUpdate['client_case_id'] = $applyUpdate['client_case_id'] ?? $clientCaseId;
if ($contractId) {
$applyInsert['contract_id'] = $contractId;
$applyUpdate['contract_id'] = $applyUpdate['contract_id'] ?? $contractId;
}
// Idempotency: if note + due_date + contract match, treat as existing
$existing = null;
$noteKey = $applyInsert['note'] ?? null;
$dueKey = $applyInsert['due_date'] ?? null;
if ($contractId && $noteKey && $dueKey) {
$existing = Activity::query()
->where('contract_id', $contractId)
->where('note', $noteKey)
->whereDate('due_date', $dueKey)
->first();
} elseif ($clientCaseId && $noteKey && $dueKey) {
$existing = Activity::query()
->where('client_case_id', $clientCaseId)
->where('note', $noteKey)
->whereDate('due_date', $dueKey)
->first();
}
if ($existing) {
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
if (! empty($changes)) {
$existing->fill($changes);
$existing->save();
return ['action' => 'updated', 'activity' => $existing, 'applied_fields' => $changes];
}
return ['action' => 'skipped', 'activity' => $existing, 'message' => 'No changes needed'];
}
$data = array_filter($applyInsert, fn ($v) => ! is_null($v));
$activityModel = new Activity;
$activityModel->forceFill($data);
if (array_key_exists('created_at', $data)) {
// Preserve provided timestamps by disabling automatic timestamps for this save
$activityModel->timestamps = false;
$activityModel->save();
$activityModel->timestamps = true;
} else {
$activityModel->save();
}
return ['action' => 'inserted', 'activity' => $activityModel, 'applied_fields' => $data];
}
private function normalizeDateTime(?string $raw): ?string
{
if ($raw === null || trim($raw) === '') {
return null;
}
try {
return Carbon::parse($raw)->format('Y-m-d H:i:s');
} catch (\Throwable $e) {
return null;
}
}
private function mappingsContainRoot($mappings, string $root): bool private function mappingsContainRoot($mappings, string $root): bool
{ {
foreach ($mappings as $map) { foreach ($mappings as $map) {
@@ -1657,7 +2132,7 @@ private function findPersonIdByIdentifiers(array $p): ?int
return null; return null;
} }
private function upsertContractChain(Import $import, array $mapped, $mappings): array private function upsertContractChain(Import $import, array $mapped, $mappings, bool $historyImport = false): array
{ {
$contractData = $mapped['contract'] ?? []; $contractData = $mapped['contract'] ?? [];
$reference = $contractData['reference'] ?? null; $reference = $contractData['reference'] ?? null;
@@ -1821,10 +2296,36 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
} }
if ($existing) { if ($existing) {
if ($historyImport) {
$this->historyFoundContractIds[$existing->id] = true;
return ['action' => 'skipped_history', 'contract' => $existing, 'message' => 'Existing contract left unchanged (history import)'];
}
// Check if contract is soft-deleted and needs reactivation
$isTrashed = $existing->trashed();
// 1) Prepare contract field changes (non-null) // 1) Prepare contract field changes (non-null)
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v)); $changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
// 2) Prepare meta changes if provided via mapping // 2) Handle reactivation defaults when contract is soft-deleted
if ($isTrashed || $existing->active == 0) {
// Check if start_date is in the mappings
$hasStartDateMapping = $this->mappingIncludes($mappings, 'contract.start_date');
if (!$hasStartDateMapping) {
// Default to current date when not in mappings
$changes['start_date'] = now()->toDateString();
}
// Check if end_date is in the mappings
$hasEndDateMapping = $this->mappingIncludes($mappings, 'contract.end_date');
if (!$hasEndDateMapping) {
// Default to null when not in mappings
$changes['end_date'] = null;
}
}
// 3) Prepare meta changes if provided via mapping
$metaUpdated = false; $metaUpdated = false;
$metaAppliedKeys = []; $metaAppliedKeys = [];
if (! empty($contractData['meta'] ?? null) && is_array($contractData['meta'])) { if (! empty($contractData['meta'] ?? null) && is_array($contractData['meta'])) {
@@ -1867,7 +2368,7 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
} }
} }
if (empty($changes) && ! $metaUpdated) { if (empty($changes) && ! $metaUpdated && ! $isTrashed) {
// Nothing to change // Nothing to change
return ['action' => 'skipped', 'message' => 'No contract fields or meta changes', 'contract' => $existing]; return ['action' => 'skipped', 'message' => 'No contract fields or meta changes', 'contract' => $existing];
} }
@@ -1875,6 +2376,12 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
if (! empty($changes)) { if (! empty($changes)) {
$existing->fill($changes); $existing->fill($changes);
} }
// Restore soft-deleted contract if it was trashed
if ($isTrashed) {
$existing->restore();
}
$existing->save(); $existing->save();
// Build applied fields info, include meta keys if any // Build applied fields info, include meta keys if any
@@ -1885,7 +2392,9 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
} }
} }
return ['action' => 'updated', 'contract' => $existing, 'applied_fields' => $applied]; $actionType = $isTrashed ? 'reactivated' : 'updated';
return ['action' => $actionType, 'contract' => $existing, 'applied_fields' => $applied];
} else { } else {
if (empty($applyInsert)) { if (empty($applyInsert)) {
return ['action' => 'skipped', 'message' => 'No contract fields marked for insert']; return ['action' => 'skipped', 'message' => 'No contract fields marked for insert'];
@@ -2061,13 +2570,57 @@ private function safeErrorMessage(string $msg): string
} }
// Fallback strip invalid bytes // Fallback strip invalid bytes
$msg = @iconv('UTF-8', 'UTF-8//IGNORE', $msg) ?: $msg; $msg = @iconv('UTF-8', 'UTF-8//IGNORE', $msg) ?: $msg;
if (strlen($msg) > 500) { // Use mb_strlen and mb_substr for UTF-8 safety
$msg = substr($msg, 0, 497).'...'; if (mb_strlen($msg) > 500) {
$msg = mb_substr($msg, 0, 497).'...';
} }
return $msg; return $msg;
} }
/**
* Extract structured exception details for logging.
*/
private function exceptionContext(\Throwable $e): array
{
$ctx = [
'exception' => get_class($e),
'message' => $this->safeErrorMessage($e->getMessage()),
'code' => $e->getCode(),
'file' => $e->getFile().':'.$e->getLine(),
];
if (method_exists($e, 'getPrevious') && $e->getPrevious()) {
$prev = $e->getPrevious();
$ctx['previous'] = [
'exception' => get_class($prev),
'message' => $this->safeErrorMessage($prev->getMessage()),
'code' => $prev->getCode(),
'file' => $prev->getFile().':'.$prev->getLine(),
];
}
if ($e instanceof QueryException) {
$ctx['sql'] = $e->getSql();
$ctx['bindings'] = $e->getBindings();
$info = $e->errorInfo ?? null;
if (is_array($info)) {
$ctx['sqlstate'] = $info[0] ?? null;
$ctx['driver_error_code'] = $info[1] ?? null;
$ctx['driver_error_message'] = $info[2] ?? null;
}
} elseif (property_exists($e, 'errorInfo')) {
$info = $e->errorInfo;
if (is_array($info)) {
$ctx['sqlstate'] = $info[0] ?? null;
$ctx['driver_error_code'] = $info[1] ?? null;
$ctx['driver_error_message'] = $info[2] ?? null;
}
}
return $ctx;
}
/** /**
* Build a trimmed raw data preview (first 8 columns, truncated values) for logging. * Build a trimmed raw data preview (first 8 columns, truncated values) for logging.
*/ */
@@ -2108,9 +2661,9 @@ private function formatAppliedFieldMessage(string $root, array $fields): string
} else { } else {
$disp = method_exists($v, '__toString') ? (string) $v : gettype($v); $disp = method_exists($v, '__toString') ? (string) $v : gettype($v);
} }
// Truncate very long values for log safety // Truncate very long values for log safety (use mb_substr for UTF-8 safety)
if (strlen($disp) > 60) { if (mb_strlen($disp) > 60) {
$disp = substr($disp, 0, 57).'...'; $disp = mb_substr($disp, 0, 57).'...';
} }
$parts[] = $k.'='.$disp; $parts[] = $k.'='.$disp;
} }
@@ -2529,6 +3082,12 @@ private function upsertEmail(int $personId, array $emailData, $mappings): array
if ($value === '') { if ($value === '') {
return ['action' => 'skipped', 'message' => 'No email value']; return ['action' => 'skipped', 'message' => 'No email value'];
} }
$normalizedEmail = filter_var($value, FILTER_VALIDATE_EMAIL);
if (! $normalizedEmail) {
return ['action' => 'skipped', 'message' => 'Invalid email format'];
}
$value = $normalizedEmail;
$emailData['value'] = $normalizedEmail;
$existing = Email::where('person_id', $personId)->where('value', $value)->first(); $existing = Email::where('person_id', $personId)->where('value', $value)->first();
$applyInsert = []; $applyInsert = [];
$applyUpdate = []; $applyUpdate = [];
@@ -2580,14 +3139,32 @@ private function upsertEmail(int $personId, array $emailData, $mappings): array
private function upsertAddress(int $personId, array $addrData, $mappings): array private function upsertAddress(int $personId, array $addrData, $mappings): array
{ {
$addressLine = trim((string) ($addrData['address'] ?? '')); $addressLine = trim((string) ($addrData['address'] ?? ''));
if ($addressLine === '') { // Normalize whitespace: collapse multiples and tighten around separators
$addressLine = preg_replace('/\s+/', ' ', $addressLine);
$addressLine = preg_replace('/\s*([,;\/])\s*/', '$1 ', $addressLine);
$addressLine = trim($addressLine);
// Skip common placeholders or missing values
if ($addressLine === '' || $addressLine === '0' || strcasecmp($addressLine, '#N/A') === 0 || preg_match('/^(#?n\/?a|na|null|none)$/i', $addressLine)) {
return ['action' => 'skipped', 'message' => 'No address value']; return ['action' => 'skipped', 'message' => 'No address value'];
} }
if (mb_strlen($addressLine) < 3) {
return ['action' => 'skipped', 'message' => 'Invalid address value'];
}
// If identical address already exists anywhere, skip to avoid constraint violation
/*$existingAny = PersonAddress::where('address', $addressLine)->first();
if ($existingAny) {
return ['action' => 'skipped', 'message' => 'Address already exists in database'];
}*/
// Default country SLO if not provided // Default country SLO if not provided
if (! isset($addrData['country']) || $addrData['country'] === null || $addrData['country'] === '') { if (! isset($addrData['country']) || $addrData['country'] === null || $addrData['country'] === '') {
$addrData['country'] = 'SLO'; $addrData['country'] = 'SLO';
} }
$existing = PersonAddress::where('person_id', $personId)->where('address', $addressLine)->first(); // Compare addresses with all spaces removed to handle whitespace variations
$addressLineNoSpaces = preg_replace('/\s+/', '', $addressLine);
$existing = PersonAddress::where('person_id', $personId)
->whereRaw("REPLACE(address, ' ', '') = ?", [$addressLineNoSpaces])
->first();
$applyInsert = []; $applyInsert = [];
$applyUpdate = []; $applyUpdate = [];
foreach ($mappings as $map) { foreach ($mappings as $map) {
@@ -2630,9 +3207,23 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
$data['person_id'] = $personId; $data['person_id'] = $personId;
$data['country'] = $data['country'] ?? 'SLO'; $data['country'] = $data['country'] ?? 'SLO';
$data['type_id'] = $data['type_id'] ?? $this->getDefaultAddressTypeId(); $data['type_id'] = $data['type_id'] ?? $this->getDefaultAddressTypeId();
$created = PersonAddress::create($data); try {
$created = PersonAddress::create($data);
return ['action' => 'inserted', 'address' => $created]; return ['action' => 'inserted', 'address' => $created];
} catch (QueryException $e) {
// If unique constraint violation, skip instead of aborting
Log::warning('Address constraint violation during import', [
'person_id' => $personId,
'address' => $addressLine,
'error' => $e->getMessage(),
]);
if ($e->getCode() === '23505' || str_contains($e->getMessage(), 'unique') || str_contains($e->getMessage(), 'duplicate')) {
return ['action' => 'skipped', 'message' => 'Address already exists (constraint violation)'];
}
throw $e;
}
} }
} }
@@ -2644,6 +3235,10 @@ private function upsertPhone(int $personId, array $phoneData, $mappings): array
if ($nu === '') { if ($nu === '') {
return ['action' => 'skipped', 'message' => 'No phone value']; return ['action' => 'skipped', 'message' => 'No phone value'];
} }
if (! preg_match('/^[0-9]{6,20}$/', $nu)) {
return ['action' => 'skipped', 'message' => 'Invalid phone value'];
}
$phoneData['nu'] = $nu;
// Find existing phone by normalized number (strip non-numeric from DB values too) // Find existing phone by normalized number (strip non-numeric from DB values too)
$existing = PersonPhone::where('person_id', $personId) $existing = PersonPhone::where('person_id', $personId)
+96 -3
View File
@@ -25,6 +25,11 @@ class ImportSimulationService
*/ */
private ?int $clientId = null; private ?int $clientId = null;
/**
* History import mode flag (from template meta).
*/
private bool $historyImport = false;
/** /**
* Public entry: simulate import applying mappings to first $limit rows. * Public entry: simulate import applying mappings to first $limit rows.
* Keeps existing machine keys for backward compatibility, but adds Slovenian * Keeps existing machine keys for backward compatibility, but adds Slovenian
@@ -79,6 +84,7 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
$simRows = []; $simRows = [];
// Determine keyref behavior for contract.reference from mappings/template // Determine keyref behavior for contract.reference from mappings/template
$tplMeta = optional($import->template)->meta ?? []; $tplMeta = optional($import->template)->meta ?? [];
$this->historyImport = (bool) ($tplMeta['history_import'] ?? false);
$contractKeyModeTpl = $tplMeta['contract_key_mode'] ?? null; // e.g. 'reference' $contractKeyModeTpl = $tplMeta['contract_key_mode'] ?? null; // e.g. 'reference'
$contractRefMode = $this->mappingModeForImport($import, 'contract.reference'); // e.g. 'keyref' $contractRefMode = $this->mappingModeForImport($import, 'contract.reference'); // e.g. 'keyref'
foreach ($rows as $idx => $rawValues) { foreach ($rows as $idx => $rawValues) {
@@ -489,6 +495,38 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
} }
} }
// History import: auto-ensure account placeholder when contract exists but no account mapping
if ($this->historyImport && $existingContract && isset($rowEntities['contract']['id']) && ! isset($rowEntities['account'])) {
if (! isset($summaries['account'])) {
$summaries['account'] = [
'root' => 'account',
'total_rows' => 0,
'create' => 0,
'update' => 0,
'missing_ref' => 0,
'invalid' => 0,
'duplicate' => 0,
'duplicate_db' => 0,
];
}
$summaries['account']['total_rows']++;
$summaries['account']['update']++;
$ref = $rowEntities['contract']['reference'] ?? null;
if ($ref === null || $ref === '') {
$ref = 'HIST-'.$rowEntities['contract']['id'];
}
$rowEntities['account'] = [
'reference' => $ref,
'exists' => true,
'id' => null,
'balance_before' => 0,
'balance_after' => 0,
'action' => 'implicit_history',
'action_label' => $translatedActions['implicit'] ?? 'posredno',
'history_zeroed' => true,
];
}
// Payment (affects account balance; may create implicit account) // Payment (affects account balance; may create implicit account)
if (isset($entityRoots['payment'])) { if (isset($entityRoots['payment'])) {
// Inject inferred account if none mapped explicitly // Inject inferred account if none mapped explicitly
@@ -891,7 +929,7 @@ private function simulateContract(callable $val, array $summaries, array $cache,
'client_case_id' => $contract?->client_case_id, 'client_case_id' => $contract?->client_case_id,
'active' => $contract?->active, 'active' => $contract?->active,
'deleted_at' => $contract?->deleted_at, 'deleted_at' => $contract?->deleted_at,
'action' => $contract ? 'update' : ($reference ? 'create' : 'skip'), 'action' => $contract ? ($this->historyImport ? 'skipped_history' : 'update') : ($reference ? 'create' : 'skip'),
]; ];
$summaries['contract']['total_rows']++; $summaries['contract']['total_rows']++;
if (! $reference) { if (! $reference) {
@@ -902,6 +940,11 @@ private function simulateContract(callable $val, array $summaries, array $cache,
$summaries['contract']['create']++; $summaries['contract']['create']++;
} }
if ($this->historyImport && $contract) {
$entity['history_reuse'] = true;
$entity['message'] = 'Existing contract reused (history import)';
}
return [$entity, $summaries, $cache]; return [$entity, $summaries, $cache];
} }
@@ -931,7 +974,7 @@ private function simulateAccount(callable $val, array $summaries, array $cache,
'exists' => (bool) $account, 'exists' => (bool) $account,
'balance_before' => $account?->balance_amount, 'balance_before' => $account?->balance_amount,
'balance_after' => $account?->balance_amount, 'balance_after' => $account?->balance_amount,
'action' => $account ? 'update' : ($reference ? 'create' : 'skip'), 'action' => $account ? ($this->historyImport ? 'skipped_history' : 'update') : ($reference ? 'create' : 'skip'),
]; ];
// Direct balance override support. // Direct balance override support.
@@ -940,7 +983,7 @@ private function simulateAccount(callable $val, array $summaries, array $cache,
$rawIncoming = $val('account.balance_amount') $rawIncoming = $val('account.balance_amount')
?? $val('accounts.balance_amount') ?? $val('accounts.balance_amount')
?? $val('account.balance'); ?? $val('account.balance');
if ($rawIncoming !== null && $rawIncoming !== '') { if (! $this->historyImport && $rawIncoming !== null && $rawIncoming !== '') {
$rawStr = (string) $rawIncoming; $rawStr = (string) $rawIncoming;
// Remove currency symbols and non numeric punctuation except , . - // Remove currency symbols and non numeric punctuation except , . -
$clean = preg_replace('/[^0-9,\.\-]+/', '', $rawStr) ?? ''; $clean = preg_replace('/[^0-9,\.\-]+/', '', $rawStr) ?? '';
@@ -974,6 +1017,19 @@ private function simulateAccount(callable $val, array $summaries, array $cache,
$summaries['account']['create']++; $summaries['account']['create']++;
} }
if ($this->historyImport) {
// History imports keep balances unchanged and do not update accounts
$entity['balance_after'] = $account?->balance_amount ?? 0;
$entity['balance_before'] = $account?->balance_amount ?? 0;
if ($account) {
$entity['message'] = 'Existing account left unchanged (history import)';
} else {
$entity['balance_after'] = 0;
$entity['balance_before'] = 0;
$entity['history_zeroed'] = true;
}
}
return [$entity, $summaries, $cache]; return [$entity, $summaries, $cache];
} }
@@ -1210,6 +1266,10 @@ private function simulateGenericRoot(
$reference = $val('phone.nu'); $reference = $val('phone.nu');
} elseif ($root === 'email') { } elseif ($root === 'email') {
$reference = $val('email.value'); $reference = $val('email.value');
} elseif ($root === 'activity') {
$noteRef = $val('activity.note');
$dueRef = $val('activity.due_date');
$reference = $noteRef || $dueRef ? trim((string) ($dueRef ?? '')).($noteRef ? ' | '.$noteRef : '') : null;
} }
} }
@@ -1253,6 +1313,13 @@ private function simulateGenericRoot(
$entity['description'] = $val('case_object.description') ?? null; $entity['description'] = $val('case_object.description') ?? null;
$entity['type'] = $val('case_object.type') ?? null; $entity['type'] = $val('case_object.type') ?? null;
break; break;
case 'activity':
$entity['note'] = $val('activity.note') ?? null;
$entity['due_date'] = $val('activity.due_date') ?? null;
$entity['amount'] = $val('activity.amount') ?? null;
$entity['action_id'] = $val('activity.action_id') ?? null;
$entity['decision_id'] = $val('activity.decision_id') ?? null;
break;
} }
if ($verbose) { if ($verbose) {
@@ -1367,6 +1434,16 @@ private function genericIdentityCandidates(string $root, callable $val): array
$ids[] = 'name:'.mb_strtolower(trim((string) $name)); $ids[] = 'name:'.mb_strtolower(trim((string) $name));
} }
return $ids;
case 'activity':
$note = $val('activity.note');
$due = $val('activity.due_date');
$contractRef = $val('contract.reference');
$ids = [];
if ($note || $due) {
$ids[] = 'activity:'.mb_strtolower(trim((string) ($note ?? ''))).'|'.mb_strtolower(trim((string) ($due ?? ''))).'|'.mb_strtolower(trim((string) ($contractRef ?? '')));
}
return $ids; return $ids;
default: default:
return []; return [];
@@ -1426,6 +1503,20 @@ private function loadExistingGenericIdentities(string $root): array
} }
} }
break; break;
case 'activity':
foreach (\App\Models\Activity::query()->get(['note', 'due_date', 'contract_id', 'client_case_id']) as $rec) {
$note = mb_strtolower(trim((string) ($rec->note ?? '')));
$due = $rec->due_date ? mb_strtolower(trim((string) $rec->due_date)) : '';
$contractRef = null;
if ($rec->contract_id) {
$contractRef = Contract::where('id', $rec->contract_id)->value('reference');
}
$key = 'activity:'.$note.'|'.$due.'|'.mb_strtolower(trim((string) ($contractRef ?? '')));
if (trim($key, 'activity:|') !== '') {
$set[$key] = true;
}
}
break;
} }
} catch (\Throwable) { } catch (\Throwable) {
// swallow and return what we have // swallow and return what we have
@@ -1730,6 +1821,8 @@ private function actionTranslations(): array
'skip' => 'preskoči', 'skip' => 'preskoči',
'implicit' => 'posredno', 'implicit' => 'posredno',
'reactivate' => 'reaktiviraj', 'reactivate' => 'reaktiviraj',
'skipped_history' => 'preskoči (zgodovina)',
'implicit_history' => 'posredno (zgodovina)',
]; ];
} }
+3 -2
View File
@@ -5,7 +5,6 @@
"keywords": ["laravel", "framework"], "keywords": ["laravel", "framework"],
"license": "MIT", "license": "MIT",
"require": { "require": {
"tijsverkoyen/css-to-inline-styles": "^2.2",
"php": "^8.2", "php": "^8.2",
"arielmejiadev/larapex-charts": "^2.1", "arielmejiadev/larapex-charts": "^2.1",
"diglactic/laravel-breadcrumbs": "^10.0", "diglactic/laravel-breadcrumbs": "^10.0",
@@ -16,9 +15,11 @@
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",
"laravel/scout": "^10.11", "laravel/scout": "^10.11",
"laravel/tinker": "^2.9", "laravel/tinker": "^2.9",
"maatwebsite/excel": "^3.1",
"meilisearch/meilisearch-php": "^1.11", "meilisearch/meilisearch-php": "^1.11",
"robertboes/inertia-breadcrumbs": "dev-laravel-12", "robertboes/inertia-breadcrumbs": "dev-laravel-12",
"tightenco/ziggy": "^2.0" "tightenco/ziggy": "^2.0",
"tijsverkoyen/css-to-inline-styles": "^2.2"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
Generated
+591 -2
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "51fd57123c1b9f51c24f28e04a692ec4", "content-hash": "d29c47a4d6813ee8e80a7c8112c2f17e",
"packages": [ "packages": [
{ {
"name": "arielmejiadev/larapex-charts", "name": "arielmejiadev/larapex-charts",
@@ -242,6 +242,162 @@
], ],
"time": "2024-02-09T16:56:22+00:00" "time": "2024-02-09T16:56:22+00:00"
}, },
{
"name": "composer/pcre",
"version": "3.3.2",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2",
"phpunit/phpunit": "^8 || ^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.3.2"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
"type": "tidelift"
}
],
"time": "2024-11-12T16:29:46+00:00"
},
{
"name": "composer/semver",
"version": "3.4.4",
"source": {
"type": "git",
"url": "https://github.com/composer/semver.git",
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95",
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95",
"shasum": ""
},
"require": {
"php": "^5.3.2 || ^7.0 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.11",
"symfony/phpunit-bridge": "^3 || ^7"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Semver\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nils Adermann",
"email": "naderman@naderman.de",
"homepage": "http://www.naderman.de"
},
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
},
{
"name": "Rob Bast",
"email": "rob.bast@gmail.com",
"homepage": "http://robbast.nl"
}
],
"description": "Semver library that offers utilities, version constraint parsing and validation.",
"keywords": [
"semantic",
"semver",
"validation",
"versioning"
],
"support": {
"irc": "ircs://irc.libera.chat:6697/composer",
"issues": "https://github.com/composer/semver/issues",
"source": "https://github.com/composer/semver/tree/3.4.4"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
}
],
"time": "2025-08-20T19:15:30+00:00"
},
{ {
"name": "dasprid/enum", "name": "dasprid/enum",
"version": "1.0.6", "version": "1.0.6",
@@ -737,6 +893,67 @@
], ],
"time": "2025-03-06T22:45:56+00:00" "time": "2025-03-06T22:45:56+00:00"
}, },
{
"name": "ezyang/htmlpurifier",
"version": "v4.19.0",
"source": {
"type": "git",
"url": "https://github.com/ezyang/htmlpurifier.git",
"reference": "b287d2a16aceffbf6e0295559b39662612b77fcf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf",
"reference": "b287d2a16aceffbf6e0295559b39662612b77fcf",
"shasum": ""
},
"require": {
"php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
},
"require-dev": {
"cerdic/css-tidy": "^1.7 || ^2.0",
"simpletest/simpletest": "dev-master"
},
"suggest": {
"cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.",
"ext-bcmath": "Used for unit conversion and imagecrash protection",
"ext-iconv": "Converts text to and from non-UTF-8 encodings",
"ext-tidy": "Used for pretty-printing HTML"
},
"type": "library",
"autoload": {
"files": [
"library/HTMLPurifier.composer.php"
],
"psr-0": {
"HTMLPurifier": "library/"
},
"exclude-from-classmap": [
"/library/HTMLPurifier/Language/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "Edward Z. Yang",
"email": "admin@htmlpurifier.org",
"homepage": "http://ezyang.com"
}
],
"description": "Standards compliant HTML filter written in PHP",
"homepage": "http://htmlpurifier.org/",
"keywords": [
"html"
],
"support": {
"issues": "https://github.com/ezyang/htmlpurifier/issues",
"source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0"
},
"time": "2025-10-17T16:34:55+00:00"
},
{ {
"name": "facade/ignition-contracts", "name": "facade/ignition-contracts",
"version": "1.0.2", "version": "1.0.2",
@@ -2695,6 +2912,272 @@
], ],
"time": "2024-12-08T08:18:47+00:00" "time": "2024-12-08T08:18:47+00:00"
}, },
{
"name": "maatwebsite/excel",
"version": "3.1.67",
"source": {
"type": "git",
"url": "https://github.com/SpartnerNL/Laravel-Excel.git",
"reference": "e508e34a502a3acc3329b464dad257378a7edb4d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/e508e34a502a3acc3329b464dad257378a7edb4d",
"reference": "e508e34a502a3acc3329b464dad257378a7edb4d",
"shasum": ""
},
"require": {
"composer/semver": "^3.3",
"ext-json": "*",
"illuminate/support": "5.8.*||^6.0||^7.0||^8.0||^9.0||^10.0||^11.0||^12.0",
"php": "^7.0||^8.0",
"phpoffice/phpspreadsheet": "^1.30.0",
"psr/simple-cache": "^1.0||^2.0||^3.0"
},
"require-dev": {
"laravel/scout": "^7.0||^8.0||^9.0||^10.0",
"orchestra/testbench": "^6.0||^7.0||^8.0||^9.0||^10.0",
"predis/predis": "^1.1"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Excel": "Maatwebsite\\Excel\\Facades\\Excel"
},
"providers": [
"Maatwebsite\\Excel\\ExcelServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Maatwebsite\\Excel\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Patrick Brouwers",
"email": "patrick@spartner.nl"
}
],
"description": "Supercharged Excel exports and imports in Laravel",
"keywords": [
"PHPExcel",
"batch",
"csv",
"excel",
"export",
"import",
"laravel",
"php",
"phpspreadsheet"
],
"support": {
"issues": "https://github.com/SpartnerNL/Laravel-Excel/issues",
"source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.67"
},
"funding": [
{
"url": "https://laravel-excel.com/commercial-support",
"type": "custom"
},
{
"url": "https://github.com/patrickbrouwers",
"type": "github"
}
],
"time": "2025-08-26T09:13:16+00:00"
},
{
"name": "maennchen/zipstream-php",
"version": "3.2.1",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5",
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-zlib": "*",
"php-64bit": "^8.3"
},
"require-dev": {
"brianium/paratest": "^7.7",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.86",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
"phpunit/phpunit": "^12.0",
"vimeo/psalm": "^6.0"
},
"suggest": {
"guzzlehttp/psr7": "^2.4",
"psr/http-message": "^2.0"
},
"type": "library",
"autoload": {
"psr-4": {
"ZipStream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paul Duncan",
"email": "pabs@pablotron.org"
},
{
"name": "Jonatan Männchen",
"email": "jonatan@maennchen.ch"
},
{
"name": "Jesse Donat",
"email": "donatj@gmail.com"
},
{
"name": "András Kolesár",
"email": "kolesar@kolesar.hu"
}
],
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
"keywords": [
"stream",
"zip"
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1"
},
"funding": [
{
"url": "https://github.com/maennchen",
"type": "github"
}
],
"time": "2025-12-10T09:58:31+00:00"
},
{
"name": "markbaker/complex",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPComplex.git",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Complex\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@lange.demon.co.uk"
}
],
"description": "PHP Class for working with complex numbers",
"homepage": "https://github.com/MarkBaker/PHPComplex",
"keywords": [
"complex",
"mathematics"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
},
"time": "2022-12-06T16:21:08+00:00"
},
{
"name": "markbaker/matrix",
"version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPMatrix.git",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpdocumentor/phpdocumentor": "2.*",
"phploc/phploc": "^4.0",
"phpmd/phpmd": "2.*",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"sebastian/phpcpd": "^4.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Matrix\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@demon-angel.eu"
}
],
"description": "PHP Class for working with matrices",
"homepage": "https://github.com/MarkBaker/PHPMatrix",
"keywords": [
"mathematics",
"matrix",
"vector"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
},
"time": "2022-12-02T22:17:43+00:00"
},
{ {
"name": "meilisearch/meilisearch-php", "name": "meilisearch/meilisearch-php",
"version": "v1.13.0", "version": "v1.13.0",
@@ -3475,6 +3958,112 @@
}, },
"time": "2024-10-02T11:20:13+00:00" "time": "2024-10-02T11:20:13+00:00"
}, },
{
"name": "phpoffice/phpspreadsheet",
"version": "1.30.1",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "fa8257a579ec623473eabfe49731de5967306c4c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/fa8257a579ec623473eabfe49731de5967306c4c",
"reference": "fa8257a579ec623473eabfe49731de5967306c4c",
"shasum": ""
},
"require": {
"composer/pcre": "^1||^2||^3",
"ext-ctype": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-gd": "*",
"ext-iconv": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-simplexml": "*",
"ext-xml": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"ext-zip": "*",
"ext-zlib": "*",
"ezyang/htmlpurifier": "^4.15",
"maennchen/zipstream-php": "^2.1 || ^3.0",
"markbaker/complex": "^3.0",
"markbaker/matrix": "^3.0",
"php": ">=7.4.0 <8.5.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
"dompdf/dompdf": "^1.0 || ^2.0 || ^3.0",
"friendsofphp/php-cs-fixer": "^3.2",
"mitoteam/jpgraph": "^10.3",
"mpdf/mpdf": "^8.1.1",
"phpcompatibility/php-compatibility": "^9.3",
"phpstan/phpstan": "^1.1",
"phpstan/phpstan-phpunit": "^1.0",
"phpunit/phpunit": "^8.5 || ^9.0",
"squizlabs/php_codesniffer": "^3.7",
"tecnickcom/tcpdf": "^6.5"
},
"suggest": {
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
"ext-intl": "PHP Internationalization Functions",
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Maarten Balliauw",
"homepage": "https://blog.maartenballiauw.be"
},
{
"name": "Mark Baker",
"homepage": "https://markbakeruk.net"
},
{
"name": "Franck Lefevre",
"homepage": "https://rootslabs.net"
},
{
"name": "Erik Tilt"
},
{
"name": "Adrien Crivelli"
}
],
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
"keywords": [
"OpenXML",
"excel",
"gnumeric",
"ods",
"php",
"spreadsheet",
"xls",
"xlsx"
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.1"
},
"time": "2025-10-26T16:01:04+00:00"
},
{ {
"name": "phpoption/phpoption", "name": "phpoption/phpoption",
"version": "1.9.3", "version": "1.9.3",
@@ -10335,6 +10924,6 @@
"platform": { "platform": {
"php": "^8.2" "php": "^8.2"
}, },
"platform-dev": {}, "platform-dev": [],
"plugin-api-version": "2.6.0" "plugin-api-version": "2.6.0"
} }
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('field_jobs', function (Blueprint $table) {
$table->boolean('added_activity')->default(false);
$table->timestamp('last_activity')->nullable()->default(null);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('field_jobs', function (Blueprint $table) {
$table->dropColumn('last_activity');
$table->dropColumn('added_activity');
});
}
};
+23
View File
@@ -175,6 +175,29 @@ public function run(): void
], ],
'ui' => ['order' => 9], 'ui' => ['order' => 9],
], ],
[
'key' => 'activities',
'canonical_root' => 'activity',
'label' => 'Activities',
'fields' => ['note', 'due_date', 'amount', 'action_id', 'decision_id', 'contract_id', 'client_case_id', 'user_id'],
'field_aliases' => [
'opis' => 'note',
'datum' => 'due_date',
'rok' => 'due_date',
'znesek' => 'amount',
],
'aliases' => ['activity', 'activities', 'opravilo', 'opravila'],
'rules' => [
['pattern' => '/^(aktivnost|activity|note|opis)\b/i', 'field' => 'note'],
['pattern' => '/^(rok|due|datum|date)\b/i', 'field' => 'due_date'],
['pattern' => '/^(znesek|amount|vrednost|value)\b/i', 'field' => 'amount'],
['pattern' => '/^(akcija|action)\b/i', 'field' => 'action_id'],
['pattern' => '/^(odlocitev|odločitev|decision)\b/i', 'field' => 'decision_id'],
['pattern' => '/^(pogodba|contract)\b/i', 'field' => 'contract_id'],
['pattern' => '/^(primer|case)\b/i', 'field' => 'client_case_id'],
],
'ui' => ['order' => 10],
],
]; ];
foreach ($defs as $d) { foreach ($defs as $d) {
+37
View File
@@ -155,5 +155,42 @@ public function run(): void
'options' => $map['options'] ?? null, 'options' => $map['options'] ?? null,
]); ]);
} }
// Activities linked to contracts demo
$activities = ImportTemplate::query()->firstOrCreate([
'name' => 'Activities CSV (contract linked)',
], [
'uuid' => (string) Str::uuid(),
'description' => 'Activities import linked to existing contracts via reference.',
'source_type' => 'csv',
'default_record_type' => 'activity',
'sample_headers' => ['contract_reference', 'note', 'due_date', 'amount', 'action', 'decision', 'user_email'],
'is_active' => true,
'meta' => [
'delimiter' => ',',
'enclosure' => '"',
'escape' => '\\',
],
]);
$activityMappings = [
['source_column' => 'contract_reference', 'target_field' => 'contract.reference', 'position' => 1],
['source_column' => 'note', 'target_field' => 'activity.note', 'position' => 2],
['source_column' => 'due_date', 'target_field' => 'activity.due_date', 'position' => 3],
['source_column' => 'amount', 'target_field' => 'activity.amount', 'position' => 4],
['source_column' => 'action', 'target_field' => 'activity.action_id', 'position' => 5],
['source_column' => 'decision', 'target_field' => 'activity.decision_id', 'position' => 6],
];
foreach ($activityMappings as $map) {
ImportTemplateMapping::firstOrCreate([
'import_template_id' => $activities->id,
'source_column' => $map['source_column'],
], [
'target_field' => $map['target_field'],
'position' => $map['position'],
'options' => $map['options'] ?? null,
]);
}
} }
} }
@@ -113,6 +113,7 @@ const store = async () => {
form form
.transform((data) => ({ .transform((data) => ({
...data, ...data,
phone_view: props.phoneMode,
due_date: formatDateForSubmit(data.due_date), due_date: formatDateForSubmit(data.due_date),
attachment_document_ids: attachment_document_ids:
templateAllowsAttachments.value && data.attach_documents templateAllowsAttachments.value && data.attach_documents
+72
View File
@@ -15,6 +15,7 @@ import Modal from "@/Components/Modal.vue"; // still potentially used elsewhere
import CsvPreviewModal from "./Partials/CsvPreviewModal.vue"; import CsvPreviewModal from "./Partials/CsvPreviewModal.vue";
import SimulationModal from "./Partials/SimulationModal.vue"; import SimulationModal from "./Partials/SimulationModal.vue";
import { useCurrencyFormat } from "./useCurrencyFormat.js"; import { useCurrencyFormat } from "./useCurrencyFormat.js";
import DialogModal from "@/Components/DialogModal.vue";
// Reintroduce props definition lost during earlier edits // Reintroduce props definition lost during earlier edits
const props = defineProps({ const props = defineProps({
@@ -185,6 +186,23 @@ function downloadUnresolvedCsv() {
window.location.href = route("imports.missing-keyref-csv", { import: importId.value }); window.location.href = route("imports.missing-keyref-csv", { import: importId.value });
} }
// History import: list of contracts that already existed in DB and were matched
const isHistoryImport = computed(() => {
const foundList = props.import?.meta?.history_found_contracts;
const hasFound = Array.isArray(foundList) && foundList.length > 0;
return Boolean(
props.import?.template?.meta?.history_import ??
props.import?.import_template?.meta?.history_import ??
props.import?.meta?.history_import ??
hasFound
);
});
const historyFoundContracts = computed(() => {
const list = props.import?.meta?.history_found_contracts;
return Array.isArray(list) ? list : [];
});
const showFoundContracts = ref(false);
// Determine if all detected columns are mapped with entity+field // Determine if all detected columns are mapped with entity+field
function evaluateMappingSaved() { function evaluateMappingSaved() {
console.log("here the evaluation happen of mapping save!"); console.log("here the evaluation happen of mapping save!");
@@ -1145,6 +1163,21 @@ async function fetchSimulation() {
<div class="py-6"> <div class="py-6">
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8"> <div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white shadow sm:rounded-lg p-6 space-y-6"> <div class="bg-white shadow sm:rounded-lg p-6 space-y-6">
<div
v-if="isHistoryImport || historyFoundContracts.length"
class="flex flex-wrap items-center gap-2 text-sm"
>
<button
class="px-3 py-1.5 bg-emerald-700 text-white text-xs rounded"
@click.prevent="showFoundContracts = true"
title="Prikaži pogodbe, ki so bile najdene in že obstajajo v bazi"
>
Najdene pogodbe
</button>
<span v-if="historyFoundContracts.length" class="text-xs text-gray-600">
{{ historyFoundContracts.length }} že obstoječih
</span>
</div>
<div v-if="isCompleted" class="p-3 border rounded bg-gray-50 text-sm"> <div v-if="isCompleted" class="p-3 border rounded bg-gray-50 text-sm">
<div class="flex flex-wrap gap-x-6 gap-y-1"> <div class="flex flex-wrap gap-x-6 gap-y-1">
<div> <div>
@@ -1378,6 +1411,45 @@ async function fetchSimulation() {
</div> </div>
</Modal> </Modal>
<!-- History import: existing contracts found -->
<DialogModal :show="showFoundContracts" max-width="3xl" @close="showFoundContracts = false">
<template #title>Obstoječe pogodbe najdene v zgodovinskem uvozu</template>
<template #content>
<div v-if="!historyFoundContracts.length" class="text-sm text-gray-600">Ni zadetkov.</div>
<ul v-else class="divide-y divide-gray-200 max-h-[70vh] overflow-auto">
<li
v-for="item in historyFoundContracts"
:key="item.contract_uuid || item.reference"
class="py-3 flex items-center justify-between gap-4"
>
<div class="min-w-0">
<div class="font-mono text-sm text-gray-900">{{ item.reference }}</div>
<div class="text-xs text-gray-600 truncate">
<span>{{ item.full_name || "" }}</span>
</div>
</div>
<div class="flex-shrink-0">
<a
v-if="item.case_uuid"
:href="route('clientCase.show', { client_case: item.case_uuid })"
class="text-blue-600 hover:underline text-xs"
>
Odpri primer
</a>
</div>
</li>
</ul>
</template>
<template #footer>
<button
class="px-3 py-1.5 bg-gray-700 text-white text-xs rounded"
@click.prevent="showFoundContracts = false"
>
Zapri
</button>
</template>
</DialogModal>
<!-- Unresolved keyref rows modal --> <!-- Unresolved keyref rows modal -->
<Modal :show="showUnresolved" max-width="5xl" @close="showUnresolved = false"> <Modal :show="showUnresolved" max-width="5xl" @close="showUnresolved = false">
<div class="p-4 max-h-[75vh] overflow-auto"> <div class="p-4 max-h-[75vh] overflow-auto">
@@ -28,6 +28,8 @@ const form = useForm({
delimiter: "", delimiter: "",
// Payments import mode // Payments import mode
payments_import: false, payments_import: false,
// History import mode
history_import: false,
// For payments mode: how to locate Contract - use single key 'reference' // For payments mode: how to locate Contract - use single key 'reference'
contract_key_mode: null, contract_key_mode: null,
}, },
@@ -59,6 +61,9 @@ const prevEntities = ref([]);
watch( watch(
() => form.meta.payments_import, () => form.meta.payments_import,
(enabled) => { (enabled) => {
if (enabled && form.meta.history_import) {
form.meta.history_import = false;
}
if (enabled) { if (enabled) {
// Save current selection and lock to the required chain // Save current selection and lock to the required chain
prevEntities.value = Array.isArray(form.entities) ? [...form.entities] : []; prevEntities.value = Array.isArray(form.entities) ? [...form.entities] : [];
@@ -74,6 +79,35 @@ watch(
} }
} }
); );
// History import: restrict entities and auto-add accounts when contracts selected
watch(
() => form.meta.history_import,
(enabled) => {
if (enabled && form.meta.payments_import) {
form.meta.payments_import = false;
form.meta.contract_key_mode = null;
}
const allowed = ["person", "person_addresses", "person_phones", "contracts", "activities", "client_cases"];
if (enabled) {
const current = Array.isArray(form.entities) ? [...form.entities] : [];
let filtered = current.filter((e) => allowed.includes(e));
if (filtered.includes("contracts") && !filtered.includes("accounts")) {
filtered = [...filtered, "accounts"];
}
form.entities = filtered;
}
}
);
watch(
() => form.entities,
(vals) => {
if (form.meta.history_import && Array.isArray(vals) && vals.includes("contracts") && ! vals.includes("accounts")) {
form.entities = [...vals, "accounts"];
}
}
);
</script> </script>
<template> <template>
@@ -112,14 +146,24 @@ watch(
<label class="block text-sm font-medium text-gray-700" <label class="block text-sm font-medium text-gray-700"
>Entities (tables)</label >Entities (tables)</label
> >
<label class="inline-flex items-center gap-2 text-sm"> <div class="flex items-center gap-4 text-sm">
<input <label class="inline-flex items-center gap-2">
type="checkbox" <input
v-model="form.meta.payments_import" type="checkbox"
class="rounded" v-model="form.meta.history_import"
/> class="rounded"
<span>Payments import</span> />
</label> <span>History import</span>
</label>
<label class="inline-flex items-center gap-2">
<input
type="checkbox"
v-model="form.meta.payments_import"
class="rounded"
/>
<span>Payments import</span>
</label>
</div>
</div> </div>
<template v-if="!form.meta.payments_import"> <template v-if="!form.meta.payments_import">
<Multiselect <Multiselect
@@ -128,11 +172,13 @@ watch(
{ value: 'person', label: 'Person' }, { value: 'person', label: 'Person' },
{ value: 'person_addresses', label: 'Person Addresses' }, { value: 'person_addresses', label: 'Person Addresses' },
{ value: 'person_phones', label: 'Person Phones' }, { value: 'person_phones', label: 'Person Phones' },
{ value: 'client_cases', label: 'Client Cases' },
{ value: 'emails', label: 'Emails' }, { value: 'emails', label: 'Emails' },
{ value: 'accounts', label: 'Accounts' }, { value: 'accounts', label: 'Accounts' },
{ value: 'contracts', label: 'Contracts' }, { value: 'contracts', label: 'Contracts' },
{ value: 'case_objects', label: 'Case Objects' }, { value: 'case_objects', label: 'Case Objects' },
{ value: 'payments', label: 'Payments' }, { value: 'payments', label: 'Payments' },
{ value: 'activities', label: 'Activities' },
]" ]"
:multiple="true" :multiple="true"
track-by="value" track-by="value"
@@ -156,6 +202,9 @@ watch(
Choose which tables this template targets. You can still define per-column Choose which tables this template targets. You can still define per-column
mappings later. mappings later.
</p> </p>
<div v-if="form.meta.history_import" class="mt-2 text-xs text-gray-600">
History mode allows only person/address/phone/contracts/activities/client cases. Accounts are auto-added when contracts are present and balances stay unchanged.
</div>
<div v-if="form.meta.payments_import" class="mt-2 text-xs text-gray-600"> <div v-if="form.meta.payments_import" class="mt-2 text-xs text-gray-600">
Payments mode locks entities to: Payments mode locks entities to:
<span class="font-medium">Contracts Accounts Payments</span> and <span class="font-medium">Contracts Accounts Payments</span> and
+126 -29
View File
@@ -26,6 +26,10 @@ const form = useForm({
// Add meta with default delimiter support // Add meta with default delimiter support
meta: { meta: {
...(props.template.meta || {}), ...(props.template.meta || {}),
payments_import: props.template.meta?.payments_import ?? false,
history_import: props.template.meta?.history_import ?? false,
activity_action_id: props.template.meta?.activity_action_id ?? props.template.meta?.action_id ?? null,
activity_decision_id: props.template.meta?.activity_decision_id ?? props.template.meta?.decision_id ?? null,
delimiter: (props.template.meta && props.template.meta.delimiter) || "", delimiter: (props.template.meta && props.template.meta.delimiter) || "",
}, },
}); });
@@ -35,6 +39,21 @@ const decisionsForSelectedAction = vComputed(() => {
return act?.decisions || []; return act?.decisions || [];
}); });
const decisionsForActivitiesAction = vComputed(() => {
const act = (props.actions || []).find((a) => a.id === form.meta.activity_action_id);
return act?.decisions || [];
});
const activityCreatedAtInput = computed({
get() {
if (!form.meta.activity_created_at) return "";
return String(form.meta.activity_created_at).replace(" ", "T");
},
set(v) {
form.meta.activity_created_at = v ? String(v).replace("T", " ") : null;
},
});
vWatch( vWatch(
() => form.meta.action_id, () => form.meta.action_id,
() => { () => {
@@ -42,6 +61,13 @@ vWatch(
} }
); );
vWatch(
() => form.meta.activity_action_id,
() => {
form.meta.activity_decision_id = null;
}
);
const entities = computed(() => props.template.meta?.entities || []); const entities = computed(() => props.template.meta?.entities || []);
const hasMappings = computed(() => (props.template.mappings?.length || 0) > 0); const hasMappings = computed(() => (props.template.mappings?.length || 0) > 0);
const canChangeClient = computed(() => !hasMappings.value); // guard reassignment when mappings exist (optional rule) const canChangeClient = computed(() => !hasMappings.value); // guard reassignment when mappings exist (optional rule)
@@ -67,6 +93,15 @@ const unassignedSourceColumns = computed(() => {
} }
return Array.from(set).sort((a, b) => a.localeCompare(b)); return Array.from(set).sort((a, b) => a.localeCompare(b));
}); });
const allSourceColumns = computed(() => {
const set = new Set();
(props.template.sample_headers || []).forEach((h) => set.add(h));
(props.template.mappings || []).forEach((m) => {
if (m.source_column) set.add(m.source_column);
});
return Array.from(set).sort((a, b) => a.localeCompare(b));
});
const unassignedState = ref({}); const unassignedState = ref({});
// Dynamic Import Entity definitions and field options from API // Dynamic Import Entity definitions and field options from API
@@ -252,6 +287,11 @@ const save = () => {
// drop client change when blocked // drop client change when blocked
delete payload.client_uuid; delete payload.client_uuid;
} }
const hasActivities = Array.isArray(payload.meta?.entities) && payload.meta.entities.includes('activities');
if (hasActivities && (!payload.meta?.activity_action_id || !payload.meta?.activity_decision_id)) {
alert('Activity imports require selecting an Action and Decision (Activities section).');
return;
}
// Normalize empty delimiter: remove from meta to allow auto-detect // Normalize empty delimiter: remove from meta to allow auto-detect
if ( if (
payload.meta && payload.meta &&
@@ -300,11 +340,27 @@ watch(
watch( watch(
() => form.meta.payments_import, () => form.meta.payments_import,
(enabled) => { (enabled) => {
if (enabled) {
if (form.meta.history_import) {
form.meta.history_import = false;
}
}
if (enabled && !form.meta.contract_key_mode) { if (enabled && !form.meta.contract_key_mode) {
form.meta.contract_key_mode = "reference"; form.meta.contract_key_mode = "reference";
} }
} }
); );
// History mode is mutually exclusive with payments mode
watch(
() => form.meta.history_import,
(enabled) => {
if (enabled && form.meta.payments_import) {
form.meta.payments_import = false;
form.meta.contract_key_mode = null;
}
}
);
</script> </script>
<template> <template>
@@ -423,7 +479,7 @@ watch(
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700" <label class="block text-sm font-medium text-gray-700"
>Privzeto Dejanja (Activity)</label >Privzeto Dejanja (post-contract activity)</label
> >
<select <select
v-model="form.meta.action_id" v-model="form.meta.action_id"
@@ -437,7 +493,7 @@ watch(
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700" <label class="block text-sm font-medium text-gray-700"
>Privzeta Odločitev</label >Privzeta Odločitev (post-contract)</label
> >
<select <select
v-model="form.meta.decision_id" v-model="form.meta.decision_id"
@@ -484,22 +540,31 @@ watch(
</div> </div>
</div> </div>
<!-- Payments import toggle and settings --> <!-- History / Payments import toggles and settings -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div class="space-y-2">
<div class="flex items-center gap-2"> <div class="flex items-center flex-wrap gap-4">
<input <label class="inline-flex items-center gap-2">
id="payments_import" <input
v-model="form.meta.payments_import" id="history_import"
type="checkbox" v-model="form.meta.history_import"
class="rounded" type="checkbox"
/> class="rounded"
<label for="payments_import" class="text-sm font-medium text-gray-700" />
>Payments import</label <span class="text-sm font-medium text-gray-700">History import</span>
> </label>
<label class="inline-flex items-center gap-2">
<input
id="payments_import"
v-model="form.meta.payments_import"
type="checkbox"
class="rounded"
/>
<span class="text-sm font-medium text-gray-700">Payments import</span>
</label>
</div> </div>
<p class="text-xs text-gray-500 mt-1"> <p class="text-xs text-gray-500">
When enabled, entities are locked to Contracts Accounts Payments. History allows person/address/phone/contracts/activities/client cases; accounts are auto-added with contracts. Payments locks entities to Contracts Accounts Payments.
</p> </p>
</div> </div>
<div v-if="form.meta.payments_import"> <div v-if="form.meta.payments_import">
@@ -856,6 +921,41 @@ watch(
<span class="text-xs text-gray-500">Klikni za razširitev</span> <span class="text-xs text-gray-500">Klikni za razširitev</span>
</summary> </summary>
<div class="mt-4 space-y-4"> <div class="mt-4 space-y-4">
<div v-if="entity === 'activities'" class="grid grid-cols-1 sm:grid-cols-2 gap-4 p-3 bg-gray-50 rounded border">
<div>
<label class="block text-sm font-medium text-gray-700">Activity action *</label>
<select
v-model="form.meta.activity_action_id"
class="mt-1 block w-full border rounded p-2"
required
>
<option :value="null">(Select action)</option>
<option v-for="a in props.actions" :key="a.id" :value="a.id">{{ a.name }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Activity decision *</label>
<select
v-model="form.meta.activity_decision_id"
class="mt-1 block w-full border rounded p-2"
:disabled="!form.meta.activity_action_id"
required
>
<option :value="null">(Select decision)</option>
<option v-for="d in decisionsForActivitiesAction" :key="d.id" :value="d.id">{{ d.name }}</option>
</select>
<p v-if="!form.meta.activity_action_id" class="text-xs text-gray-500 mt-1">Choose an action first.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Activity created at (override)</label>
<input
v-model="activityCreatedAtInput"
type="datetime-local"
class="mt-1 block w-full border rounded p-2"
/>
<p class="text-xs text-gray-500 mt-1">Optional: set a fixed timestamp for inserted activities (history imports).</p>
</div>
</div>
<!-- Existing mappings for this entity --> <!-- Existing mappings for this entity -->
<div <div
v-if="props.template.mappings && props.template.mappings.length" v-if="props.template.mappings && props.template.mappings.length"
@@ -954,22 +1054,19 @@ watch(
<div class="grid grid-cols-1 sm:grid-cols-6 gap-2 items-end"> <div class="grid grid-cols-1 sm:grid-cols-6 gap-2 items-end">
<div> <div>
<label class="block text-xs text-gray-600" <label class="block text-xs text-gray-600"
>Source column (ne-dodeljene)</label >Source column (lahko uporabiš večkrat)</label
> >
<select <input
v-model="(newRows[entity] ||= {}).source" v-model="(newRows[entity] ||= {}).source"
class="mt-1 w-full border rounded p-2" class="mt-1 w-full border rounded p-2"
> list="src-opts-{{ entity }}"
<option value="" disabled>(izberi)</option> placeholder="npr.: note, description"
<option v-for="s in unassignedSourceColumns" :key="s" :value="s"> />
{{ s }} <datalist :id="`src-opts-${entity}`">
</option> <option v-for="s in allSourceColumns" :key="s" :value="s">{{ s }}</option>
</select> </datalist>
<p <p class="text-xs text-gray-500 mt-1">
v-if="!unassignedSourceColumns.length" Več stolpcev lahko povežeš na isto polje (npr. activity.note).
class="text-xs text-gray-500 mt-1"
>
Ni nedodeljenih virov. Uporabi Bulk ali najprej dodaj vire.
</p> </p>
</div> </div>
<div> <div>
+105 -14
View File
@@ -1,5 +1,6 @@
<script setup> <script setup>
import AppPhoneLayout from "@/Layouts/AppPhoneLayout.vue"; import AppPhoneLayout from "@/Layouts/AppPhoneLayout.vue";
import { Separator } from "reka-ui";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
const props = defineProps({ const props = defineProps({
@@ -11,6 +12,9 @@ const items = computed(() => props.jobs || []);
// Client filter options derived from jobs // Client filter options derived from jobs
const clientFilter = ref(""); const clientFilter = ref("");
const listNonActivity = ref([]);
const listActivity = ref([]);
const clientOptions = computed(() => { const clientOptions = computed(() => {
const map = new Map(); const map = new Map();
for (const job of items.value) { for (const job of items.value) {
@@ -28,7 +32,7 @@ const clientOptions = computed(() => {
const search = ref(""); const search = ref("");
const filteredJobs = computed(() => { const filteredJobs = computed(() => {
const term = search.value.trim().toLowerCase(); const term = search.value.trim().toLowerCase();
return items.value.filter((job) => { const filterList = items.value.filter((job) => {
// Filter by selected client (if any) // Filter by selected client (if any)
if (clientFilter.value) { if (clientFilter.value) {
const juuid = job?.contract?.client_case?.client?.uuid; const juuid = job?.contract?.client_case?.client?.uuid;
@@ -50,6 +54,9 @@ const filteredJobs = computed(() => {
refStr.includes(term) || nameStr.includes(term) || clientNameStr.includes(term) refStr.includes(term) || nameStr.includes(term) || clientNameStr.includes(term)
); );
}); });
listNonActivity.value = filterList.filter((item) => !item.added_activity);
listActivity.value = filterList.filter((item) => !!item.added_activity);
return filterList;
}); });
function formatDateDMY(d) { function formatDateDMY(d) {
@@ -125,10 +132,12 @@ function getCaseUuid(job) {
Počisti Počisti
</button> </button>
</div> </div>
<div class="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-2 lg:grid-cols-3">
<template v-if="filteredJobs.length"> <template v-if="filteredJobs.length">
<h2 class="py-4">Nove / Ne obdelane</h2>
<div class="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-2 lg:grid-cols-3">
<div <div
v-for="job in filteredJobs" v-for="job in listNonActivity"
:key="job.id" :key="job.id"
class="bg-white rounded-lg shadow border p-3 sm:p-4" class="bg-white rounded-lg shadow border p-3 sm:p-4"
> >
@@ -180,9 +189,6 @@ function getCaseUuid(job) {
<p class="text-sm text-gray-600 truncate"> <p class="text-sm text-gray-600 truncate">
Kontrakt: {{ job.contract?.reference || job.contract?.uuid }} Kontrakt: {{ job.contract?.reference || job.contract?.uuid }}
</p> </p>
<p class="text-sm text-gray-600">
Tip: {{ job.contract?.type?.name || "—" }}
</p>
<p <p
class="text-sm text-gray-600" class="text-sm text-gray-600"
v-if=" v-if="
@@ -205,14 +211,99 @@ function getCaseUuid(job) {
</p> </p>
</div> </div>
</div> </div>
</template>
<div
v-else
class="col-span-full bg-white rounded-lg shadow border p-6 text-center text-gray-600"
>
<span v-if="search">Ni zadetkov za podani filter.</span>
<span v-else>Trenutno nimate dodeljenih terenskih opravil.</span>
</div> </div>
<h2 class="py-4">Obdelane pogodbe</h2>
<div class="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-2 lg:grid-cols-3">
<div
v-for="job in listActivity"
:key="job.id"
class="bg-white rounded-lg shadow border p-3 sm:p-4"
>
<div class="mb-4 flex gap-2">
<a
v-if="getCaseUuid(job)"
:href="
route('phone.case', {
client_case: getCaseUuid(job),
completed: props.view_mode === 'completed-today' ? 1 : undefined,
})
"
class="inline-flex-1 flex-1 text-center px-3 py-2 rounded-md bg-blue-600 text-white text-sm hover:bg-blue-700"
>
Odpri primer
</a>
<button
v-else
type="button"
disabled
class="inline-flex-1 flex-1 text-center px-3 py-2 rounded-md bg-gray-300 text-gray-600 text-sm cursor-not-allowed"
>
Manjka primer
</button>
</div>
<div class="flex items-center justify-between">
<p class="text-sm text-gray-500">
Dodeljeno:
<span class="font-medium text-gray-700">{{
formatDateDMY(job.assigned_at)
}}</span>
</p>
<span
v-if="job.priority"
class="inline-block text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-700"
>Prioriteta</span
>
</div>
<div class="mt-2">
<p class="text-base sm:text-lg font-semibold text-gray-800">
{{ job.contract?.client_case?.person?.full_name || "—" }}
</p>
<p class="text-sm text-gray-600">
Naročnik:
<span class="font-semibold text-gray-800">
{{ job.contract?.client_case?.client?.person?.full_name || "—" }}
</span>
</p>
<p class="text-sm text-gray-600 truncate">
Kontrakt: {{ job.contract?.reference || job.contract?.uuid }}
</p>
<p
class="text-sm text-gray-600"
v-if="
job.contract?.account &&
job.contract.account.balance_amount !== null &&
job.contract.account.balance_amount !== undefined
"
>
Odprto: {{ formatAmount(job.contract.account.balance_amount) }} €
</p>
</div>
<div class="mt-3 text-sm text-gray-600">
<p>
<span class="font-medium">Naslov:</span>
{{ job.contract?.client_case?.person?.addresses?.[0]?.address || "—" }}
</p>
<p>
<span class="font-medium">Telefon:</span>
{{ job.contract?.client_case?.person?.phones?.[0]?.nu || "—" }}
</p>
</div>
<div class="mt-3 text-sm text-gray-600">
<p>
<span class="font-medium">Zadnja aktivnost:</span>
{{ formatDateDMY(job.last_activity) || "—" }}
</p>
</div>
</div>
</div>
</template>
<div
v-else
class="col-span-full bg-white rounded-lg shadow border p-6 text-center text-gray-600"
>
<span v-if="search">Ni zadetkov za podani filter.</span>
<span v-else>Trenutno nimate dodeljenih terenskih opravil.</span>
</div> </div>
</div> </div>
</div> </div>
+229
View File
@@ -2,7 +2,9 @@
import AppLayout from "@/Layouts/AppLayout.vue"; import AppLayout from "@/Layouts/AppLayout.vue";
import { Link, router } from "@inertiajs/vue3"; import { Link, router } from "@inertiajs/vue3";
import { ref, computed, watch } from "vue"; import { ref, computed, watch } from "vue";
import axios from "axios";
import DataTableServer from "@/Components/DataTable/DataTableServer.vue"; import DataTableServer from "@/Components/DataTable/DataTableServer.vue";
import DialogModal from "@/Components/DialogModal.vue";
const props = defineProps({ const props = defineProps({
segment: Object, segment: Object,
@@ -27,6 +29,82 @@ const columns = [
{ key: "account", label: "Stanje", align: "right" }, { key: "account", label: "Stanje", align: "right" },
]; ];
const exportDialogOpen = ref(false);
const exportScope = ref("current");
const exportColumns = ref(columns.map((col) => col.key));
const exportError = ref("");
const isExporting = ref(false);
const contractsCurrentPage = computed(() => props.contracts?.current_page ?? 1);
const contractsPerPage = computed(() => props.contracts?.per_page ?? 15);
const totalContracts = computed(
() => props.contracts?.total ?? props.contracts?.data?.length ?? 0
);
const currentPageCount = computed(() => props.contracts?.data?.length ?? 0);
const allColumnsSelected = computed(() => exportColumns.value.length === columns.length);
const exportDisabled = computed(() => exportColumns.value.length === 0 || isExporting.value);
function toggleAllColumns(checked) {
exportColumns.value = checked ? columns.map((col) => col.key) : [];
}
function openExportDialog() {
exportDialogOpen.value = true;
exportError.value = "";
}
function closeExportDialog() {
exportDialogOpen.value = false;
}
async function submitExport() {
if (exportColumns.value.length === 0) {
exportError.value = "Izberi vsaj en stolpec.";
return;
}
try {
exportError.value = "";
isExporting.value = true;
const payload = {
scope: exportScope.value,
columns: [...exportColumns.value],
search: search.value || "",
client: selectedClient.value || "",
page: contractsCurrentPage.value,
per_page: contractsPerPage.value,
};
const response = await axios.post(
route("segments.export", { segment: props.segment?.id ?? props.segment }),
payload,
{ responseType: "blob" }
);
const blob = new Blob([response.data], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
const filename =
extractFilenameFromHeaders(response.headers) || buildDefaultFilename();
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
exportDialogOpen.value = false;
} catch (error) {
exportError.value = "Izvoz je spodletel. Poskusi znova.";
} finally {
isExporting.value = false;
}
}
// Build client options from the full list provided by the server, so the dropdown isn't limited by current filters // Build client options from the full list provided by the server, so the dropdown isn't limited by current filters
const clientOptions = computed(() => { const clientOptions = computed(() => {
const list = Array.isArray(props.clients) ? props.clients : []; const list = Array.isArray(props.clients) ? props.clients : [];
@@ -37,6 +115,17 @@ const clientOptions = computed(() => {
return opts.sort((a, b) => (a.label || "").localeCompare(b.label || "")); return opts.sort((a, b) => (a.label || "").localeCompare(b.label || ""));
}); });
const selectedClientName = computed(() => {
if (!selectedClient.value) {
return "";
}
const options = clientOptions.value || [];
const match = options.find((opt) => opt.value === selectedClient.value);
return match?.label || "";
});
// React to client selection changes by visiting the same route with updated query // React to client selection changes by visiting the same route with updated query
watch(selectedClient, (val) => { watch(selectedClient, (val) => {
const query = { search: search.value }; const query = { search: search.value };
@@ -71,6 +160,48 @@ function formatCurrency(value) {
" €" " €"
); );
} }
function slugify(value) {
if (!value) {
return "data";
}
const slug = value.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "");
return slug || "data";
}
function buildDefaultFilename() {
const now = new Date();
const dd = String(now.getDate()).padStart(2, "0");
const mm = String(now.getMonth() + 1).padStart(2, "0");
const yy = String(now.getFullYear()).slice(-2);
let base = `${dd}${mm}${yy}_${slugify(props.segment?.name || "segment")}-Pogodbe`;
const clientName = selectedClientName.value;
if (clientName) {
base += `_${slugify(clientName)}`;
}
return `${base}.xlsx`;
}
function extractFilenameFromHeaders(headers) {
if (!headers) {
return null;
}
const disposition =
headers["content-disposition"] || headers["Content-Disposition"] || "";
if (!disposition) {
return null;
}
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8Match?.[1]) {
try {
return decodeURIComponent(utf8Match[1]);
} catch (error) {
return utf8Match[1];
}
}
const asciiMatch = disposition.match(/filename="?([^";]+)"?/i);
return asciiMatch?.[1] || null;
}
</script> </script>
<template> <template>
@@ -124,6 +255,15 @@ function formatCurrency(value) {
empty-text="Ni pogodb v tem segmentu." empty-text="Ni pogodb v tem segmentu."
row-key="uuid" row-key="uuid"
> >
<template #toolbar-extra>
<button
type="button"
class="inline-flex items-center rounded-md border border-indigo-200 bg-white px-3 py-2 text-sm font-medium text-indigo-700 shadow-sm hover:bg-indigo-50"
@click="openExportDialog"
>
Izvozi v Excel
</button>
</template>
<!-- Primer (client_case) cell with link when available --> <!-- Primer (client_case) cell with link when available -->
<template #cell-client_case="{ row }"> <template #cell-client_case="{ row }">
<Link <Link
@@ -168,5 +308,94 @@ function formatCurrency(value) {
</DataTableServer> </DataTableServer>
</div> </div>
</div> </div>
<DialogModal :show="exportDialogOpen" max-width="3xl" @close="closeExportDialog">
<template #title>
<div>
<h3 class="text-lg font-semibold">Izvoz v Excel</h3>
<p class="text-sm text-gray-500">Izberi stolpce in obseg podatkov za izvoz.</p>
</div>
</template>
<template #content>
<form id="segment-export-form" class="space-y-6" @submit.prevent="submitExport">
<div>
<span class="text-sm font-semibold text-gray-700">Obseg podatkov</span>
<div class="mt-2 space-y-2">
<label class="flex items-center gap-2 text-sm text-gray-700">
<input
type="radio"
name="scope"
value="current"
class="text-indigo-600"
v-model="exportScope"
/>
Trenutna stran ({{ currentPageCount }} zapisov)
</label>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input
type="radio"
name="scope"
value="all"
class="text-indigo-600"
v-model="exportScope"
/>
Celoten segment ({{ totalContracts }} zapisov)
</label>
</div>
</div>
<div>
<div class="flex items-center justify-between">
<span class="text-sm font-semibold text-gray-700">Stolpci</span>
<label class="flex items-center gap-2 text-xs text-gray-600">
<input
type="checkbox"
:checked="allColumnsSelected"
@change="toggleAllColumns($event.target.checked)"
/>
Označi vse
</label>
</div>
<div class="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
<label
v-for="col in columns"
:key="col.key"
class="flex items-center gap-2 rounded border border-gray-200 px-3 py-2 text-sm"
>
<input
type="checkbox"
name="columns[]"
:value="col.key"
v-model="exportColumns"
class="text-indigo-600"
/>
{{ col.label }}
</label>
</div>
<p v-if="exportError" class="mt-2 text-sm text-red-600">{{ exportError }}</p>
</div>
</form>
</template>
<template #footer>
<div class="flex flex-row gap-2">
<button
type="button"
class="text-sm text-gray-600 hover:text-gray-900"
@click="closeExportDialog"
>
Prekliči
</button>
<button
type="submit"
form="segment-export-form"
class="inline-flex items-center rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="exportDisabled"
>
<span v-if="!isExporting">Prenesi Excel</span>
<span v-else>Pripravljam ...</span>
</button>
</div>
</template>
</DialogModal>
</AppLayout> </AppLayout>
</template> </template>
+1 -2
View File
@@ -167,8 +167,6 @@
Route::get('packages-contracts', [\App\Http\Controllers\Admin\PackageController::class, 'contracts'])->name('packages.contracts'); Route::get('packages-contracts', [\App\Http\Controllers\Admin\PackageController::class, 'contracts'])->name('packages.contracts');
Route::post('packages-from-contracts', [\App\Http\Controllers\Admin\PackageController::class, 'storeFromContracts'])->name('packages.store-from-contracts'); Route::post('packages-from-contracts', [\App\Http\Controllers\Admin\PackageController::class, 'storeFromContracts'])->name('packages.store-from-contracts');
}); });
// Contract document generation (JSON) - protected by auth+verified; permission enforced inside controller service // Contract document generation (JSON) - protected by auth+verified; permission enforced inside controller service
@@ -408,6 +406,7 @@
// segments index overview // segments index overview
Route::get('segments', [SegmentController::class, 'index'])->name('segments.index'); Route::get('segments', [SegmentController::class, 'index'])->name('segments.index');
Route::get('segments/{segment}', [SegmentController::class, 'show'])->name('segments.show'); Route::get('segments/{segment}', [SegmentController::class, 'show'])->name('segments.show');
Route::post('segments/{segment}/export', [SegmentController::class, 'export'])->name('segments.export');
Route::middleware('permission:manage-imports')->group(function () { Route::middleware('permission:manage-imports')->group(function () {
// imports // imports
+209
View File
@@ -0,0 +1,209 @@
<?php
namespace Tests\Feature;
use App\Exports\SegmentContractsExport;
use App\Http\Controllers\SegmentController;
use App\Http\Requests\ExportSegmentContractsRequest;
use App\Models\Client;
use App\Models\Contract;
use App\Models\Person\Person as PersonModel;
use App\Models\Segment;
use App\Models\User;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Maatwebsite\Excel\Facades\Excel;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class SegmentExportTest extends \Tests\TestCase
{
public function test_exports_only_current_page_when_requested(): void
{
Excel::fake();
Carbon::setTestNow('2025-12-10 12:00:00');
$segment = Segment::factory()->create();
$contracts = Contract::factory()->count(2)->create();
foreach ($contracts as $contract) {
DB::table('contract_segment')->insert([
'contract_id' => $contract->id,
'segment_id' => $segment->id,
'active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
$user = User::factory()->create();
$request = $this->makeExportRequest([
'scope' => 'current',
'columns' => ['reference', 'client'],
'page' => 1,
'per_page' => 1,
], $user);
$response = app(SegmentController::class)->export($request, $segment);
$this->assertInstanceOf(BinaryFileResponse::class, $response);
$expectedName = $this->expectedFilename($segment);
Excel::assertDownloaded($expectedName, function (SegmentContractsExport $export) {
$this->assertSame(1, $export->query()->get()->count());
return true;
});
}
public function test_exports_full_segment_when_scope_all(): void
{
Excel::fake();
Carbon::setTestNow('2025-12-10 12:05:00');
$segment = Segment::factory()->create();
$contracts = Contract::factory()->count(3)->create();
foreach ($contracts as $contract) {
DB::table('contract_segment')->insert([
'contract_id' => $contract->id,
'segment_id' => $segment->id,
'active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
$user = User::factory()->create();
$request = $this->makeExportRequest([
'scope' => 'all',
'columns' => SegmentContractsExport::allowedColumns(),
], $user);
$response = app(SegmentController::class)->export($request, $segment);
$this->assertInstanceOf(BinaryFileResponse::class, $response);
$request = $this->makeExportRequest([
'scope' => 'all',
'columns' => SegmentContractsExport::allowedColumns(),
], $user);
$expectedName = $this->expectedFilename($segment);
Excel::assertDownloaded($expectedName, function (SegmentContractsExport $export) use ($contracts) {
$this->assertSame($contracts->count(), $export->query()->get()->count());
return true;
});
}
public function test_export_filename_includes_client_name_when_filtered(): void
{
Excel::fake();
Carbon::setTestNow('2025-12-10 12:10:00');
$segment = Segment::factory()->create(['name' => 'VIP Segment']);
$contract = Contract::factory()->create();
DB::table('contract_segment')->insert([
'contract_id' => $contract->id,
'segment_id' => $segment->id,
'active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
$client = Client::factory()
->for(PersonModel::factory(['full_name' => 'Ana Novak']), 'person')
->create();
$contract->clientCase?->update(['client_id' => $client->id]);
$user = User::factory()->create();
$request = $this->makeExportRequest([
'scope' => 'all',
'columns' => ['reference'],
'client' => (string) $client->uuid,
], $user);
$expectedName = $this->expectedFilename($segment, 'Ana Novak');
$response = app(SegmentController::class)->export($request, $segment);
$this->assertInstanceOf(BinaryFileResponse::class, $response);
Excel::assertDownloaded($expectedName, function (SegmentContractsExport $export) {
$this->assertSame(1, $export->query()->get()->count());
return true;
});
}
public function test_column_formats_apply_to_reference_and_date_columns(): void
{
$export = new SegmentContractsExport(
Contract::query(),
['reference', 'start_date', 'client', 'end_date']
);
$this->assertSame(
[
'A' => SegmentContractsExport::TEXT_EXCEL_FORMAT,
'B' => SegmentContractsExport::DATE_EXCEL_FORMAT,
'D' => SegmentContractsExport::DATE_EXCEL_FORMAT,
],
$export->columnFormats()
);
}
public function test_date_values_are_converted_to_excel_serial_numbers(): void
{
$contract = Contract::factory()->make([
'start_date' => '2025-10-30',
]);
$export = new SegmentContractsExport(
Contract::query(),
['start_date']
);
$row = $export->map($contract);
$this->assertIsFloat($row[0]);
$this->assertGreaterThan(40000, $row[0]);
}
private function makeExportRequest(array $payload, User $user): ExportSegmentContractsRequest
{
$request = ExportSegmentContractsRequest::create('/segments/export', 'POST', $payload);
$request->setContainer($this->app);
$request->setRedirector($this->app->make(Redirector::class));
$request->setUserResolver(fn () => $user);
$request->setRouteResolver(fn () => (object) [
'parameter' => fn () => null,
]);
$request->validateResolved();
return $request;
}
private function expectedFilename(Segment $segment, ?string $clientName = null): string
{
$base = now()->format('dmy').'_'.$this->slugify($segment->name).'-Pogodbe';
if ($clientName) {
return sprintf('%s_%s.xlsx', $base, $this->slugify($clientName));
}
return sprintf('%s.xlsx', $base);
}
private function slugify(string $value): string
{
$slug = trim(preg_replace('/[^a-zA-Z0-9]+/', '-', $value), '-');
return $slug !== '' ? $slug : 'data';
}
}