<?php
// Copyright (c) Pickware GmbH. All rights reserved.
// This file is part of software that is released under a proprietary license.
// You must not copy, modify, distribute, make publicly available, or execute
// its contents or parts thereof without express permission by the copyright
// holder, unless otherwise permitted by law.

use Doctrine\DBAL\Connection as DbalConnection;
use Shopware\Components\Api\Exception as ApiException;
use Shopware\Components\Api\Manager as ResourceManager;
use Shopware\Components\Api\Resource\Order as OrderResource;
use Shopware\Components\Model\ModelManager;
use Shopware\CustomModels\ViisonPickwareERP\RestApi\RestApiIdempotentOperation;
use Shopware\CustomModels\ViisonPickwareERP\ReturnShipment\ReturnShipmentAttachment;
use Shopware\CustomModels\ViisonPickwareERP\ReturnShipment\ReturnShipmentInternalComment;
use Shopware\CustomModels\ViisonPickwareERP\ReturnShipment\ReturnShipmentItem;
use Shopware\CustomModels\ViisonPickwareERP\ReturnShipment\ReturnShipmentStatus;
use Shopware\CustomModels\ViisonPickwareERP\StockLedger\OrderStockReservation;
use Shopware\CustomModels\ViisonPickwareERP\Warehouse\BinLocation;
use Shopware\CustomModels\ViisonPickwareERP\Warehouse\Warehouse;
use Shopware\CustomModels\ViisonPickwareMobile\PickedQuantity\PickedQuantity;
use Shopware\CustomModels\ViisonPickwareMobile\PickProfile\PickProfile;
use Shopware\Models\Article\Detail as ArticleDetail;
use Shopware\Models\Media\Media;
use Shopware\Models\Order\Detail as OrderDetail;
use Shopware\Models\Order\Order;
use Shopware\Models\Order\Status as OrderStatus;
use Shopware\Plugins\ViisonCommon\Classes\ExceptionHandling\ApiExceptionHandling;
use Shopware\Plugins\ViisonCommon\Classes\Exceptions\ValidationExceptions\CustomValidationException;
use Shopware\Plugins\ViisonCommon\Classes\Util\OrderDetailUtil;
use Shopware\Plugins\ViisonCommon\Classes\Util\Util as ViisonCommonUtil;
use Shopware\Plugins\ViisonCommon\Classes\Util\UuidUtil;
use Shopware\Plugins\ViisonCommon\Components\ExceptionTranslation\ExceptionTranslator;
use Shopware\Plugins\ViisonCommon\Components\ParameterValidator;
use Shopware\Plugins\ViisonPickwareCommon\Classes\Util as PickwareUtil;
use Shopware\Plugins\ViisonPickwareERP\Components\BarcodeLabel\Article\ArticleBarcodeLabelType;
use Shopware\Plugins\ViisonPickwareERP\Components\BarcodeLabel\BarcodeLabelFacade;
use Shopware\Plugins\ViisonPickwareERP\Components\RestApi\Idempotency\ControllerActionIdempotency;
use Shopware\Plugins\ViisonPickwareERP\Components\RestApi\Idempotency\ResponseResult;
use Shopware\Plugins\ViisonPickwareERP\Components\ReturnShipment\ReturnShipmentMailingException;
use Shopware\Plugins\ViisonPickwareERP\Components\ReturnShipment\ReturnShipmentProcessor;
use Shopware\Plugins\ViisonPickwareERP\Components\ReturnShipment\ReturnShipmentException;
use Shopware\Plugins\ViisonPickwareERP\Components\StockLedger\ArticleDetailConcurrencyCoordinator;
use Shopware\Plugins\ViisonPickwareMobile\Classes\Exceptions\OrderAlreadyLockedException;
use Shopware\Plugins\ViisonPickwareMobile\Classes\PickingOrderSorter;
use Shopware\Plugins\ViisonPickwareMobile\Classes\ShippingProvider\ShippingDocumentTypeHandling;
use Shopware\Plugins\ViisonPickwareMobile\Components\PickingOrderFilter\StockBasedFilterConfiguration;
use Shopware\Plugins\ViisonPickwareMobile\Components\ShippingProviderRepositoryService;
use Shopware\Plugins\ViisonPickwareMobile\Interfaces\ShippingProvider\ReturnLabelCreation;
use Shopware\Plugins\ViisonPickwareMobile\Subscribers\Api\OrdersSubscriber as ApiOrdersSubscriber;
use ViisonPickwareMobile_Interfaces_ShippingProvider_ShippingProvider as ShippingProvider;
use ViisonPickwareMobile_Interfaces_ShippingProvider_ShippingDocument as ShippingDocument;

/**
 * This controller adds a new methods to the REST API orders resource for updating batches
 * of orders, creating and reading shipping documents as well as creating return shipments.
 */
class Shopware_Controllers_Api_ViisonPickwareMobileOrders extends Shopware_Controllers_Api_Rest
{
    use ApiExceptionHandling;
    use ControllerActionIdempotency;
    use ShippingDocumentTypeHandling;

    /**
     * Performs some additional rerouting, if necessary.
     */
    public function init()
    {
        $request = $this->Request();
        if ($request->getActionName() === 'postDetails' && $request->getParam('subId') !== null
            && $request->getParam('pickedQuantities') !== null
        ) {
            // Manually change the action name to trigger the POST pickedQuantities action on an order detail
            $request->setActionName('postDetailsPickedQuantities');
        } elseif ($request->getActionName() === 'putDetails' && $request->getParam('subId') !== null
            && $request->getParam('pickedQuantities') !== null
        ) {
            // Manually change the action name to trigger the PUT pickedQuantities action on an order detail
            $request->setActionName('putDetailsPickedQuantities');
        } elseif ($request->getActionName() === 'deleteDetails' && $request->getParam('subId') !== null
            && $request->getParam('pickedQuantities') !== null
        ) {
            // Manually change the action name to trigger the DELETE pickedQuantities action on an order detail
            $request->setActionName('deleteDetailsPickedQuantities');
        } elseif ($request->getActionName() === 'deleteDetails' && $request->getParam('subId') !== null
            && $request->getParam('stockReservations') !== null
        ) {
            // Manually change the action name to trigger the DELETE stockReservations action on an order detail
            $request->setActionName('deleteDetailsStockReservations');
        }
    }

    /**
     * 'Locks' the order by setting its order status to 'in process', which is used by the Picking app to
     * mark an order as being picked. The difference to a normal PUT on this resource is that the current
     * order status as well as the processing warehouse are checked as a precondition, which is determined
     * by the required parameters 'currentOrderStatusId' and 'processingWarehouseId'. Only if the current
     * order status and processing warehouse matches the provided values, the order is locked by changing
     * its status. It is possible to skip the order status validation by passing 'force' with value 'true'
     * as a parameter. Please note that even with 'force' set to true, the processing warehouse is still
     * checked.
     *
     * PUT /api/orders/{id}/lock
     *
     * @throws ApiException\NotFoundException
     * @throws ApiException\ParameterMissingException
     */
    public function putLockAction()
    {
        // Check the privileges
        ResourceManager::getResource('order')->checkPrivilege('update');

        // Try to get the order
        $orderId = $this->Request()->getParam('id', 0);
        /** @var Order $order */
        $order = $this->get('models')->find(Order::class, $orderId);
        if (!$order) {
            $this->Response()->setHttpResponseCode(404);
            throw new ApiException\NotFoundException(
                sprintf('Order by ID %d not found.', $orderId)
            );
        }

        // Check for a valid pickProfileId
        $pickProfileId = $this->Request()->getParam('pickProfileId', 0);
        $pickProfile = $this->get('models')->find(PickProfile::class, $pickProfileId);
        if (!$pickProfile) {
            throw new ApiException\NotFoundException(sprintf('Pick profile with ID %s not found', $pickProfileId));
        }

        // Determine the current state of the order
        $currentOrderStatusId = $order->getOrderStatus()->getId();
        $currentProcessingWarehouseId = $order->getAttribute()->getPickwareProcessingWarehouseId();

        // Check the warehouse
        $expectedProcessingWarehouseId = intval($this->Request()->getParam('processingWarehouseId', 0));
        $expectedProcessingWarehouse = $this->get('models')->find(Warehouse::class, $expectedProcessingWarehouseId);
        if (!$expectedProcessingWarehouse) {
            throw new ApiException\NotFoundException(
                sprintf('Warehouse by ID %d not found.', $expectedProcessingWarehouseId)
            );
        }

        if ($currentProcessingWarehouseId !== null && $currentProcessingWarehouseId != $expectedProcessingWarehouseId) {
            // Respond with a 'HTTP 412 Precondition failed'
            $this->Response()->setHttpResponseCode(412);
            $this->View()->assign([
                'success' => false,
                'message' => 'The warehouse, in which the order is currently being processed, does not match the given warehouse.',
                'data' => [
                    'order' => $this->getOrderData($orderId, $pickProfile, $expectedProcessingWarehouse),
                ],
            ]);

            return;
        }

        // In the single-picking flow, when an order is already locked, the user is asked whether they want to start
        // picking the order anyway. In this case, the "force" parameter is set on the request. This is useful to
        // continue picking orders that were partially picked but where the session picking the order went into an error
        // state.
        $forceLock = $this->Request()->getParam('force', false);
        if (!$forceLock) {
            // Also check that the current status of the order is the same as the one the app expects. This ensures that
            // orders which were completely picked in the meantime are not locked for picking again because the app
            // still thinks the order is in the status "open" (0) or "partially delivered" (1). This problem is specific
            // to the single-picking flow. The check for concurrent locking is in lockOrder() now.
            $expectedCurrentOrderStatusId = $this->Request()->getParam('currentOrderStatusId');
            if ($expectedCurrentOrderStatusId === null) {
                throw new ApiException\ParameterMissingException('currentOrderStatusId');
            }

            if ($currentOrderStatusId != $expectedCurrentOrderStatusId) {
                // Respond with a 'HTTP 412 Precondition failed'
                $this->Response()->setHttpResponseCode(412);
                $this->View()->assign([
                    'success' => false,
                    'message' => sprintf('The order status is currently %d, which does not match the status %d given as a precondition for locking.', $currentOrderStatusId, $expectedCurrentOrderStatusId),
                    'data' => [
                        'order' => $this->getOrderData($orderId, $pickProfile, $expectedProcessingWarehouse),
                    ],
                ]);

                return;
            }

            // Check that the order still passes the pick profile's filters, especially considering that the stocks
            // might have changed since last loading the order data
            $validOrderIds = $this->get('viison_pickware_mobile.pick_profile_order_filter_service')->getIdsOfOrdersPassingFilterOfPickProfile(
                $pickProfile,
                $expectedProcessingWarehouse
            );
            if (!in_array($orderId, $validOrderIds)) {
                // Respond with a 'HTTP 412 Precondition failed'
                $this->Response()->setHttpResponseCode(412);
                $this->View()->assign([
                    'success' => false,
                    'message' => 'The order does not pass the picking filter anymore, e.g. because the stocks have changed in the meantime.',
                    'data' => [
                        'order' => $this->getOrderData($orderId, $pickProfile, $expectedProcessingWarehouse),
                        'passesPickingFilter' => false,
                    ],
                ]);

                return;
            }
        }

        // Lock order and reserve all required stock
        try {
            $this->lockOrder($order, $expectedProcessingWarehouse, null, $forceLock);
        } catch (OrderAlreadyLockedException $e) {
            // The order is already locked, hence respond with a 'HTTP 412 Precondition failed'
            $this->Response()->setHttpResponseCode(412);
            $this->View()->assign([
                'success' => false,
                'message' => 'The order is already locked.',
                'data' => [
                    'order' => $this->getOrderData($orderId, $pickProfile, $expectedProcessingWarehouse),
                ],
            ]);

            return;
        }

        $this->View()->assign([
            'success' => true,
            'data' => [
                'order' => $this->getOrderData($orderId, $pickProfile, $expectedProcessingWarehouse),
            ],
        ]);
    }

    /**
     * Uses the OrderFilter to find potential orders for batch picking
     * which are then filtered again to select as many 'open', 'partly completed'
     * and 'partly dispatched' orders as box IDs were posted. These orders are
     * sorted before they are assigned one box ID each and 'locked'. That is their
     * status is set to 'in progress', to prevent anyone else from picking them.
     *
     * PUT /api/orders
     *
     * @throws ApiException\ParameterMissingException
     * @throws ApiException\NotFoundException
     */
    public function batchAction()
    {
        // Check the privileges
        ResourceManager::getResource('order')->checkPrivilege('update');

        $this->executeIdempotently([
            RestApiIdempotentOperation::RECOVERY_POINT_STARTED => function () {
                // Check for a valid pickProfileId
                $pickProfileId = $this->Request()->getParam('pickProfileId', 0);
                /** @var ModelManager $entityManager */
                $entityManager = $this->get('models');
                /** @var PickProfile $pickProfile */
                $pickProfile = $entityManager->find(PickProfile::class, $pickProfileId);
                if (!$pickProfile) {
                    throw new ApiException\ParameterMissingException('pickProfileId');
                }

                // Check for a valid warehouseId
                $warehouseId = $this->Request()->getParam('warehouseId', 0);
                /** @var Warehouse $warehouse */
                $warehouse = $entityManager->find(Warehouse::class, $warehouseId);
                if (!$warehouse) {
                    throw new ApiException\ParameterMissingException('warehouseId');
                }

                // Try to find the box IDs
                $requestedNumOrders = $this->Request()->getParam('numberOfOrders', 0);
                $boxIds = $this->Request()->getParam('boxIds', []);
                if (count($boxIds) > 0) {
                    $requestedNumOrders = count($boxIds);
                    // Check whether any of the given box IDs is already assigned to an order
                    $builder = $entityManager->createQueryBuilder();
                    $builder->select('orders')
                        ->from(Order::class, 'orders')
                        ->join('orders.attribute', 'attribute')
                        ->where('attribute.pickwareBatchPickingBoxId IN (:boxIds)')
                        ->orderBy('attribute.pickwareBatchPickingBoxId')
                        ->setParameter('boxIds', $boxIds);
                    /** @var Order[] $assignedOrders */
                    $assignedOrders = $builder->getQuery()->getResult();
                    if (count($assignedOrders) > 0) {
                        // At least one box is already in use (assigned to an order)
                        $boxMappings = [];
                        foreach ($assignedOrders as $order) {
                            $boxMappings[$order->getAttribute()->getPickwareBatchPickingBoxId()] = '#' . $order->getNumber();
                        }

                        return new ResponseResult(
                            400,
                            [
                                'success' => false,
                                'message' => 'Given boxes are already in use.',
                                'boxMappings' => $boxMappings,
                            ]
                        );
                    }
                }

                // Check for the requested number of orders
                if ($requestedNumOrders <= 0) {
                    return new ResponseResult(
                        400,
                        [
                            'success' => false,
                            'message' => 'You must provide either of the parameters "boxIds" or "numberOfOrders"',
                        ]
                    );
                }

                // Fetch all 'open' and 'partly shipped' orders that pass the pick profile's filters
                $pickProfileOrderFilterService = $this->get('viison_pickware_mobile.pick_profile_order_filter_service');
                $validOrderIds = $pickProfileOrderFilterService->getIdsOfOrdersPassingFilterOfPickProfile(
                    $pickProfile,
                    $warehouse
                );
                $availableOrderIds = $this->get('dbal_connection')->fetchAll(
                    'SELECT `id`
                    FROM `s_order`
                    WHERE
                        `id` IN (:validOrderIds)
                        AND `status` IN (:validStatusIds)',
                    [
                        'validOrderIds' => $validOrderIds,
                        'validStatusIds' => [
                            OrderStatus::ORDER_STATE_OPEN,
                            OrderStatus::ORDER_STATE_PARTIALLY_DELIVERED,
                        ],
                    ],
                    [
                        'validOrderIds' => DbalConnection::PARAM_INT_ARRAY,
                        'validStatusIds' => DbalConnection::PARAM_INT_ARRAY,
                    ]
                );
                $availableOrderIds = array_column($availableOrderIds, 'id');
                if (count($availableOrderIds) === 0) {
                    throw new ApiException\NotFoundException('No orders available for picking');
                }

                // Sort the order IDs
                $sorter = new PickingOrderSorter($pickProfile, $this->get('dbal_connection'));
                $availableOrderIds = $sorter->sort($availableOrderIds);

                // Try to lock as many orders as requested
                $lockedOrders = [];
                while (count($lockedOrders) < $requestedNumOrders && count($availableOrderIds) > 0) {
                    if ($pickProfile->getStockBasedOrderFilterMode() !== StockBasedFilterConfiguration::FILTER_MODE_OFF) {
                        // Apply the order filter every iteration to make sure to get the latest stock evaluations
                        $validOrderIds = $pickProfileOrderFilterService->getIdsOfOrdersPassingFilterOfPickProfile(
                            $pickProfile,
                            $warehouse
                        );
                    }

                    // Find the first relevant, available order ID
                    $selectedIndex = -1;
                    $selectedOrderId = null;
                    foreach ($availableOrderIds as $index => $orderId) {
                        if (in_array($orderId, $validOrderIds)) {
                            $selectedOrderId = $orderId;
                            $selectedIndex = $index;
                            break;
                        }
                    }
                    if ($selectedOrderId === null) {
                        // No relevant, available order ID found
                        break;
                    }

                    // Remove all available order IDs from the list up to and including the selected order ID
                    array_splice($availableOrderIds, 0, ($selectedIndex + 1));

                    try {
                        // Lock order and reserve all required stock
                        $selectedOrder = $entityManager->find(Order::class, $selectedOrderId);
                        $nextBoxId = $boxIds[0];
                        $this->lockOrder($selectedOrder, $warehouse, $nextBoxId);
                        // Only remove the box if locking was successful
                        array_shift($boxIds);
                        $lockedOrders[] = $selectedOrder;
                    } catch (OrderAlreadyLockedException $e) {
                        // Order was locked by another process in the meantime, just continue the loop and try to lock the next
                        // available order.
                        $this->get('pluginlogger')->info(sprintf('Order %s (id: %d) was already locked, trying to lock another instead', $selectedOrder->getNumber(), $selectedOrder->getId()));
                    }
                }
                if (count($lockedOrders) === 0) {
                    throw new ApiException\NotFoundException('No orders available for picking');
                }

                // Respond the IDs of the locked orders
                return new ResponseResult(
                    200,
                    [
                        'success' => true,
                        'data' => array_map(
                            function (Order $order) {
                                return $order->getId();
                            },
                            $lockedOrders
                        ),
                    ]
                );
            },
        ]);
    }

    /**
     * Checks all available shipping providers for documents belonging to the order with the given ID and collects them
     * in an array, which is then added to the response. The documents can be filtered by a URL parameter to select only
     * the documents of undispatched labels.
     *
     * GET /api/orders/{id}/shippingDocuments
     */
    public function getShippingDocumentsIndexAction()
    {
        // Try to get the order to check if the requested resource exists
        $orderId = $this->Request()->getParam('id');
        /** @var OrderResource $orderResource */
        $orderResource = ResourceManager::getResource('order');
        $orderResource->getOne($orderId);

        // Check for an 'undispatched only' filter
        $undispatchedOnly = $this->Request()->getParam('undispatched', false);
        $undispatchedOnly = !empty($undispatchedOnly);

        // Get information about all requested documents
        $documents = [];
        /** @var ShippingProvider[] $providers */
        $providers = $this->get('viison_pickware_mobile.shipping_provider_repository')->getProviders();
        foreach ($providers as $provider) {
            $providerDocuments = $provider->getAllDocumentDescriptions($orderId, $undispatchedOnly);
            $documents = array_merge(
                $documents,
                array_map(function (ShippingDocument $document) use ($provider) {
                    // Convert the document's to JSON-encodeable arrays
                    return [
                        'identifier' => $document->getIdentifier(),
                        'typeId' => ApiOrdersSubscriber::createShippingDocumentType($document, $provider),
                        'pageSize' => $document->getPageSize(),
                        'trackingCode' => $document->getTrackingCode(),
                    ];
                }, $providerDocuments)
            );
        }

        $this->View()->assign([
            'success' => true,
            'data' => $documents,
        ]);
    }

    /**
     * Gets the order id and document identifier from the URL parameters and uses them
     * to get the requested document file from the respective shipping provider.
     * Finally a JSON is responded containing the document's identifier, type and pageSize
     * as well as the base64 encoded data of the actual shipping document file.
     *
     * GET /api/orders/{id}/shippingDocuments/{shippingDocumentId}
     *
     * @throws ApiException\NotFoundException
     */
    public function getShippingDocumentsAction()
    {
        // Try to get the order to check if the requested resource exists
        $orderId = $this->Request()->getParam('id');
        /** @var OrderResource $orderResource */
        $orderResource = ResourceManager::getResource('order');
        $orderResource->getOne($orderId);

        // Try to get the shipping provider responsible for the document with the given identifier
        $identifier = $this->Request()->getParam('shippingDocuments');
        $providerRepository = $this->get('viison_pickware_mobile.shipping_provider_repository');
        /** @var ShippingProvider $provider */
        $provider = $providerRepository->findProviderForDocumentIdentifier($identifier);
        if (!$provider) {
            throw new ApiException\NotFoundException('No valid shipping provider found for given document identifier.');
        }

        // Try to get the document
        $allDocuments = $provider->getAllDocumentDescriptions($orderId);
        $document = null;
        foreach ($allDocuments as $doc) {
            if ($doc->getIdentifier() == $identifier) {
                $document = $doc;
                break;
            }
        }
        if (!$document) {
            throw new ApiException\NotFoundException(
                sprintf('The file of shipping document with identifier "%s" does not exist.', $identifier)
            );
        }

        // Try to load the document's file
        try {
            $fileData = $provider->getDocument($orderId, $identifier);
        } catch (\Exception $e) {
            throw new ApiException\NotFoundException(
                sprintf('The file of shipping document with identifier "%s" does not exist.', $identifier),
                0,
                $e
            );
        }

        // Prepare the response data
        $data = [
            'identifier' => $document->getIdentifier(),
            'typeId' => ApiOrdersSubscriber::createShippingDocumentType($document, $provider),
            'pageSize' => $document->getPageSize(),
            'trackingCode' => $document->getTrackingCode(),
            'fileData' => base64_encode($fileData),
        ];

        $this->View()->assign([
            'success' => true,
            'data' => $data,
        ]);
    }

    /**
     * First selects the shipping provider being responsible for the order with the given id.
     * Finally uses an instance of this provider to create a new shipping label
     * and returns the results from that method call.
     *
     * POST /api/orders/{id}/shippingDocuments
     *
     * @throws ApiException\CustomValidationException
     * @throws ApiException\NotFoundException
     */
    public function postShippingDocumentsAction()
    {
        // Check the privileges
        ResourceManager::getResource('order')->checkPrivilege('update');

        // Try to get the order to check if the requested resource exists
        $orderId = $this->Request()->getParam('id');
        /** @var OrderResource $orderResource */
        $orderResource = ResourceManager::getResource('order');
        $order = $orderResource->getOne($orderId);

        // Get the shipping provider and product identifiers
        $shippingProduct = $this->Request()->getParam('shippingProduct', []);
        if (!is_array($shippingProduct) || !isset($shippingProduct['provider']) || !isset($shippingProduct['productId'])) {
            throw new ApiException\CustomValidationException('Parameter "shippingProduct" must be an array containing both a "provider" and a "productId".');
        }

        // Load the shipping provider with the given name
        /** @var ShippingProviderRepositoryService $providerRepository */
        $providerRepository = $this->get('viison_pickware_mobile.shipping_provider_repository');
        $provider = $providerRepository->findProvider(function (ShippingProvider $provider) use ($shippingProduct) {
            return $provider->getIdentifier() === $shippingProduct['provider'];
        });
        if (!$provider) {
            throw new ApiException\NotFoundException(
                sprintf('The shipping provider "%s" does not exist', $shippingProduct['provider'])
            );
        }

        // Get the (optional) package information (dimension and weight) and options
        $packageInfo = $this->Request()->getParam('package', []);
        $options = $this->Request()->getParam('options', []);

        // Check for address and prepare it for label creation
        $address = $this->Request()->getParam('address');
        if (is_array($address)) {
            if (!isset($address['additionalAddressLine'])) {
                // Assemble additional address line
                $address['additionalAddressLine'] = implode(', ', array_filter([
                    $address['additional_address_line1'],
                    $address['additional_address_line2'],
                ]));
                unset($address['additional_address_line1']);
                unset($address['additional_address_line2']);
            }

            // Convert keys to all lower case to make it compatible with shipping providers
            $newAddress = [];
            foreach ($address as $key => $value) {
                $newAddress[mb_strtolower($key)] = $value;
            }
            $address = $newAddress;

            // Make sure the customer's email address and phone number are set
            if (!isset($address['email'])) {
                $address['email'] = $order['customer']['email'];
            }
            if (!isset($address['phone'])) {
                $address['phone'] = $order['billing']['phone'];
            }
        }

        // Collect all items, which have a picked quantity greater 0
        $pickedItems = [];
        foreach ($order['details'] as $item) {
            if ($item['attribute']['pickwarePickedQuantity'] > 0) {
                $pickedItems[$item['id']] = $item['attribute']['pickwarePickedQuantity'];
            }
        }

        // Try to create a new shipping label using the given provider
        try {
            $result = $provider->addLabelToOrder(
                $orderId,
                $shippingProduct['productId'],
                $packageInfo['weight'],
                $packageInfo['dimensions'],
                $options,
                $pickedItems,
                $address
            );
        } catch (\Exception $e) {
            /** @var ExceptionTranslator $exceptionTranslator */
            $exceptionTranslator = $this->get('viison_common.exception_translator');
            throw new ApiException\CustomValidationException(
                $exceptionTranslator->translate($e),
                $e->getCode(),
                $e
            );
        }

        // Create the response data
        $responseData = [
            'newTrackingCode' => $result->getNewTrackingCode(),
            'trackingCodes' => $result->getTrackingCodes(),
        ];

        // Add all documents to the response data and generate their resource URLs
        $responseData['documents'] = array_map(function (ShippingDocument $document) use ($provider) {
            return [
                'identifier' => $document->getIdentifier(),
                'typeId' => ApiOrdersSubscriber::createShippingDocumentType($document, $provider),
                'pageSize' => $document->getPageSize(),
                'trackingCode' => $document->getTrackingCode(),
            ];
        }, $result->getDocuments());

        if (!empty($responseData['newTrackingCode'])) {
            // Get the previously undispatched tracking codes
            $undispatchedCodes = $order['attribute']['viisonUndispatchedTrackingCodes'];
            $undispatchedCodes = ViisonCommonUtil::safeExplode(',', $undispatchedCodes);

            // Add the newly created tracking code to the list of undispatched tracking codes and save them
            $undispatchedCodes[] = $responseData['newTrackingCode'];
            $undispatchedCodes = implode(',', $undispatchedCodes);
            $this->get('db')->query(
                'UPDATE s_order_attributes
                SET viison_undispatched_tracking_codes = :trackingCodes
                WHERE orderID = :orderId',
                [
                    'trackingCodes' => $undispatchedCodes,
                    'orderId' => $orderId,
                ]
            );

            // Add all undispatched tracking codes to the response data
            $responseData['undispatchedTrackingCodes'] = $undispatchedCodes;
        }

        $this->Response()->setHttpResponseCode(201);
        $this->View()->assign([
            'success' => true,
            'data' => $responseData,
        ]);
    }

    /**
     * PUT /api/orders/{id}/returnLabels
     *
     * @throws \Exception
     */
    public function putReturnLabelsAction()
    {
        ResourceManager::getResource('order')->checkPrivilege('update');

        $this->executeIdempotently([
            RestApiIdempotentOperation::RECOVERY_POINT_STARTED => function () {
                // Try to find the order
                $orderId = $this->Request()->getParam('id');
                $order = $this->get('models')->find(Order::class, $orderId);
                if (!$order) {
                    throw new ApiException\NotFoundException(sprintf('Order with ID %d not found.', $orderId));
                }

                // Try to find a shipping provider for the order that supports return label creation
                $shippingProviderRepository = $this->get('viison_pickware_mobile.shipping_provider_repository');
                $shippingProvider = $shippingProviderRepository->findProviderForOrderWithId($order->getId());
                if (!$shippingProvider) {
                    throw new ApiException\CustomValidationException(sprintf(
                        'Could not find a shipping provider for order with ID %d.',
                        $order->getId()
                    ));
                }
                if (!is_subclass_of($shippingProvider, ReturnLabelCreation::class)) {
                    throw new ApiException\CustomValidationException(sprintf(
                        'Shipping provider "%s" responsible for order with ID %d does not support creating return labels.',
                        $shippingProvider->getName(),
                        $order->getId()
                    ));
                }

                // Determine estimated return shipment weight based on the picked order items
                $orderPickingService = $this->get('viison_pickware_mobile.order_picking_service');
                $pickedItemQuantities = $orderPickingService->getPickedItemQuantitiesOfOrder($order);
                $pickedItemQuantities = (count($pickedItemQuantities) > 0) ? $pickedItemQuantities : null;
                $weight = $shippingProvider->determineShipmentWeight($order->getId(), $pickedItemQuantities);

                try {
                    $returnLabel = $shippingProvider->createReturnLabel($order, $weight);
                } catch (\Exception $e) {
                    throw new \Exception(
                        $this->get('viison_common.exception_translator')->translate($e),
                        $e->getCode(),
                        $e
                    );
                }

                return new ResponseResult(
                    201,
                    [
                        'success' => true,
                        'data' => [
                            'typeId' => self::makeShippingDocumentTypeIdGloballyUnique(
                                $returnLabel->documentType,
                                $shippingProvider
                            ),
                            'pageSize' => $returnLabel->pageSize,
                            'fileData' => base64_encode($returnLabel->fileData),
                        ],
                    ]
                );
            },
        ]);
    }

    /**
     * Idempotently creates or updates a picked quantity entity for the given order detail, bin location, quantity and
     * stock entry items. If the ID of a valid stock reservation is provided, it is updated according to the given
     * quantity.
     *
     * PUT /api/orders/{id}/details/{details}/pickedQuantities
     */
    public function putDetailsPickedQuantitiesAction()
    {
        // Check the privileges
        ResourceManager::getResource('order')->checkPrivilege('update');

        $this->executeIdempotently([
            RestApiIdempotentOperation::RECOVERY_POINT_STARTED => function () {
                // Try to get the order
                $orderId = $this->Request()->getParam('id', 0);
                /** @var Order $order */
                $order = $this->get('models')->find(Order::class, $orderId);
                if (!$order) {
                    $this->Response()->setHttpResponseCode(404);
                    throw new ApiException\NotFoundException(
                        sprintf('Order with ID %d not found.', $orderId)
                    );
                }

                // Try to find the order detail in the order
                $orderDetailId = intval($this->Request()->getParam('details'));
                $orderDetail = $order->getDetails()->filter(function (OrderDetail $orderDetail) use ($orderDetailId) {
                    return $orderDetail->getId() === $orderDetailId;
                })->first();
                if (!$orderDetail) {
                    $this->Response()->setHttpResponseCode(404);
                    throw new ApiException\NotFoundException(
                        sprintf('Order detail with ID %d not found in order with ID %d.', $orderDetailId, $orderId)
                    );
                }

                // Validate parameters
                $quantity = intval($this->Request()->getParam('quantity'));
                if ($quantity <= 0) {
                    throw new ApiException\CustomValidationException(
                        sprintf('Invalid quantity: %d', $quantity)
                    );
                }
                $binLocationId = intval($this->Request()->getParam('binLocationId'));
                $binLocation = $this->get('models')->find(BinLocation::class, $binLocationId);
                if (!$binLocation) {
                    throw new ApiException\CustomValidationException(
                        sprintf('Bin location with ID %d does not exist.', $binLocationId)
                    );
                }
                $stockEntryItemData = $this->Request()->getParam('stockItems', []);

                // Flatten the stock entry item data
                $flattenedStockEntryItemData = [];
                foreach ($stockEntryItemData as $itemData) {
                    if (!is_array($itemData['propertyValues']) || count($itemData['propertyValues']) === 0) {
                        continue;
                    }

                    // Flatten the property data arrays to type/value pairs
                    $flattenedPropertyData = [];
                    foreach ($itemData['propertyValues'] as $propertyData) {
                        $flattenedPropertyData[$propertyData['itemPropertyId']] = $propertyData['value'];
                    }
                    $flattenedStockEntryItemData[] = $flattenedPropertyData;
                }

                // Save the the newly picked quantity
                /** @var PickedQuantity $pickedQuantity */
                $pickedQuantity = $this->get('viison_pickware_mobile.picked_quantity_updater')->increasePickedQuantity(
                    $orderDetail,
                    $binLocation,
                    $quantity,
                    $flattenedStockEntryItemData
                );

                // Update or delete the stock reservation, if provided
                $stockReservationId = $this->Request()->getParam('stockReservationId', 0);
                /** @var OrderStockReservation $stockReservation */
                $stockReservation = $this->get('models')->find(OrderStockReservation::class, $stockReservationId);
                if ($stockReservation) {
                    if ($stockReservation->getQuantity() > $quantity) {
                        $stockReservation->setQuantity($stockReservation->getQuantity() - $quantity);
                    } else {
                        $this->get('models')->remove($stockReservation);
                    }
                    $this->get('models')->flush($stockReservation);

                    // Update the cached reserved stocks
                    $this->get('pickware.erp.derived_property_updater_service')->recalculateReservedStockForArticleDetailInWarehouse(
                        $stockReservation->getArticleDetailBinLocationMapping()->getArticleDetail(),
                        $stockReservation->getArticleDetailBinLocationMapping()->getBinLocation()->getWarehouse()
                    );
                }

                // Get the updated picked quantity data
                $pickedQuantitiesOfOrderDetail = $this->get('models')->getRepository(PickedQuantity::class)->getPickedQuantityArrays([
                    $orderDetail->getId()
                ]);
                $relevantPickedQuantitiesOfOrderDetail = array_values(array_filter(
                    $pickedQuantitiesOfOrderDetail[$orderDetail->getId()],
                    function (array $data) use ($pickedQuantity) {
                        return $data['id'] === $pickedQuantity->getId();
                    }
                ));
                $pickedQuantityData = $relevantPickedQuantitiesOfOrderDetail[0];

                // Get the updated bin location mappings of the respective article detail
                /** @var ArticleDetail $articleDetail */
                $articleDetail = $this->get('models')->getRepository(ArticleDetail::class)->findOneBy([
                    'number' => $orderDetail->getArticleNumber(),
                ]);
                $binLocationMappings = PickwareUtil::getVariantBinLocationMappingArrays([
                    $articleDetail->getId()
                ]);

                // Build response data
                $responseData = [
                    'pickedQuantity' => $pickedQuantityData,
                ];
                if (isset($binLocationMappings[$articleDetail->getId()])) {
                    $responseData['articleDetail'] = [
                        'binLocationMappings' => $binLocationMappings[$articleDetail->getId()],
                    ];
                }

                return new ResponseResult(201, [
                    'success' => true,
                    'data' => $responseData,
                ]);
            },
        ]);
    }

    /**
     * Deletes all picked quantities of the given order detail.
     *
     * DELETE /api/orders/{id}/details/{details}/pickedQuantities
     */
    public function deleteDetailsPickedQuantitiesAction()
    {
        // Check the privileges
        ResourceManager::getResource('order')->checkPrivilege('update');

        // Try to get the order and its detail
        $order = $this->findOrderInRequest();
        $orderDetail = $this->findOrderDetailInRequest($order);

        // Clear the order detail's picked quantities and revert their stock changes
        $moveStockToOriginalLocation = $this->Request()->getParam('moveStockToOriginalLocation', false);
        $this->get('viison_pickware_mobile.picked_quantity_updater')->clearPickedQuantities(
            $orderDetail,
            true,
            $moveStockToOriginalLocation
        );

        // Add the updated bin location mappings of the respective article detail to the response
        $responseData = [
            'articleDetail' => [
                'binLocationMappings' => $this->getBinLocationMappingData($orderDetail),
            ],
        ];

        $this->View()->assign([
            'success' => true,
            'data' => $responseData,
        ]);
    }

    /**
     * Deletes all stock reservations of the given order detail.
     *
     * DELETE /api/orders/{id}/details/{details}/stockReservations
     */
    public function deleteDetailsStockReservationsAction()
    {
        // Check the privileges
        ResourceManager::getResource('order')->checkPrivilege('update');

        // Try to get the order and its detail
        $order = $this->findOrderInRequest();
        $orderDetail = $this->findOrderDetailInRequest($order);

        // Clear the order detail's stock reservations in all warehouses
        $allWarehouses = $this->get('models')->getRepository(Warehouse::class)->findAll();
        $pickwareErpStockReservation = $this->get('pickware.erp.stock_reservation_service');
        foreach ($allWarehouses as $warehouse) {
            $pickwareErpStockReservation->clearStockReservationsForOrderDetail($warehouse, $orderDetail);
        }

        // Add the updated bin location mappings of the respective article detail to the response
        $responseData = [
            'articleDetail' => [
                'binLocationMappings' => $this->getBinLocationMappingData($orderDetail),
            ],
        ];

        $this->View()->assign([
            'success' => true,
            'data' => $responseData,
        ]);
    }

    /**
     * @return Order
     * @throws ApiException\NotFoundException if the order with the ID found in the request does not exist.
     */
    protected function findOrderInRequest()
    {
        $orderId = $this->Request()->getParam('id', 0);
        $order = $this->get('models')->find(Order::class, $orderId);
        if (!$order) {
            $this->Response()->setHttpResponseCode(404);
            throw new ApiException\NotFoundException(
                sprintf('Order with ID %d not found.', $orderId)
            );
        }

        return $order;
    }

    /**
     * @param Order $order
     * @return OrderDetail
     * @throws ApiException\NotFoundException if the order detail with the ID found in the request does not exist or is
     *                                        not part of the given $order.
     */
    protected function findOrderDetailInRequest(Order $order)
    {
        $orderDetailId = intval($this->Request()->getParam('details'));
        $orderDetail = $order->getDetails()->filter(function (OrderDetail $orderDetail) use ($orderDetailId) {
            return $orderDetail->getId() === $orderDetailId;
        })->first();
        if (!$orderDetail) {
            $this->Response()->setHttpResponseCode(404);
            throw new ApiException\NotFoundException(
                sprintf('Order detail with ID %d not found in order with ID %d.', $orderDetailId, $order->getId())
            );
        }

        return $orderDetail;
    }

    /**
     * @param OrderDetail $orderDetail
     * @return array
     */
    protected function getBinLocationMappingData(OrderDetail $orderDetail)
    {
        /** @var ArticleDetail $articleDetail */
        $articleDetail = $this->get('models')->getRepository(ArticleDetail::class)->findOneBy([
            'number' => $orderDetail->getArticleNumber(),
        ]);
        if (!$articleDetail) {
            return [];
        }

        $articleDetailId = $articleDetail->getId();
        $binLocationMappings = PickwareUtil::getVariantBinLocationMappingArrays([
            $articleDetailId
        ]);

        return (isset($binLocationMappings[$articleDetailId])) ? $binLocationMappings[$articleDetailId] : [];
    }

    /**
     * Creates a new return shipment including its items and attachments and adds it to requested order.
     *
     * POST /api/orders/{id}/returnShipments
     */
    public function postReturnShipmentsAction()
    {
        try {
            // Check the privileges
            ResourceManager::getResource('order')->checkPrivilege('update');

            $orderId = $this->Request()->getParam('id');
            $returnShipmentData = $this->Request()->getPost();
            $this->validateReturnShipmentData($orderId, $returnShipmentData);

            $this->executeIdempotently([
                RestApiIdempotentOperation::RECOVERY_POINT_STARTED => function () {
                    /** @var ModelManager $entityManager */
                    $entityManager = $this->get('models');
                    /** @var ReturnShipmentProcessor $returnShipmentService */
                    $returnShipmentService = $this->get('pickware.erp.return_shipment_processor_service');

                    // As part of processing this idempotent operation, stock entries will be written for different
                    // ArticleDetails consecutively. For each of these ArticleDetails, the StockLedgerService will take
                    // `SELECT ... FOR UPDATE` lock. Since the order in which these ArticleDetails depends on their
                    // order in the request, it is theoretically possible to have two concurrent requests go into a
                    // deadlock. Because of this, the code below locks all ArticleDetails for which this request might
                    // write stock entries in a single query.
                    // At the moment, hitting the deadlock scenario is not actually possible, because generating a
                    // sequence number for the new return shipment happens first and will cause all operations for this
                    // action to be executed sequentially anyway, but adding this lock as an extra precaution against
                    // very surprising effects of future code restructuring seems prudent.
                    $orderDetailIds = array_map(
                        function ($itemData) {
                            return $itemData['orderDetailId'];
                        },
                        $this->Request()->getParam('items', [])
                    );
                    $this->lockArticleDetailIdsForOrderDetailIds($orderDetailIds);

                    /** @var Order $order */
                    $order = $entityManager->find(Order::class, $this->Request()->getParam('id'));
                    /** @var Warehouse $warehouse */
                    $warehouse = $entityManager->find(Warehouse::class, $this->Request()->getParam('warehouseId'));

                    $returnShipment = $returnShipmentService->createReturnShipment($order);
                    $returnShipment->setTargetWarehouse($warehouse);
                    $returnShipment->setUser(ViisonCommonUtil::getCurrentUser());

                    $commentData = $this->Request()->getParam('comment');
                    if ($commentData !== null) {
                        $comment = new ReturnShipmentInternalComment($returnShipment);
                        $comment->setComment($commentData);
                        $entityManager->persist($comment);
                    }

                    // Add the attachments to the return shipment
                    foreach ($this->Request()->getParam('attachments', []) as $attachmentKey => $attachmentData) {
                        /** @var Media $media */
                        $media = $entityManager->find(Media::class, $attachmentData['mediaId']);
                        $attachment = new ReturnShipmentAttachment($returnShipment, $media);
                        $entityManager->persist($returnShipment);
                        $entityManager->persist($attachment);
                    }
                    $entityManager->persist($returnShipment);
                    $entityManager->flush($returnShipment);

                    /** @var BarcodeLabelFacade $barcodeLabelService */
                    $barcodeLabelService = $this->get('pickware.erp.barcode_label_service');
                    $barcodeLabelType = $barcodeLabelService->getBarcodeLabelTypeByName(ArticleBarcodeLabelType::IDENTIFIER);

                    $itemsData = $this->Request()->getParam('items', []);
                    foreach ($itemsData as $itemData) {
                        /** @var OrderDetail $orderDetail */
                        $orderDetail = $entityManager->find(OrderDetail::class, $itemData['orderDetailId']);

                        $newReturnedQuantity = intval($itemData['returnedQuantity']);
                        $newWrittenOffQuantity = intval($itemData['writtenOffQuantity']);

                        $item = new ReturnShipmentItem($returnShipment, $orderDetail);
                        $validValues = $returnShipmentService->isReturnedAndWrittenOffQuantityAllowed($item, $newReturnedQuantity, $newWrittenOffQuantity);
                        if (!$validValues) {
                            throw ReturnShipmentException::returnedQuantityOrWrittenOffQuantityNotAllowed($newReturnedQuantity, $newWrittenOffQuantity, $item);
                        }
                        $item->setReturnedQuantity($newReturnedQuantity);
                        $item->setWrittenOffQuantity($newWrittenOffQuantity);
                        $entityManager->persist($item);
                        $entityManager->flush($item);

                        $returnShipmentService->writeStockEntriesForItemChangesOnBinLocation($item, $warehouse->getNullBinLocation());

                        // Mark the returned quantity for barcode label printing, if necessary
                        $articleDetail = OrderDetailUtil::getArticleDetailForOrderDetail($orderDetail);
                        if ($articleDetail) {
                            $stockedQuantity = $item->getReturnedQuantity() - $item->getWrittenOffQuantity();
                            if ($stockedQuantity > 0 && $itemData['createBarcodeLabels'] === true) {
                                $barcodeLabelType->enqueueForPrinting($articleDetail->getNumber(), $stockedQuantity);
                            }
                        }
                    }

                    /** @var ReturnShipmentStatus $statusReceived */
                    $statusReceived = $entityManager->find(ReturnShipmentStatus::class, ReturnShipmentStatus::STATUS_RECEIVED_ID);
                    $returnShipment->setStatus($statusReceived);
                    $entityManager->flush($returnShipment);
                    $returnShipmentService->updateAccumulatedReturnShipmentStatus($order);

                    /** @var Enlight_Config $pluginConfig */
                    $pluginConfig = $this->get('plugins')->get('Core')->get('ViisonPickwareMobile')->Config();
                    if ($pluginConfig->get('stockingAppSendReshipmentReceivedMails')) {
                        try {
                            $this->get('pickware.erp.return_shipment_mailing_service')->sendReturnReceivedEmail($returnShipment);
                        } catch (ReturnShipmentMailingException $e) {
                            // Do not send a 500 API response. Just log the error.
                            $this->get('pluginlogger')->error(
                                sprintf(
                                    'Return shipment notice mail could not be sent because of the following error: %s',
                                    $e->getMessage()
                                ),
                                [
                                    'orderNumber' => $returnShipment->getOrder()->getNumber(),
                                    'exception' => $e,
                                ]
                            );
                        }
                    }

                    // Determine the updated bin location mappings of all article details associated with the return items
                    $articleDetailIds = [];
                    foreach ($returnShipment->getItems() as $item) {
                        /** @var ArticleDetail $articleDetail */
                        $articleDetail = $this->get('models')->getRepository(ArticleDetail::class)->findOneBy([
                            'number' => $item->getOrderDetail()->getArticleNumber(),
                        ]);
                        if ($articleDetail) {
                            $articleDetailIds[$item->getId()] = $articleDetail->getId();
                        }
                    }
                    $binLocationMappings = PickwareUtil::getVariantBinLocationMappingArrays(array_values($articleDetailIds));

                    // Prepare response data
                    $returnShipmentItemData = array_map(
                        function (ReturnShipmentItem $item) use ($articleDetailIds, $binLocationMappings) {
                            // Try to find bin location mappings
                            $itemBinLocationMappings = [];
                            if ($articleDetailIds[$item->getId()] && $binLocationMappings[$articleDetailIds[$item->getId()]]) {
                                $itemBinLocationMappings = $binLocationMappings[$articleDetailIds[$item->getId()]];
                            }

                            return [
                                'id' => $item->getId(),
                                'orderDetailId' => $item->getOrderDetail()->getId(),
                                'returnedQuantity' => $item->getReturnedQuantity(),
                                'writtenOffQuantity' => $item->getWrittenOffQuantity(),
                                'articleDetail' => [
                                    'binLocationMappings' => $itemBinLocationMappings,
                                ],
                            ];
                        },
                        $returnShipment->getItems()->toArray()
                    );
                    $responseData = [
                        'id' => $returnShipment->getId(),
                        'items' => $returnShipmentItemData,
                        'location' => $this->apiBaseUrl . 'orders/' . $order->getId() . '/returnShipments/' . $returnShipment->getId(),
                    ];

                    return new ResponseResult(201, [
                        'success' => true,
                        'data' => $responseData,
                    ]);
                },
            ]);
        } catch (\Exception $e) {
            $this->handleException($e);
        }
    }

    /**
     * @param int $orderId
     * @param array $returnShipmentData
     * @throws CustomValidationException
     */
    protected function validateReturnShipmentData($orderId, array $returnShipmentData)
    {
        /** @var ModelManager $entityManager */
        $entityManager = $this->get('models');

        ParameterValidator::assertIsInteger($orderId, 'id');
        /** @var Order $order */
        $order = $entityManager->find(Order::class, $orderId);
        ParameterValidator::assertEntityFound($order, Order::class, $orderId, 'id');

        ParameterValidator::assertIsInteger($returnShipmentData['warehouseId'], 'warehouseId');
        /** @var Warehouse $warehouse */
        $warehouse = $entityManager->find(Warehouse::class, $returnShipmentData['warehouseId']);
        ParameterValidator::assertEntityFound($warehouse, Warehouse::class, $returnShipmentData['warehouseId'], 'warehouseId');

        if (isset($returnShipmentData['items'])) {
            ParameterValidator::assertIsArray($returnShipmentData['items'], 'items');

            foreach ($returnShipmentData['items'] as $returnShipmentItemKey => $returnShipmentItemData) {
                $treePosition = 'items[' . $returnShipmentItemKey . ']';
                ParameterValidator::assertIsArray($returnShipmentItemData, $treePosition);
                $treePosition .= '.';

                ParameterValidator::assertIsInteger($returnShipmentItemData['orderDetailId'], $treePosition . 'orderDetailId');
                /** @var OrderDetail $orderDetail */
                $orderDetail = $entityManager->find(OrderDetail::class, $returnShipmentItemData['orderDetailId']);
                ParameterValidator::assertEntityFound($orderDetail, OrderDetail::class, $returnShipmentItemData['orderDetailId'], $treePosition . 'orderDetailId');

                if ($orderDetail->getOrder()->getId() !== $order->getId()) {
                    throw new CustomValidationException(
                        $treePosition . 'orderDetailId',
                        $returnShipmentItemData['orderDetailId'],
                        'The OrderDetail (given by named id) does not belong to the given ReturnShipment'
                    );
                }

                if (isset($returnShipmentItemData['returnedQuantity'])) {
                    ParameterValidator::assertIsInteger($returnShipmentItemData['returnedQuantity'], $treePosition . 'returnedQuantity');
                }

                if (isset($returnShipmentItemData['writtenOffQuantity'])) {
                    ParameterValidator::assertIsInteger($returnShipmentItemData['writtenOffQuantity'], $treePosition . 'writtenOffQuantity');
                }
            }
        }

        if (isset($returnShipmentData['attachments'])) {
            ParameterValidator::assertIsArray($returnShipmentData['attachments'], 'attachments');

            foreach ($returnShipmentData['attachments'] as $attachmentKey => $attachmentData) {
                $treePosition = 'attachments[' . $attachmentKey . ']';
                ParameterValidator::assertIsArray($attachmentData, $treePosition);
                $treePosition .= '.';

                ParameterValidator::assertIsInteger($attachmentData['mediaId'], $treePosition . 'mediaId');
                /** @var Media $media */
                $media = $entityManager->find(Media::class, $attachmentData['mediaId']);
                ParameterValidator::assertEntityFound($media, Order::class, $attachmentData['mediaId'], $treePosition . 'mediaId');
            }
        }
    }

    /**
     * Calls ArticleDetailConcurrencyCoordinator to acquire stock-change locks for all article details referenced by
     * the given order detail ids.
     *
     * @see ArticleDetailConcurrencyCoordinator
     *
     * @param $orderDetailIds
     * @throws Exception
     */
    protected function lockArticleDetailIdsForOrderDetailIds($orderDetailIds)
    {
        /** @var DbalConnection $dbalConnection */
        $dbalConnection = $this->get('dbal_connection');
        /** @var ArticleDetailConcurrencyCoordinator $articleDetailConcurrencyCoordinator */
        $articleDetailConcurrencyCoordinator = $this->get(
            'pickware.erp.article_detail_concurrency_coordinator_service'
        );

        $articleDetailsFetchResult = $dbalConnection->fetchAll(
            'SELECT articleDetail.id
            FROM s_order_details orderDetail
            LEFT JOIN s_articles_details articleDetail ON articleDetail.ordernumber = orderDetail.articleordernumber
            WHERE orderDetail.id IN (:orderDetailIds)',
            [
                'orderDetailIds' => $orderDetailIds,
            ],
            [
                'orderDetailIds' => DbalConnection::PARAM_INT_ARRAY,
            ]
        );
        $articleDetailIds = array_values(array_map(
            function ($articleDetail) {
                return $articleDetail['id'];
            },
            $articleDetailsFetchResult
        ));
        $articleDetailConcurrencyCoordinator->lockArticleDetailIds(...$articleDetailIds);
    }

    /**
     * Locks the given $order, by setting the order status to 'in process' as well as the 'pickwareProcessingWarehouseId'
     * attribute to the ID of the given $warehouse. If the optional $boxId is given, it is set in the
     * 'pickwareBatchPickingBoxId' attribute.
     *
     * @param Order $order
     * @param Warehouse $warehouse
     * @param int|null $boxId
     * @param bool $force whether to also lock orders that are already in the state "in process" (1)
     *
     * @throws OrderAlreadyLockedException iff $force is false and the order was already locked by another process by
     * setting its status to "in process" (1).
     */
    protected function lockOrder(Order $order, Warehouse $warehouse, $boxId = null, $force = false)
    {
        /*
         * Up to version 4.1.4 of Pickware Mobile, the code here was not concurrency-safe. It was possible for two users
         * to start locking the same orders at the same time. This was particularly likely when two users started any of
         * the batch picking modes simultaneously, because the order filter would select the same orders for those.
         *
         * In order to prevent this issue, the following native SQL query uses the ACID properties of the database to
         * ensure that an order can only be locked by a single API request (i.e. user). This works by trying to update
         * the order state of an order to "in process" (1). Since the order was returned from the order filter, its
         * state should either be "open" (0) or "partially delivered" (6). If that order's state is "in process" (1)
         * now, that means the order was locked by a different process in the mean time. When executing the UPDATE query
         * results in 0 affected rows, we know that the order was locked concurrently.
         *
         * Because the database guarantees that only one process can successfully perform the UPDATE query below, we can
         * be certain that we are the only process who locked the order once we get a result of "1 row(s) affected".
         *
         * Please DO NOT try to replace the UPDATE query below with Doctrine calls, because that will not be
         * concurrency-safe.
         *
         * Please note that we don't call $this->get('models')->refresh($order) on purpose so the flush below still
         * triggers any model subscribers (including \Shopware\Models\Order\OrderHistorySubscriber).
         */
        /** @var Zend_Db_Adapter_Abstract $db */
        $db = $this->get('db');
        $updateResult = $db->query(
            'UPDATE s_order
            SET status = :orderStatusInProgress
            WHERE
                id = :orderId
                AND status != :orderStatusInProgress',
            [
                'orderId' => $order->getId(),
                'orderStatusInProgress' => OrderStatus::ORDER_STATE_IN_PROCESS,
            ]
        );
        if ($updateResult->rowCount() == 0 && !$force) {
            // Order was locked concurrently in the meantime
            throw new OrderAlreadyLockedException($order->getNumber());
        }

        // Reserve the pickable stock of all order details. We must clear all currently reserved stock first to make
        // sure to get the 'optimal' reservations.
        /** @var \Shopware\Plugins\ViisonPickwareERP\Components\StockReservation\StockReservation $pickwareErpStockReservation */
        $pickwareErpStockReservation = $this->get('pickware.erp.stock_reservation_service');
        foreach ($order->getDetails() as $orderDetail) {
            $pickwareErpStockReservation->clearStockReservationsForOrderDetail($warehouse, $orderDetail);
        }
        /** @var \Shopware\Plugins\ViisonPickwareMobile\Components\StockReservationService $mobileStockReservationService */
        $mobileStockReservationService = $this->get('viison_pickware_mobile.stock_reservation');
        foreach ($order->getDetails() as $orderDetail) {
            $mobileStockReservationService->reservePickableStockForOrderDetail($warehouse, $orderDetail);
        }

        // Update the status to "in process" (1) again so \Shopware\Models\Order\OrderHistorySubscriber is triggered
        $inProgressStatus = $this->get('models')->find(OrderStatus::class, OrderStatus::ORDER_STATE_IN_PROCESS);
        $order->setOrderStatus($inProgressStatus);
        // Set the processing warehouse and batch picking box ID
        $order->getAttribute()->setPickwareProcessingWarehouseId($warehouse->getId());
        if ($boxId !== null) {
            $order->getAttribute()->setPickwareBatchPickingBoxId($boxId);
        }
        $order->getAttribute()->setPickwareWmsShipmentGuid(UuidUtil::generateUuidV4());

        // Save changes
        $this->get('models')->flush([
            $order,
            $order->getAttribute(),
        ]);
    }

    /**
     * @param int $orderId
     * @param PickProfile $pickProfile
     * @param Warehouse $warehouse
     * @return array
     */
    private function getOrderData($orderId, PickProfile $pickProfile, Warehouse $warehouse)
    {
        /** @var OrderResource $orderResource */
        $orderResource = ResourceManager::getResource('order');
        $orderData = $orderResource->getOne($orderId);
        $this->Request()->setAttribute(ApiOrdersSubscriber::REQUEST_ATTR_PICK_PROFILE, $pickProfile);
        $this->Request()->setAttribute(ApiOrdersSubscriber::REQUEST_ATTR_WAREHOUSE, $warehouse);
        $orderData = $this->get('viison_pickware_common.rest_api_orders_resource_service')->enrichOrderListData(
            [$orderData],
            $this->Request()
        );

        return $orderData[0];
    }
}
