987 lines
41 KiB
PHP
987 lines
41 KiB
PHP
<?php
|
|
|
|
namespace goals\classes;
|
|
|
|
use classes\db;
|
|
use goals\helpers\goals_criteria_target_duration;
|
|
use goals\helpers\goals_criteria_type;
|
|
use goals\helpers\goals_criteria_progress_alert_frequency;
|
|
use goals\helpers\goals_criteria_progress_alert_destination;
|
|
use goals\helpers\goals_criteria_progress_alert_progress_type;
|
|
use goals\helpers\goals_criteria_progress_alert_style;
|
|
use goals\helpers\goals_criteria_progress_alert_weekday;
|
|
use goals\interfaces\goals_criteria_i;
|
|
use goals\traits\goals_result_parser_t;
|
|
use goals\traits\goals_target_t;
|
|
use goals\traits\goals_timeframe_t;
|
|
use goals\traits\goals_label_t;
|
|
use objects\order_items_o;
|
|
|
|
class goals_criteria implements goals_criteria_i
|
|
{
|
|
use goals_timeframe_t,
|
|
goals_target_t,
|
|
goals_result_parser_t,
|
|
goals_label_t;
|
|
/**
|
|
* The users criteria
|
|
* @var goals_criteria_users $users
|
|
*/
|
|
public goals_criteria_users $users;
|
|
/**
|
|
* The departments criteria
|
|
* @var goals_criteria_departments $departments
|
|
*/
|
|
public goals_criteria_departments $departments;
|
|
/**
|
|
* Criteria type (products sold, etc.)
|
|
* @var goals_criteria_type $type
|
|
*/
|
|
public goals_criteria_type $type = goals_criteria_type::NONE;
|
|
/**
|
|
* Products criteria
|
|
* @var goals_criteria_products|null $products
|
|
*/
|
|
public ?goals_criteria_products $products;
|
|
/**
|
|
* Progress alert frequency for the goal
|
|
* @var goals_criteria_progress_alert_frequency $progress_alert_frequency
|
|
*/
|
|
public goals_criteria_progress_alert_frequency $progress_alert_frequency;
|
|
/**
|
|
* Progress alert destination for the goal
|
|
* @var goals_criteria_progress_alert_destination $progress_alert_destination
|
|
*/
|
|
public goals_criteria_progress_alert_destination $progress_alert_destination;
|
|
/**
|
|
* Progress alert progress type for the goal
|
|
* @var goals_criteria_progress_alert_progress_type $progress_alert_progress_type
|
|
*/
|
|
public goals_criteria_progress_alert_progress_type $progress_alert_progress_type;
|
|
/**
|
|
* Progress alert style for the goal
|
|
* @var goals_criteria_progress_alert_style $progress_alert_style
|
|
*/
|
|
public goals_criteria_progress_alert_style $progress_alert_style;
|
|
/**
|
|
* Progress alert format (free-form string for now; rendering is handled elsewhere)
|
|
* @var string|null $progress_alert_format
|
|
*/
|
|
public ?string $progress_alert_format = null;
|
|
/**
|
|
* Weekdays on which progress alerts should be sent
|
|
* @var goals_criteria_progress_alert_weekday[] $progress_alert_weekdays
|
|
*/
|
|
public array $progress_alert_weekdays = [];
|
|
/**
|
|
* Time of day (with timezone) when progress alerts should be sent, formatted as HH:MM with timezone (e.g., 14:30Z or 14:30+02:00)
|
|
* @var string|null $progress_alert_time_of_day
|
|
*/
|
|
public ?string $progress_alert_time_of_day = null;
|
|
/**
|
|
* Optional per-department custom daily targets (department_id => daily_target)
|
|
* @var array<int,float>
|
|
*/
|
|
public array $department_daily_targets;
|
|
/**
|
|
* Optional per-department custom weekly targets (department_id => weekly_target)
|
|
* @var array<int,float>
|
|
*/
|
|
public array $department_weekly_targets;
|
|
/**
|
|
* Optional advanced target duration mode.
|
|
* When null, legacy target behavior is preserved.
|
|
* @var goals_criteria_target_duration|null
|
|
*/
|
|
public ?goals_criteria_target_duration $target_duration = null;
|
|
/**
|
|
* Optional cadence amount for advanced target duration modes.
|
|
* Used for WEEKS, MONTHS, YEARS. Ignored for ENTIRE_DURATION.
|
|
* @var int|null
|
|
*/
|
|
public ?int $target_duration_every = null;
|
|
/**
|
|
* Constructor
|
|
*/
|
|
public function __construct()
|
|
{
|
|
$this->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;
|
|
$this->department_daily_targets = [];
|
|
$this->department_weekly_targets = [];
|
|
$this->target_duration = null;
|
|
$this->target_duration_every = null;
|
|
}
|
|
|
|
/**
|
|
* @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);
|
|
}
|
|
}
|
|
|
|
// Parse advanced target duration mode (ignore unknowns)
|
|
$rawTargetDuration = null;
|
|
if (isset($data['target_duration']) && is_string($data['target_duration'])) {
|
|
$rawTargetDuration = $data['target_duration'];
|
|
} elseif (isset($data['targetDuration']) && is_string($data['targetDuration'])) {
|
|
$rawTargetDuration = $data['targetDuration'];
|
|
}
|
|
if (is_string($rawTargetDuration)) {
|
|
$duration = goals_criteria_target_duration::tryFrom($rawTargetDuration);
|
|
if ($duration !== null) {
|
|
$criteria->target_duration = $duration;
|
|
}
|
|
}
|
|
|
|
// Parse advanced target duration cadence value (>=1)
|
|
$rawTargetDurationEvery = null;
|
|
if (isset($data['target_duration_every'])) {
|
|
$rawTargetDurationEvery = $data['target_duration_every'];
|
|
} elseif (isset($data['targetDurationEvery'])) {
|
|
$rawTargetDurationEvery = $data['targetDurationEvery'];
|
|
}
|
|
if (is_numeric($rawTargetDurationEvery)) {
|
|
$criteria->target_duration_every = max(1, (int)$rawTargetDurationEvery);
|
|
}
|
|
|
|
// 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($data['progress_alert_time_of_day']);
|
|
} elseif (isset($data['progressAlertTimeOfDay']) && is_string($data['progressAlertTimeOfDay'])) {
|
|
$criteria->progress_alert_time_of_day = trim($data['progressAlertTimeOfDay']);
|
|
}
|
|
|
|
// Parse department daily targets (expects object with department_id => target)
|
|
if (isset($data['department_daily_targets']) && is_array($data['department_daily_targets'])) {
|
|
$targets = [];
|
|
foreach ($data['department_daily_targets'] as $deptId => $target) {
|
|
if (is_numeric($deptId) && is_numeric($target)) {
|
|
$id = (int)$deptId;
|
|
$t = max(0.0, (float)$target);
|
|
$targets[$id] = $t;
|
|
}
|
|
}
|
|
$criteria->department_daily_targets = $targets;
|
|
} elseif (isset($data['departmentDailyTargets']) && is_array($data['departmentDailyTargets'])) {
|
|
$targets = [];
|
|
foreach ($data['departmentDailyTargets'] as $deptId => $target) {
|
|
if (is_numeric($deptId) && is_numeric($target)) {
|
|
$id = (int)$deptId;
|
|
$t = max(0.0, (float)$target);
|
|
$targets[$id] = $t;
|
|
}
|
|
}
|
|
$criteria->department_daily_targets = $targets;
|
|
}
|
|
// Parse department weekly targets (expects object with department_id => target)
|
|
if (isset($data['department_weekly_targets']) && is_array($data['department_weekly_targets'])) {
|
|
$targets = [];
|
|
foreach ($data['department_weekly_targets'] as $deptId => $target) {
|
|
if (is_numeric($deptId) && is_numeric($target)) {
|
|
$id = (int)$deptId;
|
|
$t = max(0.0, (float)$target);
|
|
$targets[$id] = $t;
|
|
}
|
|
}
|
|
$criteria->department_weekly_targets = $targets;
|
|
} elseif (isset($data['departmentWeeklyTargets']) && is_array($data['departmentWeeklyTargets'])) {
|
|
$targets = [];
|
|
foreach ($data['departmentWeeklyTargets'] as $deptId => $target) {
|
|
if (is_numeric($deptId) && is_numeric($target)) {
|
|
$id = (int)$deptId;
|
|
$t = max(0.0, (float)$target);
|
|
$targets[$id] = $t;
|
|
}
|
|
}
|
|
$criteria->department_weekly_targets = $targets;
|
|
}
|
|
// 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, ?string $timeframe = null): float
|
|
{
|
|
$criteria = self::fromJson(json_encode($criteria_array));
|
|
if ($timeframe) {
|
|
$criteria->setTimeframeByName($timeframe);
|
|
}
|
|
return $criteria->getProgress();
|
|
}
|
|
|
|
/**
|
|
* @throws \Exception
|
|
*/
|
|
public static function calculateDepartmentalProgressFromArray(array $criteria_array): array
|
|
{
|
|
$criteria = self::fromJson(json_encode($criteria_array));
|
|
return $criteria->getDepartmentalProgress();
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
'target_duration' => $this->target_duration?->name,
|
|
'target_duration_every' => $this->target_duration_every,
|
|
'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,
|
|
'department_daily_targets' => $this->department_daily_targets,
|
|
'department_weekly_targets' => $this->department_weekly_targets
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
};
|
|
}
|
|
|
|
public function setTimeframeByName(string $timeframe): void
|
|
{
|
|
$now = new \DateTime();
|
|
$goal_start = $this->start ? clone $this->start : null;
|
|
$goal_end = $this->end ? clone $this->end : null;
|
|
$timeframe_start = null;
|
|
$timeframe_end = null;
|
|
|
|
switch ($timeframe) {
|
|
case 'today':
|
|
$timeframe_start = (clone $now)->setTime(0, 0, 0);
|
|
$timeframe_end = (clone $now)->setTime(23, 59, 59);
|
|
break;
|
|
case 'week':
|
|
$timeframe_start = (clone $now)->modify('monday this week')->setTime(0, 0, 0);
|
|
$timeframe_end = (clone $now)->setTime(23, 59, 59);
|
|
break;
|
|
case 'month':
|
|
$timeframe_start = (clone $now)->modify('first day of this month')->setTime(0, 0, 0);
|
|
$timeframe_end = (clone $now)->setTime(23, 59, 59);
|
|
break;
|
|
case 'year':
|
|
$timeframe_start = (clone $now)->modify('first day of January this year')->setTime(0, 0, 0);
|
|
$timeframe_end = (clone $now)->setTime(23, 59, 59);
|
|
break;
|
|
case 'to_date':
|
|
$timeframe_start = $goal_start ? clone $goal_start : null;
|
|
$timeframe_end = (clone $now)->setTime(23, 59, 59);
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
|
|
if ($goal_start instanceof \DateTimeInterface) {
|
|
if (!($timeframe_start instanceof \DateTimeInterface) || $timeframe_start < $goal_start) {
|
|
$timeframe_start = clone $goal_start;
|
|
}
|
|
}
|
|
if ($goal_end instanceof \DateTimeInterface) {
|
|
if (!($timeframe_end instanceof \DateTimeInterface) || $timeframe_end > $goal_end) {
|
|
$timeframe_end = clone $goal_end;
|
|
}
|
|
}
|
|
|
|
if (($timeframe_start instanceof \DateTimeInterface) && ($timeframe_end instanceof \DateTimeInterface) && $timeframe_end < $timeframe_start) {
|
|
$timeframe_end = clone $timeframe_start;
|
|
}
|
|
|
|
$this->start = $timeframe_start;
|
|
$this->end = $timeframe_end;
|
|
}
|
|
|
|
/**
|
|
* 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 usesAdvancedTargetDuration(): bool
|
|
{
|
|
return $this->target_duration instanceof goals_criteria_target_duration;
|
|
}
|
|
|
|
public function validateAndSanitize(): void
|
|
{
|
|
// Normalize custom daily targets map: ints and non-negative; filter to selected departments
|
|
$normalized = [];
|
|
$deptIds = $this->departments?->listIDs() ?? [];
|
|
foreach ($this->department_daily_targets as $k => $v) {
|
|
if (!is_numeric($k) || !is_numeric($v)) { continue; }
|
|
$dk = (int)$k; $dv = (float)$v;
|
|
if ($dk > 0 && $dv >= 0 && in_array($dk, $deptIds)) {
|
|
$normalized[$dk] = $dv;
|
|
}
|
|
}
|
|
$this->department_daily_targets = $normalized;
|
|
// Normalize custom weekly targets map: ints and non-negative; filter to selected departments
|
|
$normalizedWeekly = [];
|
|
foreach ($this->department_weekly_targets as $k => $v) {
|
|
if (!is_numeric($k) || !is_numeric($v)) { continue; }
|
|
$dk = (int)$k; $dv = (float)$v;
|
|
if ($dk > 0 && $dv >= 0 && in_array($dk, $deptIds)) {
|
|
$normalizedWeekly[$dk] = $dv;
|
|
}
|
|
}
|
|
$this->department_weekly_targets = $normalizedWeekly;
|
|
// Ensure target is non-negative
|
|
if ($this->target < 0) {
|
|
$this->target = 0;
|
|
}
|
|
|
|
// Normalize advanced target duration values
|
|
if ($this->target_duration !== null && !in_array($this->target_duration, goals_criteria_target_duration::cases(), true)) {
|
|
$this->target_duration = null;
|
|
}
|
|
if ($this->target_duration === goals_criteria_target_duration::ENTIRE_DURATION) {
|
|
$this->target_duration_every = null;
|
|
} elseif ($this->target_duration !== null) {
|
|
$every = (int)($this->target_duration_every ?? 1);
|
|
$this->target_duration_every = max(1, $every);
|
|
} else {
|
|
// Strict legacy compatibility branch marker: null duration means old behavior.
|
|
$this->target_duration_every = null;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
$this->department_daily_targets = array_filter($this->department_daily_targets, fn($v) => is_numeric($v) && $v >= 0);
|
|
$this->department_weekly_targets = array_filter($this->department_weekly_targets, fn($v) => is_numeric($v) && $v >= 0);
|
|
}
|
|
|
|
/**
|
|
* @throws \Exception
|
|
*/
|
|
private function getDepartmentalProgress(): array
|
|
{
|
|
$results = [];
|
|
$department_ids = $this->departments->listIDs();
|
|
if ($this->usesAdvancedTargetDuration()) {
|
|
foreach ($department_ids as $dept_id) {
|
|
$deptId = (int)$dept_id;
|
|
$results[$deptId] = [
|
|
'all' => $this->getAdvancedProgressDetailsForDepartment($deptId, null, $department_ids),
|
|
'today' => $this->getAdvancedProgressDetailsForDepartment($deptId, 'today', $department_ids),
|
|
'week' => $this->getAdvancedProgressDetailsForDepartment($deptId, 'week', $department_ids),
|
|
'month' => $this->getAdvancedProgressDetailsForDepartment($deptId, 'month', $department_ids),
|
|
'year' => $this->getAdvancedProgressDetailsForDepartment($deptId, 'year', $department_ids),
|
|
'to_date' => $this->getAdvancedProgressDetailsForDepartment($deptId, 'to_date', $department_ids),
|
|
];
|
|
}
|
|
return $results;
|
|
}
|
|
|
|
foreach ($department_ids as $dept_id) {
|
|
$dept_criteria = clone $this;
|
|
$dept_criteria->departments->set([(new \objects\departments_o())->select($dept_id)]);
|
|
$results[$dept_id] = [
|
|
'all' => $dept_criteria->getProgressDetails(null),
|
|
'today' => $dept_criteria->getProgressDetails('today'),
|
|
'week' => $dept_criteria->getProgressDetails('week'),
|
|
'month' => $dept_criteria->getProgressDetails('month'),
|
|
'year' => $dept_criteria->getProgressDetails('year'),
|
|
'to_date' => $dept_criteria->getProgressDetails('to_date'),
|
|
];
|
|
}
|
|
// reset the departments to original
|
|
$this->departments->set(array_map(fn($id) => (new \objects\departments_o())->select($id), $department_ids));
|
|
return $results;
|
|
}
|
|
|
|
/**
|
|
* @throws \Exception
|
|
*/
|
|
public static function calculateProgressDetailsFromArray(array $criteria_array, ?string $timeframe = null): array
|
|
{
|
|
$criteria = self::fromJson(json_encode($criteria_array));
|
|
return $criteria->getProgressDetails($timeframe);
|
|
}
|
|
|
|
public function getProgressDetails(?string $timeframe = null): array
|
|
{
|
|
$criteria = $this;
|
|
if ($timeframe !== null) {
|
|
// Avoid mutating the current instance so repeated calls for different
|
|
// periods (e.g. departmental distribution) always use original goal bounds.
|
|
$criteria = clone $this;
|
|
$criteria->setTimeframeByName($timeframe);
|
|
}
|
|
|
|
if ($this->usesAdvancedTargetDuration()) {
|
|
$target = 0.0;
|
|
if (($criteria->start instanceof \DateTimeInterface) && ($criteria->end instanceof \DateTimeInterface)) {
|
|
$target = $this->calculateTargetForRange(
|
|
$criteria->start,
|
|
$criteria->end,
|
|
null,
|
|
$this->departments->listIDs()
|
|
);
|
|
}
|
|
|
|
return [
|
|
'count' => $criteria->getProgress(),
|
|
'target' => $target,
|
|
'date_from' => ($criteria->start instanceof \DateTimeInterface) ? $criteria->start->format(DATE_ATOM) : null,
|
|
'date_end' => ($criteria->end instanceof \DateTimeInterface) ? $criteria->end->format(DATE_ATOM) : null,
|
|
];
|
|
}
|
|
|
|
$target = 0.0;
|
|
$active_dept_ids = $criteria->departments->listIDs();
|
|
|
|
if ($timeframe === 'today') {
|
|
$days = $criteria->getTimeframeDayCount();
|
|
foreach ($active_dept_ids as $id) {
|
|
$target += $criteria->getDailyTargetForDepartment((int)$id) * $days;
|
|
}
|
|
} elseif ($timeframe === 'week') {
|
|
$days = $criteria->getTimeframeDayCount();
|
|
foreach ($active_dept_ids as $id) {
|
|
$target += $criteria->getDailyTargetForDepartment((int)$id) * $days;
|
|
}
|
|
} elseif ($timeframe === 'month') {
|
|
$days = $criteria->getTimeframeDayCount();
|
|
foreach ( $active_dept_ids as $id ) {
|
|
$target += $criteria->getDailyTargetForDepartment((int)$id) * $days;
|
|
}
|
|
} elseif ($timeframe === 'year') {
|
|
$days = $criteria->getTimeframeDayCount();
|
|
foreach ( $active_dept_ids as $id ) {
|
|
$target += $criteria->getDailyTargetForDepartment((int)$id) * $days;
|
|
}
|
|
} elseif ($timeframe === 'to_date') {
|
|
$days = $criteria->getTimeframeDayCount();
|
|
foreach ($active_dept_ids as $id) {
|
|
$target += $criteria->getDailyTargetForDepartment((int)$id) * $days;
|
|
}
|
|
} else {
|
|
$target = (float)$criteria->target;
|
|
}
|
|
|
|
return [
|
|
'count' => $criteria->getProgress(),
|
|
'target' => $target,
|
|
'date_from' => ($criteria->start instanceof \DateTimeInterface) ? $criteria->start->format(DATE_ATOM) : null,
|
|
'date_end' => ($criteria->end instanceof \DateTimeInterface) ? $criteria->end->format(DATE_ATOM) : null,
|
|
];
|
|
}
|
|
|
|
private function getTimeframeDayCount(): int
|
|
{
|
|
if (!($this->start instanceof \DateTimeInterface) || !($this->end instanceof \DateTimeInterface)) {
|
|
return 0;
|
|
}
|
|
if ($this->end < $this->start) {
|
|
return 0;
|
|
}
|
|
return (int)$this->start->diff($this->end)->format('%a') + 1;
|
|
}
|
|
|
|
private function getOperatingDaysPerWeek(): int
|
|
{
|
|
$days = $this->operating_days_of_week ?? [1, 2, 3, 4, 5];
|
|
if (!is_array($days)) {
|
|
return 5;
|
|
}
|
|
$days = array_values(array_unique(array_filter(array_map('intval', $days), fn($d) => $d >= 1 && $d <= 7)));
|
|
return max(1, count($days));
|
|
}
|
|
|
|
private function getDailyTargetForDepartment(int $departmentId): float
|
|
{
|
|
if (isset($this->department_daily_targets[$departmentId])) {
|
|
return max(0.0, (float)$this->department_daily_targets[$departmentId]);
|
|
}
|
|
if (isset($this->department_weekly_targets[$departmentId])) {
|
|
return max(0.0, (float)$this->department_weekly_targets[$departmentId]) / $this->getOperatingDaysPerWeek();
|
|
}
|
|
return 0.0;
|
|
}
|
|
|
|
public function withDepartmentFilter(int $department_id): goals_criteria
|
|
{
|
|
// Clone the criteria and create a fresh departments collection to avoid mutating the original
|
|
$new_criteria = clone $this;
|
|
$new_criteria->departments = new goals_criteria_departments();
|
|
$new_criteria->departments->set([(new \objects\departments_o())->select($department_id)]);
|
|
return $new_criteria;
|
|
}
|
|
|
|
public function getAlertDaysForDepartment(int $deptId): array
|
|
{
|
|
$alert_days = [];
|
|
foreach ($this->progress_alert_weekdays as $weekday) {
|
|
if ($weekday instanceof goals_criteria_progress_alert_weekday) {
|
|
$alert_days[] = $weekday;
|
|
}
|
|
}
|
|
return $alert_days;
|
|
}
|
|
}
|