1
0
Files
breadcrumb-the-shire/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php
fs a37486c0e3 feat(helpdesk): align module with core UI standards and fix KPI accuracy
- Add server-side ticket summary endpoint for exact KPI counts instead
  of client-side calculation from first 200 tickets
- Extract summarizeTickets() as shared static method to avoid duplication
- Replace custom .helpdesk-spinner CSS with core [aria-busy] pattern
- Migrate settings connection-test feedback to showAsyncFlash()
- Replace helpdesk-clickable-rows.js with delegated event handling
- Remove unused helpdesk-search.js (dead code, never loaded)
- Harmonize German wording: Debitoren → Kunden (8 i18n keys)
- Add 6 PHPUnit tests for loadTicketSummary()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 21:05:54 +02:00

242 lines
7.8 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
{
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>>, 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 {
$contacts = $this->bcODataGateway->getContactsForCustomer($customerNo, $customerName);
} catch (\Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
}
return ['ok' => true, 'contacts' => $contacts];
}
/**
* Load tickets for a customer (for async data endpoint).
*
* @return array{ok: bool, tickets?: array<int, array<string, mixed>>, 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 {
$tickets = $this->bcODataGateway->getTicketsForCustomer($customerNo, $customerName);
} catch (\Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
}
return ['ok' => true, 'tickets' => $tickets];
}
/**
* 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'] ?? []);
}
/**
* 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
{
$closedStates = ['Erledigt', 'Resolved', 'Closed', 'Geschlossen'];
$openCount = 0;
$lastActivity = '';
foreach ($tickets as $ticket) {
$state = (string) ($ticket['Ticket_State'] ?? '');
if (!in_array($state, $closedStates, true)) {
$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,
];
}
/**
* 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,
];
}
}