feat(helpdesk): redesign dashboards with KPI groups, skeleton loading, and unified spacing
Restructure Support dashboard into 2 KPI groups (Tickets + Contracts) with section dividers, merge escalations and recommendations into "Attention needed". Redesign Sales dashboard with clickable contract timeline and dialog. Add CSS shimmer skeleton loading for all dashboard tabs and aside. Unify vertical rhythm via * + * sibling combinator on tab content containers. Remove ~265 lines of dead CSS (contract cards, escalation styles) and unused JS functions. Refactor hydrateTicketCategoryFilter into generic hydrateSelectFilter. Fix role="button" CSS bleed from shell and remove extra future year from timeline. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -47,7 +47,7 @@ class BcODataGateway
|
||||
return [];
|
||||
}
|
||||
|
||||
$escaped = $this->escapeODataString($query);
|
||||
$escaped = $this->escapeODataStrictUserInput($query);
|
||||
$upperQuery = mb_strtoupper($escaped);
|
||||
$select = '&$select=No,Name,Search_Name,Address,City,Post_Code,Phone_No,E_Mail,Salesperson_Code';
|
||||
$baseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER);
|
||||
@@ -108,13 +108,13 @@ class BcODataGateway
|
||||
|
||||
$cityClause = '';
|
||||
if ($city !== '') {
|
||||
$escapedCity = $this->escapeODataString($city);
|
||||
$escapedCity = $this->escapeODataStrictUserInput($city);
|
||||
$cityClause = "startswith(City,'" . $escapedCity . "')";
|
||||
}
|
||||
|
||||
$searchStrategies = [''];
|
||||
if ($search !== '') {
|
||||
$escapedSearch = $this->escapeODataString($search);
|
||||
$escapedSearch = $this->escapeODataStrictUserInput($search);
|
||||
$upperSearch = mb_strtoupper($escapedSearch);
|
||||
$searchStrategies = [
|
||||
"contains(Search_Name,'" . $upperSearch . "') or contains(Name,'" . $escapedSearch . "') or contains(No,'" . $escapedSearch . "')",
|
||||
@@ -180,7 +180,7 @@ class BcODataGateway
|
||||
return null;
|
||||
}
|
||||
|
||||
$escaped = $this->escapeODataString($customerNo);
|
||||
$escaped = $this->escapeODataStrictUserInput($customerNo);
|
||||
$filter = "No eq '" . $escaped . "'";
|
||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER)
|
||||
. '?$filter=' . rawurlencode($filter)
|
||||
@@ -199,20 +199,205 @@ class BcODataGateway
|
||||
/**
|
||||
* Get contacts for a customer by customer name.
|
||||
*
|
||||
* BC OData does not support filtering by IntegrationCustomerNo, so we
|
||||
* filter by Company_Name (the customer's name in the contact card).
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getContactsForCustomer(string $customerNo, string $customerName = ''): array
|
||||
{
|
||||
$result = $this->getContactsForCustomerWithMeta($customerNo, $customerName);
|
||||
|
||||
return $result['contacts'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contacts for a customer with fallback metadata.
|
||||
*
|
||||
* @return array{
|
||||
* contacts: array<int, array<string, mixed>>,
|
||||
* meta: array{
|
||||
* source: string,
|
||||
* fallback_used: bool,
|
||||
* fallback_reason: string
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
public function getContactsForCustomerWithMeta(string $customerNo, string $customerName = ''): array
|
||||
{
|
||||
$customerNo = trim($customerNo);
|
||||
$customerName = trim($customerName);
|
||||
if ($customerName === '') {
|
||||
if ($customerNo === '' || $customerName === '') {
|
||||
return [
|
||||
'contacts' => [],
|
||||
'meta' => [
|
||||
'source' => 'primary.company_name',
|
||||
'fallback_used' => false,
|
||||
'fallback_reason' => '',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$primaryError = false;
|
||||
$primaryContacts = [];
|
||||
|
||||
try {
|
||||
$primaryContacts = $this->fetchContactsByField('Company_Name', $customerName);
|
||||
} catch (\Throwable) {
|
||||
$primaryError = true;
|
||||
}
|
||||
|
||||
if ($primaryContacts !== []) {
|
||||
return [
|
||||
'contacts' => $primaryContacts,
|
||||
'meta' => [
|
||||
'source' => 'primary.company_name',
|
||||
'fallback_used' => false,
|
||||
'fallback_reason' => '',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$fallbackReason = $primaryError ? 'primary_query_failed' : 'primary_empty';
|
||||
|
||||
try {
|
||||
$integrationContacts = $this->fetchContactsByField('IntegrationCustomerNo', $customerNo);
|
||||
if ($integrationContacts !== []) {
|
||||
return [
|
||||
'contacts' => $integrationContacts,
|
||||
'meta' => [
|
||||
'source' => 'fallback.integration_customer_no',
|
||||
'fallback_used' => true,
|
||||
'fallback_reason' => $fallbackReason,
|
||||
],
|
||||
];
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Continue with second fallback.
|
||||
}
|
||||
|
||||
try {
|
||||
$companyNoContacts = $this->fetchContactsByField('Company_No', $customerNo);
|
||||
if ($companyNoContacts !== []) {
|
||||
return [
|
||||
'contacts' => $companyNoContacts,
|
||||
'meta' => [
|
||||
'source' => 'fallback.company_no',
|
||||
'fallback_used' => true,
|
||||
'fallback_reason' => $fallbackReason,
|
||||
],
|
||||
];
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Fall back to primary result (empty set).
|
||||
}
|
||||
|
||||
return [
|
||||
'contacts' => $this->dedupeRowsByNo($primaryContacts),
|
||||
'meta' => [
|
||||
'source' => 'primary.company_name',
|
||||
'fallback_used' => false,
|
||||
'fallback_reason' => '',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tickets for a customer by customer name.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getTicketsForCustomer(string $customerNo, string $customerName = ''): array
|
||||
{
|
||||
$result = $this->getTicketsForCustomerWithMeta($customerNo, $customerName);
|
||||
|
||||
return $result['tickets'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tickets for a customer with fallback metadata.
|
||||
*
|
||||
* @return array{
|
||||
* tickets: array<int, array<string, mixed>>,
|
||||
* meta: array{
|
||||
* source: string,
|
||||
* fallback_used: bool,
|
||||
* fallback_reason: string
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
public function getTicketsForCustomerWithMeta(string $customerNo, string $customerName = ''): array
|
||||
{
|
||||
$customerNo = trim($customerNo);
|
||||
$customerName = trim($customerName);
|
||||
if ($customerNo === '' || $customerName === '') {
|
||||
return [
|
||||
'tickets' => [],
|
||||
'meta' => [
|
||||
'source' => 'primary.company_contact_name',
|
||||
'fallback_used' => false,
|
||||
'fallback_reason' => '',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$primaryError = false;
|
||||
$primaryTickets = [];
|
||||
|
||||
try {
|
||||
$primaryTickets = $this->fetchTicketsByCompanyContactName($customerName);
|
||||
} catch (\Throwable) {
|
||||
$primaryError = true;
|
||||
}
|
||||
|
||||
if ($primaryTickets !== []) {
|
||||
return [
|
||||
'tickets' => $primaryTickets,
|
||||
'meta' => [
|
||||
'source' => 'primary.company_contact_name',
|
||||
'fallback_used' => false,
|
||||
'fallback_reason' => '',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$fallbackReason = $primaryError ? 'primary_query_failed' : 'primary_empty';
|
||||
try {
|
||||
$fallbackTickets = $this->fetchTicketsByCustomerNoFromLv($customerNo, $customerName);
|
||||
} catch (\Throwable) {
|
||||
$fallbackTickets = [];
|
||||
}
|
||||
|
||||
if ($fallbackTickets !== []) {
|
||||
return [
|
||||
'tickets' => $fallbackTickets,
|
||||
'meta' => [
|
||||
'source' => 'fallback.customer_no_lv',
|
||||
'fallback_used' => true,
|
||||
'fallback_reason' => $fallbackReason,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'tickets' => $primaryTickets,
|
||||
'meta' => [
|
||||
'source' => 'primary.company_contact_name',
|
||||
'fallback_used' => false,
|
||||
'fallback_reason' => '',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function fetchContactsByField(string $field, string $value): array
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$escaped = $this->escapeODataString($customerName);
|
||||
$filter = "Company_Name eq '" . $escaped . "'";
|
||||
$escaped = $this->escapeODataTrustedLiteral($value);
|
||||
$filter = $field . " eq '" . $escaped . "'";
|
||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTACT)
|
||||
. '?$filter=' . rawurlencode($filter)
|
||||
. '&$top=100'
|
||||
@@ -223,25 +408,23 @@ class BcODataGateway
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->extractODataValues($response);
|
||||
return $this->dedupeRowsByNo($this->extractODataValues($response));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tickets for a customer by customer name.
|
||||
*
|
||||
* Uses the PBI_FP_Tickets entity (FactPage / Power BI view) which supports
|
||||
* server-side $filter and $orderby — unlike the Card-based "Tickets" entity.
|
||||
* server-side $filter and $orderby.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getTicketsForCustomer(string $customerNo, string $customerName = ''): array
|
||||
private function fetchTicketsByCompanyContactName(string $customerName): array
|
||||
{
|
||||
$customerName = trim($customerName);
|
||||
if ($customerName === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$escaped = $this->escapeODataString($customerName);
|
||||
$escaped = $this->escapeODataTrustedLiteral($customerName);
|
||||
$filter = "Company_Contact_Name eq '" . $escaped . "'";
|
||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS)
|
||||
. '?$filter=' . rawurlencode($filter)
|
||||
@@ -254,7 +437,59 @@ class BcODataGateway
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->extractODataValues($response);
|
||||
return $this->dedupeRowsByNo($this->extractODataValues($response));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback ticket source by customer number using LV entity.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function fetchTicketsByCustomerNoFromLv(string $customerNo, string $customerName): array
|
||||
{
|
||||
$customerNo = trim($customerNo);
|
||||
if ($customerNo === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$escaped = $this->escapeODataTrustedLiteral($customerNo);
|
||||
$filter = "Customer_No eq '" . $escaped . "'";
|
||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV)
|
||||
. '?$filter=' . rawurlencode($filter)
|
||||
. '&$top=300'
|
||||
. '&$select=' . rawurlencode('No,Customer_No,Category_1_Code,Escalation_Code,Ticket_State,Last_Activity_Date,Created_On,Process_Stage,Process_Code,Support_User_Name')
|
||||
. '&$orderby=' . rawurlencode('Created_On desc');
|
||||
|
||||
$response = $this->request('GET', $url);
|
||||
if ($response === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->extractODataValues($response);
|
||||
$mapped = [];
|
||||
foreach ($rows as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$ticketNo = trim((string) ($row['No'] ?? ''));
|
||||
if ($ticketNo === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mapped[] = [
|
||||
'No' => $ticketNo,
|
||||
'Description' => trim((string) ($row['Process_Code'] ?? '')),
|
||||
'Company_Contact_Name' => $customerName,
|
||||
'Current_Contact_Name' => '',
|
||||
'Category_1_Description' => trim((string) ($row['Category_1_Code'] ?? '')),
|
||||
'Ticket_State' => trim((string) ($row['Ticket_State'] ?? '')),
|
||||
'Support_User_Name' => trim((string) ($row['Support_User_Name'] ?? '')),
|
||||
'Created_On' => trim((string) ($row['Created_On'] ?? '')),
|
||||
'Last_Activity_Date' => trim((string) ($row['Last_Activity_Date'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->dedupeRowsByNo($mapped);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -275,7 +510,7 @@ class BcODataGateway
|
||||
}
|
||||
|
||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_ESCALATION)
|
||||
. '?$top=200'
|
||||
. '?$top=50'
|
||||
. '&$select=' . rawurlencode('Code,Description,Target_Time,No_of_Escalation_Level')
|
||||
. '&$orderby=' . rawurlencode('Code asc');
|
||||
|
||||
@@ -305,11 +540,11 @@ class BcODataGateway
|
||||
return [];
|
||||
}
|
||||
|
||||
$escaped = $this->escapeODataString($customerNo);
|
||||
$escaped = $this->escapeODataTrustedLiteral($customerNo);
|
||||
$filter = "Customer_No eq '" . $escaped . "'";
|
||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV)
|
||||
. '?$filter=' . rawurlencode($filter)
|
||||
. '&$top=250'
|
||||
. '&$top=200'
|
||||
. '&$select=' . rawurlencode('No,Customer_No,Category_1_Code,Escalation_Code,Ticket_State,Last_Activity_Date')
|
||||
. '&$orderby=' . rawurlencode('Last_Activity_Date desc');
|
||||
|
||||
@@ -336,11 +571,11 @@ class BcODataGateway
|
||||
return [];
|
||||
}
|
||||
|
||||
$escaped = $this->escapeODataString($customerNo);
|
||||
$escaped = $this->escapeODataTrustedLiteral($customerNo);
|
||||
$filter = "Customer_No eq '" . $escaped . "'";
|
||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV)
|
||||
. '?$filter=' . rawurlencode($filter)
|
||||
. '&$top=500'
|
||||
. '&$top=300'
|
||||
. '&$select=' . rawurlencode('No,Customer_No,Category_1_Code,Escalation_Code,Ticket_State,Last_Activity_Date,Created_On,Process_Stage,Process_Code,Support_User_Name')
|
||||
. '&$orderby=' . rawurlencode('Created_On desc');
|
||||
|
||||
@@ -418,7 +653,7 @@ class BcODataGateway
|
||||
*/
|
||||
private function fetchContractsByCustomerField(string $customerNo, string $customerField, string $select): array
|
||||
{
|
||||
$escaped = $this->escapeODataString($customerNo);
|
||||
$escaped = $this->escapeODataTrustedLiteral($customerNo);
|
||||
$filter = $customerField . " eq '" . $escaped . "'";
|
||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACTS)
|
||||
. '?$filter=' . rawurlencode($filter)
|
||||
@@ -446,12 +681,12 @@ class BcODataGateway
|
||||
return [];
|
||||
}
|
||||
|
||||
$escaped = $this->escapeODataString($customerNo);
|
||||
$escaped = $this->escapeODataTrustedLiteral($customerNo);
|
||||
$filter = "Customer_No eq '" . $escaped . "'";
|
||||
$select = 'Header_No,Line_No,PI_Header_State,PI_Header_Description,Type,Description,Quantity,Unit_of_Measure_Code,Unit_Price,Line_Discount_Percent,Line_Amount,Line_State,Starting_Date,Ending_Date,Position,Attached_to_Line_No';
|
||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_LINES)
|
||||
. '?$filter=' . rawurlencode($filter)
|
||||
. '&$top=500'
|
||||
. '&$top=300'
|
||||
. '&$select=' . rawurlencode($select)
|
||||
. '&$orderby=' . rawurlencode('Header_No asc,Line_No asc');
|
||||
|
||||
@@ -475,7 +710,7 @@ class BcODataGateway
|
||||
return null;
|
||||
}
|
||||
|
||||
$escaped = $this->escapeODataString($ticketNo);
|
||||
$escaped = $this->escapeODataStrictUserInput($ticketNo);
|
||||
$filter = "No eq '" . $escaped . "'";
|
||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS)
|
||||
. '?$filter=' . rawurlencode($filter)
|
||||
@@ -509,7 +744,7 @@ class BcODataGateway
|
||||
return [];
|
||||
}
|
||||
|
||||
$escaped = $this->escapeODataString($ticketNo);
|
||||
$escaped = $this->escapeODataStrictUserInput($ticketNo);
|
||||
$filter = "Support_Ticket_No eq '" . $escaped . "'";
|
||||
|
||||
// Fetch detailed log (user, time, Record_ID)
|
||||
@@ -529,21 +764,31 @@ class BcODataGateway
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch status subtypes (human-readable state names)
|
||||
$lvUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKET_LOG_LV)
|
||||
. '?$filter=' . rawurlencode($filter)
|
||||
. '&$orderby=' . rawurlencode('Entry_No desc')
|
||||
. '&$top=100'
|
||||
. '&$select=Entry_No,State_Subtype';
|
||||
// Only fetch status subtypes if detail entries contain status-type rows
|
||||
$hasStatusEntries = false;
|
||||
foreach ($entries as $entry) {
|
||||
if (($entry['Type'] ?? '') === 'Status') {
|
||||
$hasStatusEntries = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$lvResponse = $this->request('GET', $lvUrl);
|
||||
$subtypeMap = [];
|
||||
if ($lvResponse !== null) {
|
||||
foreach ($this->extractODataValues($lvResponse) as $lv) {
|
||||
$entryNo = $lv['Entry_No'] ?? null;
|
||||
$subtype = (string) ($lv['State_Subtype'] ?? '');
|
||||
if ($entryNo !== null && $subtype !== '' && $subtype !== '0') {
|
||||
$subtypeMap[$entryNo] = $subtype;
|
||||
if ($hasStatusEntries) {
|
||||
$lvUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKET_LOG_LV)
|
||||
. '?$filter=' . rawurlencode($filter)
|
||||
. '&$orderby=' . rawurlencode('Entry_No desc')
|
||||
. '&$top=100'
|
||||
. '&$select=Entry_No,State_Subtype';
|
||||
|
||||
$lvResponse = $this->request('GET', $lvUrl);
|
||||
if ($lvResponse !== null) {
|
||||
foreach ($this->extractODataValues($lvResponse) as $lv) {
|
||||
$entryNo = $lv['Entry_No'] ?? null;
|
||||
$subtype = (string) ($lv['State_Subtype'] ?? '');
|
||||
if ($entryNo !== null && $subtype !== '' && $subtype !== '0') {
|
||||
$subtypeMap[$entryNo] = $subtype;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -639,7 +884,7 @@ class BcODataGateway
|
||||
return ['error' => 'Not configured or empty customer number'];
|
||||
}
|
||||
|
||||
$escapedCustomerNo = $this->escapeODataString($customerNo);
|
||||
$escapedCustomerNo = $this->escapeODataStrictUserInput($customerNo);
|
||||
$results = [];
|
||||
|
||||
// 1. Customer entity
|
||||
@@ -656,7 +901,7 @@ class BcODataGateway
|
||||
// 2. Contact entity — filter by Company_Name
|
||||
$contactBaseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTACT);
|
||||
if ($customerName !== '') {
|
||||
$escapedCustomerName = $this->escapeODataString((string) $customerName);
|
||||
$escapedCustomerName = $this->escapeODataTrustedLiteral((string) $customerName);
|
||||
$contactFilteredUrl = $contactBaseUrl
|
||||
. '?$filter=' . rawurlencode("Company_Name eq '" . $escapedCustomerName . "'")
|
||||
. '&$top=10&$select=' . rawurlencode('No,Name,Type,Company_No,Company_Name,E_Mail,Phone_No,Job_Title');
|
||||
@@ -668,7 +913,7 @@ class BcODataGateway
|
||||
// 3. Tickets entity (PBI_FP_Tickets) — filter by Company_Contact_Name
|
||||
$ticketsBaseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS);
|
||||
if ($customerName !== '') {
|
||||
$escapedCustomerName = $this->escapeODataString((string) $customerName);
|
||||
$escapedCustomerName = $this->escapeODataTrustedLiteral((string) $customerName);
|
||||
$ticketsFilteredUrl = $ticketsBaseUrl
|
||||
. '?$filter=' . rawurlencode("Company_Contact_Name eq '" . $escapedCustomerName . "'")
|
||||
. '&$top=10&$orderby=' . rawurlencode('Created_On desc')
|
||||
@@ -887,15 +1132,35 @@ class BcODataGateway
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize and escape a string for use inside an OData $filter string literal.
|
||||
*
|
||||
* Rejects characters that could manipulate OData filter logic (parentheses,
|
||||
* boolean operators). Only allows letters, digits, spaces, dots, hyphens,
|
||||
* underscores, umlauts and common punctuation.
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function dedupeRowsByNo(array $rows): array
|
||||
{
|
||||
$uniqueRows = [];
|
||||
foreach ($rows as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$no = trim((string) ($row['No'] ?? ''));
|
||||
$rowKey = $no !== '' ? $no : md5((string) json_encode($row));
|
||||
if (isset($uniqueRows[$rowKey])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$uniqueRows[$rowKey] = $row;
|
||||
}
|
||||
|
||||
return array_values($uniqueRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize and escape a user-provided string for use in OData filters.
|
||||
*
|
||||
* @throws \InvalidArgumentException If the value contains disallowed characters.
|
||||
*/
|
||||
private function escapeODataString(string $value): string
|
||||
private function escapeODataStrictUserInput(string $value): string
|
||||
{
|
||||
if (preg_match('/^[\p{L}\p{N}\s.\-_@\/&,;:#+*]+$/u', $value) !== 1) {
|
||||
throw new \InvalidArgumentException('Search query contains invalid characters.');
|
||||
@@ -904,6 +1169,14 @@ class BcODataGateway
|
||||
return str_replace("'", "''", $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape trusted literals (e.g. BC-sourced names/codes) for OData filters.
|
||||
*/
|
||||
private function escapeODataTrustedLiteral(string $value): string
|
||||
{
|
||||
return str_replace("'", "''", trim($value));
|
||||
}
|
||||
|
||||
private function resolveCurrentTenantId(): int
|
||||
{
|
||||
$session = $this->sessionStore->all();
|
||||
|
||||
@@ -109,14 +109,14 @@ class BcSoapGateway
|
||||
}
|
||||
|
||||
$parsed = $this->parseGetTicketCommunicationTextResponse($rawBody);
|
||||
if (!($parsed['ok'] ?? false)) {
|
||||
if (!$parsed['ok']) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'soap_used' => false,
|
||||
'return_value' => 0,
|
||||
'communication_text' => '',
|
||||
'message_entries' => '',
|
||||
'error' => (string) ($parsed['error'] ?? 'Could not parse SOAP response'),
|
||||
'error' => isset($parsed['error']) ? (string) $parsed['error'] : 'Could not parse SOAP response',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,16 @@ class DebitorDetailService
|
||||
/**
|
||||
* Load contacts for a customer (for async data endpoint).
|
||||
*
|
||||
* @return array{ok: bool, contacts?: array<int, array<string, mixed>>, error?: string}
|
||||
* @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
|
||||
{
|
||||
@@ -72,18 +81,38 @@ class DebitorDetailService
|
||||
}
|
||||
|
||||
try {
|
||||
$contacts = $this->bcODataGateway->getContactsForCustomer($customerNo, $customerName);
|
||||
$result = $this->bcODataGateway->getContactsForCustomerWithMeta($customerNo, $customerName);
|
||||
} catch (\Throwable $e) {
|
||||
return ['ok' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'contacts' => $contacts];
|
||||
$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>>, error?: string}
|
||||
* @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
|
||||
{
|
||||
@@ -99,12 +128,23 @@ class DebitorDetailService
|
||||
}
|
||||
|
||||
try {
|
||||
$tickets = $this->bcODataGateway->getTicketsForCustomer($customerNo, $customerName);
|
||||
$result = $this->bcODataGateway->getTicketsForCustomerWithMeta($customerNo, $customerName);
|
||||
} catch (\Throwable $e) {
|
||||
return ['ok' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'tickets' => $tickets];
|
||||
$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'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,7 +202,7 @@ class DebitorDetailService
|
||||
|
||||
$tickets = is_array($result['tickets'] ?? null) ? $result['tickets'] : [];
|
||||
$escalation = $this->loadEscalationHealth($customerNo, $tickets);
|
||||
if (!($escalation['ok'] ?? false)) {
|
||||
if (!$escalation['ok']) {
|
||||
$escalation = null;
|
||||
}
|
||||
|
||||
@@ -277,7 +317,7 @@ class DebitorDetailService
|
||||
}
|
||||
|
||||
$targetSeconds = $targetSecondsByCode[$escalationCode] ?? null;
|
||||
if (!is_int($targetSeconds) || $targetSeconds <= 0) {
|
||||
if (!is_int($targetSeconds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -316,12 +356,12 @@ class DebitorDetailService
|
||||
}
|
||||
|
||||
usort($entries, static function (array $a, array $b): int {
|
||||
$cmp = ((int) ($b['overdue_seconds'] ?? 0)) <=> ((int) ($a['overdue_seconds'] ?? 0));
|
||||
$cmp = $b['overdue_seconds'] <=> $a['overdue_seconds'];
|
||||
if ($cmp !== 0) {
|
||||
return $cmp;
|
||||
}
|
||||
|
||||
return strcmp((string) ($a['ticket_no'] ?? ''), (string) ($b['ticket_no'] ?? ''));
|
||||
return strcmp($a['ticket_no'], $b['ticket_no']);
|
||||
});
|
||||
|
||||
return [
|
||||
@@ -1362,11 +1402,6 @@ class DebitorDetailService
|
||||
'backlog_critical' => $criticalOpen,
|
||||
],
|
||||
'trend' => array_values($buckets),
|
||||
'efficiency' => [
|
||||
'resolution_hours' => $resolutionMedian,
|
||||
'unassigned_count' => $unassignedOpen,
|
||||
'oldest_open_hours' => $oldestOpenAgeHours,
|
||||
],
|
||||
'risk_indicators' => $riskIndicators,
|
||||
'risk_summary' => [
|
||||
'triggered' => $triggeredCount,
|
||||
@@ -1457,7 +1492,7 @@ class DebitorDetailService
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, mixed>> $rules
|
||||
* @return array<int, array{rule_id: string, label: string, triggered: bool, value: string, threshold: string}>
|
||||
* @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,
|
||||
@@ -1478,10 +1513,13 @@ class DebitorDetailService
|
||||
: ($createdInPeriod > 0 ? 100.0 : 0.0);
|
||||
$indicators[] = [
|
||||
'rule_id' => 'ticket_volume_increase',
|
||||
'label' => 'Ticket volume increase',
|
||||
'triggered' => $increase > $threshold,
|
||||
'label' => 'Ticket volume',
|
||||
'description' => 'risk_desc_ticket_volume',
|
||||
'triggered' => $increase >= $threshold,
|
||||
'value' => ($increase > 0 ? '+' : '') . $increase . '%',
|
||||
'threshold' => $threshold . '%',
|
||||
'raw_value' => max(0, $increase),
|
||||
'threshold_value' => $threshold,
|
||||
'unit' => '%',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1489,13 +1527,17 @@ class DebitorDetailService
|
||||
$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;
|
||||
$triggered = $avgResolution !== null && $avgResolution >= $threshold;
|
||||
$rawValue = $avgResolution !== null ? round($avgResolution, 1) : 0;
|
||||
$indicators[] = [
|
||||
'rule_id' => 'avg_resolution_high',
|
||||
'label' => 'High avg. resolution time',
|
||||
'label' => 'Resolution time',
|
||||
'description' => 'risk_desc_resolution_time',
|
||||
'triggered' => $triggered,
|
||||
'value' => $avgResolution !== null ? round($avgResolution, 1) . 'h' : '—',
|
||||
'threshold' => $threshold . 'h',
|
||||
'raw_value' => $rawValue,
|
||||
'threshold_value' => $threshold,
|
||||
'unit' => 'h',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1505,10 +1547,13 @@ class DebitorDetailService
|
||||
$threshold = (int) ($rule['threshold_hours'] ?? 168);
|
||||
$indicators[] = [
|
||||
'rule_id' => 'open_aging',
|
||||
'label' => 'Open ticket aging',
|
||||
'triggered' => $oldestOpenAgeHours > $threshold,
|
||||
'label' => 'Oldest open ticket',
|
||||
'description' => 'risk_desc_open_aging',
|
||||
'triggered' => $oldestOpenAgeHours >= $threshold,
|
||||
'value' => $oldestOpenAgeHours . 'h',
|
||||
'threshold' => $threshold . 'h',
|
||||
'raw_value' => $oldestOpenAgeHours,
|
||||
'threshold_value' => $threshold,
|
||||
'unit' => 'h',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1518,10 +1563,13 @@ class DebitorDetailService
|
||||
$threshold = (int) ($rule['threshold_percent'] ?? 20);
|
||||
$indicators[] = [
|
||||
'rule_id' => 'unassigned_ratio',
|
||||
'label' => 'Unassigned ticket ratio',
|
||||
'triggered' => $unassignedRate > $threshold,
|
||||
'label' => 'Unassigned',
|
||||
'description' => 'risk_desc_unassigned',
|
||||
'triggered' => $unassignedRate >= $threshold,
|
||||
'value' => $unassignedRate . '%',
|
||||
'threshold' => $threshold . '%',
|
||||
'raw_value' => $unassignedRate,
|
||||
'threshold_value' => $threshold,
|
||||
'unit' => '%',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -383,7 +383,7 @@ class HelpdeskSettingsGateway
|
||||
{
|
||||
$normalized = self::normalizeSystemRecommendationsConfig($config);
|
||||
$encoded = json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
if (!is_string($encoded) || $encoded === '') {
|
||||
if (!is_string($encoded)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -617,7 +617,7 @@ class HelpdeskSettingsGateway
|
||||
{
|
||||
$normalized = self::normalizeControllingRiskConfig($config);
|
||||
$encoded = json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
if (!is_string($encoded) || $encoded === '') {
|
||||
if (!is_string($encoded)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ class SystemRecommendationEngine
|
||||
continue;
|
||||
}
|
||||
|
||||
$escalationCode = strtoupper(trim((string) ($match['escalation_code'] ?? '')));
|
||||
$escalationCode = strtoupper(trim($match['escalation_code']));
|
||||
if ($escalationCode === '' || !isset($codeMap[$escalationCode])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user