Files
breadcrumb-the-shire/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php
fs f2aa45dd2c feat(helpdesk): add trend delta indicator per agent in team dashboard
Shows a small arrow next to the ticket count in each agent card header:
↑3 (orange) = received 3 more than resolved → backlog growing
↓2 (green) = resolved 2 more than received → backlog shrinking
Hidden when delta is 0 (stable).

Delta is computed from received_in_period (tickets created in the
selected period assigned to this agent) minus resolved_in_period.
No snapshot storage needed — derived from existing ticket data.

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

1803 lines
66 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, open_ticket_details: list<array{no: string, description: string, age_hours: int, critical: bool, customer: string}>}> $agents */
$agents = [];
$globalOpen = 0;
$globalUnassigned = 0;
$globalAgeSum = 0;
$globalOpenCount = 0;
$globalResolved = 0;
foreach ($tickets as $ticket) {
$state = trim((string) ($ticket['Ticket_State'] ?? ''));
// Team query uses PBI_FP_Tickets which has no Process_Stage;
// fall back to Process_Stage only when present (LV entity).
$isClosed = self::isClosedTicketState($state)
|| (isset($ticket['Process_Stage']) && (int) $ticket['Process_Stage'] === 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,
'received_in_period' => 0,
'open_ticket_details' => [],
'resolved_customers' => [],
'resolved_categories' => [],
'resolution_hours' => [],
'resolved_ticket_details' => [],
];
}
if ($isClosed) {
$createdTs = self::parseIsoToTimestamp((string) ($ticket['Created_On'] ?? ''));
if ($createdTs !== null && $createdTs >= $periodStart) {
$agents[$normalizedUser]['received_in_period']++;
}
if ($activityTs !== null && $activityTs >= $periodStart) {
$agents[$normalizedUser]['resolved_in_period']++;
$globalResolved++;
$customerName = trim((string) ($ticket['Company_Contact_Name'] ?? (string) ($ticket['Cust_Name'] ?? '')));
$category = trim((string) ($ticket['Category_1_Description'] ?? (string) ($ticket['Category_1_Code'] ?? '')));
if ($customerName !== '') {
$agents[$normalizedUser]['resolved_customers'][$customerName] = ($agents[$normalizedUser]['resolved_customers'][$customerName] ?? 0) + 1;
}
if ($category !== '') {
$agents[$normalizedUser]['resolved_categories'][$category] = ($agents[$normalizedUser]['resolved_categories'][$category] ?? 0) + 1;
}
$resolutionH = null;
if ($createdTs !== null && $activityTs > $createdTs) {
$resolutionH = intdiv($activityTs - $createdTs, 3600);
$agents[$normalizedUser]['resolution_hours'][] = $resolutionH;
}
$agents[$normalizedUser]['resolved_ticket_details'][] = [
'customer_name' => $customerName,
'category' => $category,
'resolution_hours' => $resolutionH,
];
}
} else {
$agents[$normalizedUser]['open_tickets']++;
$globalOpen++;
$createdTs = self::parseIsoToTimestamp((string) ($ticket['Created_On'] ?? ''));
if ($createdTs !== null && $createdTs >= $periodStart) {
$agents[$normalizedUser]['received_in_period']++;
}
if ($rawUser === '') {
$globalUnassigned++;
}
$ageHours = 0;
$isCritical = false;
if ($activityTs !== null) {
$ageHours = intdiv(max(0, $nowTs - $activityTs), 3600);
$agents[$normalizedUser]['age_hours_sum'] += $ageHours;
if ($ageHours > $criticalThresholdHours) {
$agents[$normalizedUser]['critical_tickets']++;
$isCritical = true;
}
$globalAgeSum += $ageHours;
$globalOpenCount++;
}
$lastActivityRaw = trim((string) ($ticket['Last_Activity_Date'] ?? ''));
$lastActivityDisplay = ($lastActivityRaw !== '' && !str_starts_with($lastActivityRaw, '0001'))
? $lastActivityRaw
: trim((string) ($ticket['Created_On'] ?? ''));
$agents[$normalizedUser]['open_ticket_details'][] = [
'no' => trim((string) ($ticket['No'] ?? '')),
'description' => trim((string) ($ticket['Description'] ?? '')),
'category' => trim((string) ($ticket['Category_1_Description'] ?? (string) ($ticket['Category_1_Code'] ?? ''))),
'age_hours' => $ageHours,
'critical' => $isCritical,
'customer_name' => trim((string) ($ticket['Company_Contact_Name'] ?? (string) ($ticket['Cust_Name'] ?? ''))),
'customer_no' => trim((string) ($ticket['Customer_No'] ?? '')),
'last_activity' => $lastActivityDisplay,
];
}
}
$members = [];
foreach ($agents as $key => $agent) {
$avgAge = $agent['open_tickets'] > 0
? (int) round($agent['age_hours_sum'] / $agent['open_tickets'])
: 0;
// Sort ticket details: critical first, then by age descending
$details = $agent['open_ticket_details'];
usort($details, static function (array $a, array $b): int {
if ($a['critical'] !== $b['critical']) {
return $b['critical'] <=> $a['critical'];
}
return $b['age_hours'] <=> $a['age_hours'];
});
// Performance insights from resolved tickets
$topCustomers = $agent['resolved_customers'];
arsort($topCustomers);
$topCustomers = array_slice($topCustomers, 0, 3, true);
$topCategories = $agent['resolved_categories'];
arsort($topCategories);
$topCategories = array_slice($topCategories, 0, 3, true);
$resHours = $agent['resolution_hours'];
$avgResolution = $resHours !== [] ? (int) round(array_sum($resHours) / count($resHours)) : null;
$minResolution = $resHours !== [] ? min($resHours) : null;
$maxResolution = $resHours !== [] ? max($resHours) : null;
$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'],
'received_in_period' => $agent['received_in_period'],
'trend_delta' => $agent['received_in_period'] - $agent['resolved_in_period'],
'open_ticket_details' => $details,
'performance' => [
'top_customers' => array_map(
static fn (string $name, int $count): array => ['name' => $name, 'count' => $count],
array_keys($topCustomers),
array_values($topCustomers)
),
'top_categories' => array_map(
static fn (string $name, int $count): array => ['name' => $name, 'count' => $count],
array_keys($topCategories),
array_values($topCategories)
),
'avg_resolution_hours' => $avgResolution,
'min_resolution_hours' => $minResolution,
'max_resolution_hours' => $maxResolution,
'resolved_details' => $agent['resolved_ticket_details'],
],
];
}
// Sort: unassigned last, then critical_tickets desc, then open_tickets desc
usort($members, static function (array $a, array $b): int {
if ($a['support_user'] === '') {
return 1;
}
if ($b['support_user'] === '') {
return -1;
}
if ($a['critical_tickets'] !== $b['critical_tickets']) {
return $b['critical_tickets'] <=> $a['critical_tickets'];
}
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 !== '')),
],
];
}
}