<?php
declare(strict_types=1);
use EnneoSDK\Api;
use EnneoSDK\ApiEnneo;
use EnneoSDK\Helpers;
use EnneoSDK\IntentInfo;
use EnneoSDK\IntentOption;
use EnneoSDK\Interaction;
require(getenv()['SDK'] ?? 'sdk.php');
/** @var stdClass $in */
######### Expected Input:
// contractId -> ID of the contract (required)
// readingDate -> date of the reading. Optional. If no date is provided, the ticket creation date will be used
// ticketDate -> date of ticket creation (required)
// value -> reading value (optional). If no value is provided, the function will check attachments and try OCR there
// ticketId -> ID of the ticket (required)
// channel -> ticket channel. Voice and chat can be handled synchronously.
// counter -> consumption-related counter. Value is empty while first execution.
// plausibilityReason -> reason to save invalid readings. Used only if value can't be saved without a reason
######### Possible Actions:
// For better readability, actions can be prefixed with "base_", if they are handled in main agent business logic
// save reading without reason
const BASE_SAVE_READING = 'base_save_reading';
// save invalid reading using plausibility reason
const BASE_SAVE_INVALID_READING = 'base_save_invalid_reading';
// reading date is not valid
const READING_DATE_INVALID = 'reading_date_invalid';
// no reading value provided
const READING_VALUE_NOT_PROVIDED = 'reading_value_not_provided';
// reading value is not valid
const READING_VALUE_INVALID = 'reading_value_invalid';
// success
const SAVE_READING_COMPLETED = 'save_reading_completed';
// Create an Interaction object for output and fetch contract data from ERP
$interaction = new Interaction($in);
$contract = ApiEnneo::getContract($in->contractId);
######### STEP: if "Save" or "Save unplausibel" action is already selected, try to save reading
if ($in->_action === BASE_SAVE_READING || $in->_action === BASE_SAVE_INVALID_READING) {
try {
$parameters = [
'tenant' => 'tenant',
'mockedStatus' => 'OK', // use PLAUSIBILITY_REASON_NEEDED to test unplausible reading
'contractId' => $in->contractId,
'readingDate' => $in->readingDate,
'newConsumptions' => [
[
'consumption' => $in->value,
'consumptionUnit' => 'KILOWATT_HOURS',
'consumptionRelatedCounter' => $in->counter
]
]
];
if ($in->_acttion === BASE_SAVE_INVALID_READING) {
if (!$in->_plausibilityReason) {
$interaction->infos[] = new IntentInfo(
type: 'warning',
message: 'Bitte den Plausibilitätsgrund angeben'
);
$interaction->options[] = new IntentOption(
type: BASE_SAVE_INVALID_READING,
name: 'Erneut versuchen',
recommended: true
);
stopProcessing($interaction);
}
$parameters['params']['newConsumptions'][0]['plausibilityReason'] = $in->_plausibilityReason;
}
$response = Api::call(
method: 'POST',
url: 'https://echo.enneo.ai',
params: $parameters
);
} catch (Throwable $exception) {
$interaction->infos[] = new IntentInfo(
type: 'danger',
message: 'Zählerstand konnte nicht gespeichert werden'
);
$interaction->options[] = new IntentOption(
type: BASE_SAVE_READING,
name: 'Erneut versuchen',
recommended: true
);
stopProcessing($interaction);
}
// On invalid readings: save using a plausibility reason or inform the customer
if ($response->mockedStatus === 'PLAUSIBILITY_REASON_NEEDED') {
$interaction->infos[] = new IntentInfo(
type: 'warning',
message: 'Zählerstand ist nicht plausibel. Bitte den Grund wür die Abweichung angeben.'
);
$interaction->options[] = new IntentOption(
type: BASE_SAVE_INVALID_READING,
name: 'Plausibilitätsgrund angeben und speichern',
recommended: true
);
$interaction->options[] = new IntentOption(
type: READING_VALUE_INVALID,
name: 'Kunden über inplausiblen ZS informieren',
recommended: false
);
stopProcessing($interaction);
}
// Success
$interaction->infos[] = new IntentInfo(
type: 'success',
message: 'Zählerstand gespeichert'
);
$in->_action = SAVE_READING_COMPLETED;
stopProcessing($interaction);
}
######### STEP: fetch existing readings
$meterReadingsDataResponse = Api::call(
method: 'POST',
url: 'https://echo.enneo.ai',
params: [
'contractId' => $in->contractId,
'mockedResult' => getMockedMeterDataResponse()
]
);
$meterReadingsData = $meterReadingsDataResponse->mockedResult ?? getMockedMeterDataResponse();
// set consumption-related counter
$in->counter = $meterReadingsData->meters[0]->counterType;
######### STEP: get reading date and validate it
$in->readingDate = empty($in->readingDate) ? $in->ticketDate : $in->readingDate;
$isReadingDateValid = true;
// TODO:
// reading date can be validated here.
// On synchronous channel, user will be requested to change the date. On async channels, text template can be sent
$latestExistingReadingDate = max(array_map(fn($item) => $item->readingDate, $meterReadingsData->meters[0]->meterReadings));
if ($in->readingDate <= $latestExistingReadingDate) {
$interaction->infos[] = new IntentInfo(
type: 'warning',
message: isSyncChannel($in)
? sprintf('Bitte geben Sie ein Datum nach dem %s ein', Helpers::formatDate($latestExistingReadingDate))
: sprintf('Es liget bereits eine Ablseung vom %s vor', Helpers::formatDate($latestExistingReadingDate))
);
$interaction->options[] = new IntentOption(
type: READING_DATE_INVALID,
name: isSyncChannel($in) ? 'Datum korrigieren' : 'Kunden informieren',
recommended: true
);
stopProcessing($interaction);
}
######### STEP: get reading value and validate it
if (!$in->value && !isSyncChannel($in)) {
// on async channels (email), value could be provided as image in email attachment
$in->value = getValueUsingOcrOnAttachments($in, $meterReadingsData);
}
if (!$in->value) {
$interaction->infos[] = new IntentInfo(
type: 'warning',
message: 'Es wurde kein Zählerstand übermittelt'
);
$interaction->options[] = new IntentOption(
type: READING_VALUE_NOT_PROVIDED,
name: isSyncChannel($in) ? 'Zählerstand eingeben' : 'Kunden informieren',
recommended: true
);
stopProcessing($interaction);
}
// TODO:
// reading value validation can be adjusted and improved here
$latestReadingValue = max(array_map(fn($item) => $item->consumption, $meterReadingsData->meters[0]->meterReadings));
if ($in->value <= $latestReadingValue) {
$interaction->infos[] = new IntentInfo(
type: 'warning',
message: sprintf(
'Zählerstand ist kleiner als zuletzt übermittelter Zählerstand (%s, %s)',
Helpers::formatDate($latestExistingReadingDate),
$latestReadingValue
)
);
$interaction->options[] = new IntentOption(
type: READING_VALUE_INVALID,
name: isSyncChannel($in) ? 'Anderen Zählerstand eingeben' : 'Kunden informieren',
recommended: true
);
stopProcessing($interaction);
}
######### STEP: data validation passed. Add "save" action
$interaction->options[] = new IntentOption(
type: BASE_SAVE_READING,
name: 'Zählerstand speichern',
recommended: true
);
stopProcessing($interaction);
######### HELPER FUNCTIONS #########
function stopProcessing(Interaction $interaction): void
{
echo json_encode($interaction);
exit();
}
function isSyncChannel($in): bool
{
return in_array($in->channel, ['chat', 'phone']);
}
function getValueUsingOcrOnAttachments(stdClass $in, stdClass $meterReadingsData): ?float
{
// Last reading date & value can be provided to improve the OCR
$latestReadingValue = max(array_map(fn($item) => $item->consumption, $meterReadingsData->meters[0]->meterReadings));
$latestReadingDate = max(array_map(fn($item) => $item->readingDate, $meterReadingsData->meters[0]->meterReadings));
$ticket = ApiEnneo::getTicket($in->ticketId);
foreach ($ticket->attachments as $attachment) {
if ($attachment->size < 10000) {
// attachment to small (logo etc)
continue;
}
if (!in_array($attachment->fileEnding, ['jpg', 'jpeg', 'png', 'gif'])) {
// unsupported file extension
continue;
}
$response = ApiEnneo::post(
endpoint: '/api/cortex/ocrMeter',
body: [
'ticketId' => $in->ticketId,
'fileUrl' => $_ENV['ENNEO_API_URL'] . $attachment->url,
'readingDate' => $in->readingDate,
'lastReading' => $latestReadingValue,
'lastReadingDate' => $latestReadingDate,
]
);
if ($response->responses ?? $response->response) {
$body = $response->responses ?? $response->response;
// TODO: value2 can be used for HT/NT implementation
$value2 = (float)($body->meter_reading_2 ?? $body->meterReading2);
return (float)($body->meter_reading_1 ?? $body->meterReading1);
}
}
return null;
}
/**
* Generates a mocked response for meter data including meter details,
* reading details, and associated properties.
*/
function getMockedMeterDataResponse(): array
{
return [
'meters' => [
[
'meterNumber' => '000000000000123456',
'counterType' => 'ET',
'numberOfPreDecimalDigitsForMeter' => 6,
'numberOfPostDecimalDigitsForMeter' => 1,
'addMeterReadingPossible' => true,
'meterReadings' => [
[
'consumptionRelatedCounter' => 'ET',
'readingDate' => '2024-05-22',
'consumption' => 80048,
'consumptionUnit' => 'KILOWATT_HOURS',
'reason' => 'INITIAL_METER_COUNT'
],
[
'consumptionRelatedCounter' => 'ET',
'readingDate' => '2024-12-21',
'consumption' => 81246,
'consumptionUnit' => 'KILOWATT_HOURS',
'reason' => 'INTERMEDIATE_READING'
]
]
]
]
];
}