Files
api/services/nginx/app/classes/economic_v2_revenue_statistics_service.php
T

504 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,
'unique_customers' => 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;
}
}