Files
breadcrumb-the-shire/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php
fs cae66e5361 feat(helpdesk): add team workload dashboard with per-agent metrics
New standalone page (helpdesk/team) showing support agent workload
aggregated from BC tickets. KPI bar (open, unassigned, avg age, resolved),
per-agent table sorted by open tickets, period selector (30/90/180/365d).
Global OData query via getTicketsForTeam() without customer filter.
New permission helpdesk.team-workload.view with admin role assignment.
Includes 6 PHPUnit tests for buildTeamWorkloadDashboard().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 18:46:54 +02:00

1699 lines
60 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace MintyPHP\Module\Helpdesk\Service;
/**
* Service for loading debtor detail data (master data, contacts, tickets).
*
* Provides both a combined loadDetail() method (legacy) and individual
* methods for async loading via data endpoints.
*/
class DebitorDetailService
{
/** @var array<int, string> */
private const CLOSED_TICKET_STATES = ['Erledigt', 'Resolved', 'Closed', 'Geschlossen'];
public function __construct(
private readonly BcODataGateway $bcODataGateway,
private readonly HelpdeskSettingsGateway $settingsGateway
) {
}
/**
* Load only the customer master data (1 OData call).
*
* Used by the main page load — contacts and tickets are loaded async.
*
* @return array{status: string, customer?: array<string, mixed>, error?: string}
*/
public function loadCustomer(string $customerNo): array
{
$customerNo = trim($customerNo);
if ($customerNo === '') {
return ['status' => 'not_found'];
}
if (!$this->settingsGateway->isConfigured()) {
return ['status' => 'not_configured', 'error' => 'BC connection not configured'];
}
try {
$customer = $this->bcODataGateway->getCustomer($customerNo);
} catch (\Throwable) {
return ['status' => 'error', 'error' => 'BC connection failed'];
}
if ($customer === null) {
return ['status' => 'not_found'];
}
return [
'status' => 'success',
'customer' => $customer,
];
}
/**
* Load contacts for a customer (for async data endpoint).
*
* @return array{
* ok: bool,
* contacts?: array<int, array<string, mixed>>,
* meta?: array{
* source: string,
* fallback_used: bool,
* fallback_reason: string
* },
* error?: string
* }
*/
public function loadContacts(string $customerNo, string $customerName): array
{
$customerNo = trim($customerNo);
$customerName = trim($customerName);
if ($customerNo === '' || $customerName === '') {
return ['ok' => false, 'error' => 'Missing customer number or name'];
}
if (!$this->settingsGateway->isConfigured()) {
return ['ok' => false, 'error' => 'BC connection not configured'];
}
try {
$result = $this->bcODataGateway->getContactsForCustomerWithMeta($customerNo, $customerName);
} catch (\Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
}
$contacts = $result['contacts'];
$meta = $result['meta'];
return [
'ok' => true,
'contacts' => $contacts,
'meta' => [
'source' => $meta['source'],
'fallback_used' => $meta['fallback_used'],
'fallback_reason' => $meta['fallback_reason'],
],
];
}
/**
* Load tickets for a customer (for async data endpoint).
*
* @return array{
* ok: bool,
* tickets?: array<int, array<string, mixed>>,
* meta?: array{
* source: string,
* fallback_used: bool,
* fallback_reason: string
* },
* error?: string
* }
*/
public function loadTickets(string $customerNo, string $customerName): array
{
$customerNo = trim($customerNo);
$customerName = trim($customerName);
if ($customerNo === '' || $customerName === '') {
return ['ok' => false, 'error' => 'Missing customer number or name'];
}
if (!$this->settingsGateway->isConfigured()) {
return ['ok' => false, 'error' => 'BC connection not configured'];
}
try {
$result = $this->bcODataGateway->getTicketsForCustomerWithMeta($customerNo, $customerName);
} catch (\Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
}
$tickets = $result['tickets'];
$meta = $result['meta'];
return [
'ok' => true,
'tickets' => $tickets,
'meta' => [
'source' => $meta['source'],
'fallback_used' => $meta['fallback_used'],
'fallback_reason' => $meta['fallback_reason'],
],
];
}
/**
* Load a summary of ticket KPIs for a customer (exact counts, not sampled).
*
* @return array{ok: bool, total?: int, open?: int, last_activity?: string, error?: string}
*/
public function loadTicketSummary(string $customerNo, string $customerName): array
{
$result = $this->loadTickets($customerNo, $customerName);
if (!$result['ok']) {
return $result;
}
return self::summarizeTickets($result['tickets'] ?? []);
}
/**
* Load support dashboard data for a debtor.
*
* @return array{
* ok: bool,
* health?: array{
* total_tickets: int,
* open_tickets: int,
* critical_tickets: int,
* oldest_open_age_hours: int|null,
* escalation_count: int|null,
* escalation_available: bool
* },
* actions?: array<int, array{
* action_id: string,
* type: string,
* priority: int,
* ticket_no: string,
* age_hours: int|null
* }>,
* meta?: array{
* critical_threshold_hours: int,
* tickets_considered: int
* },
* error?: string
* }
*/
public function loadSupportDashboard(string $customerNo, string $customerName, int $criticalThresholdHours = 48): array
{
$result = $this->loadTickets($customerNo, $customerName);
if (!$result['ok']) {
return [
'ok' => false,
'error' => (string) $result['error'],
];
}
$tickets = is_array($result['tickets'] ?? null) ? $result['tickets'] : [];
$escalation = $this->loadEscalationHealth($customerNo, $tickets);
if (!$escalation['ok']) {
$escalation = null;
}
return self::buildSupportDashboard($tickets, $criticalThresholdHours, is_array($escalation) ? $escalation : null);
}
/**
* Load escalation health for a debtor by matching ticket escalation codes
* against escalation target definitions.
*
* @param array<int, array<string, mixed>> $tickets Current ticket list used in support dashboard
* @return array{
* ok: bool,
* overdue_count?: int,
* entries?: array<int, array{
* ticket_no: string,
* escalation_code: string,
* target_seconds: int,
* last_activity_iso: string,
* overdue_seconds: int
* }>,
* matches?: array<int, array{
* ticket_no: string,
* escalation_code: string,
* target_seconds: int,
* age_hours: int,
* is_overdue: bool
* }>,
* tickets_considered?: int,
* tickets_matched?: int,
* definitions_count?: int,
* error?: string
* }
*/
public function loadEscalationHealth(string $customerNo, array $tickets): array
{
$customerNo = trim($customerNo);
if ($customerNo === '') {
return ['ok' => false, 'error' => 'Missing customer number'];
}
if (!$this->settingsGateway->isConfigured()) {
return ['ok' => false, 'error' => 'BC connection not configured'];
}
try {
$definitions = $this->bcODataGateway->getEscalationDefinitions();
$ticketEscalations = $this->bcODataGateway->getEscalationTicketsForCustomer($customerNo);
} catch (\Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
}
if ($definitions === []) {
return ['ok' => false, 'error' => 'No escalation definitions available'];
}
$targetSecondsByCode = [];
foreach ($definitions as $definition) {
$code = trim((string) ($definition['Code'] ?? ''));
if ($code === '') {
continue;
}
$targetSeconds = self::parseIsoDurationToSeconds((string) ($definition['Target_Time'] ?? ''));
if ($targetSeconds === null || $targetSeconds <= 0) {
continue;
}
$targetSecondsByCode[$code] = $targetSeconds;
}
if ($targetSecondsByCode === []) {
return ['ok' => false, 'error' => 'No valid escalation targets available'];
}
$escalationByTicketNo = [];
foreach ($ticketEscalations as $ticketEscalation) {
$ticketNo = trim((string) ($ticketEscalation['No'] ?? ''));
if ($ticketNo === '') {
continue;
}
$escalationByTicketNo[$ticketNo] = $ticketEscalation;
}
$ticketsConsidered = 0;
$ticketsMatched = 0;
$overdueCount = 0;
$entries = [];
$matches = [];
$nowTs = time();
foreach ($tickets as $ticket) {
$state = trim((string) ($ticket['Ticket_State'] ?? ''));
if (self::isClosedTicketState($state)) {
continue;
}
$ticketNo = trim((string) ($ticket['No'] ?? ''));
if ($ticketNo === '') {
continue;
}
$ticketsConsidered++;
$escalationTicket = $escalationByTicketNo[$ticketNo] ?? null;
if (!is_array($escalationTicket)) {
continue;
}
$escalationCode = trim((string) ($escalationTicket['Escalation_Code'] ?? ''));
if ($escalationCode === '') {
continue;
}
$targetSeconds = $targetSecondsByCode[$escalationCode] ?? null;
if (!is_int($targetSeconds)) {
continue;
}
$activityTs = self::resolveTicketActivityTimestamp($ticket);
if ($activityTs === null) {
continue;
}
$ticketsMatched++;
$ageSeconds = max(0, $nowTs - $activityTs);
$ageHours = max(0, intdiv($ageSeconds, 3600));
$isOverdue = $ageSeconds > $targetSeconds;
$matches[] = [
'ticket_no' => $ticketNo,
'escalation_code' => $escalationCode,
'target_seconds' => $targetSeconds,
'age_hours' => $ageHours,
'is_overdue' => $isOverdue,
];
if ($isOverdue) {
$overdueCount++;
$lastActivityIso = trim((string) ($ticket['Last_Activity_Date'] ?? ''));
if ($lastActivityIso === '' || $lastActivityIso === '0001-01-01T00:00:00Z') {
$lastActivityIso = trim((string) ($ticket['Created_On'] ?? ''));
}
$entries[] = [
'ticket_no' => $ticketNo,
'escalation_code' => $escalationCode,
'target_seconds' => $targetSeconds,
'last_activity_iso' => $lastActivityIso,
'overdue_seconds' => max(1, $ageSeconds - $targetSeconds),
];
}
}
usort($entries, static function (array $a, array $b): int {
$cmp = $b['overdue_seconds'] <=> $a['overdue_seconds'];
if ($cmp !== 0) {
return $cmp;
}
return strcmp($a['ticket_no'], $b['ticket_no']);
});
return [
'ok' => true,
'overdue_count' => $overdueCount,
'entries' => array_slice($entries, 0, 50),
'matches' => array_slice($matches, 0, 300),
'tickets_considered' => $ticketsConsidered,
'tickets_matched' => $ticketsMatched,
'definitions_count' => count($targetSecondsByCode),
];
}
/**
* Load customer contracts/products for support dashboard widgets.
*
* @return array{
* ok: bool,
* entries?: array<int, array{
* contract_no: string,
* product_type: string,
* description: string,
* state: string,
* next_invoicing_date: string,
* sell_to_customer_no: string,
* sell_to_customer_name: string,
* bill_to_customer_no: string,
* bill_to_customer_name: string,
* total_payoff_amount: float,
* active_positions: int,
* support_hours: float
* }>,
* summary?: array{
* total_contracts: int,
* active_contracts: int,
* product_types: array<int, array{type: string, count: int}>
* },
* error?: string
* }
*/
public function loadContracts(string $customerNo): array
{
$customerNo = trim($customerNo);
if ($customerNo === '') {
return ['ok' => false, 'error' => 'Missing customer number'];
}
if (!$this->settingsGateway->isConfigured()) {
return ['ok' => false, 'error' => 'BC connection not configured'];
}
try {
$contracts = $this->bcODataGateway->getContractsForCustomer($customerNo);
} catch (\Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
}
$normalized = self::buildContractsWidgetData($contracts, $customerNo);
return [
'ok' => true,
'entries' => $normalized['entries'],
'summary' => $normalized['summary'],
];
}
/**
* Aggregate ticket KPIs from a raw ticket array.
*
* Used by both the service method and the data endpoint (which has its own cache layer).
*
* @param array<int, array<string, mixed>> $tickets
* @return array{ok: true, total: int, open: int, last_activity: string}
*/
public static function summarizeTickets(array $tickets): array
{
$openCount = 0;
$lastActivity = '';
foreach ($tickets as $ticket) {
$state = (string) ($ticket['Ticket_State'] ?? '');
if (!self::isClosedTicketState($state)) {
$openCount++;
}
$activityDate = (string) ($ticket['Last_Activity_Date'] ?? '');
if ($activityDate !== '' && $activityDate !== '0001-01-01T00:00:00Z' && $activityDate > $lastActivity) {
$lastActivity = $activityDate;
}
}
return [
'ok' => true,
'total' => count($tickets),
'open' => $openCount,
'last_activity' => $lastActivity,
];
}
/**
* @param array<int, array<string, mixed>> $tickets
* @return array{
* ok: true,
* health: array{
* total_tickets: int,
* open_tickets: int,
* critical_tickets: int,
* oldest_open_age_hours: int|null,
* escalation_count: int|null,
* escalation_available: bool
* },
* actions: array<int, array{
* action_id: string,
* type: string,
* priority: int,
* ticket_no: string,
* age_hours: int|null
* }>,
* meta: array{
* critical_threshold_hours: int,
* tickets_considered: int,
* escalation_tickets_considered?: int,
* escalation_tickets_matched?: int,
* escalation_definitions_count?: int
* }
* }
*/
public static function buildSupportDashboard(array $tickets, int $criticalThresholdHours = 48, ?array $escalationHealth = null): array
{
$criticalThresholdHours = max(1, min(168, $criticalThresholdHours));
$nowTs = time();
$totalTickets = count($tickets);
$openTickets = [];
$criticalTickets = 0;
$oldestOpenAgeHours = 0;
foreach ($tickets as $ticket) {
$state = trim((string) ($ticket['Ticket_State'] ?? ''));
if (self::isClosedTicketState($state)) {
continue;
}
$ticketNo = trim((string) ($ticket['No'] ?? ''));
if ($ticketNo === '') {
continue;
}
$activityTs = self::resolveTicketActivityTimestamp($ticket);
$ageHours = null;
if ($activityTs !== null) {
$ageHours = max(0, intdiv(max(0, $nowTs - $activityTs), 3600));
$oldestOpenAgeHours = max($oldestOpenAgeHours, $ageHours);
if ($ageHours >= $criticalThresholdHours) {
$criticalTickets++;
}
}
$openTickets[] = [
'ticket_no' => $ticketNo,
'support_user_name' => trim((string) ($ticket['Support_User_Name'] ?? '')),
'age_hours' => $ageHours,
];
}
// Trend KPIs: 30-day windows
$trendDays = 30;
$boundary30 = $nowTs - ($trendDays * 86400);
$boundary60 = $nowTs - (2 * $trendDays * 86400);
$createdCurrent = 0;
$createdPrevious = 0;
$closedCurrent = 0;
$closedPrevious = 0;
$openAgeSum = 0;
$openAgeCount = 0;
foreach ($tickets as $ticket) {
$createdRaw = trim((string) ($ticket['Created_On'] ?? ''));
$createdTs = ($createdRaw !== '' && !str_starts_with($createdRaw, '0001')) ? strtotime($createdRaw) : false;
if ($createdTs !== false) {
if ($createdTs >= $boundary30) {
$createdCurrent++;
} elseif ($createdTs >= $boundary60) {
$createdPrevious++;
}
}
$state = trim((string) ($ticket['Ticket_State'] ?? ''));
if (self::isClosedTicketState($state)) {
$activityTs = self::resolveTicketActivityTimestamp($ticket);
if ($activityTs !== null) {
if ($activityTs >= $boundary30) {
$closedCurrent++;
} elseif ($activityTs >= $boundary60) {
$closedPrevious++;
}
}
}
}
foreach ($openTickets as $openTicket) {
if ($openTicket['age_hours'] !== null) {
$openAgeSum += $openTicket['age_hours'];
$openAgeCount++;
}
}
$avgOpenAgeHours = $openAgeCount > 0 ? (int) round($openAgeSum / $openAgeCount) : null;
$escalationCount = null;
$escalationAvailable = false;
$meta = [
'critical_threshold_hours' => $criticalThresholdHours,
'tickets_considered' => $totalTickets,
];
if (is_array($escalationHealth) && ($escalationHealth['ok'] ?? false)) {
$escalationCount = (int) ($escalationHealth['overdue_count'] ?? 0);
$escalationAvailable = true;
$meta['escalation_tickets_considered'] = (int) ($escalationHealth['tickets_considered'] ?? 0);
$meta['escalation_tickets_matched'] = (int) ($escalationHealth['tickets_matched'] ?? 0);
$meta['escalation_definitions_count'] = (int) ($escalationHealth['definitions_count'] ?? 0);
}
return [
'ok' => true,
'health' => [
'total_tickets' => $totalTickets,
'open_tickets' => count($openTickets),
'critical_tickets' => $criticalTickets,
'oldest_open_age_hours' => $openTickets === [] ? null : $oldestOpenAgeHours,
'avg_open_age_hours' => $avgOpenAgeHours,
'created_30d' => $createdCurrent,
'created_prev_30d' => $createdPrevious,
'closed_30d' => $closedCurrent,
'closed_prev_30d' => $closedPrevious,
'escalation_count' => $escalationCount,
'escalation_available' => $escalationAvailable,
],
'actions' => self::buildSupportActions($openTickets, $criticalThresholdHours),
'meta' => $meta,
];
}
/**
* @param array<int, array<string, mixed>> $contracts
* @return array{
* entries: array<int, array{
* contract_no: string,
* product_type: string,
* description: string,
* state: string,
* next_invoicing_date: string,
* sell_to_customer_no: string,
* sell_to_customer_name: string,
* bill_to_customer_no: string,
* bill_to_customer_name: string,
* total_payoff_amount: float,
* active_positions: int,
* support_hours: float
* }>,
* summary: array{
* total_contracts: int,
* active_contracts: int,
* product_types: array<int, array{type: string, count: int}>
* }
* }
*/
public static function buildContractsWidgetData(array $contracts, string $customerNo): array
{
$customerNo = trim($customerNo);
$entries = [];
$productTypeCounts = [];
$activeContracts = 0;
foreach ($contracts as $contract) {
$contractNo = trim((string) ($contract['No'] ?? ''));
if ($contractNo === '') {
continue;
}
$billToNo = trim((string) ($contract['Bill_to_Cust_No'] ?? ''));
$sellToNo = trim((string) ($contract['Sell_to_Cust_No'] ?? ''));
if ($customerNo !== '' && $billToNo !== $customerNo && $sellToNo !== $customerNo) {
continue;
}
$state = trim((string) ($contract['State'] ?? ''));
if (mb_strtolower($state) === 'aktiv') {
$activeContracts++;
}
$productType = trim((string) ($contract['Periodic_Invoicing_Type'] ?? ''));
if ($productType !== '') {
$productTypeCounts[$productType] = (int) ($productTypeCounts[$productType] ?? 0) + 1;
}
$nextInvoicingDate = trim((string) ($contract['Next_Invoicing_Date'] ?? ''));
if ($nextInvoicingDate === '0001-01-01' || $nextInvoicingDate === '0001-01-01T00:00:00Z') {
$nextInvoicingDate = '';
}
$entries[] = [
'contract_no' => $contractNo,
'product_type' => $productType,
'description' => trim((string) ($contract['Description'] ?? '')),
'state' => $state,
'next_invoicing_date' => $nextInvoicingDate,
'sell_to_customer_no' => $sellToNo,
'sell_to_customer_name' => trim((string) ($contract['Sell_to_Cust_Name'] ?? '')),
'bill_to_customer_no' => $billToNo,
'bill_to_customer_name' => trim((string) ($contract['Bill_to_Cust_Name'] ?? '')),
'total_payoff_amount' => (float) ($contract['TotalPayoffAmount'] ?? 0),
'active_positions' => (int) ($contract['No_of_Active_Position'] ?? 0),
'support_hours' => (float) ($contract['Support_Hours'] ?? 0),
];
}
usort($entries, static function (array $a, array $b): int {
$aActive = mb_strtolower((string) $a['state']) === 'aktiv' ? 1 : 0;
$bActive = mb_strtolower((string) $b['state']) === 'aktiv' ? 1 : 0;
if ($aActive !== $bActive) {
return $bActive <=> $aActive;
}
$aDate = (string) $a['next_invoicing_date'];
$bDate = (string) $b['next_invoicing_date'];
if ($aDate === '' && $bDate !== '') {
return 1;
}
if ($aDate !== '' && $bDate === '') {
return -1;
}
if ($aDate !== $bDate) {
return strcmp($aDate, $bDate);
}
return strcmp((string) $a['contract_no'], (string) $b['contract_no']);
});
arsort($productTypeCounts);
$productTypes = [];
foreach ($productTypeCounts as $type => $count) {
$productTypes[] = [
'type' => (string) $type,
'count' => (int) $count,
];
}
return [
'entries' => $entries,
'summary' => [
'total_contracts' => count($entries),
'active_contracts' => $activeContracts,
'product_types' => $productTypes,
],
];
}
/**
* Load sales dashboard data for a debtor: KPIs, contracts with line items, contacts.
*
* @return array{
* ok: bool,
* kpis?: array{active_contracts: int, monthly_volume: float, support_hours: float, next_invoicing_date: string},
* contracts?: array<int, array<string, mixed>>,
* contacts?: array<int, array<string, mixed>>,
* error?: string
* }
*/
public function loadSalesDashboard(string $customerNo, string $customerName): array
{
$customerNo = trim($customerNo);
$customerName = trim($customerName);
if ($customerNo === '' || $customerName === '') {
return ['ok' => false, 'error' => 'Missing customer number or name'];
}
if (!$this->settingsGateway->isConfigured()) {
return ['ok' => false, 'error' => 'BC connection not configured'];
}
$contracts = [];
$lines = [];
$contacts = [];
$contractsError = '';
$linesError = '';
try {
$contracts = $this->bcODataGateway->getContractsForCustomer($customerNo);
} catch (\Throwable $e) {
$contractsError = $e->getMessage();
}
try {
$lines = $this->bcODataGateway->getContractLinesForCustomer($customerNo);
} catch (\Throwable $e) {
$linesError = $e->getMessage();
}
try {
$contacts = $this->bcODataGateway->getContactsForCustomer($customerNo, $customerName);
} catch (\Throwable) {
// Contacts are secondary — don't fail the dashboard
}
if ($contracts === [] && $contractsError !== '') {
return ['ok' => false, 'error' => $contractsError];
}
$dashboard = self::buildSalesDashboard($contracts, $lines, $customerNo);
return [
'ok' => true,
'kpis' => $dashboard['kpis'],
'contracts' => $dashboard['contracts'],
'contacts' => $contacts,
];
}
/**
* Build sales dashboard from raw contract headers and line items.
*
* @param array<int, array<string, mixed>> $contracts
* @param array<int, array<string, mixed>> $lines
* @return array{
* kpis: array{active_contracts: int, monthly_volume: float, support_hours: float, next_invoicing_date: string},
* contracts: array<int, array<string, mixed>>
* }
*/
public static function buildSalesDashboard(array $contracts, array $lines, string $customerNo): array
{
$customerNo = trim($customerNo);
$twoYearsAgo = date('Y-m-d', strtotime('-2 years'));
// Group lines by Header_No
$linesByContract = [];
foreach ($lines as $line) {
$headerNo = trim((string) ($line['Header_No'] ?? ''));
if ($headerNo === '') {
continue;
}
$linesByContract[$headerNo][] = $line;
}
$activeContracts = 0;
$totalContracts = 0;
$monthlyVolume = 0.0;
$supportHours = 0.0;
$nextInvoicingDate = '';
$largestContractVolume = 0.0;
$totalPositions = 0;
// 30-day trend windows for contracts
$trendDays = 30;
$nowTs = time();
$boundary30 = date('Y-m-d', $nowTs - ($trendDays * 86400));
$boundary60 = date('Y-m-d', $nowTs - (2 * $trendDays * 86400));
$startedCurrent = 0;
$startedPrevious = 0;
$endedCurrent = 0;
$endedPrevious = 0;
$volumeNewContracts = 0.0;
$entries = [];
foreach ($contracts as $contract) {
$contractNo = trim((string) ($contract['No'] ?? ''));
if ($contractNo === '') {
continue;
}
$billToNo = trim((string) ($contract['Bill_to_Cust_No'] ?? ''));
$sellToNo = trim((string) ($contract['Sell_to_Cust_No'] ?? ''));
if ($customerNo !== '' && $billToNo !== $customerNo && $sellToNo !== $customerNo) {
continue;
}
$state = trim((string) ($contract['State'] ?? ''));
$stateLower = mb_strtolower($state);
$isActive = $stateLower === 'aktiv';
// Filter ended contracts older than 2 years
if ($stateLower === 'beendet') {
$nextInv = self::normalizeDateField((string) ($contract['Next_Invoicing_Date'] ?? ''));
$startDate = self::normalizeDateField((string) ($contract['Starting_Date'] ?? ''));
$relevantDate = $nextInv !== '' ? $nextInv : $startDate;
if ($relevantDate !== '' && $relevantDate < $twoYearsAgo) {
continue;
}
}
$totalContracts++;
// Trend: count started/ended contracts in 30d windows
$contractStartDate = self::normalizeDateField((string) ($contract['Starting_Date'] ?? ''));
if ($contractStartDate !== '') {
if ($contractStartDate >= $boundary30) {
$startedCurrent++;
if ($isActive) {
$volumeNewContracts += (float) ($contract['MontlyAmount_InvoicingDate'] ?? 0);
}
} elseif ($contractStartDate >= $boundary60) {
$startedPrevious++;
}
}
if ($stateLower === 'beendet') {
$endRelevant = self::normalizeDateField((string) ($contract['Next_Invoicing_Date'] ?? ''));
if ($endRelevant === '') {
$endRelevant = $contractStartDate;
}
if ($endRelevant !== '') {
if ($endRelevant >= $boundary30) {
$endedCurrent++;
} elseif ($endRelevant >= $boundary60) {
$endedPrevious++;
}
}
}
// KPI aggregation (only active)
if ($isActive) {
$activeContracts++;
$contractMonthly = (float) ($contract['MontlyAmount_InvoicingDate'] ?? 0);
$monthlyVolume += $contractMonthly;
$supportHours += (float) ($contract['Support_Hours'] ?? 0);
if ($contractMonthly > $largestContractVolume) {
$largestContractVolume = $contractMonthly;
}
$invDate = self::normalizeDateField((string) ($contract['Next_Invoicing_Date'] ?? ''));
if ($invDate !== '' && ($nextInvoicingDate === '' || $invDate < $nextInvoicingDate)) {
$nextInvoicingDate = $invDate;
}
}
// Normalize line items for this contract
$contractLines = [];
foreach (($linesByContract[$contractNo] ?? []) as $line) {
$lineDesc = trim((string) ($line['Description'] ?? ''));
if ($lineDesc === '') {
continue;
}
$contractLines[] = [
'line_no' => (int) ($line['Line_No'] ?? 0),
'description' => $lineDesc,
'type' => trim((string) ($line['Type'] ?? '')),
'quantity' => (float) ($line['Quantity'] ?? 0),
'unit_price' => (float) ($line['Unit_Price'] ?? 0),
'line_amount' => (float) ($line['Line_Amount'] ?? 0),
'line_discount_percent' => (float) ($line['Line_Discount_Percent'] ?? 0),
'line_state' => trim((string) ($line['Line_State'] ?? '')),
'starting_date' => self::normalizeDateField((string) ($line['Starting_Date'] ?? '')),
'ending_date' => self::normalizeDateField((string) ($line['Ending_Date'] ?? '')),
];
}
$entries[] = [
'contract_no' => $contractNo,
'description' => trim((string) ($contract['Description'] ?? '')),
'state' => $state,
'product_type' => trim((string) ($contract['Periodic_Invoicing_Type'] ?? '')),
'starting_date' => self::normalizeDateField((string) ($contract['Starting_Date'] ?? '')),
'next_invoicing_date' => self::normalizeDateField((string) ($contract['Next_Invoicing_Date'] ?? '')),
'invoicing_period' => trim((string) ($contract['Invoicing_Period'] ?? '')),
'monthly_amount' => (float) ($contract['MontlyAmount_InvoicingDate'] ?? 0),
'total_payoff_amount' => (float) ($contract['TotalPayoffAmount'] ?? 0),
'support_hours' => (float) ($contract['Support_Hours'] ?? 0),
'active_positions' => (int) ($contract['No_of_Active_Position'] ?? 0),
'sell_to_customer_name' => trim((string) ($contract['Sell_to_Cust_Name'] ?? '')),
'bill_to_customer_name' => trim((string) ($contract['Bill_to_Cust_Name'] ?? '')),
'salesperson_code' => trim((string) ($contract['Salesperson_Purchaser_Code'] ?? '')),
'main_contract' => (bool) ($contract['Main_Contract'] ?? false),
'lines' => $contractLines,
];
}
// Sort: active first, then by next invoicing date
usort($entries, static function (array $a, array $b): int {
$aActive = mb_strtolower((string) $a['state']) === 'aktiv' ? 1 : 0;
$bActive = mb_strtolower((string) $b['state']) === 'aktiv' ? 1 : 0;
if ($aActive !== $bActive) {
return $bActive <=> $aActive;
}
$aDate = (string) $a['next_invoicing_date'];
$bDate = (string) $b['next_invoicing_date'];
if ($aDate === '' && $bDate !== '') {
return 1;
}
if ($aDate !== '' && $bDate === '') {
return -1;
}
return strcmp($aDate, $bDate);
});
// Count actual line items from active contracts
foreach ($entries as $entry) {
if (mb_strtolower((string) $entry['state']) === 'aktiv') {
$totalPositions += count($entry['lines']);
}
}
$avgContractValue = $activeContracts > 0 ? round($monthlyVolume / $activeContracts, 2) : 0.0;
// Days until next invoicing
$daysUntilNextInvoicing = null;
if ($nextInvoicingDate !== '') {
$nowDate = new \DateTimeImmutable('today', new \DateTimeZone('UTC'));
$invDateObj = \DateTimeImmutable::createFromFormat('Y-m-d', $nextInvoicingDate, new \DateTimeZone('UTC'));
if ($invDateObj !== false) {
$daysUntilNextInvoicing = (int) $nowDate->diff($invDateObj)->format('%r%a');
}
}
return [
'kpis' => [
'active_contracts' => $activeContracts,
'total_contracts' => $totalContracts,
'monthly_volume' => round($monthlyVolume, 2),
'support_hours' => round($supportHours, 2),
'next_invoicing_date' => $nextInvoicingDate,
'avg_contract_value' => $avgContractValue,
'largest_contract_volume' => round($largestContractVolume, 2),
'total_positions' => $totalPositions,
'days_until_next_invoicing' => $daysUntilNextInvoicing,
'started_30d' => $startedCurrent,
'started_prev_30d' => $startedPrevious,
'ended_30d' => $endedCurrent,
'ended_prev_30d' => $endedPrevious,
'volume_new_contracts' => round($volumeNewContracts, 2),
],
'contracts' => $entries,
];
}
private static function normalizeDateField(string $value): string
{
$value = trim($value);
if ($value === '' || $value === '0001-01-01' || str_starts_with($value, '0001-01-01')) {
return '';
}
// Handle ISO datetime (strip time part for display)
if (str_contains($value, 'T')) {
$value = substr($value, 0, 10);
}
return $value;
}
private static function isClosedTicketState(string $state): bool
{
return in_array(trim($state), self::CLOSED_TICKET_STATES, true);
}
/**
* @param array<string, mixed> $ticket
*/
private static function resolveTicketActivityTimestamp(array $ticket): ?int
{
$lastActivity = trim((string) ($ticket['Last_Activity_Date'] ?? ''));
if ($lastActivity !== '' && $lastActivity !== '0001-01-01T00:00:00Z') {
$ts = strtotime($lastActivity);
if (is_int($ts)) {
return $ts;
}
}
$createdOn = trim((string) ($ticket['Created_On'] ?? ''));
if ($createdOn !== '' && $createdOn !== '0001-01-01T00:00:00Z') {
$ts = strtotime($createdOn);
if (is_int($ts)) {
return $ts;
}
}
return null;
}
private static function parseIsoDurationToSeconds(string $value): ?int
{
$value = strtoupper(trim($value));
if ($value === '' || !str_starts_with($value, 'P')) {
return null;
}
if (!preg_match('/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/', $value, $matches)) {
return null;
}
$days = isset($matches[1]) && $matches[1] !== '' ? (int) $matches[1] : 0;
$hours = isset($matches[2]) && $matches[2] !== '' ? (int) $matches[2] : 0;
$minutes = isset($matches[3]) && $matches[3] !== '' ? (int) $matches[3] : 0;
$secondsFloat = isset($matches[4]) && $matches[4] !== '' ? (float) $matches[4] : 0.0;
$seconds = (int) floor($secondsFloat);
return ($days * 86400) + ($hours * 3600) + ($minutes * 60) + $seconds;
}
/**
* @param array<int, array{
* ticket_no: string,
* support_user_name: string,
* age_hours: int|null
* }> $openTickets
* @return array<int, array{
* action_id: string,
* type: string,
* priority: int,
* ticket_no: string,
* age_hours: int|null
* }>
*/
private static function buildSupportActions(array $openTickets, int $criticalThresholdHours): array
{
$candidates = [];
foreach ($openTickets as $ticket) {
$ticketNo = $ticket['ticket_no'];
$ageHours = $ticket['age_hours'];
$supportUserName = trim($ticket['support_user_name']);
if (is_int($ageHours) && $ageHours >= $criticalThresholdHours) {
$candidates[] = [
'action_id' => 'critical_stale-' . $ticketNo,
'type' => 'critical_stale',
'priority' => 3,
'ticket_no' => $ticketNo,
'age_hours' => $ageHours,
];
}
if ($supportUserName === '') {
$candidates[] = [
'action_id' => 'unassigned_support-' . $ticketNo,
'type' => 'unassigned_support',
'priority' => 2,
'ticket_no' => $ticketNo,
'age_hours' => $ageHours,
];
}
}
$oldestOpen = null;
foreach ($openTickets as $ticket) {
$ticketNo = $ticket['ticket_no'];
$ageHours = $ticket['age_hours'];
if ($oldestOpen === null || ($ageHours ?? -1) > ((int) ($oldestOpen['age_hours'] ?? -1))) {
$oldestOpen = [
'ticket_no' => $ticketNo,
'age_hours' => $ageHours,
];
}
}
if (is_array($oldestOpen)) {
$candidates[] = [
'action_id' => 'oldest_open_followup-' . $oldestOpen['ticket_no'],
'type' => 'oldest_open_followup',
'priority' => 1,
'ticket_no' => $oldestOpen['ticket_no'],
'age_hours' => $oldestOpen['age_hours'],
];
}
usort($candidates, static function (array $a, array $b): int {
$priorityCmp = $b['priority'] <=> $a['priority'];
if ($priorityCmp !== 0) {
return $priorityCmp;
}
$ageA = is_int($a['age_hours'] ?? null) ? (int) $a['age_hours'] : -1;
$ageB = is_int($b['age_hours'] ?? null) ? (int) $b['age_hours'] : -1;
$ageCmp = $ageB <=> $ageA;
if ($ageCmp !== 0) {
return $ageCmp;
}
return strcmp($a['ticket_no'], $b['ticket_no']);
});
return array_slice($candidates, 0, 3);
}
/**
* Load activity log for a ticket (for async data endpoint).
*
* @return array{ok: bool, log?: array<int, array<string, mixed>>, error?: string}
*/
public function loadTicketLog(string $ticketNo): array
{
$ticketNo = trim($ticketNo);
if ($ticketNo === '') {
return ['ok' => false, 'error' => 'Missing ticket number'];
}
if (!$this->settingsGateway->isConfigured()) {
return ['ok' => false, 'error' => 'BC connection not configured'];
}
try {
$log = $this->bcODataGateway->getTicketLog($ticketNo);
} catch (\Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
}
return ['ok' => true, 'log' => $log];
}
/**
* Load full detail for a debtor: master data, contacts, and tickets.
*
* @deprecated Use loadCustomer() + async endpoints for contacts/tickets instead.
*
* @return array{status: string, customer?: array<string, mixed>, contacts?: array<int, array<string, mixed>>, tickets?: array<int, array<string, mixed>>, error?: string, contactsError?: string, ticketsError?: string}
*/
public function loadDetail(string $customerNo): array
{
$customerNo = trim($customerNo);
if ($customerNo === '') {
return ['status' => 'not_found'];
}
if (!$this->settingsGateway->isConfigured()) {
return ['status' => 'not_configured', 'error' => 'BC connection not configured'];
}
try {
$customer = $this->bcODataGateway->getCustomer($customerNo);
} catch (\Throwable) {
return ['status' => 'error', 'error' => 'BC connection failed'];
}
if ($customer === null) {
return ['status' => 'not_found'];
}
// Load contacts — BC contacts are linked via Company_Name, not customer number.
// IntegrationCustomerNo is not filterable in BC OData, so we use the customer name.
$contacts = [];
$contactsError = '';
$customerName = (string) ($customer['Name'] ?? '');
try {
$contacts = $this->bcODataGateway->getContactsForCustomer($customerNo, $customerName);
} catch (\Throwable $e) {
$contactsError = $e->getMessage();
}
// Load tickets via PBI_FP_Tickets entity which supports server-side filtering.
$tickets = [];
$ticketsError = '';
try {
$tickets = $this->bcODataGateway->getTicketsForCustomer($customerNo, $customerName);
} catch (\Throwable $e) {
$ticketsError = $e->getMessage();
}
return [
'status' => 'success',
'customer' => $customer,
'contacts' => $contacts,
'tickets' => $tickets,
'contactsError' => $contactsError,
'ticketsError' => $ticketsError,
];
}
// --- Controlling dashboard ---
/**
* Build controlling dashboard from raw tickets and contract KPIs.
*
* @param array<int, array<string, mixed>> $tickets Raw OData tickets with Created_On, Process_Stage
* @param int $periodDays One of 30, 90, 180, 365
* @param array<string, mixed> $riskConfig Risk rules from settings
*
* @return array<string, mixed>
*/
public static function buildControllingDashboard(
array $tickets,
int $periodDays,
array $riskConfig
): array {
$nowTs = time();
$periodStart = $nowTs - ($periodDays * 86400);
$prevPeriodStart = $periodStart - ($periodDays * 86400);
// --- Single-pass counters ---
$createdInPeriod = 0;
$closedInPeriod = 0;
$createdInPrev = 0;
$closedInPrev = 0;
// Resolution: all tickets CLOSED in the period (regardless of creation date)
$resolutionHoursCurrent = [];
// Resolution: all tickets CLOSED in the previous period
$resolutionHoursPrev = [];
// Backlog: snapshot of currently open tickets (period-independent)
$openTickets = 0;
$unassignedOpen = 0;
$criticalOpen = 0;
$oldestOpenAgeHours = 0;
$criticalThresholdHours = 48;
// Trend buckets
$buckets = self::initTrendBuckets($periodDays, $nowTs);
foreach ($tickets as $ticket) {
$state = trim((string) ($ticket['Ticket_State'] ?? ''));
$isClosed = self::isClosedTicketState($state)
|| (int) ($ticket['Process_Stage'] ?? 0) === 40;
$createdTs = self::parseIsoToTimestamp((string) ($ticket['Created_On'] ?? ''));
$activityTs = self::resolveTicketActivityTimestamp($ticket);
// --- Created in current period ---
if ($createdTs !== null && $createdTs >= $periodStart) {
$createdInPeriod++;
$bucketKey = self::resolveBucketKey($createdTs, $periodDays);
if (isset($buckets[$bucketKey])) {
$buckets[$bucketKey]['created']++;
}
}
// --- Created in previous period ---
if ($createdTs !== null && $createdTs >= $prevPeriodStart && $createdTs < $periodStart) {
$createdInPrev++;
}
// --- Closed in current period (any creation date) ---
if ($isClosed && $activityTs !== null && $activityTs >= $periodStart) {
$closedInPeriod++;
$bucketKey = self::resolveBucketKey($activityTs, $periodDays);
if (isset($buckets[$bucketKey])) {
$buckets[$bucketKey]['closed']++;
}
// Resolution time: Last_Activity_Date Created_On
if ($createdTs !== null && $activityTs > $createdTs) {
$resolutionHoursCurrent[] = intdiv($activityTs - $createdTs, 3600);
}
}
// --- Closed in previous period (any creation date) ---
if ($isClosed && $activityTs !== null && $activityTs >= $prevPeriodStart && $activityTs < $periodStart) {
$closedInPrev++;
if ($createdTs !== null && $activityTs > $createdTs) {
$resolutionHoursPrev[] = intdiv($activityTs - $createdTs, 3600);
}
}
// --- Open ticket snapshot (period-independent) ---
if (!$isClosed) {
$openTickets++;
$supportUser = trim((string) ($ticket['Support_User_Name'] ?? ''));
if ($supportUser === '') {
$unassignedOpen++;
}
if ($activityTs !== null) {
$ageHours = intdiv(max(0, $nowTs - $activityTs), 3600);
if ($ageHours > $criticalThresholdHours) {
$criticalOpen++;
}
if ($ageHours > $oldestOpenAgeHours) {
$oldestOpenAgeHours = $ageHours;
}
}
}
}
// --- KPI 1: Volume (created in period, delta vs prev) ---
$volumeDelta = $createdInPeriod - $createdInPrev;
// --- KPI 2: Close-Rate (closed ÷ created, as factor) ---
$closeRate = $createdInPeriod > 0
? round($closedInPeriod / $createdInPeriod, 2)
: null;
$closeRatePrev = $createdInPrev > 0
? round($closedInPrev / $createdInPrev, 2)
: null;
// --- KPI 3: Resolution (median hours, all tickets closed in period) ---
$resolutionMedian = self::computeMedian($resolutionHoursCurrent);
$resolutionMedianPrev = self::computeMedian($resolutionHoursPrev);
// --- KPI 4: Backlog (open snapshot) ---
// No computation needed — $openTickets, $unassignedOpen, $criticalOpen are ready
// --- Efficiency (detail section) ---
$unassignedRate = $openTickets > 0
? round(($unassignedOpen / $openTickets) * 100, 1)
: 0.0;
// --- Risk evaluation ---
$rules = is_array($riskConfig['rules'] ?? null) ? $riskConfig['rules'] : [];
$riskIndicators = self::evaluateControllingRisks(
$rules,
$createdInPeriod,
$createdInPrev,
$resolutionMedian,
$oldestOpenAgeHours,
$unassignedRate
);
$triggeredCount = 0;
$totalRules = count($riskIndicators);
foreach ($riskIndicators as $indicator) {
if ($indicator['triggered']) {
$triggeredCount++;
}
}
$riskLevel = $triggeredCount === 0 ? 'low' : ($triggeredCount <= 2 ? 'medium' : 'high');
return [
'kpis' => [
'volume' => $createdInPeriod,
'volume_prev' => $createdInPrev,
'volume_delta' => $volumeDelta,
'close_rate' => $closeRate,
'close_rate_prev' => $closeRatePrev,
'resolution_hours' => $resolutionMedian,
'resolution_hours_prev' => $resolutionMedianPrev,
'backlog_open' => $openTickets,
'backlog_unassigned' => $unassignedOpen,
'backlog_critical' => $criticalOpen,
],
'trend' => array_values($buckets),
'risk_indicators' => $riskIndicators,
'risk_summary' => [
'triggered' => $triggeredCount,
'total' => $totalRules,
'level' => $riskLevel,
],
'meta' => [
'period_days' => $periodDays,
'created_in_period' => $createdInPeriod,
'closed_in_period' => $closedInPeriod,
'created_in_prev' => $createdInPrev,
'closed_in_prev' => $closedInPrev,
],
];
}
/**
* @return array<string, array{label: string, created: int, closed: int}>
*/
private static function initTrendBuckets(int $periodDays, int $nowTs): array
{
$buckets = [];
if ($periodDays <= 30) {
// Weekly buckets for 30-day period
for ($i = 3; $i >= 0; $i--) {
$weekStart = $nowTs - (($i + 1) * 7 * 86400);
$dt = new \DateTimeImmutable('@' . $weekStart);
$key = $dt->format('o-W');
$buckets[$key] = ['label' => 'KW' . $dt->format('W'), 'created' => 0, 'closed' => 0];
}
} else {
// Monthly buckets
$months = (int) ceil($periodDays / 30);
for ($i = $months - 1; $i >= 0; $i--) {
$dt = (new \DateTimeImmutable('@' . $nowTs))->modify("-{$i} months");
$key = $dt->format('Y-m');
$monthNames = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
$buckets[$key] = ['label' => $monthNames[(int) $dt->format('n') - 1], 'created' => 0, 'closed' => 0];
}
}
return $buckets;
}
private static function resolveBucketKey(int $timestamp, int $periodDays): string
{
$dt = new \DateTimeImmutable('@' . $timestamp);
if ($periodDays <= 30) {
return $dt->format('o-W');
}
return $dt->format('Y-m');
}
/**
* @param array<int, int> $values
*/
private static function computeMedian(array $values): ?float
{
if ($values === []) {
return null;
}
sort($values);
$count = count($values);
$mid = intdiv($count, 2);
if ($count % 2 === 0) {
return round(($values[$mid - 1] + $values[$mid]) / 2, 1);
}
return (float) $values[$mid];
}
private static function parseIsoToTimestamp(string $value): ?int
{
$value = trim($value);
if ($value === '' || str_starts_with($value, '0001-01-01')) {
return null;
}
$ts = strtotime($value);
return is_int($ts) ? $ts : null;
}
/**
* @param array<string, array<string, mixed>> $rules
* @return array<int, array{rule_id: string, label: string, description: string, triggered: bool, value: string, raw_value: float|int, threshold_value: int, unit: string}>
*/
private static function evaluateControllingRisks(
array $rules,
int $createdInPeriod,
int $createdInPrev,
?float $avgResolution,
int $oldestOpenAgeHours,
float $unassignedRate
): array {
$indicators = [];
// Ticket volume increase
$rule = is_array($rules['ticket_volume_increase'] ?? null) ? $rules['ticket_volume_increase'] : [];
if (($rule['enabled'] ?? true) === true) {
$threshold = (int) ($rule['threshold_percent'] ?? 30);
$increase = $createdInPrev > 0
? round((($createdInPeriod - $createdInPrev) / $createdInPrev) * 100, 1)
: ($createdInPeriod > 0 ? 100.0 : 0.0);
$indicators[] = [
'rule_id' => 'ticket_volume_increase',
'label' => 'Ticket volume',
'description' => 'risk_desc_ticket_volume',
'triggered' => $increase >= $threshold,
'value' => ($increase > 0 ? '+' : '') . $increase . '%',
'raw_value' => max(0, $increase),
'threshold_value' => $threshold,
'unit' => '%',
];
}
// Avg resolution high
$rule = is_array($rules['avg_resolution_high'] ?? null) ? $rules['avg_resolution_high'] : [];
if (($rule['enabled'] ?? true) === true) {
$threshold = (int) ($rule['threshold_hours'] ?? 72);
$triggered = $avgResolution !== null && $avgResolution >= $threshold;
$rawValue = $avgResolution !== null ? round($avgResolution, 1) : 0;
$indicators[] = [
'rule_id' => 'avg_resolution_high',
'label' => 'Resolution time',
'description' => 'risk_desc_resolution_time',
'triggered' => $triggered,
'value' => $avgResolution !== null ? round($avgResolution, 1) . 'h' : '—',
'raw_value' => $rawValue,
'threshold_value' => $threshold,
'unit' => 'h',
];
}
// Open aging
$rule = is_array($rules['open_aging'] ?? null) ? $rules['open_aging'] : [];
if (($rule['enabled'] ?? true) === true) {
$threshold = (int) ($rule['threshold_hours'] ?? 168);
$indicators[] = [
'rule_id' => 'open_aging',
'label' => 'Oldest open ticket',
'description' => 'risk_desc_open_aging',
'triggered' => $oldestOpenAgeHours >= $threshold,
'value' => $oldestOpenAgeHours . 'h',
'raw_value' => $oldestOpenAgeHours,
'threshold_value' => $threshold,
'unit' => 'h',
];
}
// Unassigned ratio
$rule = is_array($rules['unassigned_ratio'] ?? null) ? $rules['unassigned_ratio'] : [];
if (($rule['enabled'] ?? true) === true) {
$threshold = (int) ($rule['threshold_percent'] ?? 20);
$indicators[] = [
'rule_id' => 'unassigned_ratio',
'label' => 'Unassigned',
'description' => 'risk_desc_unassigned',
'triggered' => $unassignedRate >= $threshold,
'value' => $unassignedRate . '%',
'raw_value' => $unassignedRate,
'threshold_value' => $threshold,
'unit' => '%',
];
}
return $indicators;
}
/**
* Build a team workload dashboard from a global (non-customer-scoped) ticket set.
*
* Groups tickets by Support_User_Name and computes per-agent metrics.
* Unassigned tickets (empty Support_User_Name) are collected into a dedicated entry.
*
* @param array<int, array<string, mixed>> $tickets Raw OData tickets
* @param int $periodDays One of 30, 90, 180, 365
*
* @return array{kpis: array<string, mixed>, members: array<int, array<string, mixed>>, meta: array<string, mixed>}
*/
public static function buildTeamWorkloadDashboard(array $tickets, int $periodDays): array
{
$nowTs = time();
$periodStart = $nowTs - ($periodDays * 86400);
$criticalThresholdHours = 48;
/** @var array<string, array{display_name: string, support_user: string, open_tickets: int, critical_tickets: int, age_hours_sum: int, resolved_in_period: int}> $agents */
$agents = [];
$globalOpen = 0;
$globalUnassigned = 0;
$globalAgeSum = 0;
$globalOpenCount = 0;
$globalResolved = 0;
foreach ($tickets as $ticket) {
$state = trim((string) ($ticket['Ticket_State'] ?? ''));
$isClosed = self::isClosedTicketState($state)
|| (int) ($ticket['Process_Stage'] ?? 0) === 40;
$rawUser = trim((string) ($ticket['Support_User_Name'] ?? ''));
$normalizedUser = $rawUser === '' ? '' : strtolower($rawUser);
$activityTs = self::resolveTicketActivityTimestamp($ticket);
if (!isset($agents[$normalizedUser])) {
$agents[$normalizedUser] = [
'display_name' => $rawUser,
'support_user' => $normalizedUser,
'open_tickets' => 0,
'critical_tickets' => 0,
'age_hours_sum' => 0,
'resolved_in_period' => 0,
];
}
if ($isClosed) {
if ($activityTs !== null && $activityTs >= $periodStart) {
$agents[$normalizedUser]['resolved_in_period']++;
$globalResolved++;
}
} else {
$agents[$normalizedUser]['open_tickets']++;
$globalOpen++;
if ($rawUser === '') {
$globalUnassigned++;
}
if ($activityTs !== null) {
$ageHours = intdiv(max(0, $nowTs - $activityTs), 3600);
$agents[$normalizedUser]['age_hours_sum'] += $ageHours;
if ($ageHours > $criticalThresholdHours) {
$agents[$normalizedUser]['critical_tickets']++;
}
$globalAgeSum += $ageHours;
$globalOpenCount++;
}
}
}
$members = [];
foreach ($agents as $key => $agent) {
$avgAge = $agent['open_tickets'] > 0
? (int) round($agent['age_hours_sum'] / $agent['open_tickets'])
: 0;
$members[] = [
'support_user' => $agent['support_user'],
'display_name' => $agent['display_name'],
'open_tickets' => $agent['open_tickets'],
'critical_tickets' => $agent['critical_tickets'],
'avg_age_hours' => $avgAge,
'resolved_in_period' => $agent['resolved_in_period'],
];
}
usort($members, static function (array $a, array $b): int {
if ($a['support_user'] === '') {
return 1;
}
if ($b['support_user'] === '') {
return -1;
}
return $b['open_tickets'] <=> $a['open_tickets'];
});
$globalAvgAge = $globalOpenCount > 0
? (int) round($globalAgeSum / $globalOpenCount)
: 0;
return [
'kpis' => [
'open_tickets' => $globalOpen,
'unassigned' => $globalUnassigned,
'avg_age_hours' => $globalAvgAge,
'resolved_in_period' => $globalResolved,
],
'members' => $members,
'meta' => [
'period_days' => $periodDays,
'total_tickets' => count($tickets),
'unique_users' => count(array_filter(array_keys($agents), static fn (string $k): bool => $k !== '')),
],
];
}
}