<?php
namespace Diplix\KMGBundle\Service;
use Diplix\Commons\DataHandlingBundle\Entity\SysLogEntry;
use Diplix\Commons\DataHandlingBundle\Repository\SysLogRepository;
use Diplix\KMGBundle\Entity\Accounting\CoopMember;
use Diplix\KMGBundle\Entity\Address;
use Diplix\KMGBundle\Entity\Availability;
use Diplix\KMGBundle\Entity\Order;
use Diplix\KMGBundle\Entity\OrderStatus;
use Diplix\KMGBundle\Entity\PriceList;
use Diplix\KMGBundle\Util\XmlCapable;
use Doctrine\DBAL\Driver\Connection;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityManagerInterface;
use DOMDocument;
use GuzzleHttp;
use SimpleXMLElement;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class TaMiConnector extends XmlCapable
{
const TAMI_OK = 1; // ErgebnisCode für OK
const TAMI_ERR = 2; // ErgebnisCode !=1 = nicht ok
public static $tamiStatusMap = [
0 => 'Offen',
1 => 'Vermittelt',
2 => 'Erfolgreich',
3 => 'Fehlfahrt',
4 => 'Storniert',
11 => 'Angebot'
];
const LogRequest = 101;
const LogResponse = 102;
const LogCallback = 103;
const TimestampFormat = "Y-m-d\TH:i:sP";
public static $connectionSettings = array(
'connect_timeout' => "5", // abort if no response after X seconds
'verify' => false, // we do not care if the cert is valid or not
);
/**
* @var GuzzleHttp\Client
*/
protected $client;
/**
* @var EntityManager
*/
protected $em;
/**
* @var Connection
*/
protected $db;
protected $statusCache = array();
protected $serverAddress = "http://localhost/KMG/Web/web/app_dev.php/kmg/server-mock/";
protected $clientID = "KMGONLINE";
protected $logPath;
protected $additionalSyslogging = true;
protected $trace = false;
protected $tamiEnabled = true;
public function enableTami($enableTami)
{
$this->tamiEnabled = $enableTami;
}
public function __construct(EntityManagerInterface $em)
{
parent::__construct();
$this->logPath = __DIR__ . "/../../../../app/logs/tami.log";
$this->em = $em;
$this->db = $this->em->getConnection();
// init http client (guzzle 6.x)
$this->client = new GuzzleHttp\Client($this::$connectionSettings);
// cache status entities
$rep = $this->em->getRepository(OrderStatus::class);
$all = $rep->findAll();
/** @var OrderStatus $a */
foreach ($all as $a)
{
$this->statusCache[$a->getTamiStatus()] = $a;
}
}
public function setServerAddress($url)
{
$this->serverAddress = $url;
}
public function enableAdditionalSyslogging($enable)
{
$this->additionalSyslogging = $enable;
}
protected function logData($dir=0,$data)
{
$msg = "[".date("Y-m-d H:i:s")."]".($dir == 0 ? ">>>\n" : ($dir == 1 ? "<<<\n" : "(trace)" ) );
$msg .= $data . "\n";
file_put_contents($this->logPath, $msg, FILE_APPEND);
}
protected function formatXml($simpleXml)
{
$xml = parent::formatXml($simpleXml);
if ($this->trace)
{
$this->logData(2,"formatXml()\n".$xml);
}
return $xml;
}
/**
* Used to remove the <?xml> tag since LIBXML_NOXMLDECL is not working properly
* @param $text
* @return string
*/
protected function stripFirstLine($text)
{
$stripped = substr( $text, strpos($text, "\n")+1 );
if ($this->trace)
{
$this->logData(2,"stripFirstLine()\n".$text);
}
return $stripped;
}
/**
* @param array|SimpleXMLElement $attributeArray
* @return mixed
* @throws \Exception
*/
protected function aid($attributeArray, $throwException=true)
{
if ($attributeArray instanceof SimpleXMLElement )
{
// no idea why ->attributes() is not working here. we use this workaround instead
$attributeArray = (array) $attributeArray;
$attributeArray = $attributeArray["@attributes"];
}
// The documentation differs from the live data, so we support both cases
if (array_key_exists("AuftragID",$attributeArray))
return $attributeArray["AuftragID"];
else
if (array_key_exists("AuftragId",$attributeArray))
return $attributeArray["AuftragId"];
else
{
if ($throwException) throw new \Exception("No AuftragId/AuftragID in node attributes !");
}
}
/**
* @param Order $order
* @return SimpleXMLElement
*/
protected function createOrderXml(Order $order)
{
$now = new \DateTime();
$xml = new SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><VermAuftrag/>',LIBXML_NOEMPTYTAG);
$xml->addAttribute("Sender",$this->clientID);
$fa = $xml->addChild("FahrAuftrag");
$fa->addAttribute("AuftragID",$order->getRemoteOrderId()); // created by remote server if empty, else handled as update
$fa->addAttribute("ZSt",$now->format($this::TimestampFormat));
$fa->addChild("Personen",$order->getPersonCount());
$fa->addChild("Zahlart",$order->getPaymentType()->getTamiCode());
// Preis soll nicht im Auftag erscheinen, nur als Info :: $fa->addChild("Preis",$order->getLastEstimatedPrice());
$best = $fa->addChild("Besteller");
// this name is used by tami. use customer company name instead of orderer's name
$customerNameForTami = $order->getCustomer()->getName();
if (
($order->getPriceList()!==null)
&&
($order->getPriceList()->getOverwriteCustomerNameForTamiWithThis()!==null)
&&
(strlen(trim($order->getPriceList()->getOverwriteCustomerNameForTamiWithThis()))>0)
)
{
$customerNameForTami = $order->getPriceList()->getOverwriteCustomerNameForTamiWithThis();
}
$best->addChild("Name", trim($customerNameForTami) );
$best->addChild("Telefon",$order->getOrdererPhone());
$comment = "";
if ($order->getAlreadyPaid())
{
$comment.="BEZAHLT;";
}
$comment.= "P: ".number_format($order->getLastEstimatedPrice(),2,',',".").";";
if ($order->getIsVip())
{
$comment.= "VIP;";
}
if ($order->getLastEstimatedDistance()>0)
{
$comment.= ceil($order->getLastEstimatedDistance())."KM;";
}
if ($order->getCostCenter()!="")
{
$comment.= sprintf("KoSt: %s;",$order->getCostCenter());
}
if ($order->getChildSeatCount()>0)
{
$comment.= sprintf("Kindersitze: %d (%s);",$order->getChildSeatCount(),$order->getChildSeatInfo());
}
/* do not submit flightno in comment anymore, moved to first address
if ($order->getFlightNo()!="")
{
$comment.= sprintf("FlugNr: %s;",$order->getFlightNo());
}
*/
if ($order->getCarType()!="")
{
$comment.= sprintf("Fhzg: %s;",$order->getCarType());
}
if ($order->getCreditCardNo()!="")
{
$comment .= sprintf("KK:%s,%s/%s;",$order->getCreditCardNo(),$order->getCreditCardMonth(),$order->getCreditCardYear());
}
$idx = 0;
/** @var Address $address */
foreach ($order->getAddressList() as $address)
{
if ($address->getBeDeleted()) continue; // maybe deleted already but still available in the current instance
$comment .= /*sprintf("Quick%d:",$idx+1) .*/ $address->getName();
if ($address->getPassenger()!="") $comment .= sprintf("(%s)",$address->getPassenger());
if ($address->getCreditCardNo()!="") $comment .= sprintf("(KK:%s,%s/%s)",$address->getCreditCardNo(),$address->getCreditCardMonth(),$address->getCreditCardYear());
if ($address->getMobileNumber()!="") $comment.= sprintf("(Tel:%s)",$address->getMobileNumber());
if ($idx < $order->getAddressList()->count()-1) $comment.=">";
/** @var SimpleXMLElement $wp */
$wp = $fa->addChild("Wegpunkt");
$einaus = ($idx < $order->getAddressList()->count()-1 ? "Einstieg" : "Ausstieg"); // only last waypoint is exit
$wp->addAttribute("Typ",$einaus);
if ($idx == 0) $wp->addChild("Zeit",$order->getOrderTime()->format($this::TimestampFormat)); // Timestamp only relevant for first address
$a = $wp->addChild("Adresse");
$a->addChild("Quick",htmlspecialchars($address->getName()));
$a->addChild("Ort",$address->getCity());
$a->addChild("PLZ",$address->getZipCode());
$street = $address->getStreet();
// insert flight-no for first address if available
if (($idx==0)&&($order->getFlightNo()!=""))
{
$street = sprintf("%s; %s",$order->getFlightNo(),$street);
}
$a->addChild("Strasse",$street);
$a->addChild("HNr",$address->getNumber());
$idx++;
if ($this->trace)
{
$this->logData(2,"createOrderXml():address".($idx-1).": ".$address->getName()."\n" );
}
}
$opt = $fa->addChild("Optionen" , implode(' ', $order->getCustomer()->getTamiOptions()));
$comment .= ";;".$order->getComment();
$comment = str_replace(";",",",$comment);
$comment = str_replace("|",",",$comment);
$fa->addChild("Information", htmlspecialchars($comment));
if ($this->trace)
{
$this->logData(2,"createOrderXml()\n".$xml->asXML());
}
return $xml;
}
protected function processOrderConfirmation($responseText, Order $order)
{
$root = simplexml_load_string($responseText);
/** @var SimpleXMLElement $fab */
$faba = $root->{"FahrAuftragBestaetigen"}->attributes();
$resultCode = $faba["Ergebniscode"];
$resultMsg = $faba["Ergebnistext"];
if ($resultCode==1)
{
$order->setRemoteOrderId( $this->aid($faba));
$order->setRemoteOrderSubmittedTime( new \DateTime() );
$order->setRemoteStatus(Order::REMOTE_SUCCESS);
}
else
{
$order->setRemoteStatus(Order::REMOTE_ERROR);
}
$order->setRemoteResult($resultCode);
$order->setRemoteResultText($resultMsg);
if ($this->additionalSyslogging) SysLogRepository::logEntityOperation($this->db,self::LogResponse,"Order",$order->getId(),[],
sprintf("TaMi:FahrAuftragBestaetigen:%d:%s:%s",$resultCode,$resultMsg,$this->aid($faba,false)));
return ($order->getRemoteStatus()==Order::REMOTE_SUCCESS);
}
/**
* @param SimpleXMLElement $fasNode
* @return true|string true on success, else error message as string
*/
protected function processStatusUpdateCallbackNode($fasNode)
{
$oid = $this->aid($fasNode);
$status = $fasNode->{"AuftragStatus"};
$rep = $this->em->getRepository(Order::class);
/** @var Order $order */
$order = $rep->findOneBy(array("remoteOrderId"=>$oid));
if (!is_object($order))
{
return "Unbekannte AuftragID";
}
$order->setOrderStatus($this->statusCache[(int)$status]);
return true;
}
/**
* @param $results array( AuftragID=>(true|string)
* @return SimpleXMLElement
*/
protected function createStatusUpdateCallbackReponseXml($results)
{
$now = new \DateTime();
$xml = new SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><VermAuftrag/>');
$xml->addAttribute("Sender",$this->clientID);
foreach ($results as $oid=>$status)
{
$fa = $xml->addChild("FahrAuftragStatusBestaetigen");
$fa->addAttribute("AuftragID",$oid);
$fa->addAttribute("ZSt",$now->format($this::TimestampFormat));
$fa->addAttribute("Ergebniscode", ($status===true? self::TAMI_OK : self::TAMI_ERR));
$fa->addAttribute("Ergebnistext", ($status!==true? $status : "OK"));
}
return $xml;
}
protected function VermAuftrag($xml)
{
$body = $this->stripFirstLine( $this->formatXml( $xml ) );
$this->logData(0,$body);
$res = $this->client->post($this->serverAddress . "VermAuftrag" ,array(
'headers' =>['Content-Type' => 'text/xml' ],
"body" =>$body));
$resultXml = $res->getBody()->getContents();
$this->logData(1,$resultXml);
if ($res->getStatusCode() != 200) // actually on error status codes, guzzle itsself throws an exception beforehand
{
throw new \Exception(sprintf("HTTP %d: %s",$res->getStatusCode(),$res->getReasonPhrase()));
}
return $resultXml;
}
/**
* Create or update an order on remote TaMi Server
* TODO: directly link pricelist via entity, remove separate parameter
* @param Order $order
* @return bool
*/
public function submitOrder(Order $order)
{
if ($this->additionalSyslogging) SysLogRepository::logEntityOperation($this->db,self::LogRequest,"Order",$order->getId(),[],sprintf("TaMi:FahrAuftrag"));
try
{
$resultXml = $this->VermAuftrag( $this->createOrderXml($order) );
$ok = $this->processOrderConfirmation($resultXml,$order);
$this->em->flush($order);
return $ok;
}
catch (\Exception $ex)
{
$order->setRemoteStatus(Order::REMOTE_ERROR);
$xtra = "";
if ($ex instanceof GuzzleHttp\Exception\RequestException)
{
$r = $ex->getResponse();
if (is_object($r)) $xtra = $r->getBody();
}
else
{
$xtra = $ex->getTraceAsString();
}
SysLogRepository::logEntityOperation($this->db,self::LogResponse,"Order",$order->getId(),[],
sprintf("TaMi:EXCEPTION:%s:%s",$ex->getMessage(),$xtra));
}
return false;
}
/**
* Cancel an open order on remote TaMi Server
* @param Order $order
* @return bool
*/
public function cancelOrder(Order $order)
{
if ($this->additionalSyslogging) SysLogRepository::logEntityOperation($this->db,self::LogRequest,"Order",$order->getId(),[],sprintf("TaMi:FahrauftragStorno"));
try
{
$resultXml = $this->VermAuftrag( $this->createStornoXml($order) );
$ok = $this->processStornoConfirmation($resultXml,$order);
$this->em->flush($order);
return $ok;
}
catch (\Exception $ex)
{
$order->setRemoteStatus(Order::REMOTE_ERROR);
$xtra = "";
if ($ex instanceof GuzzleHttp\Exception\RequestException)
{
$r = $ex->getResponse();
if (is_object($r)) $xtra = $r->getBody();
}
SysLogRepository::logEntityOperation($this->db,self::LogResponse,"Order",$order->getId(),[],
sprintf("TaMi:EXCEPTION:%s:%s",$ex->getMessage(),$xtra));
}
return false;
}
/**
* React to a status callback coming from the remote TaMi Server
* @param Request $request
* @return Response
*/
public function fahrAuftragStatusCallbackAction(Request $request)
{
if (!$this->tamiEnabled)
{
SysLogRepository::logEntityOperation($this->db,self::LogCallback,'',0,[],
'Tami:fahrAuftragStatusCallback: Tami interface disabled.');
return new Response('Tami interface is disabled.',400);
}
// TODO: IP-Whitelisting to prevent malicious updates
try
{
$data = $request->getContent();
SysLogRepository::logEntityOperation($this->db,self::LogCallback,'',0,[],
sprintf("TaMi:fahrAuftragStatusCallback %s",$data));
$this->logData(1,$data);
$root = simplexml_load_string( $data );
$results = array();
foreach ($root->{"VermAuftrag"}->{"FahrAuftragStatus"} as $fas)
{
$oid = $this->aid($fas);
$results [$oid] = $this->processStatusUpdateCallbackNode($fas);
}
$this->em->flush();
$responseText = $this->createStatusUpdateCallbackReponseXml($results)->asXML();
$this->logData(0,$responseText);
return new Response($responseText, 200, ['Content-Type' => 'text/xml'] );
}
catch (\Exception $ex)
{
SysLogRepository::logEntityOperation($this->db,500,"",0,[],
sprintf("TaMi:fahrAuftragStatusCallback:EXCEPTION:%s",$ex->getMessage()));
$r = new Response($ex->getMessage());
$r->setStatusCode(400,$ex->getMessage());
return $r;
}
}
protected function updateAvailabilityFromOrder(array $one,$knownOrder)
{
$rep = $this->em->getRepository(Availability::class);
$mrep = $this->em->getRepository(CoopMember::class);
$member = $mrep->findOneBy(['number'=>$one['carId']]);
$av = $rep->findOneBy(['createdFromRemoteOrderId'=>$one['orderId']]);
if ($av===null)
{
$av = new Availability();
$av->setAvailType( $knownOrder ? Availability::KMG : Availability::BASF );
$av->setCreatedFromRemoteOrderId($one['orderId']);
}
$av->setAvailFrom( new \DateTime($one['time']) );
$until = clone($av->getAvailFrom());
$until->add(new \DateInterval('PT1H'));
$av->setAvailUntil( $until );
$av->setMember($member);
$statusText = array_key_exists($one['tamiStatus'],self::$tamiStatusMap) ? self::$tamiStatusMap[$one['tamiStatus']] : $one['tamiStatus'];
$av->setExtraInfo(sprintf('#%s: %s',$one['orderId'],$statusText));
$rep->persistFlush($av);
}
public function updateCallbackAction(Request $request)
{
if (!$this->tamiEnabled)
{
SysLogRepository::logEntityOperation($this->db,self::LogCallback,'',0,[],
'Tami:updateCallback: Tami interface is disabled');
return new JsonResponse(['success' =>false, 'message'=>'Tami interface is disabled']);
}
try
{
$xml = $request->getContent();
$rep = $this->em->getRepository(Order::class);
$this->logData(1,$xml);
$root = simplexml_load_string( $xml );
$data = [];
foreach ($root->{"JobList"}->{"Job"} as $job)
{
$data []= [
'orderId' => $this->aid($job),
'carId' => (int)$job->{"FhzId"},
'driverId' => (int)$job->{"FhrId"},
'tamiStatus' => (int)$job->{"Status"},
'time' => (string)$job->{"ZeitFahrt"}
] ;
}
SysLogRepository::logEntityOperation($this->db,self::LogCallback,'',0,$data,
sprintf("TaMi:Callback %s",$request->getMethod()));
foreach ($data as $one)
{
/** @var Order $order */
$order = $rep->findOneBy(array("remoteOrderId"=>$one['orderId']));
if ($order!==null)
{
$order->setOrderStatus($this->statusCache[$one['tamiStatus']]);
$rep->flush($order);
$this->updateAvailabilityFromOrder($one,true);
}
else
{
$this->updateAvailabilityFromOrder($one,false);
}
}
return new JsonResponse(['success' =>true, 'data'=>$data]);
}
catch (\Exception $ex)
{
SysLogRepository::logEntityOperation($this->db,500,"",0,[],
sprintf("TaMi:Callback:EXCEPTION:%s",$ex->getMessage()));
return new JsonResponse(['success' =>false, 'message'=>$ex->getMessage()]);
}
}
protected function createStornoXml(Order $order)
{
$now = new \DateTime();
$xml = new SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><VermAuftrag/>',LIBXML_NOEMPTYTAG);
$xml->addAttribute("Sender",$this->clientID);
$fa = $xml->addChild("FahrauftragStorno");
$fa->addAttribute("AuftragID",$order->getRemoteOrderId()); // created by remote server if empty, else handled as update
$fa->addAttribute("ZSt",$now->format($this::TimestampFormat));
return $xml;
}
protected function processStornoConfirmation($responseText, Order $order)
{
$root = simplexml_load_string($responseText);
/** @var SimpleXMLElement $fab */
$faba = $root->{"FahrauftragStornoBestaetigen"}->attributes();
$resultCode = $faba["Ergebniscode"];
$resultMsg = $faba["Ergebnistext"];
$auftragId = $this->aid($faba);
if ($resultCode==1)
{
// OrderStatus nicht mehr von Tami :: $order->setOrderStatus( $this->em->getReference("Diplix\\KMGBundle\\Entity\\OrderStatus",OrderStatus::STATUS_CANCELED) );
$order->setRemoteStatus(Order::REMOTE_SUCCESS);
}
else
{
$order->setRemoteStatus(Order::REMOTE_ERROR);
}
$order->setRemoteResult($resultCode);
$order->setRemoteResultText($resultMsg);
if ($this->additionalSyslogging) SysLogRepository::logEntityOperation($this->db,self::LogResponse,"Order",$order->getId(),[],
sprintf("TaMi:FahrauftragStornoBestaetigen:%d:%s:%s",$resultCode,$resultMsg,$this->aid($faba,false)));
return ($order->getRemoteStatus()==Order::REMOTE_SUCCESS);
}
/**
* @param Order $order
* @return bool
*/
public function getOrderStatus(Order $order)
{
if ($this->additionalSyslogging) SysLogRepository::logEntityOperation($this->db,self::LogRequest,"Order",$order->getId(),[],sprintf("TaMi:FahrauftragStatus>"));
try
{
$resultXml = $this->VermAuftrag( $this->createStatusXml($order) );
$ok = $this->processStatusResponse($resultXml,$order);
$this->em->flush($order);
return $ok;
}
catch (\Exception $ex)
{
$order->setRemoteStatus(Order::REMOTE_ERROR);
$xtra = "";
if ($ex instanceof GuzzleHttp\Exception\RequestException)
{
$r = $ex->getResponse();
if (is_object($r)) $xtra = $r->getBody();
}
SysLogRepository::logEntityOperation($this->db,self::LogResponse,"Order",$order->getId(),[],
sprintf("TaMi:EXCEPTION:%s:%s",$ex->getMessage(),$xtra));
}
return false;
}
protected function createStatusXml(Order $order)
{
$now = new \DateTime();
$xml = new SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><VermAuftrag/>',LIBXML_NOEMPTYTAG);
$xml->addAttribute("Sender",$this->clientID);
$fa = $xml->addChild("FahrauftragStatus");
$fa->addAttribute("AuftragID",$order->getRemoteOrderId()); // created by remote server if empty, else handled as update
$fa->addAttribute("ZSt",$now->format($this::TimestampFormat));
return $xml;
}
protected function processStatusResponse($responseText, Order $order)
{
$root = simplexml_load_string($responseText);
/** @var SimpleXMLElement $fab */
$faba = $root->{"FahrauftragStatusBestaetigen"}->attributes();
$resultCode = $faba["Ergebniscode"];
$resultMsg = $faba["Ergebnistext"];
$auftragId = $this->aid($faba);
if ($this->additionalSyslogging) SysLogRepository::logEntityOperation($this->db,self::LogResponse,"Order",$order->getId(),[],
sprintf("TaMi:FahrauftragStatusBestaetigen:%d:%s:%s",$resultCode,$resultMsg,$this->aid($faba,false)));
if ($resultCode==1)
{
$status = (int)$root->{"FahrauftragStatusBestaetigen"}->{'AuftragStatus'};
if (array_key_exists($status,$this->statusCache))
{
$order->setOrderStatus( $this->statusCache[$status] );
}
else
{
if ($this->additionalSyslogging) SysLogRepository::logEntityOperation($this->db,self::LogResponse,"Order",$order->getId(),[],
sprintf("TaMi:FahrauftragStatusBestaetigen:Unbekannter Status Code:%d",$status));
return false;
}
}
else
{
// ignore on error
}
// being unable to update the status should not set the whole order to be in REMOTE_ERROR state
// nor change the result of the last active operation
// that's why we dont update the entities remote status fields
return ($resultCode == 1);
}
}