361 lines
13 KiB
PHP
361 lines
13 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
namespace MintyPHP\Module\Helpdesk\Service;
|
||
|
|
|
||
|
|
use MintyPHP\Http\SessionStoreInterface;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Gateway for Business Central SOAP codeunit calls used by helpdesk communication.
|
||
|
|
*/
|
||
|
|
class BcSoapGateway
|
||
|
|
{
|
||
|
|
private const CONNECT_TIMEOUT = 10;
|
||
|
|
private const REQUEST_TIMEOUT = 30;
|
||
|
|
private const CODEUNIT_NAME = 'ICISupportWebserviceAPI';
|
||
|
|
private const SOAP_ACTION_GET_TICKET_COMMUNICATION_TEXT = 'urn:microsoft-dynamics-schemas/codeunit/ICISupportWebserviceAPI:GetTicketCommunicationText';
|
||
|
|
|
||
|
|
public function __construct(
|
||
|
|
private readonly HelpdeskSettingsGateway $settingsGateway,
|
||
|
|
private readonly HelpdeskOAuthTokenService $oauthTokenService,
|
||
|
|
private readonly SessionStoreInterface $sessionStore
|
||
|
|
) {
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @return array{
|
||
|
|
* ok: bool,
|
||
|
|
* soap_used: bool,
|
||
|
|
* return_value: int,
|
||
|
|
* communication_text: string,
|
||
|
|
* message_entries: string,
|
||
|
|
* error?: string
|
||
|
|
* }
|
||
|
|
*/
|
||
|
|
public function getTicketCommunicationText(string $ticketNo, string $messageEntries = ''): array
|
||
|
|
{
|
||
|
|
$ticketNo = trim($ticketNo);
|
||
|
|
if ($ticketNo === '') {
|
||
|
|
return [
|
||
|
|
'ok' => false,
|
||
|
|
'soap_used' => false,
|
||
|
|
'return_value' => 0,
|
||
|
|
'communication_text' => '',
|
||
|
|
'message_entries' => '',
|
||
|
|
'error' => 'Missing ticket number',
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!$this->settingsGateway->isConfigured()) {
|
||
|
|
return [
|
||
|
|
'ok' => false,
|
||
|
|
'soap_used' => false,
|
||
|
|
'return_value' => 0,
|
||
|
|
'communication_text' => '',
|
||
|
|
'message_entries' => '',
|
||
|
|
'error' => 'BC connection not configured',
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
$endpoint = $this->buildSoapEndpoint();
|
||
|
|
if ($endpoint === null) {
|
||
|
|
return [
|
||
|
|
'ok' => false,
|
||
|
|
'soap_used' => false,
|
||
|
|
'return_value' => 0,
|
||
|
|
'communication_text' => '',
|
||
|
|
'message_entries' => '',
|
||
|
|
'error' => 'Could not derive SOAP endpoint from BC URL',
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
$requestBody = $this->buildGetTicketCommunicationTextEnvelope($ticketNo, $messageEntries);
|
||
|
|
$request = $this->buildRequestContext($endpoint, $requestBody);
|
||
|
|
if (isset($request['error'])) {
|
||
|
|
return [
|
||
|
|
'ok' => false,
|
||
|
|
'soap_used' => false,
|
||
|
|
'return_value' => 0,
|
||
|
|
'communication_text' => '',
|
||
|
|
'message_entries' => '',
|
||
|
|
'error' => (string) $request['error'],
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
$response = $this->sendSoapRequest(
|
||
|
|
$endpoint,
|
||
|
|
self::SOAP_ACTION_GET_TICKET_COMMUNICATION_TEXT,
|
||
|
|
$requestBody,
|
||
|
|
(array) ($request['headers'] ?? []),
|
||
|
|
(string) ($request['basic_user'] ?? ''),
|
||
|
|
(string) ($request['basic_password'] ?? '')
|
||
|
|
);
|
||
|
|
|
||
|
|
$httpCode = (int) ($response['http_code'] ?? 0);
|
||
|
|
$rawBody = (string) ($response['body'] ?? '');
|
||
|
|
if ($httpCode < 200 || $httpCode >= 300) {
|
||
|
|
$errorSnippet = trim((string) ($response['error'] ?? ''));
|
||
|
|
if ($errorSnippet === '' && $rawBody !== '') {
|
||
|
|
$errorSnippet = mb_substr($rawBody, 0, 250);
|
||
|
|
}
|
||
|
|
|
||
|
|
return [
|
||
|
|
'ok' => false,
|
||
|
|
'soap_used' => false,
|
||
|
|
'return_value' => 0,
|
||
|
|
'communication_text' => '',
|
||
|
|
'message_entries' => '',
|
||
|
|
'error' => 'BC SOAP request failed: HTTP ' . $httpCode . ($errorSnippet !== '' ? ' - ' . $errorSnippet : ''),
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
$parsed = $this->parseGetTicketCommunicationTextResponse($rawBody);
|
||
|
|
if (!($parsed['ok'] ?? false)) {
|
||
|
|
return [
|
||
|
|
'ok' => false,
|
||
|
|
'soap_used' => false,
|
||
|
|
'return_value' => 0,
|
||
|
|
'communication_text' => '',
|
||
|
|
'message_entries' => '',
|
||
|
|
'error' => (string) ($parsed['error'] ?? 'Could not parse SOAP response'),
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
$returnValue = (int) ($parsed['return_value'] ?? 0);
|
||
|
|
if ($returnValue >= 400) {
|
||
|
|
return [
|
||
|
|
'ok' => false,
|
||
|
|
'soap_used' => false,
|
||
|
|
'return_value' => $returnValue,
|
||
|
|
'communication_text' => (string) ($parsed['communication_text'] ?? ''),
|
||
|
|
'message_entries' => (string) ($parsed['message_entries'] ?? ''),
|
||
|
|
'error' => 'BC SOAP returned error code ' . $returnValue,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
return [
|
||
|
|
'ok' => true,
|
||
|
|
'soap_used' => true,
|
||
|
|
'return_value' => $returnValue,
|
||
|
|
'communication_text' => (string) ($parsed['communication_text'] ?? ''),
|
||
|
|
'message_entries' => (string) ($parsed['message_entries'] ?? ''),
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
private function buildGetTicketCommunicationTextEnvelope(string $ticketNo, string $messageEntries): string
|
||
|
|
{
|
||
|
|
$escapedTicketNo = htmlspecialchars($ticketNo, ENT_XML1 | ENT_QUOTES, 'UTF-8');
|
||
|
|
$normalizedMessageEntries = trim($messageEntries);
|
||
|
|
if ($normalizedMessageEntries === '') {
|
||
|
|
$normalizedMessageEntries = '0';
|
||
|
|
}
|
||
|
|
$escapedMessageEntries = htmlspecialchars($normalizedMessageEntries, ENT_XML1 | ENT_QUOTES, 'UTF-8');
|
||
|
|
|
||
|
|
return <<<XML
|
||
|
|
<?xml version="1.0" encoding="utf-8"?>
|
||
|
|
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:urn="urn:microsoft-dynamics-schemas/codeunit/ICISupportWebserviceAPI">
|
||
|
|
<soap:Body>
|
||
|
|
<urn:GetTicketCommunicationText>
|
||
|
|
<urn:ticketNo>{$escapedTicketNo}</urn:ticketNo>
|
||
|
|
<urn:communicationText></urn:communicationText>
|
||
|
|
<urn:messageEntries>{$escapedMessageEntries}</urn:messageEntries>
|
||
|
|
</urn:GetTicketCommunicationText>
|
||
|
|
</soap:Body>
|
||
|
|
</soap:Envelope>
|
||
|
|
XML;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @return array{headers?: array<int, string>, basic_user?: string, basic_password?: string, error?: string}
|
||
|
|
*/
|
||
|
|
private function buildRequestContext(string $endpoint, string $requestBody): array
|
||
|
|
{
|
||
|
|
$headers = [
|
||
|
|
'Content-Type: text/xml; charset=utf-8',
|
||
|
|
'Accept: text/xml,application/xml',
|
||
|
|
'SOAPAction: "' . self::SOAP_ACTION_GET_TICKET_COMMUNICATION_TEXT . '"',
|
||
|
|
'Content-Length: ' . strlen($requestBody),
|
||
|
|
];
|
||
|
|
|
||
|
|
$authMode = $this->settingsGateway->getAuthMode();
|
||
|
|
if ($authMode === HelpdeskSettingsGateway::AUTH_MODE_BASIC) {
|
||
|
|
return [
|
||
|
|
'headers' => $headers,
|
||
|
|
'basic_user' => $this->settingsGateway->getBasicUser() ?? '',
|
||
|
|
'basic_password' => $this->settingsGateway->getBasicPassword() ?? '',
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($authMode !== HelpdeskSettingsGateway::AUTH_MODE_OAUTH2) {
|
||
|
|
return ['headers' => $headers];
|
||
|
|
}
|
||
|
|
|
||
|
|
$tenantId = $this->resolveCurrentTenantId();
|
||
|
|
if ($tenantId <= 0) {
|
||
|
|
return ['error' => 'OAuth2 mode requires active tenant context'];
|
||
|
|
}
|
||
|
|
|
||
|
|
$audience = $this->buildOAuthAudienceFromEndpoint($endpoint);
|
||
|
|
if ($audience === null) {
|
||
|
|
return ['error' => 'Could not build OAuth audience for SOAP endpoint'];
|
||
|
|
}
|
||
|
|
|
||
|
|
$token = $this->oauthTokenService->getAccessToken($tenantId, $audience);
|
||
|
|
if ($token === null || trim($token) === '') {
|
||
|
|
return ['error' => 'Could not obtain OAuth2 access token for SOAP request'];
|
||
|
|
}
|
||
|
|
|
||
|
|
$headers[] = 'Authorization: Bearer ' . $token;
|
||
|
|
|
||
|
|
return ['headers' => $headers];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @return array{ok: bool, return_value?: int, communication_text?: string, message_entries?: string, error?: string}
|
||
|
|
*/
|
||
|
|
private function parseGetTicketCommunicationTextResponse(string $body): array
|
||
|
|
{
|
||
|
|
$body = trim($body);
|
||
|
|
if ($body === '') {
|
||
|
|
return ['ok' => false, 'error' => 'Empty SOAP response'];
|
||
|
|
}
|
||
|
|
|
||
|
|
$dom = new \DOMDocument();
|
||
|
|
$previousLibxmlState = libxml_use_internal_errors(true);
|
||
|
|
$loaded = $dom->loadXML($body);
|
||
|
|
libxml_clear_errors();
|
||
|
|
libxml_use_internal_errors($previousLibxmlState);
|
||
|
|
if (!$loaded) {
|
||
|
|
return ['ok' => false, 'error' => 'Invalid SOAP XML response'];
|
||
|
|
}
|
||
|
|
|
||
|
|
$xpath = new \DOMXPath($dom);
|
||
|
|
$faultNode = $xpath->query("//*[local-name()='Fault']/*[local-name()='faultstring']")->item(0);
|
||
|
|
if ($faultNode instanceof \DOMNode) {
|
||
|
|
return ['ok' => false, 'error' => trim((string) $faultNode->textContent) ?: 'SOAP fault'];
|
||
|
|
}
|
||
|
|
|
||
|
|
$returnNode = $xpath->query("//*[local-name()='return_value']")->item(0);
|
||
|
|
$communicationNode = $xpath->query("//*[local-name()='communicationText']")->item(0);
|
||
|
|
$messageEntriesNode = $xpath->query("//*[local-name()='messageEntries']")->item(0);
|
||
|
|
|
||
|
|
return [
|
||
|
|
'ok' => true,
|
||
|
|
'return_value' => $returnNode instanceof \DOMNode && is_numeric(trim((string) $returnNode->textContent))
|
||
|
|
? (int) trim((string) $returnNode->textContent)
|
||
|
|
: 0,
|
||
|
|
'communication_text' => $communicationNode instanceof \DOMNode ? (string) $communicationNode->textContent : '',
|
||
|
|
'message_entries' => $messageEntriesNode instanceof \DOMNode ? (string) $messageEntriesNode->textContent : '',
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function sendSoapRequest(
|
||
|
|
string $endpoint,
|
||
|
|
string $soapAction,
|
||
|
|
string $requestBody,
|
||
|
|
array $headers,
|
||
|
|
string $basicUser,
|
||
|
|
string $basicPassword
|
||
|
|
): array {
|
||
|
|
$ch = curl_init();
|
||
|
|
if ($ch === false) {
|
||
|
|
return ['http_code' => 0, 'body' => '', 'error' => 'curl_init failed'];
|
||
|
|
}
|
||
|
|
|
||
|
|
curl_setopt_array($ch, [
|
||
|
|
CURLOPT_URL => $endpoint,
|
||
|
|
CURLOPT_RETURNTRANSFER => true,
|
||
|
|
CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
|
||
|
|
CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT,
|
||
|
|
CURLOPT_SSL_VERIFYPEER => true,
|
||
|
|
CURLOPT_SSL_VERIFYHOST => 2,
|
||
|
|
CURLOPT_POST => true,
|
||
|
|
CURLOPT_POSTFIELDS => $requestBody,
|
||
|
|
CURLOPT_HTTPHEADER => $headers,
|
||
|
|
]);
|
||
|
|
|
||
|
|
if ($basicUser !== '' || $basicPassword !== '') {
|
||
|
|
curl_setopt($ch, CURLOPT_USERPWD, $basicUser . ':' . $basicPassword);
|
||
|
|
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
|
||
|
|
}
|
||
|
|
|
||
|
|
$responseBody = curl_exec($ch);
|
||
|
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
|
|
$error = curl_error($ch);
|
||
|
|
unset($ch);
|
||
|
|
|
||
|
|
return [
|
||
|
|
'http_code' => $httpCode,
|
||
|
|
'body' => is_string($responseBody) ? $responseBody : '',
|
||
|
|
'error' => $error,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
private function buildSoapEndpoint(): ?string
|
||
|
|
{
|
||
|
|
$baseUrl = trim($this->settingsGateway->getODataBaseUrl());
|
||
|
|
if ($baseUrl === '') {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
$parsed = parse_url($baseUrl);
|
||
|
|
if (!is_array($parsed)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
$scheme = strtolower((string) ($parsed['scheme'] ?? 'https'));
|
||
|
|
$host = trim((string) ($parsed['host'] ?? ''));
|
||
|
|
if ($host === '') {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
$odataPath = '/' . ltrim((string) ($parsed['path'] ?? ''), '/');
|
||
|
|
$instancePath = (string) preg_replace('#/ODataV4(?:/.*)?$#i', '', $odataPath);
|
||
|
|
$instancePath = rtrim($instancePath !== '' ? $instancePath : '/BusinessCentral', '/');
|
||
|
|
if ($instancePath === '') {
|
||
|
|
$instancePath = '/BusinessCentral';
|
||
|
|
}
|
||
|
|
|
||
|
|
$odataPort = (int) ($parsed['port'] ?? 0);
|
||
|
|
$soapPort = $odataPort === 7048 ? 7047 : $odataPort;
|
||
|
|
|
||
|
|
$authority = $host;
|
||
|
|
if ($soapPort > 0 && !(($scheme === 'https' && $soapPort === 443) || ($scheme === 'http' && $soapPort === 80))) {
|
||
|
|
$authority .= ':' . $soapPort;
|
||
|
|
}
|
||
|
|
|
||
|
|
$company = rawurlencode($this->settingsGateway->getCompanyName());
|
||
|
|
|
||
|
|
return $scheme . '://' . $authority . $instancePath . '/WS/' . $company . '/Codeunit/' . self::CODEUNIT_NAME;
|
||
|
|
}
|
||
|
|
|
||
|
|
private function buildOAuthAudienceFromEndpoint(string $endpoint): ?string
|
||
|
|
{
|
||
|
|
$parsed = parse_url($endpoint);
|
||
|
|
if (!is_array($parsed)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
$scheme = strtolower((string) ($parsed['scheme'] ?? 'https'));
|
||
|
|
$host = trim((string) ($parsed['host'] ?? ''));
|
||
|
|
if ($host === '') {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
$port = (int) ($parsed['port'] ?? 0);
|
||
|
|
$authority = $host;
|
||
|
|
if ($port > 0 && !(($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80))) {
|
||
|
|
$authority .= ':' . $port;
|
||
|
|
}
|
||
|
|
|
||
|
|
return $scheme . '://' . $authority;
|
||
|
|
}
|
||
|
|
|
||
|
|
private function resolveCurrentTenantId(): int
|
||
|
|
{
|
||
|
|
$session = $this->sessionStore->all();
|
||
|
|
$tenantId = (int) (($session['current_tenant']['id'] ?? 0));
|
||
|
|
|
||
|
|
return $tenantId > 0 ? $tenantId : 0;
|
||
|
|
}
|
||
|
|
}
|