Files
api/services/nginx/app/modules/goals/classes/goals_criteria.php
T
Jeppe Bundgaard 84bfbdad25 Refactor Slack notifications and enhance departmental progress calculation
- Replace `send_webhook_message` with `send_message` for improved Slack notification rendering using `goals_progress_alert_renderer`.
- Add methods for calculating and retrieving departmental progress and distribution in `goals_criteria`.
- Update `renderDanishPeriodSummary` and Slack cron logic to support departmental-specific summaries.
- Include departmental progress in serialized goal objects for better reporting.
2026-01-29 18:30:04 +01:00

644 lines
26 KiB
PHP

<?php
namespace goals\classes;
use classes\db;
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;
/**
* 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;
}
/**
* @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();
}
/**
* @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,
'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;
}
}
}
/**
* @throws \Exception
*/
private function getDepartmentalProgress(): array
{
$results = [];
$department_ids = $this->departments->listIDs();
foreach ($department_ids as $dept_id) {
$dept_criteria = clone $this;
$dept_criteria->departments->set([(new \objects\departments_o())->select($dept_id)]);
$results[$dept_id] = $dept_criteria->getProgress();
}
// reset the departments to original
$this->departments->set(array_map(fn($id) => (new \objects\departments_o())->select($id), $department_ids));
return $results;
}
public function withDepartmentFilter(int $department_id): goals_criteria
{
$new_criteria = clone $this;
$new_criteria->departments->set([(new \objects\departments_o())->select($department_id)]);
return $new_criteria;
}
}