OrderRequestServiceImpl.java

/*
 * Copyright 2020 Global Crop Diversity Trust
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.gringlobal.service.impl;

import java.io.OutputStream;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.text.MessageFormat;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;

import javax.validation.Valid;
import javax.validation.constraints.NotNull;

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.binary.StringUtils;
import org.apache.commons.collections4.CollectionUtils;
import org.gringlobal.api.exception.InvalidApiUsageException;
import org.gringlobal.custom.elasticsearch.SearchException;
import org.gringlobal.model.Inventory;
import org.gringlobal.model.InventoryAction;
import org.gringlobal.model.InventoryMaintenancePolicy;
import org.gringlobal.model.OrderRequest;
import org.gringlobal.model.OrderRequestAction;
import org.gringlobal.model.OrderRequestAttach;
import org.gringlobal.model.OrderRequestItem;
import org.gringlobal.model.QInventory;
import org.gringlobal.model.QOrderRequest;
import org.gringlobal.model.QOrderRequestAction;
import org.gringlobal.model.QOrderRequestAttach;
import org.gringlobal.model.QOrderRequestItem;
import org.gringlobal.model.community.CommunityCodeValues;
import org.gringlobal.persistence.InventoryActionRepository;
import org.gringlobal.persistence.InventoryMaintenancePolicyRepository;
import org.gringlobal.persistence.InventoryRepository;
import org.gringlobal.persistence.OrderRequestActionRepository;
import org.gringlobal.persistence.OrderRequestItemRepository;
import org.gringlobal.persistence.OrderRequestRepository;
import org.gringlobal.service.InventoryService;
import org.gringlobal.service.OrderRequestActionService;
import org.gringlobal.service.OrderRequestActionService.OrderRequestActionRequest;
import org.gringlobal.service.OrderRequestActionService.OrderRequestActionScheduleFilter;
import org.gringlobal.service.OrderRequestAttachmentService;
import org.gringlobal.service.OrderRequestItemService;
import org.gringlobal.service.OrderRequestService;
import org.gringlobal.service.filter.OrderRequestActionFilter;
import org.gringlobal.service.filter.OrderRequestFilter;
import org.gringlobal.service.filter.OrderRequestItemFilter;
import org.gringlobal.service.glis.impl.GlisSMTAReportingManager;
import org.hibernate.Hibernate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import com.querydsl.core.types.EntityPath;
import com.querydsl.core.types.Predicate;
import com.querydsl.core.types.dsl.NumberPath;
import com.querydsl.jpa.impl.JPAQuery;

import static org.gringlobal.service.glis.impl.GlisSMTAReportingManager.RESPONSE_ERROR;

/**
 * @author Maxym Borodenko
 */
@Service
@Transactional(readOnly = true)
@Slf4j
public class OrderRequestServiceImpl extends FilteredCRUDService2Impl<OrderRequest, OrderRequestFilter, OrderRequestRepository> implements OrderRequestService {

	@Autowired
	private InventoryRepository inventoryRepository;
	
	@Autowired
	private InventoryService inventoryService;

	@Autowired
	private InventoryMaintenancePolicyRepository inventoryMaintenancePolicyRepository;

	@Autowired
	private InventoryActionRepository inventoryActionRepository;

	@Autowired
	private OrderRequestItemRepository itemRepository;

	@Autowired
	private OrderRequestItemService itemService;

	@Autowired
	private OrderRequestActionService actionSupport;

	@Autowired
	private GlisSMTAReportingManager glisSMTAReportingManager;

	@Component
	protected static class ActionSupport extends BaseActionSupport<OrderRequest, OrderRequestAction, OrderRequestActionFilter, OrderRequestActionRepository, OrderRequestActionRequest, OrderRequestActionScheduleFilter>
			implements OrderRequestActionService {

		@Autowired
		private OrderRequestRepository orderRequestRepository;

		@Override
		protected EntityPath<OrderRequest> getOwningEntityPath() {
			return QOrderRequestAction.orderRequestAction.orderRequest();
		}

		@Override
		protected void initializeActionDetails(List<OrderRequestAction> actions) {
			actions.forEach(action -> {
				Hibernate.initialize(action.getOrderRequest());
				Hibernate.initialize(action.getOrderRequest().getFinalRecipientCooperator());
			});
		}

		@Override
		protected void applyOwningEntityFilter(OrderRequestActionScheduleFilter filter, String owningEntityAlias, List<Predicate> predicates) {
			QOrderRequest qOrderRequest = new QOrderRequest(owningEntityAlias);
			if (predicates != null && filter.orderRequest != null) {
				predicates.addAll(filter.orderRequest.collectPredicates(qOrderRequest));
			}
		}

		@Override
		protected OrderRequestAction createAction(OrderRequest owningEntity, OrderRequestActionRequest requestData) {
			OrderRequestAction action = new OrderRequestAction();
			action.setOrderRequest(owningEntity);
			return action;
		}

		@Override
		protected void updateAction(OrderRequestAction action, OrderRequestActionRequest request) {
			action.setActionCost(request.actionCost);
			action.setActionInformation(request.actionInformation);
		}

		@Override
		@Transactional
		public OrderRequestAction create(OrderRequestAction source) {
			log.debug("Create OrderRequestAction. Input data {}", source);
			OrderRequestAction entity = new OrderRequestAction();
			entity.apply(source);

			OrderRequestAction saved = get(repository.save(entity));
			saved.lazyLoad();

			return saved;
		}

		@Override
		protected Iterable<OrderRequest> findOwningEntities(Set<Long> id) {
			return orderRequestRepository.findAll(QOrderRequest.orderRequest.id.in(id));
		}

		@Override
		@Transactional
		public void logAction(OrderRequest orderRequest, String note, List<OrderRequestItem> updatedItems) {
			OrderRequestAction action = new OrderRequestAction();
			action.setOrderRequest(orderRequest);
			action.setActionNameCode(CommunityCodeValues.ORDER_REQUEST_ACTION_LOG.value);
			action.setStartedDate(Instant.now());
			action.setStartedDateCode(CommunityCodeValues.DATE_FORMAT_DATETIME.value);
			action.setCompletedDate(action.getStartedDate());
			action.setCompletedDateCode(CommunityCodeValues.DATE_FORMAT_DATETIME.value);
			if (CollectionUtils.isNotEmpty(updatedItems)) {
				action.setActionInformation(updatedItems.stream().map((item) -> item.getId().toString()).collect(Collectors.joining(";")));
			}
			action.setNote(note);
			actionRepository.save(action);
		}

		@Override
		public Page<OrderRequestAction> listByOrderRequest(OrderRequest orderRequest, Pageable page) {
			orderRequest = orderRequestRepository.getReferenceById(orderRequest.getId());

			// build filter
			OrderRequestActionFilter filter = new OrderRequestActionFilter();
			filter.orderRequest().id(Set.of(orderRequest.getId()));

			var query = jpaQueryFactory.selectFrom(QOrderRequestAction.orderRequestAction)
					// order request
					.join(QOrderRequestAction.orderRequestAction.orderRequest()).fetchJoin()
					// cooperator
					.leftJoin(QOrderRequestAction.orderRequestAction.cooperator()).fetchJoin()
					.where(filter.buildPredicate());

			return actionRepository.findAll(query, page);
		}
	}

	@Component
	protected static class AttachmentSupport extends BaseAttachmentSupport<OrderRequest, OrderRequestAttach, OrderRequestAttachmentService.OrderRequestAttachmentRequest> implements OrderRequestAttachmentService {

		public AttachmentSupport() {
			super(QOrderRequestAttach.orderRequestAttach.orderRequest().id, QOrderRequestAttach.orderRequestAttach.id);
		}

		@Override
		protected Path createRepositoryPath(OrderRequest orderRequest) {
			orderRequest = owningEntityRepository.getReferenceById(orderRequest.getId());
			return Paths.get("/ORA/" + orderRequest.getId());
		}

		@Override
		@PreAuthorize("hasAuthority('GROUP_ADMINS') or @ggceSec.actionAllowed('Request', 'WRITE')")
		protected OrderRequestAttach createAttach(OrderRequest entity, OrderRequestAttach source) {
			OrderRequestAttach attach = new OrderRequestAttach();
			attach.apply(source);
			attach.setVirtualPath(source.getVirtualPath()); // SOAP uses this to create the record
			attach.setOrderRequest(entity);
			return attach;
		}

		@Override
		@PreAuthorize("hasAuthority('GROUP_ADMINS') or @ggceSec.actionAllowed('Request', 'WRITE')")
		public OrderRequestAttach create(OrderRequestAttach source) {
			var owningEntity = owningEntityRepository.getReferenceById(source.getOrderRequest().getId());

			var attach = createAttach(owningEntity, source);
			var savedAttach = repository.save(attach);
			return _lazyLoad(savedAttach);
		}

		@Override
		@Transactional
		@PreAuthorize("hasAuthority('GROUP_ADMINS') or @ggceSec.actionAllowed('Request', 'WRITE')")
		public OrderRequestAttach update(OrderRequestAttach updated, OrderRequestAttach target) {
			target.apply(updated);
			return _lazyLoad(repository.save(target));
		}

		@Override
		@PreAuthorize("hasAuthority('GROUP_ADMINS') or @ggceSec.actionAllowed('Request', 'WRITE')")
		public OrderRequestAttach remove(OrderRequestAttach entity) {
			return super.remove(entity);
		}
	}

	@Override
	protected JPAQuery<OrderRequest> entityListQuery() {
		return jpaQueryFactory.selectFrom(QOrderRequest.orderRequest)
			// final recipient
			.join(QOrderRequest.orderRequest.finalRecipientCooperator()).fetchJoin()
			// requestor
			.leftJoin(QOrderRequest.orderRequest.requestorCooperator()).fetchJoin()
			// shipTo
			.leftJoin(QOrderRequest.orderRequest.shipToCooperator()).fetchJoin()
			// owner
			.join(QOrderRequest.orderRequest.ownedBy()).fetchJoin();
	}

	@Override
	protected NumberPath<Long> entityIdPredicate() {
		return QOrderRequest.orderRequest.id;
	}

	@Override
	public Page<OrderRequestItem> filterItems(final OrderRequest orderRequest, OrderRequestItemFilter filter, Pageable page) throws SearchException {
		filter.orderRequest = Sets.newHashSet(orderRequest.getId());
		var items = itemService.list(filter, page);
		items.getContent().forEach(item -> {
			if (item.getWithdrawnInventory() != null) {
				item.getWithdrawnInventory().getId();
			}
		});
		return items;
	}

	@Override
	@PreAuthorize("@ggceSec.actionAllowed('Request', 'READ')")
	public OrderRequestDetails getOrderRequestDetails(OrderRequest orderRequest) {
		orderRequest = reload(orderRequest);

		// initialize lazy data
		if (orderRequest.getAttachments() != null) {
			orderRequest.getAttachments().size();
			orderRequest.getAttachments().forEach(a -> {
				if (a.getRepositoryFile() != null) {
					a.getRepositoryFile().getId();
				}
				if (a.getAttachCooperator() != null) {
					a.getAttachCooperator().getId();
				}
			});
		}

		var details = new OrderRequestDetails();
		details.orderRequest = orderRequest;
		details.attachments = orderRequest.getAttachments();

		return details;
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('Request', 'ADMINISTRATION')")
	public List<OrderRequestItem> addInventories(OrderRequest orderRequest, List<OrderRequestedInventory> inventories) {

		AtomicInteger sequenceNumber = new AtomicInteger(0);
		LinkedList<OrderRequestedInventory> toAdd = new LinkedList<>(inventories);
		List<OrderRequestItem> existingItems;
		// don't add an item with the same inventory
		if (CollectionUtils.isNotEmpty(toAdd)) {
			existingItems = Lists.newArrayList(itemRepository.findAll(QOrderRequestItem.orderRequestItem.orderRequest().id.eq(orderRequest.getId())));
			if (!existingItems.isEmpty()) {
				existingItems.forEach(e -> toAdd.removeIf((ri) -> Objects.equals(e.getInventory().getId(), ri.inventoryId)));
				Integer maxSeqNo = existingItems.stream().map(OrderRequestItem::getSequenceNumber).filter(Objects::nonNull).max(Comparator.comparing(Integer::valueOf)).orElse(0);
				sequenceNumber.set(maxSeqNo);
			}
		}
		if (toAdd.isEmpty()) {
			return Collections.emptyList();
		}

		Map<Long, OrderRequestedInventory> orderRequestedInventories = new HashMap<>();
		toAdd.forEach(ri -> {
			orderRequestedInventories.put(ri.inventoryId, ri);
		});

		Set<Long> inventoryIds = orderRequestedInventories.keySet();

		List<OrderRequestItem> items = inventoryRepository.findAllById(inventoryIds).stream().map(inventory -> {
			OrderRequestItem item = new OrderRequestItem();
			item.setSequenceNumber(sequenceNumber.incrementAndGet());
			item.setOrderRequest(orderRequest);
			item.setInventory(inventory);
			if (! inventory.isSystemInventory()) {
				item.setDistributionFormCode(inventory.getDistributionDefaultFormCode());
				item.setQuantityShipped(inventory.getDistributionDefaultQuantity());
				item.setQuantityShippedUnitCode(inventory.getDistributionUnitCode());
			}

			OrderRequestedInventory orderRequestedInventory = orderRequestedInventories.get(inventory.getId());
			if (orderRequestedInventory != null) {
				item.setName(orderRequestedInventory.requestedName);
				item.setExternalTaxonomy(orderRequestedInventory.requestedTaxon);
			}

			item.setStatusCode(CommunityCodeValues.ORDER_REQUEST_ITEM_STATUS_NEW.value);
			return itemRepository.save(item);
		}).collect(Collectors.toList());

		log.debug("Done saving {} items.", items.size());
		return items;
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('Request', 'ADMINISTRATION')")
	public List<OrderRequestItem> setDefaultInventories(OrderRequest orderRequest, Set<Long> itemIds) {
		List<OrderRequestItem> existingItems = Lists.newArrayList(itemRepository.findAll(QOrderRequestItem.orderRequestItem.orderRequest().id.eq(orderRequest.getId())));
		List<OrderRequestItem> toAssign = existingItems.stream()
			.filter(item -> itemIds.contains(item.getId()) && item.getStatusCode().equals(CommunityCodeValues.ORDER_REQUEST_ITEM_STATUS_NEW.value))
			.filter(item -> item.getWithdrawnInventory() == null)
			.collect(Collectors.toList());
		
		if (toAssign.isEmpty()) {
			return toAssign;
		}

		Set<Long> itemsAccessionIds = toAssign.stream().map(item -> item.getInventory().getAccession().getId()).collect(Collectors.toSet());

		var accessionInventories = StreamSupport
			.stream(inventoryRepository.findAll(QInventory.inventory.accession().id.in(itemsAccessionIds)).spliterator(), false)
			.collect(Collectors.groupingBy(inventory -> inventory.getAccession().getId()));

		for (OrderRequestItem item : toAssign) {
			var inventories = accessionInventories.get(item.getInventory().getAccession().getId());
			var firstDistributableInventory = inventories.stream()
				.filter(inventory -> Objects.equals(inventory.getIsDistributable(), "Y") && inventory.getQuantityOnHand() != null && inventory.getDistributionCriticalQuantity() != null && inventory.getQuantityOnHand() >= inventory.getDistributionCriticalQuantity())
				.min(Comparator.comparing(Inventory::getDistributionRank, Comparator.nullsLast(Comparator.naturalOrder())))
				.orElse(inventories.stream().filter(Inventory::isSystemInventory).findFirst().get());
			item.setInventory(firstDistributableInventory);
			if (! firstDistributableInventory.isSystemInventory()) {
				item.setDistributionFormCode(firstDistributableInventory.getDistributionDefaultFormCode());
				item.setQuantityShipped(firstDistributableInventory.getDistributionDefaultQuantity());
				item.setQuantityShippedUnitCode(firstDistributableInventory.getDistributionUnitCode());
			} else {
				item.setDistributionFormCode(null);
				item.setQuantityShipped(null);
				item.setQuantityShippedUnitCode(null);
			}
			item.setStatusCode(CommunityCodeValues.ORDER_REQUEST_ITEM_STATUS_NEW.value);
		}
		
		return itemRepository.saveAllAndFlush(toAssign);
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('Request', 'ADMINISTRATION')")
	public List<OrderRequestItem> removeOrderItems(final OrderRequest orderRequest, final Set<Long> itemIds) {
		log.debug("Remove inventories {} from order request {}.", itemIds, orderRequest.getId());
		if (CollectionUtils.isEmpty(itemIds)) {
			return List.of();
		}

		List<OrderRequestItem> existingItems = Lists.newArrayList(itemRepository.findAll(QOrderRequestItem.orderRequestItem.orderRequest().id.eq(orderRequest.getId())));
		List<OrderRequestItem> toRemove = existingItems.stream().filter(x -> itemIds.contains(x.getId())).collect(Collectors.toList());
		itemRepository.deleteAll(toRemove);
		log.debug("Done removing {} items from order request {}", toRemove.size(), orderRequest.getId());
		return toRemove;
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('Request', 'ADMINISTRATION')")
	public OrderRequest updateFast(@NotNull @Valid OrderRequest updated, OrderRequest target) {
		target.apply(updated);
		return repository.save(target);
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('Request', 'ADMINISTRATION')")
	public OrderRequest update(final OrderRequest input, OrderRequest target) {
		log.debug("Update order request. Input data {}", input);
		target.apply(input);

		OrderRequest saved = repository.save(target);
		return _lazyLoad(saved);
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('Request', 'CREATE')")
	public OrderRequest createFast(OrderRequest source) {
		if (source.getOrderedDate() == null) {
			source.setOrderedDate(new Date());
		}
		return repository.save(source);
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('Request', 'CREATE')")
	public OrderRequest create(final OrderRequest source) {
		log.debug("Create order request. Input data {}", source);
		OrderRequest orderRequest = new OrderRequest();
		orderRequest.apply(source);
		if (orderRequest.getOrderedDate() == null) {
			orderRequest.setOrderedDate(new Date());
		}
		OrderRequest saved = repository.save(orderRequest);
		return _lazyLoad(saved);
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('Request', 'CREATE')")
	public OrderRequest create(final OrderRequest source, final List<OrderRequestItem> orderRequestItems) {
		log.debug("Create order request. Input data {}", source);
		OrderRequest orderRequest = new OrderRequest();
		orderRequest.apply(source);
		if (orderRequest.getOrderedDate() == null) {
			orderRequest.setOrderedDate(new Date());
		}
		OrderRequest saved = repository.save(orderRequest);

		AtomicInteger sequenceNumber = new AtomicInteger(0);
		orderRequestItems.forEach((ori) -> {
			if (! ori.isNew()) {
				throw new InvalidApiUsageException("OrderRequestItem must not be persisted");
			}
			ori.setOrderRequest(orderRequest);
			ori.setSequenceNumber(sequenceNumber.incrementAndGet()); // order
			ori.setStatusCode(CommunityCodeValues.ORDER_REQUEST_ITEM_STATUS_NEW.value); // NEW item
			ori.setStatusDate(new Date());
		});

		itemRepository.saveAll(orderRequestItems); // Save order items

		return _lazyLoad(saved);
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('Request', 'ADMINISTRATION')")
	public void renumberOrderRequestItems(OrderRequest orderRequest) {
		orderRequest = get(orderRequest);

		try {
			var items = itemRepository.findAll(QOrderRequestItem.orderRequestItem.orderRequest().eq(orderRequest), QOrderRequestItem.orderRequestItem.sequenceNumber.asc().nullsLast());

			int sequenceNumber = 1;
			for (OrderRequestItem item : items) {
				if (item.getStatusCode().equals(CommunityCodeValues.ORDER_REQUEST_ITEM_STATUS_CANCEL.value) || item.getStatusCode().equals(CommunityCodeValues.ORDER_REQUEST_ITEM_STATUS_SPLIT.value)) {
					item.setSequenceNumber(null);
				} else {
					item.setSequenceNumber(sequenceNumber);
					sequenceNumber++;
				}
			}
			itemRepository.saveAll(items);
		} catch (Exception e) {
			throw new InvalidApiUsageException("Error while renumbering items of OrderRequest " + orderRequest, e);
		}
	}

	@Override
	@PreAuthorize("@ggceSec.actionAllowed('Request', 'READ')")
	public Page<OrderRequest> list(OrderRequestFilter filter, Pageable page) throws SearchException {
		return super.list(OrderRequest.class, filter, page);
	}

	@Override
	@PreAuthorize("@ggceSec.actionAllowed('Request', 'DELETE')")
	public OrderRequest remove(OrderRequest entity) {
		entity = get(entity);

		var hasNotNewItems = itemRepository.exists(QOrderRequestItem.orderRequestItem.orderRequest().eq(entity)
				.and(QOrderRequestItem.orderRequestItem.statusCode.ne(CommunityCodeValues.ORDER_REQUEST_ITEM_STATUS_NEW.value)));
		if (hasNotNewItems) {
			throw new InvalidApiUsageException("Refusing to remove order. All items of the order must be in the 'NEW' status.");
		}

		var filter = new OrderRequestActionFilter();
		filter.orderRequest().id(Set.of(entity.getId()));
		if (actionSupport.countActions(filter) > 0) {
			throw new InvalidApiUsageException("Refusing to remove order with actions.");
		}

		return super.remove(entity);
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('Request', 'ADMINISTRATION')")
	public List<OrderRequestItem> updateItemStatus(OrderRequest orderRequest, String newStatus, Set<Long> itemIds) {
		return updateItemStatus(orderRequest, newStatus, itemRepository.findAllById(itemIds));
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('Request', 'ADMINISTRATION')")
	public List<OrderRequestItem> updateItemStatus(OrderRequest orderRequest, String newStatus, List<OrderRequestItem> orderItems) {
		orderItems = Lists.newArrayList(itemRepository.findAll(
			// order
			QOrderRequestItem.orderRequestItem.orderRequest().id.eq(orderRequest.getId())
				// selected items
				.and(QOrderRequestItem.orderRequestItem.in(orderItems))
				// not having selected status
				.and(QOrderRequestItem.orderRequestItem.statusCode.ne(newStatus))));

		var shippedItemsIds = orderItems.stream().filter(item -> CommunityCodeValues.ORDER_REQUEST_ITEM_STATUS_SHIPPED.is(item.getStatusCode()))
			.map(OrderRequestItem::getId).collect(Collectors.toList());

		// Updated items only
		List<OrderRequestItem> updatedItems = orderItems.stream().map((orderItem) -> updateItemStatus(orderItem, newStatus))
			// if updatedItem is null, the change is not permitted
			.filter((updatedItem) -> updatedItem != null).collect(Collectors.toList());

		if (CommunityCodeValues.ORDER_REQUEST_ITEM_STATUS_SPLIT.is(newStatus) && updatedItems.size() > 0) {
			// TODO Needs testing that some items remain in this order
			OrderRequest splitOrder = splitOrder(orderRequest, updatedItems);
			log.warn("Created split order id={} with {} items", splitOrder.getId(), splitOrder.getOrderRequestItems().size());
		}

		itemRepository.saveAll(updatedItems);

		Set<Inventory> updatedInventories = new HashSet<>();

		if (CommunityCodeValues.ORDER_REQUEST_ITEM_STATUS_SHIPPED.is(newStatus)) {
			// update inventory quantity if item status is updated to shipped
			updatedInventories = updatedItems.stream().map(item -> {
				var inventory = item.getInventory();
				if (shouldAutoDeductInventory(item)) {
					if (inventory.getQuantityOnHand() < item.getQuantityShipped()) {
						throw new InvalidApiUsageException("Insufficient inventory quantity on hand");
					}
					inventory.setQuantityOnHand(inventory.getQuantityOnHand() - item.getQuantityShipped());
				}
				return inventory;
			}).collect(Collectors.toSet());
		} else if (!shippedItemsIds.isEmpty()) {
			// update inventory quantity if item status is updated from shipped to something else
			updatedInventories = updatedItems.stream().filter(updatedItem -> shippedItemsIds.contains(updatedItem.getId())).map(item -> {
				var inventory = item.getInventory();
				if (shouldAutoDeductInventory(item)) {
					inventory.setQuantityOnHand(inventory.getQuantityOnHand() + item.getQuantityShipped());
				}
				return inventory;
			}).collect(Collectors.toSet());
		}

		if (!updatedInventories.isEmpty()) {
			inventoryRepository.saveAll(updatedInventories);
		}

		// Log change based on: "Order Request Item status_code changed by xxxxxx to
		// SHIPPED for 11 items."
		actionSupport.logAction(orderRequest, MessageFormat.format("Item status changed to {0} for {1} items.", newStatus, updatedItems.size()), updatedItems);

		// Return selected items regardless of status
		return Lists.newArrayList(itemRepository.findAll(
			// order
			QOrderRequestItem.orderRequestItem.orderRequest().id.eq(orderRequest.getId())
				// selected items
				.and(QOrderRequestItem.orderRequestItem.in(orderItems))));
	}

	/**
	 * Determine if the inventory of order request item should be automatically deducted
	 * @param item order request item
	 * @return <code>true</code> if form and unit codes match, and inventory is auto-deductible 
	 */
	private boolean shouldAutoDeductInventory(OrderRequestItem item) {
		Inventory inventory = item.getInventory();
		if (inventory == null)
			return false;
		return
				// inventory is auto-deductible
				StringUtils.equals(inventory.getIsAutoDeducted(), "Y")
				// item form type code matches inventory distribtion form code
				&& StringUtils.equals(item.getDistributionFormCode(), inventory.getFormTypeCode())
				// item units match inventory units
				&& StringUtils.equals(item.getQuantityShippedUnitCode(), inventory.getQuantityOnHandUnitCode())
				;
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('Request', 'ADMINISTRATION')")
	public OrderRequest generateInventories(OrderRequest order, InventoryMaintenancePolicy withdrawnInventoriesPolicy) {
		assert order.getId() != null;
		order = get(order);
		var withdrawnPolicy = withdrawnInventoriesPolicy != null && withdrawnInventoriesPolicy.getId() != null ?
			inventoryMaintenancePolicyRepository.getReferenceById(withdrawnInventoriesPolicy.getId()) : null;
		var orderRequestId = order.getId();
		List<OrderRequestItem> itemsForUpdate = new ArrayList<>();
		List<InventoryAction> logActions = new ArrayList<>();

		var currentDateTime = Instant.now();

		order.getOrderRequestItems().forEach(item -> {
			if (item.getWithdrawnInventory() != null) {
				return; // We already have an inventory!
			}

			if (Objects.equals(item.getStatusCode(), CommunityCodeValues.ORDER_REQUEST_ITEM_STATUS_CANCEL.value)) {
				return; // Item is canceled
			}
			if (Objects.equals(item.getStatusCode(), CommunityCodeValues.ORDER_REQUEST_ITEM_STATUS_SPLIT.value)) {
				return; // Item is split
			}

			Inventory inventory = item.getInventory();
			if (inventory.isSystemInventory()) {
				return; // Never withdraw SYSTEM inventory
			}

			Inventory withdrawnInventory = new Inventory();
//			withdrawnInventory.apply(inventory); // Do NOT apply all

			withdrawnInventory.setSite(inventory.getSite());
			withdrawnInventory.setAccession(inventory.getAccession());
			withdrawnInventory.setParentInventory(inventory);
			withdrawnInventory.setInventoryNumberPart1(inventory.getInventoryNumberPart1());
			withdrawnInventory.setInventoryNumberPart2(-1L);
			withdrawnInventory.setInventoryNumberPart3(inventory.getInventoryNumberPart3());
			withdrawnInventory.setInventoryMaintenancePolicy(withdrawnPolicy != null ? withdrawnPolicy : inventory.getInventoryMaintenancePolicy());
			withdrawnInventory.setFormTypeCode(inventory.getFormTypeCode());
			withdrawnInventory.setGeneration(inventory.getGeneration());
			withdrawnInventory.setHundredSeedWeight(inventory.getHundredSeedWeight());
			withdrawnInventory.setQuantityOnHand(item.getQuantityShipped());
			withdrawnInventory.setPathogenStatusCode(inventory.getPathogenStatusCode());
			withdrawnInventory.setPlantSexCode(inventory.getPlantSexCode());
			withdrawnInventory.setRootstock(inventory.getRootstock());
			withdrawnInventory.setPollinationMethodCode(inventory.getPollinationMethodCode());
			withdrawnInventory.setPollinationVectorCode(inventory.getPollinationVectorCode());
			withdrawnInventory.setPropagationDate(inventory.getPropagationDate());
			withdrawnInventory.setPropagationDateCode(inventory.getPropagationDateCode());
			withdrawnInventory.setContainerTypeCode(item.getContainerTypeCode());

//			withdrawnInventory.setIsAvailable("N"); // default = N
//			withdrawnInventory.setIsDistributable("N"); // default = N
//			withdrawnInventory.setIsAutoDeducted("N"); // default = N

			withdrawnInventory.setAvailabilityStatusCode(CommunityCodeValues.INVENTORY_AVAILABILITY_NOTSET.value); // TODO determine
			withdrawnInventory.setNote("Created from order_request_id=" + orderRequestId + " order_request_item_id=" + item.getId());
			var saved = inventoryRepository.save(withdrawnInventory);
			inventoryService.assignBarcode(saved);
			saved = inventoryRepository.getReferenceById(saved.getId());

			item.setWithdrawnInventory(saved);
			itemsForUpdate.add(item);

			InventoryAction inventoryAction = new InventoryAction();
			inventoryAction.setActionNameCode(CommunityCodeValues.INVENTORY_ACTION_LOG.value);
			inventoryAction.setInventory(saved);
			inventoryAction.setStartedDate(currentDateTime);
			inventoryAction.setStartedDateCode(CommunityCodeValues.DATE_FORMAT_DATE.value);
			inventoryAction.setCompletedDate(currentDateTime);
			inventoryAction.setCompletedDateCode(CommunityCodeValues.DATE_FORMAT_DATE.value);
			inventoryAction.setNote(String.format("Created from order_request_id=%s order_request_item_id=%s", orderRequestId, item.getId()));
			logActions.add(inventoryAction);

			inventoryAction = new InventoryAction();
			inventoryAction.setActionNameCode(CommunityCodeValues.INVENTORY_ACTION_QUANTITYSET.value);
			inventoryAction.setInventory(saved);
			inventoryAction.setNotBeforeDate(currentDateTime);
			inventoryAction.setNotBeforeDateCode(CommunityCodeValues.DATE_FORMAT_DATE.value);
			logActions.add(inventoryAction);
		});

		itemRepository.saveAllAndFlush(itemsForUpdate);

		inventoryActionRepository.saveAllAndFlush(logActions);

		return order;
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('Request', 'ADMINISTRATION')")
	public GlisSMTAReportingManager.GlisSMTAReportResponse reportSMTA(OrderRequest order) throws Exception {
		order = load(order.getId());
		OrderRequestActionFilter filter = (OrderRequestActionFilter) new OrderRequestActionFilter()
			.orderRequest((OrderRequestFilter) new OrderRequestFilter().id(Set.of(order.getId())))
			.actionNameCode(Set.of(CommunityCodeValues.ORDER_REQUEST_ACTION_REPORT_TO_ITPGRFA.value));
		var actions = actionSupport.listActions(filter, Pageable.unpaged()).getContent();
		if (actions.stream().anyMatch(action -> action.getCompletedDate() != null)) {
			throw new InvalidApiUsageException("Order request with id=" + order.getId() + " is already reported.");
		} else {
			if (actions.isEmpty() || actions.get(0).getStartedDate() == null) {
				OrderRequestActionRequest actionRequest = OrderRequestActionRequest.builder()
					.actionNameCode(CommunityCodeValues.ORDER_REQUEST_ACTION_REPORT_TO_ITPGRFA.value)
					.id(Set.of(order.getId()))
					.build();
				actionSupport.startAction(actionRequest);
			}
		}
		return glisSMTAReportingManager.uploadOrderRequestReport(order);
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('Request', 'ADMINISTRATION')")
	public void generateSMTA(OrderRequest order, OutputStream outputStream) throws Exception {
		order = load(order.getId());
		var response = glisSMTAReportingManager.generateSMTA(order);
		if (RESPONSE_ERROR.equals(response.status)) {
			throw new InvalidApiUsageException(RESPONSE_ERROR.concat(": ").concat(String.join("\n", response.errors)));
		} else {
			outputStream.write(Base64.getDecoder().decode(response.pdf));
		}
	}

	/**
	 * Split order.
	 *
	 * @param originalRequest the original (source) order request
	 * @param originalItems the items split from the source order request
	 * @return the split order
	 */
	private OrderRequest splitOrder(OrderRequest originalRequest, List<OrderRequestItem> originalItems) {
		log.warn("Splitting {} items to new order", originalItems.size());
		OrderRequest splitRequest = new OrderRequest();
		splitRequest.apply(originalRequest);
		splitRequest.setFeedback(null);
		splitRequest.setCompletedDate(null); // reset completion
		splitRequest.setOriginalOrderRequest(originalRequest); // update original

		OrderRequest savedSplitRequest = repository.save(splitRequest);
		List<OrderRequestItem> splitItems = originalItems.stream().map(orderItem -> {
			OrderRequestItem splitItem = new OrderRequestItem();
			splitItem.apply(orderItem);
			splitItem.setStatusCode(CommunityCodeValues.ORDER_REQUEST_ITEM_STATUS_NEW.value);
			splitItem.setStatusDate(new Date());
			splitItem.setOrderRequest(savedSplitRequest);
			return splitItem;
		}).collect(Collectors.toList());

		savedSplitRequest.setOrderRequestItems(itemRepository.saveAll(splitItems));
		return savedSplitRequest;
	}

	/**
	 * Change item status and update statusDate.
	 *
	 * @param orderItem the order item
	 * @param newStatus
	 * @return the updated item
	 */
	private OrderRequestItem updateItemStatus(OrderRequestItem orderItem, String newStatus) {
		if (CommunityCodeValues.ORDER_REQUEST_ITEM_STATUS_SPLIT.is(orderItem.getStatusCode())) {
			log.warn("Item is SPLIT, ignoring all changes");
			return null;
		}

		if (StringUtils.equals(orderItem.getStatusCode(), newStatus)) {
			log.info("Status code {} not altered for id={}", newStatus, orderItem.getId());
			return null;
		}

		orderItem.setStatusCode(newStatus);
		orderItem.setStatusDate(new Date());

		return orderItem;
	}
}