Resolve recommended-profile Critical and High findings, retain narrow analyzer exceptions, and update the edge-broker WebSocket dependency to a non-vulnerable release.
502 lines
19 KiB
PHP
502 lines
19 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
class economic_v2_revenue_statistics_service
|
|
{
|
|
private const DEFAULT_PAGE_SIZE = 1000;
|
|
private const DEFAULT_MAX_PAGES = 10;
|
|
private const EPSILON = 0.00001;
|
|
|
|
private economic $economic;
|
|
|
|
/** @var array<int, array{customer_number: int, name: ?string, barred: ?bool, status: string}> */
|
|
private array $customer_cache = [];
|
|
|
|
public function __construct(?economic $economic = null)
|
|
{
|
|
$this->economic = $economic ?? new economic();
|
|
}
|
|
|
|
public function getBookedRevenueStatistics(array $filters = []): array
|
|
{
|
|
$normalized_filters = $this->normalizeFilters($filters);
|
|
$warnings = [];
|
|
|
|
$booked_invoices = $this->fetchBookedInvoices($normalized_filters, $warnings);
|
|
$invoice_ids = [];
|
|
foreach ($booked_invoices as $invoice) {
|
|
$invoice_id = (int)($invoice->bookedInvoiceNumber ?? 0);
|
|
if ($invoice_id > 0) {
|
|
$invoice_ids[] = $invoice_id;
|
|
}
|
|
}
|
|
|
|
$invoice_lines_map = [];
|
|
if (!empty($invoice_ids)) {
|
|
try {
|
|
$invoice_lines_map = $this->economic->invoices->booked->get_invoice_lines($invoice_ids);
|
|
} catch (\Throwable $e) {
|
|
$warnings[] = 'Unable to fetch booked invoice lines in bulk: ' . $e->getMessage();
|
|
}
|
|
}
|
|
|
|
$summary = [
|
|
'invoice_count' => 0,
|
|
'line_count' => 0,
|
|
'net_amount' => 0.0,
|
|
'vat_amount' => 0.0,
|
|
'gross_amount' => 0.0,
|
|
'average_invoice_net_amount' => 0.0,
|
|
];
|
|
|
|
$customers = [];
|
|
$departments = [];
|
|
$currencies = [];
|
|
$seen_customers = [];
|
|
|
|
$customer_filter_map = array_fill_keys($normalized_filters['customer_numbers'], true);
|
|
$department_filter_map = array_fill_keys($normalized_filters['department_numbers'], true);
|
|
$has_customer_filter = !empty($customer_filter_map);
|
|
$has_department_filter = !empty($department_filter_map);
|
|
|
|
foreach ($booked_invoices as $invoice) {
|
|
$invoice_id = (int)($invoice->bookedInvoiceNumber ?? 0);
|
|
if ($invoice_id <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$customer_number = (int)($invoice->customer->customerNumber ?? 0);
|
|
if ($has_customer_filter && !isset($customer_filter_map[$customer_number])) {
|
|
continue;
|
|
}
|
|
|
|
$customer_snapshot = $this->resolveCustomerSnapshot($customer_number, $warnings);
|
|
if (!$this->passesBarredFilter($customer_snapshot['barred'], $normalized_filters['barred'])) {
|
|
continue;
|
|
}
|
|
|
|
$invoice_currency = strtoupper((string)($invoice->currency ?? ''));
|
|
if ($normalized_filters['currency'] !== null && $invoice_currency !== $normalized_filters['currency']) {
|
|
continue;
|
|
}
|
|
|
|
$invoice_lines = $invoice_lines_map[$invoice_id] ?? [];
|
|
$line_reduction = $this->reduceInvoiceLines(
|
|
$invoice_id,
|
|
$invoice_lines,
|
|
$departments,
|
|
$department_filter_map,
|
|
$has_department_filter
|
|
);
|
|
|
|
if ($has_department_filter && !$line_reduction['has_matching_departments']) {
|
|
continue;
|
|
}
|
|
|
|
$invoice_net = $has_department_filter
|
|
? (float)$line_reduction['net_amount']
|
|
: (float)($invoice->netAmount ?? $invoice->net_amount ?? $line_reduction['net_amount']);
|
|
$invoice_vat = $has_department_filter
|
|
? (float)$line_reduction['vat_amount']
|
|
: (float)($invoice->vatAmount ?? $invoice->vat_amount ?? $line_reduction['vat_amount']);
|
|
$invoice_gross = $has_department_filter
|
|
? (float)$line_reduction['gross_amount']
|
|
: (float)($invoice->grossAmount ?? $invoice->gross_amount ?? ($invoice_net + $invoice_vat));
|
|
|
|
if (
|
|
$has_department_filter &&
|
|
abs($invoice_net) < self::EPSILON &&
|
|
abs($invoice_vat) < self::EPSILON &&
|
|
abs($invoice_gross) < self::EPSILON
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
$summary['invoice_count']++;
|
|
$summary['line_count'] += (int)$line_reduction['line_count'];
|
|
$summary['net_amount'] += $invoice_net;
|
|
$summary['vat_amount'] += $invoice_vat;
|
|
$summary['gross_amount'] += $invoice_gross;
|
|
|
|
if (!isset($seen_customers[$customer_number])) {
|
|
$seen_customers[$customer_number] = true;
|
|
}
|
|
|
|
if (!isset($customers[$customer_number])) {
|
|
$customers[$customer_number] = [
|
|
'customer_number' => $customer_number,
|
|
'customer_name' => $customer_snapshot['name'],
|
|
'barred' => $customer_snapshot['barred'],
|
|
'invoice_count' => 0,
|
|
'net_amount' => 0.0,
|
|
'vat_amount' => 0.0,
|
|
'gross_amount' => 0.0,
|
|
];
|
|
}
|
|
$customers[$customer_number]['invoice_count']++;
|
|
$customers[$customer_number]['net_amount'] += $invoice_net;
|
|
$customers[$customer_number]['vat_amount'] += $invoice_vat;
|
|
$customers[$customer_number]['gross_amount'] += $invoice_gross;
|
|
|
|
$currency_key = $invoice_currency !== '' ? $invoice_currency : 'UNKNOWN';
|
|
if (!isset($currencies[$currency_key])) {
|
|
$currencies[$currency_key] = [
|
|
'currency' => $currency_key,
|
|
'invoice_count' => 0,
|
|
'net_amount' => 0.0,
|
|
'vat_amount' => 0.0,
|
|
'gross_amount' => 0.0,
|
|
];
|
|
}
|
|
$currencies[$currency_key]['invoice_count']++;
|
|
$currencies[$currency_key]['net_amount'] += $invoice_net;
|
|
$currencies[$currency_key]['vat_amount'] += $invoice_vat;
|
|
$currencies[$currency_key]['gross_amount'] += $invoice_gross;
|
|
}
|
|
|
|
$summary['unique_customers'] = count($seen_customers);
|
|
if ($summary['invoice_count'] > 0) {
|
|
$summary['average_invoice_net_amount'] = $summary['net_amount'] / $summary['invoice_count'];
|
|
}
|
|
|
|
$customer_rows = array_values($customers);
|
|
usort($customer_rows, static function (array $a, array $b): int {
|
|
return $b['invoice_count'] <=> $a['invoice_count'];
|
|
});
|
|
|
|
$department_rows = [];
|
|
foreach ($departments as $department_key => $row) {
|
|
$department_rows[] = [
|
|
'department_key' => $department_key,
|
|
'department_number' => is_numeric((string)$department_key) ? (int)$department_key : null,
|
|
'invoice_count' => count($row['invoice_ids']),
|
|
'line_count' => $row['line_count'],
|
|
'net_amount' => $row['net_amount'],
|
|
'vat_amount' => $row['vat_amount'],
|
|
'gross_amount' => $row['gross_amount'],
|
|
];
|
|
}
|
|
usort($department_rows, static function (array $a, array $b): int {
|
|
return abs((float)$b['net_amount']) <=> abs((float)$a['net_amount']);
|
|
});
|
|
|
|
$currency_rows = array_values($currencies);
|
|
usort($currency_rows, static function (array $a, array $b): int {
|
|
return $b['invoice_count'] <=> $a['invoice_count'];
|
|
});
|
|
|
|
return [
|
|
'filters' => [
|
|
'dateFrom' => $normalized_filters['dateFrom'],
|
|
'dateTo' => $normalized_filters['dateTo'],
|
|
'customer_numbers' => array_values($normalized_filters['customer_numbers']),
|
|
'department_numbers' => array_values($normalized_filters['department_numbers']),
|
|
'currency' => $normalized_filters['currency'],
|
|
'barred' => $normalized_filters['barred'],
|
|
'max_pages' => $normalized_filters['max_pages'],
|
|
],
|
|
'summary' => $this->roundNumericValues($summary),
|
|
'customers' => $this->roundRows($customer_rows),
|
|
'departments' => $this->roundRows($department_rows),
|
|
'currencies' => $this->roundRows($currency_rows),
|
|
'warnings' => array_values(array_unique($warnings)),
|
|
];
|
|
}
|
|
|
|
private function normalizeFilters(array $filters): array
|
|
{
|
|
$date_from = (string)($filters['dateFrom'] ?? date('Y-m-01'));
|
|
$date_to = (string)($filters['dateTo'] ?? date('Y-m-d'));
|
|
|
|
$customer_numbers = $this->normalizeIntegerList($filters['customer_numbers'] ?? []);
|
|
$department_numbers = $this->normalizeIntegerList($filters['department_numbers'] ?? []);
|
|
|
|
$currency = isset($filters['currency']) && trim((string)$filters['currency']) !== ''
|
|
? strtoupper(trim((string)$filters['currency']))
|
|
: null;
|
|
|
|
$barred = strtolower(trim((string)($filters['barred'] ?? 'all')));
|
|
if (!in_array($barred, ['all', 'barred', 'active'], true)) {
|
|
$barred = 'all';
|
|
}
|
|
|
|
$max_pages = (int)($filters['max_pages'] ?? self::DEFAULT_MAX_PAGES);
|
|
$max_pages = max(1, min(200, $max_pages));
|
|
|
|
return [
|
|
'dateFrom' => $date_from,
|
|
'dateTo' => $date_to,
|
|
'customer_numbers' => $customer_numbers,
|
|
'department_numbers' => $department_numbers,
|
|
'currency' => $currency,
|
|
'barred' => $barred,
|
|
'max_pages' => $max_pages,
|
|
];
|
|
}
|
|
|
|
private function fetchBookedInvoices(array $normalized_filters, array &$warnings): array
|
|
{
|
|
$filters = [
|
|
'(date$gte:' . $normalized_filters['dateFrom'] . '$and:date$lte:' . $normalized_filters['dateTo'] . ')' => '',
|
|
];
|
|
if ($normalized_filters['currency'] !== null) {
|
|
$filters['currency'] = '$eq:' . $normalized_filters['currency'];
|
|
}
|
|
if (count($normalized_filters['customer_numbers']) === 1) {
|
|
$filters['customer.customerNumber'] = '$eq:' . $normalized_filters['customer_numbers'][0];
|
|
}
|
|
|
|
$all = [];
|
|
for ($page = 0; $page < $normalized_filters['max_pages']; $page++) {
|
|
$response = $this->economic->invoices->booked->get(
|
|
$filters,
|
|
[
|
|
'skipPages' => $page,
|
|
'pageSize' => self::DEFAULT_PAGE_SIZE,
|
|
]
|
|
);
|
|
$collection = is_array($response->collection ?? null) ? $response->collection : [];
|
|
$all = array_merge($all, $collection);
|
|
|
|
if (count($collection) < self::DEFAULT_PAGE_SIZE) {
|
|
break;
|
|
}
|
|
if ($page + 1 >= $normalized_filters['max_pages']) {
|
|
$warnings[] = 'Reached pagination safety limit (max_pages=' . $normalized_filters['max_pages'] . ').';
|
|
}
|
|
}
|
|
|
|
return $all;
|
|
}
|
|
|
|
/**
|
|
* @param array<int|string, mixed> $department_totals
|
|
* @param array<int, bool> $department_filter_map
|
|
* @return array{net_amount:float,vat_amount:float,gross_amount:float,line_count:int,has_matching_departments:bool}
|
|
*/
|
|
private function reduceInvoiceLines(
|
|
int $invoice_id,
|
|
array $invoice_lines,
|
|
array &$department_totals,
|
|
array $department_filter_map,
|
|
bool $has_department_filter
|
|
): array {
|
|
$invoice_net = 0.0;
|
|
$invoice_vat = 0.0;
|
|
$invoice_gross = 0.0;
|
|
$line_count = 0;
|
|
$has_matching_departments = false;
|
|
|
|
foreach ($invoice_lines as $line_raw) {
|
|
$line = $this->toArray($line_raw);
|
|
|
|
$quantity = isset($line['quantity']) ? (float)$line['quantity'] : 0.0;
|
|
$unit_net_price = isset($line['unitNetPrice'])
|
|
? (float)$line['unitNetPrice']
|
|
: (isset($line['unit_net_price']) ? (float)$line['unit_net_price'] : 0.0);
|
|
$line_net_amount = isset($line['totalNetAmount'])
|
|
? (float)$line['totalNetAmount']
|
|
: (isset($line['total_net_amount']) ? (float)$line['total_net_amount'] : $quantity * $unit_net_price);
|
|
$line_vat_amount = isset($line['vatAmount'])
|
|
? (float)$line['vatAmount']
|
|
: (isset($line['vat_amount']) ? (float)$line['vat_amount'] : $line_net_amount * ((float)($line['vatRate'] ?? 0.0) / 100));
|
|
$line_gross_amount = $line_net_amount + $line_vat_amount;
|
|
|
|
$is_billable = abs($line_net_amount) > self::EPSILON || abs($quantity) > self::EPSILON;
|
|
if (!$is_billable) {
|
|
continue;
|
|
}
|
|
|
|
$distribution = $this->extractDepartmentDistribution($line);
|
|
$matching_percentage_total = 0.0;
|
|
foreach ($distribution as $department_key => $percentage) {
|
|
if ($has_department_filter && !isset($department_filter_map[(int)$department_key])) {
|
|
continue;
|
|
}
|
|
$matching_percentage_total += (float)$percentage;
|
|
$has_matching_departments = true;
|
|
|
|
if (!isset($department_totals[$department_key])) {
|
|
$department_totals[$department_key] = [
|
|
'invoice_ids' => [],
|
|
'line_count' => 0,
|
|
'net_amount' => 0.0,
|
|
'vat_amount' => 0.0,
|
|
'gross_amount' => 0.0,
|
|
];
|
|
}
|
|
|
|
$ratio = ((float)$percentage / 100.0);
|
|
$department_totals[$department_key]['invoice_ids'][$invoice_id] = true;
|
|
$department_totals[$department_key]['line_count']++;
|
|
$department_totals[$department_key]['net_amount'] += $line_net_amount * $ratio;
|
|
$department_totals[$department_key]['vat_amount'] += $line_vat_amount * $ratio;
|
|
$department_totals[$department_key]['gross_amount'] += $line_gross_amount * $ratio;
|
|
}
|
|
|
|
if ($has_department_filter && $matching_percentage_total <= self::EPSILON) {
|
|
continue;
|
|
}
|
|
|
|
$factor = $has_department_filter ? ($matching_percentage_total / 100.0) : 1.0;
|
|
$invoice_net += $line_net_amount * $factor;
|
|
$invoice_vat += $line_vat_amount * $factor;
|
|
$invoice_gross += $line_gross_amount * $factor;
|
|
$line_count++;
|
|
}
|
|
|
|
return [
|
|
'net_amount' => $invoice_net,
|
|
'vat_amount' => $invoice_vat,
|
|
'gross_amount' => $invoice_gross,
|
|
'line_count' => $line_count,
|
|
'has_matching_departments' => $has_matching_departments,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<int|string, float>
|
|
*/
|
|
private function extractDepartmentDistribution(array $line): array
|
|
{
|
|
$distribution = [];
|
|
$departmental_distribution = $line['departmentalDistribution'] ?? $line['departmental_distribution'] ?? null;
|
|
if (!is_array($departmental_distribution)) {
|
|
return ['unassigned' => 100.0];
|
|
}
|
|
|
|
$distributions = $departmental_distribution['distributions'] ?? null;
|
|
if (is_array($distributions)) {
|
|
foreach ($distributions as $entry_raw) {
|
|
$entry = $this->toArray($entry_raw);
|
|
$department_number = $entry['department']['departmentNumber']
|
|
?? $entry['department']['department_number']
|
|
?? null;
|
|
if ($department_number === null) {
|
|
continue;
|
|
}
|
|
$distribution[(int)$department_number] = (float)($entry['percentage'] ?? 0.0);
|
|
}
|
|
}
|
|
|
|
if (empty($distribution)) {
|
|
$fallback_number = $departmental_distribution['departmentalDistributionNumber']
|
|
?? $departmental_distribution['departmental_distribution_number']
|
|
?? null;
|
|
if ($fallback_number !== null) {
|
|
$distribution[(int)$fallback_number] = 100.0;
|
|
}
|
|
}
|
|
|
|
if (empty($distribution)) {
|
|
$distribution['unassigned'] = 100.0;
|
|
}
|
|
|
|
return $distribution;
|
|
}
|
|
|
|
/**
|
|
* @return array{customer_number: int, name: ?string, barred: ?bool, status: string}
|
|
*/
|
|
private function resolveCustomerSnapshot(int $customer_number, array &$warnings): array
|
|
{
|
|
if (isset($this->customer_cache[$customer_number])) {
|
|
return $this->customer_cache[$customer_number];
|
|
}
|
|
|
|
$snapshot = [
|
|
'customer_number' => $customer_number,
|
|
'name' => null,
|
|
'barred' => null,
|
|
'status' => 'unknown',
|
|
];
|
|
|
|
if ($customer_number <= 0) {
|
|
$this->customer_cache[$customer_number] = $snapshot;
|
|
return $snapshot;
|
|
}
|
|
|
|
try {
|
|
$raw = $this->economic->customers->customers->get($customer_number);
|
|
if (isset($raw->customerNumber)) {
|
|
$snapshot['name'] = isset($raw->name) ? (string)$raw->name : null;
|
|
$snapshot['barred'] = isset($raw->barred) ? (bool)$raw->barred : null;
|
|
$snapshot['status'] = 'resolved';
|
|
} else {
|
|
$warnings[] = 'Unable to resolve e-conomic customer ' . $customer_number . ' while evaluating barred filter.';
|
|
}
|
|
} catch (\Throwable $e) {
|
|
$warnings[] = 'Failed to fetch e-conomic customer ' . $customer_number . ': ' . $e->getMessage();
|
|
}
|
|
|
|
$this->customer_cache[$customer_number] = $snapshot;
|
|
return $snapshot;
|
|
}
|
|
|
|
private function passesBarredFilter(?bool $barred, string $mode): bool
|
|
{
|
|
return match ($mode) {
|
|
'barred' => $barred === true,
|
|
'active' => $barred !== true,
|
|
default => true,
|
|
};
|
|
}
|
|
|
|
private function toArray(mixed $value): array
|
|
{
|
|
if (is_array($value)) {
|
|
return $value;
|
|
}
|
|
if (!is_object($value)) {
|
|
return [];
|
|
}
|
|
return json_decode(json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), true) ?: [];
|
|
}
|
|
|
|
private function normalizeIntegerList(mixed $raw): array
|
|
{
|
|
$values = [];
|
|
if (is_array($raw)) {
|
|
$values = $raw;
|
|
} elseif (is_string($raw)) {
|
|
$values = explode(',', $raw);
|
|
} elseif (is_numeric($raw)) {
|
|
$values = [$raw];
|
|
}
|
|
|
|
$normalized = [];
|
|
foreach ($values as $value) {
|
|
$int_value = (int)$value;
|
|
if ($int_value > 0) {
|
|
$normalized[$int_value] = true;
|
|
}
|
|
}
|
|
|
|
return array_map('intval', array_keys($normalized));
|
|
}
|
|
|
|
private function roundRows(array $rows): array
|
|
{
|
|
$result = [];
|
|
foreach ($rows as $row) {
|
|
$result[] = $this->roundNumericValues($row);
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
private function roundNumericValues(array $data): array
|
|
{
|
|
foreach ($data as $key => $value) {
|
|
if (is_array($value)) {
|
|
$data[$key] = $this->roundNumericValues($value);
|
|
continue;
|
|
}
|
|
if (is_float($value)) {
|
|
$data[$key] = round($value, 5);
|
|
}
|
|
}
|
|
return $data;
|
|
}
|
|
}
|