diff --git a/app/Http/Controllers/WorkflowController.php b/app/Http/Controllers/WorkflowController.php index a6d6083..a3b298c 100644 --- a/app/Http/Controllers/WorkflowController.php +++ b/app/Http/Controllers/WorkflowController.php @@ -7,6 +7,7 @@ use App\Models\Decision; use App\Models\EmailTemplate; use App\Models\Segment; +use App\Services\DecisionEvents\ConditionEvaluator; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; use Inertia\Inertia; @@ -22,6 +23,8 @@ public function index(Request $request) 'email_templates' => EmailTemplate::query()->where('active', true)->get(['id', 'name', 'entity_types']), 'events' => \App\Models\Event::query()->orderBy('name')->get(['id', 'name', 'key', 'description', 'active']), 'archive_settings' => ArchiveSetting::query()->where('enabled', true)->orderBy('id')->get(['id', 'name']), + 'condition_fields' => ConditionEvaluator::availableFields(), + 'condition_operators' => ConditionEvaluator::availableOperators(), ]); } @@ -83,6 +86,9 @@ public function updateAction(int $id, Request $request) public function storeDecision(Request $request) { + $allowedConditionFields = collect(ConditionEvaluator::availableFields())->pluck('key')->implode(','); + $allowedOperators = 'in:=,!=,>,>=,<,<=,contains'; + $attributes = $request->validate([ 'name' => 'required|string|max:50', 'color_tag' => 'nullable|string|max:25', @@ -96,6 +102,14 @@ public function storeDecision(Request $request) 'events.*.active' => 'sometimes|boolean', 'events.*.run_order' => 'nullable|integer', 'events.*.config' => 'nullable|array', + 'events.*.config.segment_id' => 'nullable|integer|exists:segments,id', + 'events.*.config.deactivate_previous' => 'sometimes|boolean', + 'events.*.config.archive_setting_id' => 'nullable|integer|exists:archive_settings,id', + 'events.*.config.reactivate' => 'sometimes|boolean', + 'events.*.config.conditions' => 'nullable|array', + 'events.*.config.conditions.*.field' => "required_with:events.*.config.conditions.*|string|in:{$allowedConditionFields}", + 'events.*.config.conditions.*.operator' => "required_with:events.*.config.conditions.*|string|{$allowedOperators}", + 'events.*.config.conditions.*.value' => 'required_with:events.*.config.conditions.*', ]); $actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray(); @@ -112,12 +126,12 @@ public function storeDecision(Request $request) $key = $eventModel?->key ?? ($ev['key'] ?? null); if ($key === 'add_segment') { $seg = $ev['config']['segment_id'] ?? null; - if (empty($seg) || ! Segment::where('id', $seg)->exists()) { + if (empty($seg)) { $validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.'; } } elseif ($key === 'archive_contract') { $as = $ev['config']['archive_setting_id'] ?? null; - if (empty($as) || ! ArchiveSetting::where('id', $as)->exists()) { + if (empty($as)) { $validationErrors["events.$i.config.archive_setting_id"] = 'Please select a valid archive setting for the archive_contract event.'; } } @@ -174,6 +188,9 @@ public function updateDecision(int $id, Request $request) { $row = Decision::findOrFail($id); + $allowedConditionFields = collect(ConditionEvaluator::availableFields())->pluck('key')->implode(','); + $allowedOperators = 'in:=,!=,>,>=,<,<=,contains'; + $attributes = $request->validate([ 'name' => 'required|string|max:50', 'color_tag' => 'nullable|string|max:25', @@ -187,6 +204,14 @@ public function updateDecision(int $id, Request $request) 'events.*.active' => 'sometimes|boolean', 'events.*.run_order' => 'nullable|integer', 'events.*.config' => 'nullable|array', + 'events.*.config.segment_id' => 'nullable|integer|exists:segments,id', + 'events.*.config.deactivate_previous' => 'sometimes|boolean', + 'events.*.config.archive_setting_id' => 'nullable|integer|exists:archive_settings,id', + 'events.*.config.reactivate' => 'sometimes|boolean', + 'events.*.config.conditions' => 'nullable|array', + 'events.*.config.conditions.*.field' => "required_with:events.*.config.conditions.*|string|in:{$allowedConditionFields}", + 'events.*.config.conditions.*.operator' => "required_with:events.*.config.conditions.*|string|{$allowedOperators}", + 'events.*.config.conditions.*.value' => 'required_with:events.*.config.conditions.*', ]); $actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray(); @@ -203,12 +228,12 @@ public function updateDecision(int $id, Request $request) $key = $eventModel?->key ?? ($ev['key'] ?? null); if ($key === 'add_segment') { $seg = $ev['config']['segment_id'] ?? null; - if (empty($seg) || ! Segment::where('id', $seg)->exists()) { + if (empty($seg)) { $validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.'; } } elseif ($key === 'archive_contract') { $as = $ev['config']['archive_setting_id'] ?? null; - if (empty($as) || ! ArchiveSetting::where('id', $as)->exists()) { + if (empty($as)) { $validationErrors["events.$i.config.archive_setting_id"] = 'Please select a valid archive setting for the archive_contract event.'; } } diff --git a/app/Jobs/RunDecisionEvent.php b/app/Jobs/RunDecisionEvent.php index b4c26f3..49e5a35 100644 --- a/app/Jobs/RunDecisionEvent.php +++ b/app/Jobs/RunDecisionEvent.php @@ -4,6 +4,7 @@ use App\Models\Activity; use App\Models\Event as DecisionEventModel; +use App\Services\DecisionEvents\ConditionEvaluator; use App\Services\DecisionEvents\DecisionEventContext; use App\Services\DecisionEvents\Registry; use Illuminate\Bus\Queueable; @@ -68,6 +69,23 @@ public function handle(): void user: $activity->user, ); + // [2] Condition check — skip the event if any condition is not met + $conditions = $this->config['conditions'] ?? []; + if (! empty($conditions)) { + $conditionsMet = app(ConditionEvaluator::class)->evaluate($conditions, $context); + if (! $conditionsMet) { + DB::table('decision_event_logs')->where('idempotency_key', $idempotencyKey)->update([ + 'status' => 'skipped', + 'message' => 'Condition not met', + 'finished_at' => now(), + 'updated_at' => now(), + ]); + + return; + } + } + + // [3] Resolve handler → handle() $handler->handle($context, $this->config); DB::table('decision_event_logs')->where('idempotency_key', $idempotencyKey)->update([ diff --git a/app/Services/DecisionEvents/ConditionEvaluator.php b/app/Services/DecisionEvents/ConditionEvaluator.php new file mode 100644 index 0000000..86c65a5 --- /dev/null +++ b/app/Services/DecisionEvents/ConditionEvaluator.php @@ -0,0 +1,123 @@ + $conditions + */ + public function evaluate(array $conditions, DecisionEventContext $context): bool + { + foreach ($conditions as $condition) { + if (! $this->evaluateOne($condition, $context)) { + return false; + } + } + + return true; + } + + protected function evaluateOne(array $condition, DecisionEventContext $context): bool + { + $field = $condition['field'] ?? ''; + $operator = $condition['operator'] ?? '='; + $expected = $condition['value'] ?? null; + + $actual = $this->resolveField($field, $context); + + return $this->compare($actual, $operator, $expected); + } + + protected function resolveField(string $field, DecisionEventContext $context): mixed + { + return match ($field) { + 'activity.amount' => $context->activity?->amount, + 'activity.note' => $context->activity?->note, + 'contract.active' => $context->contract !== null ? (bool) $context->contract->active : null, + 'contract.account.balance_amount' => $this->resolveAccountBalance($context), + default => null, + }; + } + + private function resolveAccountBalance(DecisionEventContext $context): mixed + { + if (! $context->contract) { + return null; + } + + $context->contract->loadMissing('account'); + + return $context->contract->account?->balance_amount; + } + + protected function compare(mixed $actual, string $operator, mixed $expected): bool + { + if ($actual === null) { + return false; + } + + if (in_array($operator, ['>', '>=', '<', '<='], true)) { + $actual = (float) $actual; + $expected = (float) $expected; + } + + return match ($operator) { + '=' => $actual == $expected, + '!=' => $actual != $expected, + '>' => $actual > $expected, + '>=' => $actual >= $expected, + '<' => $actual < $expected, + '<=' => $actual <= $expected, + 'contains' => str_contains((string) $actual, (string) $expected), + default => false, + }; + } + + /** + * Returns available condition field definitions for the frontend. + * + * @return array + */ + public static function availableFields(): array + { + return [ + ['key' => 'activity.amount', 'label' => 'Aktivnost – znesek', 'type' => 'numeric'], + ['key' => 'activity.note', 'label' => 'Aktivnost – opomba', 'type' => 'string'], + ['key' => 'contract.active', 'label' => 'Pogodba – aktivna', 'type' => 'boolean'], + ['key' => 'contract.account.balance_amount', 'label' => 'Račun – stanje', 'type' => 'numeric'], + ]; + } + + /** + * Returns available operators grouped by field type. + * + * @return array> + */ + public static function availableOperators(): array + { + return [ + 'numeric' => [ + ['key' => '=', 'label' => 'je enako'], + ['key' => '!=', 'label' => 'ni enako'], + ['key' => '>', 'label' => 'je večje od'], + ['key' => '>=', 'label' => 'je večje ali enako'], + ['key' => '<', 'label' => 'je manjše od'], + ['key' => '<=', 'label' => 'je manjše ali enako'], + ], + 'string' => [ + ['key' => '=', 'label' => 'je enako'], + ['key' => '!=', 'label' => 'ni enako'], + ['key' => 'contains', 'label' => 'vsebuje'], + ], + 'boolean' => [ + ['key' => '=', 'label' => 'je'], + ['key' => '!=', 'label' => 'ni'], + ], + ]; + } +} diff --git a/resources/js/Pages/Settings/Workflow/Index.vue b/resources/js/Pages/Settings/Workflow/Index.vue index 29e38d7..e07e21e 100644 --- a/resources/js/Pages/Settings/Workflow/Index.vue +++ b/resources/js/Pages/Settings/Workflow/Index.vue @@ -15,6 +15,8 @@ const props = defineProps({ email_templates: { type: Array, default: () => [] }, events: { type: Array, default: () => [] }, archive_settings: { type: Array, default: () => [] }, + condition_fields: { type: Array, default: () => [] }, + condition_operators: { type: Object, default: () => ({}) }, }); const activeTab = ref("actions"); @@ -57,6 +59,8 @@ const activeTab = ref("actions"); :available-events="events" :segments="segments" :archive-settings="archive_settings" + :condition-fields="condition_fields" + :condition-operators="condition_operators" /> diff --git a/resources/js/Pages/Settings/Workflow/Partials/DecisionTable.vue b/resources/js/Pages/Settings/Workflow/Partials/DecisionTable.vue index e25091b..c8f8a21 100644 --- a/resources/js/Pages/Settings/Workflow/Partials/DecisionTable.vue +++ b/resources/js/Pages/Settings/Workflow/Partials/DecisionTable.vue @@ -33,7 +33,16 @@ import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue"; import InlineColorPicker from "@/Components/InlineColorPicker.vue"; import Dropdown from "@/Components/Dropdown.vue"; import AppPopover from "@/Components/app/ui/AppPopover.vue"; -import { FilterIcon, MoreHorizontal, Pencil, Trash } from "lucide-vue-next"; +import { + FilterIcon, + MoreHorizontal, + Pencil, + Plus, + Trash, + Trash2, + X, +} from "lucide-vue-next"; +import { Switch } from "@/Components/ui/switch"; import { DropdownMenu, DropdownMenuContent, @@ -48,6 +57,8 @@ const props = defineProps({ availableEvents: { type: Array, default: () => [] }, segments: { type: Array, default: () => [] }, archiveSettings: { type: Array, default: () => [] }, + conditionFields: { type: Array, default: () => [] }, + conditionOperators: { type: Object, default: () => ({}) }, }); const drawerEdit = ref(false); @@ -223,6 +234,39 @@ function defaultEventPayload() { }; } +function operatorsForField(fieldKey) { + const field = (props.conditionFields || []).find((f) => f.key === fieldKey); + if (!field) { + return props.conditionOperators?.numeric ?? []; + } + return props.conditionOperators?.[field.type] ?? []; +} + +function addCondition(ev) { + if (!Array.isArray(ev.config.conditions)) { + ev.config.conditions = []; + } + const firstField = (props.conditionFields || [])[0]; + const firstOperator = firstField + ? operatorsForField(firstField.key)[0]?.key ?? "=" + : "="; + ev.config.conditions.push({ + field: firstField?.key ?? "", + operator: firstOperator, + value: "", + }); +} + +function removeCondition(ev, idx) { + ev.config.conditions.splice(idx, 1); +} + +function onConditionFieldChange(condition) { + const ops = operatorsForField(condition.field); + condition.operator = ops[0]?.key ?? "="; + condition.value = ""; +} + function tryAdoptRaw(ev) { try { const obj = JSON.parse(ev.__rawJson || "{}"); @@ -532,7 +576,7 @@ const destroyDecision = () => {