forked from fa/breadcrumb-the-shire
- 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>
242 lines
7.8 KiB
PHP
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,
|
|
];
|
|
}
|
|
}
|