forked from fa/breadcrumb-the-shire
Clicking a customer or category in the Historical tab recalculates Ø/Min/Max resolution times for only that filter. Click again to reset. All computation is client-side from resolved_details already in the response — no extra API calls. Active filter highlighted with primary color, resolution values turn primary when filtered. Backend now includes resolved_ticket_details per agent (customer_name, category, resolution_hours) for client-side filtering. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1792 lines
66 KiB
PHP
1792 lines
66 KiB
PHP
<?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,
|
||
'open_ticket_details' => [],
|
||
'resolved_customers' => [],
|
||
'resolved_categories' => [],
|
||
'resolution_hours' => [],
|
||
'resolved_ticket_details' => [],
|
||
];
|
||
}
|
||
|
||
if ($isClosed) {
|
||
$createdTs = self::parseIsoToTimestamp((string) ($ticket['Created_On'] ?? ''));
|
||
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++;
|
||
|
||
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'],
|
||
'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 !== '')),
|
||
],
|
||
];
|
||
}
|
||
}
|