Der Zählerstand-Agent ist auf die effiziente Verarbeitung von übermittelten Zählerständen spezialisiert. Er erkennt, validiert und verarbeitet Zählerstandsmeldungen vollautomatisch und sorgt für eine reibungslose Integration in Abrechnungssysteme. Besonders innovativ: Der Agent analysiert E-Mail-Anhänge mit OCR-Technologie und extrahiert selbstständig Zählerstandsdaten aus übermittelten Bildern oder Dokumenten.

Kernfunktionen

Der Zählerstand-Agent übernimmt den gesamten Prozess der Zählerstandserfassung und bietet dabei folgende wesentliche Funktionen:

  • Intelligente Datenerkennung: Automatische Identifikation von Zählerständen und zugehörigen Zählernummern in Kundenanfragen
  • OCR-Bildanalyse: Erkennung und Extraktion von Zählerstandsdaten aus E-Mail-Anhängen wie Fotos oder gescannten Dokumenten
  • Plausibilitätsprüfung: Validierung der gemeldeten Stände im Vergleich zu historischen Werten und typischen Verbrauchsmustern
  • Systemintegration: Nahtlose Übertragung validierter Zählerstände in die Abrechnungssysteme
  • Kundenkommunikation: Automatisierte Bestätigung der erfassten Daten oder Rückfragen bei Unklarheiten

Prozessablauf

Der Agent arbeitet nach einem strukturierten Verfahren, das Genauigkeit und Effizienz kombiniert:

  1. Extraktion der Zählerinformationen aus der Kundenanfrage oder durch OCR-Analyse von Anhängen
  2. Abgleich mit Stammdaten zur Zuordnung zum richtigen Vertrag und Zähler
  3. Durchführung von Plausibilitätsprüfungen zur Sicherstellung realistischer Verbrauchswerte
  4. Übermittlung der validierten Daten an die Abrechnungssysteme
  5. Rückmeldung an den Kunden mit Bestätigung der erfolgreichen Verarbeitung

Die Kombination aus intelligenter Textanalyse und moderner OCR-Technologie macht den Zählerstand-Agenten besonders leistungsfähig. Er verarbeitet Zählerstände nicht nur aus strukturierten Eingaben, sondern erkennt diese auch in Bildern von Zählern, die Kunden als E-Mail-Anhänge übermitteln - ohne dass diese manuell erfasst werden müssen.

<?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'
                    ]
                ]
            ]
        ]
    ];
}