<?php
namespace App\Service;
use App\Entity\VoyageInterface;
use App\Exception\LMDVException;
use App\Exception\WebServiceException;
use App\Service\Payment\PaymentEventInterface;
use App\Service\Process\ProcessInterface;
use App\ValueObject\Flight;
use App\ValueObject\Money;
use App\ValueObject\PriceDetail;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Lock\LockFactory;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class WebServiceLMDV implements WebServiceLMDVInterface {
public const TYPE_OPP_PARENT = 0;
public const TYPE_OPP_FILLE = 1;
public const TIMEOUT_LOCK_CREATEBOOKING = 60;
/**
* @var \Symfony\Component\HttpFoundation\Request
*/
protected $request;
/**
* @var \App\Service\SalesForceConnectorInterface
*/
protected $sf;
/**
* @var \Psr\Log\LoggerInterface
*/
protected $logger;
/**
* @var \Symfony\Contracts\Translation\TranslatorInterface
*/
protected $translator;
/**
* @var \Symfony\Component\Lock\LockFactory
*/
protected $lockFactory;
/**
* Constructor with dependency injection.
*
* @param RequestStack $request_stack
* @param HttpClientInterface $client
* @param LoggerInterface $logger
* @param array $config
*/
public function __construct(RequestStack $request_stack, SalesForceConnectorInterface $sf, LoggerInterface $logger, TranslatorInterface $translator, LockFactory $lockFactory)
{
$this->request = $request_stack->getCurrentRequest();
$this->sf = $sf;
$this->logger = $logger;
$this->translator = $translator;
$this->lockFactory = $lockFactory;
}
/**
* Gets the binary PDF content from a WS APEX call.
*
* @param VoyageInterface $voyage
* @return array $result
* An array avec two keys "type" (content or url) and "content" with the binary content or the url, If "content" is empty, it means there is no PDF.
*/
public function getPdfVoyage(VoyageInterface $voyage) {
$data = $this->sf->getPdfVoyage($voyage);
return $data;
}
/**
* Gets the codeAgent calling a WS APEX that will create it if it does not exist.
*
* This is used in GIRDistrib.
*
* @param VoyageInterface $voyage
* @return string
*/
public function getCodeAgent(VoyageInterface $voyage) {
$data = $this->sf->getCodeAgent($voyage);
$agentCode = $data['agentCode'];
return $agentCode;
}
/**
* Returns a Money object
*
* @param VoyageInterface $voyage
* @param string $no_error
* If it is TRUE, we don't bubble the exception and instead we will return NULL.
* @return \App\ValueObject\Money
*/
public function getTotalAmount(VoyageInterface $voyage, $no_error = FALSE) : ?Money {
// This condition is to avoid calling getQA if we are not yet in etape1
if(! $voyage->getEtape1()) {
return NULL;
}
// If we don't fill up correctly etape1 and we go back to etape0, when arriving
// a second time to etape1, we will not return NULL because getEtape1()
// will be (partially) filled up, so we must use a try/catch here to avoid triggering an
// error in case of problems (for example, if assurances had not yet been selected)
try {
$data = $this->sf->getQA($voyage);
}
catch(\Exception $e) {
// getBoxRecap uses $no_error = TRUE to avoid bubbling.
if($no_error) {
return NULL;
}
else {
throw $e;
}
}
$money = new Money(
$data['prices']['totalAmount'],
$data['prices']['Currency'],
$data['prices']['Decimals']
);
return $money;
}
/**
* Returns a Money object
*
* @param VoyageInterface $voyage
* @return \App\ValueObject\Money
*/
public function getTotalCommission(VoyageInterface $voyage) : ?Money {
try {
$data = $this->sf->getQA($voyage);
}
catch(\Exception $e) {
throw $e;
}
$money = new Money(
$data['prices']['Commission'] ?? 0,
$data['prices']['Currency'],
$data['prices']['Decimals']
);
return $money;
}
/**
* Returns a Money object after having substracted the assurances costs.
*
* @param VoyageInterface $voyage
* @return \App\ValueObject\Money
*/
public function getTotalAmountHorsAssurances(VoyageInterface $voyage) : Money {
$data = $this->sf->getQA($voyage);
$details = $data['prices']['DetailPrices'];
$costs = new Money(0, $data['prices']['Currency'], $data['prices']['Decimals']);
foreach($details as $d) {
if(is_array(@$d['codes'])) {
foreach($d['codes'] as $c) {
if($c['name'] == 'Formule') {
if(strlen($c['value']) == 3 && $c['value'][0] == 'A' && $c['value'][1] == 'S' && \is_numeric($c['value'][2])) {
$aux = new Money($d['unitPrice'], $data['prices']['Currency'], $data['prices']['Decimals']);
$aux = $aux->multiply($d['quantity']);
$costs = $costs->add($aux);
}
}
}
}
}
$total = $this->getTotalAmount($voyage);
$result = $total->substract($costs);
return $result;
}
/**
* Returns a Money object.
*
* @param VoyageInterface $voyage
* @return \App\ValueObject\Money
*/
public function getPayment(VoyageInterface $voyage) : Money {
$data = $this->sf->getQA($voyage);
$money = new Money(
$data['prices']['Payment']['Amount'],
$data['prices']['Payment']['Currency'],
$data['prices']['Payment']['Decimals']
);
return $money;
}
/**
* Returns an array of PriceDetail objects
*
* @param VoyageInterface $voyage
* @return \App\ValueObject\PriceDetail[]
*/
public function getPriceDetails(VoyageInterface $voyage) {
$data = $this->sf->getQA($voyage);
$decimals = $data['prices']['Decimals'];
$details = [];
foreach($data['prices']['DetailPrices'] as $d) {
$priceDetail = new PriceDetail(
$d['CommissionPercentage'] > 0 ? $d['title'] . " (" . $d['CommissionPercentage'] . "%)" : $d['title'],
$d['quantity'],
new Money($d['unitPrice'], $data['prices']['Currency'], $decimals),
new Money($d['subTotalPrice'], $data['prices']['Currency'], $decimals)
);
$details[] = $priceDetail;
}
return $details;
}
public function getMiscellaneous(VoyageInterface $voyage) {
$data = $this->sf->getInfo($voyage);
$miscellaneous = [];
if(is_array(@$data['miscellaneous'])) {
foreach($data['miscellaneous'] as $m) {
foreach($m['segments'] as $s) {
foreach($s['descriptions'] as $d) {
$visa_name = $d['text'];
}
$segment_code = $s['code']['value'];
$group = $s['group'];
}
$miscellaneous_code = $m['codes'][0]['value'];
$miscellaneous[$group][] = [$visa_name => $visa_name . '#' . $segment_code . '#' . $miscellaneous_code . '#' . $group];
}
}
return $miscellaneous;
}
public function getAssurances(VoyageInterface $voyage) {
$data = $this->sf->getInfo($voyage);
$assurances = [];
if(is_array(@$data['insurances'])) {
foreach($data['insurances'] as $m) {
foreach($m['segments'] as $s) {
foreach($s['descriptions'] as $d) {
$assurance_name = $d['text'];
}
$segment_code = $s['code']['value'];
$group = $s['group'];
}
$miscellaneous_code = $m['codes'][0]['value'];
$assurances[$group][] = [$assurance_name => $assurance_name . '#' . $segment_code . '#' . $miscellaneous_code . '#' . $group];
}
}
return $assurances;
}
public function getAssuranceDescription($choice, $key, $value) {
list($assurance_name, $segment_code, $miscellaneous_code, $group) = explode('#', $value);
$debug = '';
if(in_array($_ENV['APP_ENV'], ['dev', 'test'])) {
$debug = ' ('. $segment_code .') ';
}
$text = 'etape1.assurances.desc.' . $segment_code;
$textTranslated = $this->translator->trans($text);
// Translation has not been found, better do not show anything
if($textTranslated == $text) {
$textTranslated = '';
}
return $key . $debug . $textTranslated;
}
public function getAssuranceGroupLabel($group, $assurance_choices) {
$paragraphs = [];
foreach($assurance_choices as $arr) {
foreach($arr as $value) {
list($assurance_name, $segment_code, $miscellaneous_code, $g) = explode('#', $value);
// When showing the assurances label (etape1.assurances.label.GROUP),
// we assume grp = 2 => "assistance" and grp = 3 => "annulation" but this is not
// always the case, so we are forced to search by substrings inside "assurance_name"
// TODO : The APEX WS should always send "assistances" with group 2 and "assurances" with group 3
if($group != 0) {
if(strstr($assurance_name, 'ANNU')) {
if($group != 3) {
$this->logger->error("Assurances group mismatch, expected '3' (ANNU) received '$group'");
$group = 3;
}
}
if(strstr($assurance_name, 'ASSIS')) {
if($group != 2) {
$this->logger->error("Assurances group mismatch, expected '2' (ASSIS) received '$group'");
$group = 2;
}
}
}
$text = 'etape1.assurances.desc.' . $segment_code;
$textTranslated = $this->translator->trans($text);
if($textTranslated == $text) {
$textTranslated = '';
}
elseif(strstr($textTranslated, '<small></small>')) {
$textTranslated = '';
}
else {
$paragraphs[] = $textTranslated;
}
}
}
$text = "etape1.assurances.label.GROUP". (int)$group;
$textTranslated = $this->translator->trans($text);
$output = '<h2>'.$textTranslated.'</h2>';
if(count($paragraphs) > 1) {
$output .= $this->translator->trans('etape1.assurances.label.multiples', ['total' => count($paragraphs)]);
$output .= '<ul>';
foreach($paragraphs as $p) {
$output .= '<li>' . $p . '</li>';
}
$output .= '</ul>';
}
else {
foreach($paragraphs as $p) {
$output .= '<p>' . $p . '</p>';
}
}
return $output;
}
public function getPrestations(VoyageInterface $voyage) {
$data = $this->sf->getInfo($voyage);
$prestations = [];
if(is_array(@$data['optional'])) {
foreach($data['optional'] as $o) {
foreach($o['segments'] as $s) {
foreach($s['descriptions'] as $d) {
$prestations_name = $d['text'];
}
$segment_code = $s['code']['value'];
}
$optional_code = $o['codes'][0]['value'];
$prestations[] = [$prestations_name => $prestations_name . '#' . $segment_code . '#' . $optional_code];
}
}
return $prestations;
}
/**
* Returns an array of values like this :
*
* ```php
* [
* 'code' => ''
* 'segmentCode' => ''
* 'category' => ''
* 'persons' => [
* 'max' => '',
* 'min' => ''
* ],
* 'adults' => [
* 'max' => '',
* 'min' => ''
* ],
* 'children' => [
* 'max' => '',
* 'min' => ''
* ]
* ]
* ```
*
* @todo: This should be converted to an ValueObject object.
*
* @param VoyageInterface $voyage
* @throws WebServiceException
* @return array
*/
public function getChambresInformation(VoyageInterface $voyage) {
$data = $this->sf->getInfo($voyage);
$results = [];
foreach($data['stays'] as $s) {
$result = [];
$room = $s['rooms'][0];
foreach($room['occupancies'] as $o) {
if(isset($o['max']) && $o['max'] == 99) {
$o['max'] = 3;
}
if($o['unit'] == 'Person') {
$result['persons']['max'] = isset($o['max']) ? $o['max'] : 3;
$result['persons']['min'] = isset($o['min']) ? $o['min'] : 0;
}
if($o['unit'] == 'Adult') {
$result['adults']['max'] = isset($o['max']) ? $o['max'] : 3;
$result['adults']['min'] = isset($o['min']) ? $o['min'] : 0;
}
if($o['unit'] == 'Child') {
$result['children']['max'] = isset($o['max']) ? $o['max'] : 3;
$result['children']['min'] = isset($o['min']) ? $o['min'] : 0;
}
}
$result['category'] = $room['descriptions'][0]['text'];
$result['code'] = $room['code']['value'];
$segmentCode = '';
foreach($s['codes'] as $c) {
if($c['name'] == 'INTID') {
$segmentCode = $c['value'];
}
}
$result['segmentCode'] = $segmentCode;
// Add element to the final array
$results[] = $result;
}
// Filtering : do not show rooms that have "min persons" > "total persons"
foreach($results as $key => $result) {
if($result['persons']['min'] > ($voyage->getEtape0()->getNbrAdultes() + $voyage->getEtape0()->getNbrEnfants())) {
unset($results[$key]);
}
}
return $results;
}
/**
* Gets the travel "title" from getInfo()
*
* @param VoyageInterface $voyage
* @return string
*/
public function getTitleVoyage(VoyageInterface $voyage) {
$data = $this->sf->getInfo($voyage);
$title = $data['title'];
return $title;
}
/**
* Gets the travel nights from getInfo()
*
* @param VoyageInterface $voyage
* @return int
* The number of nights
*/
public function getRoomNights(VoyageInterface $voyage) {
$data = $this->sf->getInfo($voyage);
foreach($data['durations'] as $d) {
if($d['Unit'] == 'Night') {
return $d['Value'];
}
}
}
/**
* Gets the travel days from getInfo()
*
* @param VoyageInterface $voyage
* @return int
* The number of days
*/
public function getRoomDays(VoyageInterface $voyage) {
$data = $this->sf->getInfo($voyage);
foreach($data['durations'] as $d) {
if($d['Unit'] == 'Day') {
return $d['Value'];
}
}
}
/**
* Gets a list of Flight objects from getInfo()
*
* @param VoyageInterface $voyage
* @param string $type
* @return \App\ValueObject\Flight[] flights
*/
protected function getVols(VoyageInterface $voyage, $type) {
$data = $this->sf->getInfo($voyage);
$vols = [];
foreach($data['structuredFlight']['list'] as $l) {
foreach($l['list'] as $l2) {
if($l2['direction'] == $type) {
foreach($l2['segments'] as $s) {
$flight = new Flight(
$s['flight']['number'], // $flightNumber
$s['operatedBy']['code'], // $flightCode
$s['operatedBy'], // $operatedBy
isset($s['carrier']) ? $s['carrier'] : ($s['operatedBy']['text'] ? $s['operatedBy'] : ''),
$s['from']['city'],
$s['to']['city'],
strtotime($s['begin']['value']),
strtotime($s['end']['value']),
$l2['descriptions'][0]['text'],
strtotime($s['end']['value']) - strtotime($s['begin']['value'])
);
$vols[] = $flight;
}
}
}
}
usort($vols, function(Flight $a, Flight $b) {
return ($a->getBeginDate() <= $b->getBeginDate()) ? -1 : 1;
});
return $vols;
}
/**
* Gets information about the departure flight from getInfo()
*
* @param VoyageInterface $voyage
* @return \App\ValueObject\Flight $flight
*/
public function getVolAller(VoyageInterface $voyage) {
$vols_aller = [];
try {
$vols_aller = $this->getVols($voyage, 'Outbound');
}
catch(\Exception $e) {
// This is not blocking but we save the parsing error to the log
$this->logger->error("getVols error", ['message' => $e->getMessage(), 'backtrace' => $e->getTrace()]);
}
return $vols_aller;
}
/**
* Gets information about the return flight from getInfo()
*
* @param VoyageInterface $voyage
* @return \App\ValueObject\Flight $flight
*/
public function getVolRetour(VoyageInterface $voyage) {
$vols_retour = [];
try {
$vols_retour = $this->getVols($voyage, 'Inbound');
}
catch(\Exception $e) {
// This is not blocking but we save the parsing error to the log
$this->logger->error("getVols error", ['message' => $e->getMessage(), 'backtrace' => $e->getTrace()]);
}
return $vols_retour;
}
/**
* Sets some $session variables to be used by TWIG extensions functions in order to
* show the banner box that contains the summary of the travel.
*
* @param VoyageInterface $voyage
*/
public function getBoxRecap(VoyageInterface $voyage) {
$price = '';
$title = '';
$roomNights = '';
$roomDays = '';
// totalAmount: this can only be called when we are sure that getQA works, from step2 onwards only,
// because even with a try/catch block inside getTotalAmount, LMDV gets an error message from SalesForce.
$routeName = $this->request->get('_route');
if(strstr($routeName, 'step0') || strstr($routeName, 'step1')) {
$price = NULL;
}
else {
$price = $this->getTotalAmount($voyage, TRUE);
}
if(! $price) {
$this->logger->error("getBoxRecap error", ['message' => "We didn't call getQA() because we are not yet ready.", 'backtrace' => []]);
}
// titleVoyage
try {
$title = $this->getTitleVoyage($voyage);
}
catch(\Exception $e) {
$this->logger->error("getBoxRecap error", ['message' => $e->getMessage(), 'backtrace' => $e->getTrace()]);
}
// roomNights
try {
$roomNights = $this->getRoomNights($voyage);
}
catch(\Exception $e) {
$this->logger->error("getBoxRecap error", ['message' => $e->getMessage(), 'backtrace' => $e->getTrace()]);
}
// roomDays
try {
$roomDays = $this->getRoomDays($voyage);
}
catch(\Exception $e) {
$this->logger->error("getBoxRecap error", ['message' => $e->getMessage(), 'backtrace' => $e->getTrace()]);
}
// Save values into session to use them in TwigExtension class.
$this->request->getSession()->set('TWIG_totalPrice', $price);
$this->request->getSession()->set('TWIG_title', $title);
$this->request->getSession()->set('TWIG_roomNights', $roomNights);
$this->request->getSession()->set('TWIG_roomDays', $roomDays);
}
/**
* creates the booking in SAPEIG.
*
* To avoid calling multiple times createBooking when using CTRL-R, we use an exclusive lock to be sure that only one request will be sent.
* We do not use exclusive locks for other WS requests because they are fast, but createBooking is extremely slow (15s - 30s).
*
* The lock is a blocking one, because if the process that acquired the lock finishes,
* the response will be cached and the second process will received a cached response.
*
* @param VoyageInterface $voyage
* @return array
*/
public function createBooking(VoyageInterface $voyage) {
$key = $voyage->getSessionId();
$lockW = $this->lockFactory->createLock($key, self::TIMEOUT_LOCK_CREATEBOOKING);
if($lockW->acquire(TRUE)) {
try {
$content = $this->sf->createBooking($voyage);
}
catch(\Exception $e) {
$lockW->release();
throw $e;
}
$lockW->release();
return $content;
}
else {
// This is a blocking lock so we should never arrive here !.
throw new WebServiceException('Exclusive lock failed to acquire without waiting', LMDVException::ERROR_TIMEOUT_CREATEBOOKING);
}
}
/**
* Adds a payment by HiPay to SAPEIG
*
* @param PaymentEventInterface $event
* @return array
*/
public function addPayment(PaymentEventInterface $event) {
return $this->sf->addPayment($event);
}
/**
* Auxiliary function that answer the question "is this IdOPP the child of another one" ?
*
* @param string $id_opp
* The opportunity ID
* @return boolean
* returns TRUE if there is an opportunity parent, FALSE otherwise
*/
public function isOpportunityFille($id_opp) {
// We just need to know if this opp has a parent, so no validation is needed.
$result = $this->sf->getSalesForceData($id_opp, TRUE);
if($result['type'] == 'Groupe_Fille' && empty($result['idOppSFParent'])) {
throw new \Exception($this->translator->trans("error.opportunity.fille.no_parent"));
}
if(! empty($result['idOppSFParent'])) {
return TRUE;
}
else {
return FALSE;
}
}
/**
* If $id_opp is of type FILLE, we must retrieve the FILLE data + the PARENT businessCode
* which must be used for calling getInfo/getQA/createBooking
*
* If we get a FILLE opp but we discover that there is no PARENT, then we must switch to the
* tunnel as if they were not a FILLE, but a PARENT (GIRDirect)
*
* The $voyage object member "idOppSF" holds the opp. used to prefill up fields, it can be a FILLE
* opp (if there is a parent one which will then be saved in "idOppSFParent") or it can be an opp without
* any PARENT. Thus, in order to discover what type of opp is used in "idOppSF", we have to consider the tunnel
* we are in and if there is something in "idOppSFParent". The type of opp. received is not saved in
* the $voyage object (TODO: This is confusing and maybe we should better structure it, because as we
* have a member "idOppSFParent" we might tend to think that "idOppSF" is always a FILLE, which is not the case.)
*
* Only for GRPFactIndivWeb, we must empty "idOppSF" opp and save the incoming opp as "idOppSFParent",
* which will be sent in createBooking in order to create the FILLE opp and to attach it to the PARENT.
*
* @param string $id_opp
* The id_opp we received as a parameter
* @param string $type
* The id_opp can be of type FILLE or PARENT
* @param bool $is_grp
* TRUE if we are asking for a GRP opp (GRP*) or not (GIR*)
*/
public function getSalesForceDataIntoSession($id_opp, $type, $is_grp) {
$result = $this->sf->getSalesForceData($id_opp);
$session = $this->request->getSession();
if($type == self::TYPE_OPP_PARENT) {
$session->set('businessCode', $result['businessCode']);
}
elseif($type == self::TYPE_OPP_FILLE) {
$session->set('businessCode', $result['businessCodeParent']);
$session->set('idOppSFParent', $result['idOppSFParent']);
}
else {
throw new \Exception($this->translator->trans("error.opportunity.type_unknown"));
}
// Raise an error if we are GRP* and we don't have businessCode
if($is_grp && empty($session->get('businessCode'))) {
throw new \Exception($this->translator->trans("error.opportunity.businessCode"));
}
if($is_grp && ! empty($result['reference_pdf'])) {
$session->set('reference_pdf', $result['reference_pdf']);
}
// Testing for emptiness before overriding session information, because we can have $_GET parameters
// that we do not want to overwrite with empty values.
if(! empty($result['agentCode'])) {
$session->set('agentCode', $result['agentCode']);
}
if(! empty($result['productCode'])) {
$session->set('productCode', $result['productCode']);
}
if(! empty($result['departureDate'])) {
$session->set('departureDate', $result['departureDate']);
}
if(! empty($result['endDate'])) {
$session->set('endDate', $result['endDate']);
}
if(! empty($result['departureCity'])) {
$session->set('departureCity', $result['departureCity']);
}
if(! empty($result['adults'])) {
$session->set('adults', $result['adults']);
}
if(! empty($result['children'])) {
$session->set('children', $result['children']);
}
if(! empty($result['stageName'])) {
$session->set('stageName', $result['stageName']);
if(!empty($result['stageNameFille']) && $is_grp && $type == self::TYPE_OPP_FILLE && !($this->isAgent()
|| (in_array($_ENV['APP_ENV'], ['dev', 'test']) && $this->request->query->get('test_agence')))){
$session->set('stageName', $result['stageNameFille']);
}
}
}
public function getSalesForceParticipants($idOppSF) {
return $this->sf->getSalesForceParticipants($idOppSF);
}
public function getSalesForceResponsable($idOppSF) {
return $this->sf->getSalesForceResponsable($idOppSF);
}
public function getSalesForceTitleVoyage(VoyageInterface $voyage) {
return $this->sf->getSalesForceTitleVoyage($voyage);
}
public function getVolsWarning(VoyageInterface $voyage) {
$data = $this->sf->getInfo($voyage);
if($data['productStatus'] == 'Available') {
return FALSE;
}
elseif($data['productStatus'] == 'Request') {
return TRUE;
}
else {
return FALSE;
}
}
public function isAgent() {
if(strpos($_ENV['IP_ADDRESS_AGENCE'], $this->request->getClientIp()) !== FALSE) {
return TRUE;
}
else {
return FALSE;
}
}
}