users = new goals_criteria_users(); $this->departments = new goals_criteria_departments(); $this->products = new goals_criteria_products(); $this->start = new \DateTime(); $this->end = new \DateTime(); $this->progress_alert_frequency = goals_criteria_progress_alert_frequency::NONE; $this->progress_alert_destination = goals_criteria_progress_alert_destination::NONE; $this->progress_alert_progress_type = goals_criteria_progress_alert_progress_type::NONE; $this->progress_alert_style = goals_criteria_progress_alert_style::NONE; } /** * @throws \Exception If the JSON is invalid */ public static function fromJson(string $json): goals_criteria { $data = json_decode($json, true); if (!is_array($data) || json_last_error() !== JSON_ERROR_NONE) { throw new \InvalidArgumentException('Invalid JSON for goals criteria'); } $criteria = new goals_criteria(); // Sanitize type (ignore unknowns) if (isset($data['type'])) { $type = goals_criteria_type::tryFrom((string)$data['type']); if ($type !== null) { $criteria->type = $type; } } // Sanitize target: numeric and non-negative if (isset($data['target'])) { if (is_numeric($data['target'])) { $target = (float)$data['target']; $criteria->target = max(0, $target); } } // Sanitize label: trim, strip tags, collapse whitespace, max length 255 if (isset($data['label']) && is_string($data['label'])) { $label = trim($data['label']); $label = strip_tags($label); $label = preg_replace('/\s+/', ' ', $label); if ($label !== null) { $label = mb_substr($label, 0, 255); } $criteria->label = ($label === '') ? null : $label; } // Parse progress alert frequency (ignore unknowns) if (isset($data['progress_alert_frequency']) && is_string($data['progress_alert_frequency'])) { $freq = goals_criteria_progress_alert_frequency::tryFrom($data['progress_alert_frequency']); if ($freq !== null) { $criteria->progress_alert_frequency = $freq; } } elseif (isset($data['progressAlertFrequency']) && is_string($data['progressAlertFrequency'])) { // allow camelCase $freq = goals_criteria_progress_alert_frequency::tryFrom($data['progressAlertFrequency']); if ($freq !== null) { $criteria->progress_alert_frequency = $freq; } } // Parse progress alert destination (ignore unknowns) if (isset($data['progress_alert_destination']) && is_string($data['progress_alert_destination'])) { $dest = goals_criteria_progress_alert_destination::tryFrom($data['progress_alert_destination']); if ($dest !== null) { $criteria->progress_alert_destination = $dest; } } elseif (isset($data['progressAlertDestination']) && is_string($data['progressAlertDestination'])) { $dest = goals_criteria_progress_alert_destination::tryFrom($data['progressAlertDestination']); if ($dest !== null) { $criteria->progress_alert_destination = $dest; } } // Parse progress alert progress type (ignore unknowns) if (isset($data['progress_alert_progress_type']) && is_string($data['progress_alert_progress_type'])) { $ptype = goals_criteria_progress_alert_progress_type::tryFrom($data['progress_alert_progress_type']); if ($ptype !== null) { $criteria->progress_alert_progress_type = $ptype; } } elseif (isset($data['progressAlertProgressType']) && is_string($data['progressAlertProgressType'])) { $ptype = goals_criteria_progress_alert_progress_type::tryFrom($data['progressAlertProgressType']); if ($ptype !== null) { $criteria->progress_alert_progress_type = $ptype; } } // Parse progress alert style (ignore unknowns) if (isset($data['progress_alert_style']) && is_string($data['progress_alert_style'])) { $style = goals_criteria_progress_alert_style::tryFrom($data['progress_alert_style']); if ($style !== null) { $criteria->progress_alert_style = $style; } } elseif (isset($data['progressAlertStyle']) && is_string($data['progressAlertStyle'])) { $style = goals_criteria_progress_alert_style::tryFrom($data['progressAlertStyle']); if ($style !== null) { $criteria->progress_alert_style = $style; } } // Parse progress alert format (string or object with render) if (isset($data['progress_alert_format'])) { $fmt = $data['progress_alert_format']; if (is_string($fmt)) { $criteria->progress_alert_format = trim($fmt); } elseif (is_array($fmt) || is_object($fmt)) { // Support structures coming from classes implementing goals_criteria_progress_alert_format_t // Expected keys: 'render' or 'format' $arr = (array)$fmt; if (isset($arr['render']) && is_string($arr['render'])) { $criteria->progress_alert_format = trim($arr['render']); } elseif (isset($arr['format']) && is_string($arr['format'])) { $criteria->progress_alert_format = trim($arr['format']); } } } elseif (isset($data['progressAlertFormat'])) { $fmt = $data['progressAlertFormat']; if (is_string($fmt)) { $criteria->progress_alert_format = trim($fmt); } elseif (is_array($fmt) || is_object($fmt)) { $arr = (array)$fmt; if (isset($arr['render']) && is_string($arr['render'])) { $criteria->progress_alert_format = trim($arr['render']); } elseif (isset($arr['format']) && is_string($arr['format'])) { $criteria->progress_alert_format = trim($arr['format']); } } } // Parse progress alert weekdays (array of strings) if (isset($data['progress_alert_weekdays']) && is_array($data['progress_alert_weekdays'])) { $seen = []; $weekdays = []; foreach ($data['progress_alert_weekdays'] as $wd) { if (!is_string($wd)) { continue; } $e = goals_criteria_progress_alert_weekday::tryFrom($wd); if ($e !== null) { $key = $e->name; if (!isset($seen[$key])) { $seen[$key] = true; $weekdays[] = $e; } } } $criteria->progress_alert_weekdays = $weekdays; } elseif (isset($data['progressAlertWeekdays']) && is_array($data['progressAlertWeekdays'])) { $seen = []; $weekdays = []; foreach ($data['progressAlertWeekdays'] as $wd) { if (!is_string($wd)) { continue; } $e = goals_criteria_progress_alert_weekday::tryFrom($wd); if ($e !== null) { $key = $e->name; if (!isset($seen[$key])) { $seen[$key] = true; $weekdays[] = $e; } } } $criteria->progress_alert_weekdays = $weekdays; } // Parse progress alert time of day (string with timezone) if (isset($data['progress_alert_time_of_day']) && is_string($data['progress_alert_time_of_day'])) { $criteria->progress_alert_time_of_day = trim((string)$data['progress_alert_time_of_day']); } elseif (isset($data['progressAlertTimeOfDay']) && is_string($data['progressAlertTimeOfDay'])) { $criteria->progress_alert_time_of_day = trim((string)$data['progressAlertTimeOfDay']); } // Parse timeframe with validation if (isset($data['start']) && is_string($data['start'])) { try { $criteria->start = new \DateTime($data['start']); } catch (\Exception) { // keep default } } if (isset($data['end']) && is_string($data['end'])) { try { $criteria->end = new \DateTime($data['end']); } catch (\Exception) { // keep default } } // Ensure end is not before start (swap if needed) if (($criteria->start instanceof \DateTimeInterface) && ($criteria->end instanceof \DateTimeInterface)) { if ($criteria->end < $criteria->start) { $tmp = $criteria->start; $criteria->start = $criteria->end; $criteria->end = $tmp; } } // Parse users (expects array of customer_numbers or objects with customer_number) if (isset($data['users']) && is_array($data['users'])) { $seen = []; $users = array_map(function ($item) use (&$seen) { $customerNumber = null; if (is_array($item)) { if (isset($item['customer_number'])) { $customerNumber = (int)$item['customer_number']; } elseif (isset($item['customerNumber'])) { // allow camelCase $customerNumber = (int)$item['customerNumber']; } } elseif (is_numeric($item)) { $customerNumber = (int)$item; } if ($customerNumber === null || $customerNumber <= 0) { return null; } if (isset($seen[$customerNumber])) { return null; // dedupe early } $seen[$customerNumber] = true; return (new \objects\users_o())->getUserByCustomerNumber($customerNumber); }, $data['users']); // Filter out nulls in case of malformed entries $users = array_values(array_filter($users)); $criteria->users->set($users); } // Parse departments (expects array of IDs or objects with id) if (isset($data['departments']) && is_array($data['departments'])) { $seenDept = []; $departments = array_map(function ($item) use (&$seenDept) { $id = null; if (is_array($item)) { if (isset($item['id'])) { $id = (int)$item['id']; } } elseif (is_numeric($item)) { $id = (int)$item; } if ($id === null || $id <= 0) { return null; } if (isset($seenDept[$id])) { return null; } $seenDept[$id] = true; return (new \objects\departments_o())->select($id); }, $data['departments']); $departments = array_values(array_filter($departments)); $criteria->departments->set($departments); } // Parse products (expects array of IDs or objects with id) if (isset($data['products']) && is_array($data['products'])) { $seenProd = []; $products = array_map(function ($item) use (&$seenProd) { $id = null; if (is_array($item)) { if (isset($item['id'])) { $id = (int)$item['id']; } } elseif (is_numeric($item)) { $id = (int)$item; } if ($id === null || $id <= 0) { return null; } if (isset($seenProd[$id])) { return null; } $seenProd[$id] = true; return (new \objects\products_o())->select($id); }, $data['products']); $products = array_values(array_filter($products)); $criteria->products->set($products); } return $criteria; } /** * @throws \Exception */ public static function calculateProgressFromArray(array $criteria_array): float { $criteria = self::fromJson(json_encode($criteria_array)); return $criteria->getProgress(); } /** * Export the criteria as an associative array suitable for JSON encoding. * Ensures a canonical schema matching fromJson expectations. * @return array */ public function toArray(): array { global /** @var db $db */ $db; // Normalize lists to identifiers only $users = $this->users?->listCustomerNumbers() ?? []; $departments = $this->departments?->listIDs() ?? []; $products = $this->products?->listIDs() ?? []; // Remove nulls and duplicates just in case $users = array_values(array_unique(array_filter($users, fn($v) => $v !== null))); $departments = array_values(array_unique(array_filter($departments, fn($v) => $v !== null))); $products = array_values(array_unique(array_filter($products, fn($v) => $v !== null))); return [ 'type' => $this->type?->name ?? goals_criteria_type::NONE->name, 'target' => $this->target ?? 0, 'label' => (string)$this->label, 'start' => ($this->start instanceof \DateTimeInterface) ? $this->start->format(DATE_ATOM) : null, 'end' => ($this->end instanceof \DateTimeInterface) ? $this->end->format(DATE_ATOM) : null, 'users' => $users, 'departments' => $departments, 'products' => $products, 'progress_alert_frequency' => $this->progress_alert_frequency?->name ?? goals_criteria_progress_alert_frequency::NONE->name, 'progress_alert_destination' => $this->progress_alert_destination?->name ?? goals_criteria_progress_alert_destination::NONE->name, 'progress_alert_progress_type' => $this->progress_alert_progress_type?->name ?? goals_criteria_progress_alert_progress_type::NONE->name, 'progress_alert_style' => $this->progress_alert_style?->name ?? goals_criteria_progress_alert_style::NONE->name, 'progress_alert_format' => $this->progress_alert_format, 'progress_alert_weekdays' => array_map(fn($e) => ($e instanceof goals_criteria_progress_alert_weekday) ? $e->name : (string)$e, $this->progress_alert_weekdays ?? []), 'progress_alert_time_of_day' => $this->progress_alert_time_of_day, ]; } /** * Export the criteria to JSON * @return string */ public function toJson(): string { return json_encode($this->toArray()); } /** * Render a progress alert message based on current destination, progress type, style and optional template */ public function renderProgressAlert(): string { return \goals\services\goals_progress_alert_renderer::render($this); } private function getProgress(): int { return match ($this->type) { goals_criteria_type::PRODUCT => $this->products->count($this), goals_criteria_type::REVENUE => $this->sumRevenue(), goals_criteria_type::VISITS => $this->countVisits(), default => 0, }; } /** * Calculate revenue within the criteria filters. * Revenue is computed as sum(price * quantity) for matching order items. * Prices are assumed to be stored as integers (e.g., cents) in the database. */ private function sumRevenue(): int { $datetime_start = $this->start ?? new \DateTime(); $datetime_end = $this->end ?? new \DateTime(); $items = order_items_o::getListByCriteria( department_ids: $this->departments?->listIDs() ?? [], product_ids: $this->products?->listIDs() ?? [], customer_numbers: $this->users?->listCustomerNumbers() ?? [], datetime_start: $datetime_start, datetime_end: $datetime_end, ); $sum = 0; foreach ($items as $item) { $price = (int)$item->price->value(); $qty = (int)$item->quantity->value(); $sum += $price * $qty; } return $sum; } /** * Count visits within the criteria filters. * In this domain a "visit" corresponds to unique order records. */ private function countVisits(): int { $datetime_start = $this->start ?? new \DateTime(); $datetime_end = $this->end ?? new \DateTime(); $items = order_items_o::getListByCriteria( department_ids: $this->departments?->listIDs() ?? [], product_ids: $this->products?->listIDs() ?? [], customer_numbers: $this->users?->listCustomerNumbers() ?? [], datetime_start: $datetime_start, datetime_end: $datetime_end, ); // Use an associative array to track unique order IDs $unique_orders = []; foreach ($items as $item) { $order_id = (int)$item->order_id->value(); $unique_orders[$order_id] = true; // Value doesn't matter } return count($unique_orders); } /** * Public accessor for the current result based on criteria type. * Allows external callers (e.g., goals class) to retrieve the computed result * without duplicating switch logic. */ public function currentResult(): int|float { return $this->getProgress(); } public function validateAndSanitize(): void { // Ensure target is non-negative if ($this->target < 0) { $this->target = 0; } // Ensure timeframe is valid if (($this->start instanceof \DateTimeInterface) && ($this->end instanceof \DateTimeInterface)) { if ($this->end < $this->start) { $tmp = $this->start; $this->start = $this->end; $this->end = $tmp; } } // Ensure unknown enum values are reset to defaults if (!in_array($this->type, goals_criteria_type::cases())) { $this->type = goals_criteria_type::NONE; } if (!in_array($this->progress_alert_frequency, goals_criteria_progress_alert_frequency::cases())) { $this->progress_alert_frequency = goals_criteria_progress_alert_frequency::NONE; } if (!in_array($this->progress_alert_destination, goals_criteria_progress_alert_destination::cases())) { $this->progress_alert_destination = goals_criteria_progress_alert_destination::NONE; } if (!in_array($this->progress_alert_progress_type, goals_criteria_progress_alert_progress_type::cases())) { $this->progress_alert_progress_type = goals_criteria_progress_alert_progress_type::NONE; } if (!in_array($this->progress_alert_style, goals_criteria_progress_alert_style::cases())) { $this->progress_alert_style = goals_criteria_progress_alert_style::NONE; } // Sanitize label if (is_string($this->label)) { $label = trim($this->label); $label = strip_tags($label); $label = preg_replace('/\s+/', ' ', $label); if ($label !== null) { $label = mb_substr($label, 0, 255); $this->label = $label; } } // Sanitize progress alert format (length depends on destination) if (is_string($this->progress_alert_format)) { $fmt = trim($this->progress_alert_format); $fmt = strip_tags($fmt); $fmt = preg_replace('/\s+/', ' ', $fmt); if ($fmt !== null) { $maxLen = match ($this->progress_alert_destination) { \goals\helpers\goals_criteria_progress_alert_destination::SMS => 160, default => 1024, }; $fmt = mb_substr($fmt, 0, $maxLen); $this->progress_alert_format = $fmt === '' ? null : $fmt; } } // Sanitize weekdays: keep only valid enum values, dedupe, and normalize order (Mon..Sun) if (is_array($this->progress_alert_weekdays)) { $seen = []; $clean = []; foreach ($this->progress_alert_weekdays as $wd) { $enum = $wd instanceof goals_criteria_progress_alert_weekday ? $wd : (is_string($wd) ? goals_criteria_progress_alert_weekday::tryFrom($wd) : null); if ($enum !== null) { $k = $enum->name; if (!isset($seen[$k])) { $seen[$k] = true; $clean[] = $enum; } } } // Order by weekday index $order = [ 'MONDAY' => 1, 'TUESDAY' => 2, 'WEDNESDAY' => 3, 'THURSDAY' => 4, 'FRIDAY' => 5, 'SATURDAY' => 6, 'SUNDAY' => 7, ]; usort($clean, function ($a, $b) use ($order) { $an = $a->name ?? (string)$a; $bn = $b->name ?? (string)$b; return ($order[$an] ?? 99) <=> ($order[$bn] ?? 99); }); $this->progress_alert_weekdays = $clean; } // Sanitize time of day with timezone (HH:MMZ or HH:MM±HH:MM) if (is_string($this->progress_alert_time_of_day)) { $time = trim($this->progress_alert_time_of_day); // Accept Z, +HH:MM, -HH:MM (offset colon optional: +HHMM) $pattern = '/^([01]\d|2[0-3]):[0-5]\d(?:Z|[+-](?:[01]\d|2[0-3]):?[0-5]\d)$/'; if (!preg_match($pattern, $time)) { // Try to normalize offsets like +HHMM to +HH:MM if (preg_match('/^([01]\d|2[0-3]):([0-5]\d)([+-])(\d{2})(\d{2})$/', $time, $m)) { $time = sprintf('%s:%s%s%s:%s', $m[1], $m[2], $m[3], $m[4], $m[5]); } } if (preg_match($pattern, $time)) { $this->progress_alert_time_of_day = $time; } else { $this->progress_alert_time_of_day = null; } } } }