commercetools-8.x-1.2-alpha1/src/CommercetoolsCarts.php

src/CommercetoolsCarts.php
<?php

namespace Drupal\commercetools;

use Drupal\commercetools\Event\CommercetoolsOrderCreate;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\State\StateInterface;
use GraphQL\Actions\Mutation;
use GraphQL\Actions\Query;
use GraphQL\Entities\Node;
use GraphQL\Entities\Variable;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

/**
 * The commercetools cart service.
 */
class CommercetoolsCarts {

  /**
   * Translatable order fields.
   */
  const FIELDS_TRANSLATABLE_ORDER = [];

  /**
   * Translatable cart fields.
   */
  const FIELDS_TRANSLATABLE_CART = [];

  /**
   * Translatable Line item fields.
   */
  const FIELDS_TRANSLATABLE_LINE_ITEM = [
    'name',
    'productSlug',
  ];

  const ACTIVE_CART_STATE = 'Active';

  const SESSION_NAME_PREFIX = 'CT_CART_';

  /**
   * CommercetoolsCarts constructor.
   *
   * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
   *   The config factory service.
   * @param \Drupal\commercetools\CommercetoolsService $ct
   *   The Commercetools service.
   * @param \Drupal\commercetools\CommercetoolsApiServiceInterface $ctApi
   *   The Commercetools API service.
   * @param \Drupal\commercetools\CommercetoolsCustomers $ctCustomers
   *   The Commercetools customers service.
   * @param \Drupal\commercetools\CommercetoolsProducts $ctProducts
   *   The commercetools products service.
   * @param \Symfony\Component\HttpFoundation\Session\SessionInterface $session
   *   The session.
   * @param \Drupal\Core\State\StateInterface $state
   *   The Drupal state storage service.
   * @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $eventDispatcher
   *   An event dispatcher instance.
   */
  public function __construct(
    protected ConfigFactoryInterface $configFactory,
    protected CommercetoolsService $ct,
    protected CommercetoolsApiServiceInterface $ctApi,
    protected CommercetoolsCustomers $ctCustomers,
    protected CommercetoolsProducts $ctProducts,
    protected SessionInterface $session,
    protected StateInterface $state,
    protected EventDispatcherInterface $eventDispatcher,
  ) {
  }

  /**
   * Get the user's current cart.
   *
   * Conditionally replaces the current cart with customer cart
   * if customer ID exists and mismatches the current cart customer ID.
   *
   * @param bool $replace
   *   Indicates whether to replace a current cart with customer cart
   *   if cart customer ID mismatch.
   *
   * @return \Drupal\commercetools\CacheableCommercetoolsResponse|null
   *   The cart CacheableCommercetoolsResponse or empty value.
   *
   * @todo Implement merge carts operation.
   */
  public function getCurrentCart(bool $replace = TRUE): CacheableCommercetoolsResponse|null {
    // Try to load cart by previously set cart ID.
    if ($cartId = $this->getCurrentCartId()) {
      $response = $this->loadCart($cartId);
      $cart = $response->getData();
      if (
        $this->isCartMatchingAttributes($cart, [
          'cartState' => self::ACTIVE_CART_STATE,
          'customerId' => $this->ctCustomers->getCurrentUserCustomerId(),
        ])) {
        $cacheableMetadata = $response->getCacheableMetadata();
        return $this->ctApi->prepareResponse($cart, $cacheableMetadata);
      }
      else {
        $this->deleteCurrentCart();
      }
    }

    // Try to load cart by customer ID otherwise.
    if ($customerId = $this->ctCustomers->getCurrentUserCustomerId()) {
      $response = $this->loadCustomerCart($customerId);
      $cart = $response->getData();
      $cacheableMetadata = $response->getCacheableMetadata();
      if ($this->isCartMatchingAttributes($cart, ['cartState' => self::ACTIVE_CART_STATE])) {
        if ($replace) {
          $this->setCurrentCart($cart);
        }
        return $this->ctApi->prepareResponse($cart, $cacheableMetadata);
      }
    }

    return NULL;
  }

  /**
   * Return the session key for storing the cart ID.
   */
  protected function getSessionCartName(): string {
    $connectionConfig = $this->ctApi->getConnectionConfig();
    return self::SESSION_NAME_PREFIX . ':' . $connectionConfig[CommercetoolsApiServiceInterface::CONFIG_PROJECT_KEY] . ':' . $connectionConfig[CommercetoolsApiServiceInterface::CONFIG_HOSTED_REGION];
  }

  /**
   * Set the user's current cart.
   *
   * @param array $cart
   *   The cart array.
   */
  public function setCurrentCart(array $cart): void {
    $this->session->set($this->getSessionCartName(), $cart['id']);
  }

  /**
   * Get the user's current cart ID.
   *
   * @return string|null
   *   The cart ID if set.
   */
  public function getCurrentCartId(): ?string {
    return $this->session->get($this->getSessionCartName());
  }

  /**
   * Unsets the user's current cart.
   */
  public function deleteCurrentCart(): void {
    $this->session->remove($this->getSessionCartName());
  }

  /**
   * Create a user cart.
   *
   * @param array $draft
   *   The array of values which will be used for creating cart.
   *
   * @return \Drupal\commercetools\CacheableCommercetoolsResponse
   *   Cacheable cart response.
   */
  public function createCart(array $draft = []): CacheableCommercetoolsResponse {
    $settingsLocale = $this->configFactory->get(CommercetoolsLocalization::CONFIGURATION_NAME);

    // Add required fields.
    $draft = $draft + array_filter([
      'currency' => $settingsLocale->get(CommercetoolsLocalization::CONFIG_CURRENCY),
      'country' => $settingsLocale->get(CommercetoolsLocalization::CONFIG_COUNTRY),
    ]);

    // Set customer ID if available.
    if ($customerId = $this->ctCustomers->getCurrentUserCustomerId()) {
      $draft['customerId'] = $customerId;
    }

    $arguments = [
      'draft' => new Variable('draft', 'CartDraft!'),
    ];
    $cartMutation = new Mutation('createCart', $arguments);
    $this->addCartFields($cartMutation);

    $variables = array_filter([
      'draft' => $draft,
    ]);

    $response = $this->ctApi->executeGraphQlOperation($cartMutation, $variables);
    $cart = $this->cartDataToCart($response->getData()['createCart']);

    return $this->ctApi->prepareResponse($cart, $response->getCacheableMetadata());
  }

  /**
   * Update cart.
   *
   * @param string $cartId
   *   The cart id.
   * @param int $cartVersion
   *   The cart version.
   * @param array $cartActions
   *   The array with update actions.
   *
   * @return \Drupal\commercetools\CacheableCommercetoolsResponse
   *   Cacheable cart response.
   */
  public function updateCart(string $cartId, int $cartVersion, array $cartActions): CacheableCommercetoolsResponse {
    $arguments = [
      'id' => new Variable('id', 'String'),
      'version' => new Variable('version', 'Long!'),
      'actions' => new Variable('actions', '[CartUpdateAction!]!'),
    ];
    $updateCart = new Mutation('updateCart', $arguments);
    $this->addCartFields($updateCart);
    $variables = [
      'id' => $cartId,
      'version' => $cartVersion,
      'actions' => $cartActions,
    ];
    $response = $this->ctApi->executeGraphQlOperation($updateCart, $variables);
    $cart = $this->cartDataToCart($response->getData()['updateCart']);

    return $this->ctApi->prepareResponse($cart, $response->getCacheableMetadata());
  }

  /**
   * Create order form the cart.
   *
   * @param array $cart
   *   The cart is used for creating order.
   *
   * @return \Drupal\commercetools\CacheableCommercetoolsResponse
   *   Cacheable order response.
   */
  public function createOrderFromCart(array $cart): CacheableCommercetoolsResponse {
    $arguments = [
      'draft' => new Variable('draft', 'OrderCartCommand!'),
    ];
    $createOrder = new Mutation('createOrderFromCart', $arguments);
    $this->addOrderFields($createOrder);
    $variables = [
      'draft' => [
        'id' => $cart['id'],
        'version' => $cart['version'],
        'orderNumber' => $this->generateOrderNumber(),
        'orderState' => 'Open',
        'paymentState' => 'Pending',
        'shipmentState' => 'Pending',
      ],
    ];

    $response = $this->ctApi->executeGraphQlOperation($createOrder, $variables);
    $order = $this->orderDataToOrder($response->getData()['createOrderFromCart']);

    // Dispatch the order creation event.
    $event = new CommercetoolsOrderCreate($order);
    $this->eventDispatcher->dispatch($event);

    return $this->ctApi->prepareResponse($order, $response->getCacheableMetadata());
  }

  /**
   * Load cart by the card id.
   *
   * @param string $cartId
   *   The cart id.
   *
   * @return \Drupal\commercetools\CacheableCommercetoolsResponse
   *   Cacheable cart response.
   */
  public function loadCart(string $cartId): CacheableCommercetoolsResponse {
    $arguments = [
      'id' => new Variable('id', 'String!'),
    ];
    $query = new Query('cart', $arguments);
    $this->addCartFields($query);
    $query->shippingAddress([])->use(...CommercetoolsCustomers::ADDRESS_FIELDS);
    $query->billingAddress([])->use(...CommercetoolsCustomers::ADDRESS_FIELDS);
    $response = $this->ctApi->executeGraphQlOperation($query, ['id' => $cartId]);
    $cartData = $response->getData()['cart'] ?? [];
    $cart = empty($cartData) ? [] : $this->cartDataToCart($cartData);

    return $this->ctApi->prepareResponse($cart, $response->getCacheableMetadata());
  }

  /**
   * Retrieves cart by the customer ID.
   *
   * @param string $customerId
   *   The given customer ID to query by.
   *
   * @return \Drupal\commercetools\CacheableCommercetoolsResponse
   *   Cacheable cart response.
   *
   * @throws \Throwable
   */
  public function loadCustomerCart(string $customerId): CacheableCommercetoolsResponse {
    $arguments = [
      'customerId' => new Variable('customerId', 'String!'),
    ];
    $query = new Query('customerActiveCart', $arguments);
    $this->addCartFields($query);
    $response = $this->ctApi->executeGraphQlOperation($query, ['customerId' => $customerId]);
    $data = $response->getData()['customerActiveCart'];
    $cart = $data ? $this->cartDataToCart($data) : [];

    return $this->ctApi->prepareResponse($cart, $response->getCacheableMetadata());
  }

  /**
   * Retrieves orders based on the provided parameters.
   *
   * @param array $args
   *   Here the following array values are used:
   *    - sort: An associative array of sort options to apply to the query.
   *    - where: An string of filters to apply to the query.
   *    - offset: The number of orders to skip before starting to collect the
   *    result set. Defaults to 0.
   *    - limit: The maximum number of orders to return. Defaults to 10.
   * @param bool|null $includeDetails
   *   Whether to include detailed order information. Defaults to NULL.
   *
   * @return \Drupal\commercetools\CacheableCommercetoolsResponse
   *   The response containing the product data.
   */
  public function getOrders(array $args = [], ?bool $includeDetails = NULL): CacheableCommercetoolsResponse {
    $variables = [
      'limit' => $args['limit'] ?? 10,
      'offset' => $args['offset'] ?? 0,
      'sort' => $args['sort'] ?? NULL,
      'where' => $args['where'] ?? NULL,
    ];

    $arguments = [
      'where' => new Variable('where', 'String'),
      'sort' => new Variable('sort', '[String!]'),
      'limit' => new Variable('limit', 'Int'),
      'offset' => new Variable('offset', 'Int'),
    ];
    $query = new Query('orders', $arguments);
    $query->use('total');
    $this->addOrderFields($query->results([]), $includeDetails);
    $response = $this->ctApi->executeGraphQlOperation($query, $variables);
    $result = $response->getData();
    $orderDataList = $result['orders'] ?? ['results' => []];
    foreach ($orderDataList['results'] as &$orderData) {
      $orderData = $this->orderDataToOrder($orderData);
    }

    return $this->ctApi->prepareResponse($orderDataList, $response->getCacheableMetadata());
  }

  /**
   * Add order fields to request node.
   *
   * @param \GraphQL\Entities\Node $orderQuery
   *   The order node to which the fields will be added.
   * @param bool|null $includeDetails
   *   Whether to include detailed product information. Defaults to NULL.
   */
  protected function addOrderFields(Node $orderQuery, ?bool $includeDetails = NULL): void {
    $this->ct->addFieldsToNode(
      $orderQuery,
      [
        'id',
        'createdAt',
        'orderNumber',
        'orderState',
        'customerId',
      ],
      self::FIELDS_TRANSLATABLE_ORDER,
    );
    $this->ct->addPriceNodeFields($orderQuery->totalPrice([]));
    $this->addLineItemsFields($orderQuery->lineItems([]));

    if ($includeDetails) {
      $this->ct->addPriceNodeFields($orderQuery->shippingInfo([])->use('shippingMethodName')->price([]));
      $this->ct->addAddressNodeFields($orderQuery->shippingAddress([]));
      $this->ct->addAddressNodeFields($orderQuery->billingAddress([]));

      $taxedPriceQuery = $orderQuery->taxedPrice([]);
      $taxPortions = $taxedPriceQuery->taxPortions([])->use('name', 'rate');
      $this->ct->addPriceNodeFields($taxPortions->amount([]));
      $this->ct->addPriceNodeFields($taxedPriceQuery->totalGross([]));
    }

  }

  /**
   * Converts the raw order data response to a simplified order array.
   *
   * @param array $orderData
   *   The array of the order response data.
   *
   * @return array
   *   The simplified order array.
   */
  protected function orderDataToOrder(array $orderData): array {
    $orderData['subtotalPrice'] = $this->ct->formatPrice(array_reduce($orderData['lineItems'], function ($carry, $item) {
      $carry['centAmount'] += $item['totalPrice']['centAmount'];
      return $carry;
    }, ['centAmount' => 0] + $orderData['totalPrice']));
    $orderData['totalPrice'] = $this->ct->formatPrice($orderData['totalPrice']);
    $orderData['totalLineItemQuantity'] ??= 0;
    $orderData['lineItems'] = $this->lineItemsDataToLineItems($orderData['lineItems']);

    if (isset($orderData['shippingInfo'])) {
      $orderData['shippingInfo']['price'] = $this->ct->formatPrice($orderData['shippingInfo']['price']);
    }

    $orderData['taxedPrice'] = empty($orderData['taxedPrice']) ? [] : array_filter($orderData['taxedPrice']);
    if (!empty($orderData['taxedPrice'])) {
      foreach ($orderData['taxedPrice']['taxPortions'] as &$portion) {
        $portion['amount'] = $this->ct->formatPrice($portion['amount']);
      }
      $orderData['taxedPrice']['totalGross'] = $this->ct->formatPrice($orderData['taxedPrice']['totalGross']);
    }
    return $orderData;
  }

  /**
   * Add cart fields to request node.
   *
   * @param \GraphQL\Entities\Node $cartQuery
   *   The cart node to which the fields will be added.
   */
  protected function addCartFields(Node $cartQuery): void {
    $this->ct->addFieldsToNode(
      $cartQuery,
      [
        'id',
        'cartState',
        'version',
        'customerId',
        'totalLineItemQuantity',
      ],
      self::FIELDS_TRANSLATABLE_CART,
    );
    $this->ct->addPriceNodeFields($cartQuery->totalPrice([]));
    $this->ct->addAddressNodeFields($cartQuery->billingAddress([]));
    $this->ct->addAddressNodeFields($cartQuery->shippingAddress([]));
    $this->addCartDiscountsFields($cartQuery);
    $this->addLineItemsFields($cartQuery->lineItems([]));
  }

  /**
   * Add cart discounts fields.
   */
  protected function addCartDiscountsFields($cartQuery): void {
    $includedDiscounts = $cartQuery->discountOnTotalPrice([])->includedDiscounts([]);
    $this->ct->addFieldsToNode(
      $includedDiscounts->discount([]),
      ['id', 'name'],
      ['name'],
    );
    $this->ct->addPriceNodeFields(
      $includedDiscounts->discountedAmount([])
    );

    $discountCode = $cartQuery->discountCodes([])->discountCode([]);
    $this->ct->addFieldsToNode(
      $discountCode,
      ['id', 'name', 'code'],
      ['name'],
    );
    $discountCode->cartDiscounts([])->use('id');
  }

  /**
   * Add line items fields to request node.
   *
   * @param \GraphQL\Entities\Node $lineItemsQuery
   *   The line items node to which the fields will be added.
   */
  protected function addLineItemsFields(Node $lineItemsQuery): void {
    $this->ct->addFieldsToNode($lineItemsQuery, ['id', 'name', 'productSlug', 'quantity'], self::FIELDS_TRANSLATABLE_LINE_ITEM);
    $lineItemsQuery->productType([])->use('id', 'key', 'name');

    $this->ctProducts->addProductVariantFields($lineItemsQuery->variant([]));
    $this->ct->addPriceNode($lineItemsQuery);
    $this->ct->addPriceNodeFields($lineItemsQuery->totalPrice([]));

    $this->addLineItemsDiscountsFields($lineItemsQuery);
  }

  /**
   * Add line items discounts fields.
   */
  protected function addLineItemsDiscountsFields(Node $lineItemsQuery): void {
    $discountedPricePerQuantity = $lineItemsQuery->discountedPricePerQuantity([])->use('quantity');
    $discountedPrice = $discountedPricePerQuantity->discountedPrice([]);
    $this->ct->addPriceNodeFields(
      $discountedPrice->value([]),
    );
    $discountedPriceAttrDefinition = $discountedPrice->includedDiscounts([]);
    $discountedPriceAttrDefinition->discount([])->use('id');
    $this->ct->addPriceNodeFields(
      $discountedPriceAttrDefinition->discountedAmount([]),
    );
  }

  /**
   * Converts the raw cart data response to a simplified cart array.
   *
   * @param array $cartData
   *   The array of the cart response data.
   *
   * @return array
   *   The simplified cart array.
   */
  protected function cartDataToCart(array $cartData): array {
    $cartData['totalLineItemQuantity'] ??= 0;
    $cartData['lineItems'] = $this->lineItemsDataToLineItems($cartData['lineItems']);
    $this->enhanceTotalPrice($cartData);
    $this->enhanceDiscounts($cartData);
    return $cartData;
  }

  /**
   * Enhance total price.
   */
  protected function enhanceTotalPrice(array &$cartData): void {
    $originalTotal = 0;
    $discount = 0;
    foreach ($cartData['lineItems'] as $lineItem) {
      $originalTotal += $lineItem['originalTotalPrice']['centAmount'] ?? 0;
      $discount += !empty($lineItem['discounted']['centAmount'])
        ? ($lineItem['price']['centAmount'] - $lineItem['discounted']['centAmount']) * $lineItem['quantity']
        : 0;
    }

    $cartData['originalTotalPrice'] = $this->mergePriceData(['centAmount' => $originalTotal], $cartData['totalPrice']);
    $cartData['discount'] = $this->mergePriceData(['centAmount' => $discount], $cartData['totalPrice']);
    $cartData['totalPrice'] = $this->ct->formatPrice($cartData['totalPrice']);
    $cartData['originalTotalPrice'] = $this->ct->formatPrice($cartData['originalTotalPrice']);
    $cartData['discount'] = $this->ct->formatPrice($cartData['discount']);
    $cartData['isDiscounted'] = $originalTotal !== $cartData['totalPrice']['centAmount'];
  }

  /**
   * Enhance discounts.
   */
  protected function enhanceDiscounts(array &$cartData): void {
    foreach ($cartData['discountCodes'] as &$discountCode) {
      $discountCode = $discountCode['discountCode'];
      $refIds = array_column($discountCode['cartDiscounts'], 'id');
      $totalCentAmount = 0;

      foreach ($cartData['lineItems'] as $lineItem) {
        foreach ($lineItem['discountedPricePerQuantity'] as $dp) {
          foreach ($dp['discountedPrice']['includedDiscounts'] as $inc) {
            $discountId = $inc['discount']['id'] ?? NULL;
            if (in_array($discountId, $refIds, TRUE)) {
              $totalCentAmount += $inc['discountedAmount']['centAmount']
                ? $inc['discountedAmount']['centAmount'] * $dp['quantity']
                : 0;
            }
          }
        }
      }

      if (!empty($cartData['discountOnTotalPrice']['includedDiscounts'])) {
        foreach ($cartData['discountOnTotalPrice']['includedDiscounts'] as $inc) {
          $id = $inc['discount']['id'] ?? NULL;
          if (in_array($id, $refIds, TRUE)) {
            $totalCentAmount += $inc['discountedAmount']['centAmount'] ?? 0;
          }
        }
      }

      $discountCode['amount'] = $this->ct->formatPrice(
        $this->mergePriceData(['centAmount' => $totalCentAmount], $cartData['totalPrice'])
      );
    }
  }

  /**
   * Converts the raw line items data response to a simplified line items array.
   *
   * @param array $lineItemsData
   *   The array of the line items response data.
   *
   * @return array
   *   The simplified line items array.
   */
  protected function lineItemsDataToLineItems(array $lineItemsData): array {
    foreach ($lineItemsData as &$lineItemData) {
      $this->addDiscountDataToLineItem($lineItemData);
      $lineItemData['discounted'] = isset($lineItemData['price']['discounted']['value'])
        ? $this->ct->formatPrice($lineItemData['price']['discounted']['value'])
        : NULL;
      $lineItemData['price'] = $this->ct->formatPrice($lineItemData['price']['value']);
      // The slug value is crucial for rendering the product link, set the
      // fallback value if it's missing.
      $lineItemData['productSlug'] ??= CommercetoolsProducts::MISSING_SLUG_VALUE;
      $lineItemData['totalPrice'] = $this->ct->formatPrice($lineItemData['totalPrice']);
      $lineItemData['variant'] = $this->ctProducts->variantDataToVariant($lineItemData['variant'], $lineItemData['productType']['key']);
    }
    return $lineItemsData;
  }

  /**
   * Add discounted fields to line items.
   */
  protected function addDiscountDataToLineItem(array &$lineItemData): void {
    $originalTotalPrice = $lineItemData['quantity'] * $lineItemData['price']['value']['centAmount'];
    $lineItemData['originalTotalPrice'] = $this->mergePriceData(['centAmount' => $originalTotalPrice], $lineItemData['price']['value']);
    $lineItemData['isDiscounted'] = $lineItemData['totalPrice']['centAmount'] !== $originalTotalPrice;
    $lineItemData['originalTotalPrice'] = $this->ct->formatPrice($lineItemData['originalTotalPrice']);
  }

  /**
   * Validates the cart by given attributes list.
   *
   * @param array $cart
   *   The cart data.
   * @param array $attributes
   *   The list of attributes to validate given cart by.
   *
   * @return bool
   *   Indicates whether the cart matches all given attributes.
   */
  protected function isCartMatchingAttributes(array $cart, array $attributes): bool {
    $result = TRUE;

    // Empty cart is invalid by default.
    if (empty($cart)) {
      return FALSE;
    }

    foreach ($attributes as $attribute => $value) {
      if (!$value) {
        continue;
      }
      if (empty($cart[$attribute]) || $cart[$attribute] !== $value) {
        $result = FALSE;
      }
    }
    return $result;
  }

  /**
   * Temporary function for generation order number.
   *
   * @return string
   *   Generated order number.
   *
   * @todo Move to external service and make pattern configurable.
   */
  public function generateOrderNumber(): string {
    $sequence = $this->state->get('commercetools_order_sequence', 100);
    $this->state->set('commercetools_order_sequence', ++$sequence);
    return str_ireplace('{sequence}', $sequence, 'DRUPAL-ORDER-{sequence}');
  }

  /**
   * Merges calculated price values with data fields from the original price.
   *
   * When computing a partial price, typically only
   * `centAmount` is recalculated.
   *
   *  This method pulls all other data fields (e.g., `currencyCode`,
   *  `fractionDigits`, etc.) from the original price and
   *  combines them with the calculated price array, producing a complete
   *  price structure.
   *
   * @param array $calculatedPrice
   *   An associative array containing the newly calculated price values,
   *   at minimum:
   *    - centAmount (int): the recalculated amount in the smallest currency
   *    unit. It may also include other computed fields.
   * @param array $sourcePrice
   *   The source price array, which must include data fields such as:
   *   - currencyCode (string): currency code.
   *   - fractionDigits (int): number of decimal places.
   *   - any other display.
   *
   * @return array
   *   A merged price array with:
   *   - centAmount: from $calculatedPrice
   *   - currencyCode, fractionDigits, and other data: from $originalPrice
   *   - any additional computed fields: from $calculatedPrice
   */
  protected function mergePriceData(array $calculatedPrice, array $sourcePrice): array {
    return array_merge($sourcePrice, $calculatedPrice);
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc