- Add `validateAndSanitize` method to `goals_criteria` for input validation and defaulting incorrect values. - Ensure JSON encoding in `response` and database interactions respects Unicode (`JSON_UNESCAPED_UNICODE`). - Sanitize and validate input for `department_goals_o::add` and criteria usage. - Fix header character encoding in response (`Content-Type: application/json; charset=utf-8`). - Update criteria `label` sanitization with trimming, length limits, and safe character handling.
376 lines
14 KiB
PHP
376 lines
14 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\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;
|
|
/**
|
|
* 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;
|
|
|
|
}
|
|
|
|
/**
|
|
* @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 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,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Export the criteria to JSON
|
|
* @return string
|
|
*/
|
|
public function toJson(): string
|
|
{
|
|
return json_encode($this->toArray());
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
}
|
|
} |