OrderRequestServiceImpl.java

/*
 * Copyright 2026 Global Crop Diversity Trust
 * Licensed under the Apache License, Version 2.0
 * See LICENSE file in project root folder or http://www.apache.org/licenses/LICENSE-2.0
 */

package org.gringlobal.service.impl;

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

import java.io.IOException;
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.HashSet;
import java.util.LinkedHashMap;
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.Stream;
import java.util.stream.StreamSupport;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;

import org.genesys.filerepository.InvalidRepositoryFileDataException;
import org.genesys.filerepository.InvalidRepositoryPathException;

import org.gringlobal.api.exception.InsufficientInventoryQuantityException;
import org.gringlobal.api.exception.InvalidApiUsageException;
import org.gringlobal.application.config.GGCESecurityConfig;
import org.gringlobal.custom.elasticsearch.SearchException;
import org.gringlobal.model.AccessionInvAttach;
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.Site;
import org.gringlobal.model.community.CommunityCodeValues;
import org.gringlobal.model.community.SecurityAction;
import org.gringlobal.model.workflow.WorkflowActionStep;
import org.gringlobal.persistence.AccessionInvAttachRepository;
import org.gringlobal.persistence.InventoryActionRepository;
import org.gringlobal.persistence.InventoryMaintenancePolicyRepository;
import org.gringlobal.persistence.InventoryRepository;
import org.gringlobal.persistence.OrderRequestActionRepository;
import org.gringlobal.persistence.OrderRequestAttachRepository;
import org.gringlobal.persistence.OrderRequestItemRepository;
import org.gringlobal.persistence.OrderRequestRepository;
import org.gringlobal.service.InventoryExtraService;
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.filter.SiteFilter;
import org.gringlobal.service.glis.impl.GlisSMTAReportingManager;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

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 org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.Strings;
import org.hibernate.Hibernate;

/**
 * @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 InventoryExtraService inventoryExtraService;

	@Autowired
	private InventoryMaintenancePolicyRepository inventoryMaintenancePolicyRepository;

	@Autowired
	private InventoryActionRepository inventoryActionRepository;

	@Autowired
	private OrderRequestItemRepository itemRepository;

	@Autowired
	private OrderRequestItemService itemService;

	@Autowired
	private AccessionInvAttachRepository accessionInvAttachRepository;

	@Autowired
	private OrderRequestActionService actionSupport;

	@Autowired
	private GlisSMTAReportingManager glisSMTAReportingManager;

	@Autowired
	@Lazy
	private GGCESecurityConfig.GgceSec ggceSec;

	@Autowired
	private OrderRequestAttachRepository orderRequestAttachRepository;

	@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 OrderRequestActionFilter adjustFilter(OrderRequestActionFilter filter) {
			var siteIds = ggceSec.getSiteIds(SecurityAction.Request.name(), "READ");
			if (siteIds != null) {
				log.debug("Forcing Request Site IDs: {}", siteIds);
				var siteFilter = new OrderRequestActionFilter()
					.orderRequest(new OrderRequestFilter()
						.site(new SiteFilter()
							.id(siteIds)
						)
					)
					.AND(filter);
				return siteFilter;
			}
			return filter;
		}

		@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) {
			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
		protected OrderRequestAction prepareNextWorkflowStepAction(WorkflowActionStep nextStep, OrderRequestAction completedAction) {
			OrderRequestAction nextAction = new OrderRequestAction();
			nextAction.setOrderRequest(new OrderRequest(completedAction.getOrderRequest().getId()));
			return nextAction;
		}

		@Override
		@Transactional
		@PostAuthorize("@ggceSec.actionAllowed('Request', 'ADMINISTRATION', returnObject.orderRequest.site)")
		public OrderRequestAction create(OrderRequestAction source) {
			return _lazyLoad(createFast(source));
		}

		@Override
		@Transactional
		@PostAuthorize("@ggceSec.actionAllowed('Request', 'ADMINISTRATION', returnObject.orderRequest.site)")
		public OrderRequestAction createFast(OrderRequestAction source) {
			log.debug("Create OrderRequestAction. Input data {}", source);
			OrderRequestAction entity = new OrderRequestAction();
			entity.apply(source);
			entity.setOrderRequest(orderRequestRepository.getReferenceById(source.getOrderRequest().getId()));
			return repository.save(entity);
		}

		@Override
		@Transactional
		@PostAuthorize("@ggceSec.actionAllowed('Request', 'ADMINISTRATION', returnObject.![orderRequest.site])")
		public List<OrderRequestAction> scheduleAction(OrderRequestActionRequest actionData) {
			return super.scheduleAction(actionData);
		}

		@Override
		@Transactional
		@PostAuthorize("@ggceSec.actionAllowed('Request', 'ADMINISTRATION', returnObject.![orderRequest.site])")
		public List<OrderRequestAction> startAction(OrderRequestActionRequest actionData) {
			return super.startAction(actionData);
		}

		@Override
		@Transactional
		@PostAuthorize("@ggceSec.actionAllowed('Request', 'ADMINISTRATION', returnObject.![orderRequest.site])")
		public List<OrderRequestAction> completeAction(OrderRequestActionRequest actionData) {
			return super.completeAction(actionData);
		}

		@Override
		@Transactional
		@PostAuthorize("@ggceSec.actionAllowed('Request', 'ADMINISTRATION', returnObject.![orderRequest.site])")
		public List<OrderRequestAction> cancelAction(OrderRequestActionRequest actionData) {
			return super.cancelAction(actionData);
		}

		@Override
		@Transactional
		@PostAuthorize("@ggceSec.actionAllowed('Request', 'ADMINISTRATION', returnObject.![orderRequest.site])")
		public List<OrderRequestAction> reopenAction(OrderRequestActionRequest actionData) {
			return super.reopenAction(actionData);
		}

		@Override
		@Transactional
		@PostAuthorize("@ggceSec.actionAllowed('Request', 'ADMINISTRATION', returnObject.orderRequest.site)")
		public OrderRequestAction update(OrderRequestAction updated, OrderRequestAction target) {
			return super.update(updated, target);
		}

		@Override
		@Transactional
		@PostAuthorize("@ggceSec.actionAllowed('Request', 'ADMINISTRATION', returnObject.orderRequest.site)")
		public OrderRequestAction updateFast(OrderRequestAction updated, OrderRequestAction target) {
			return super.updateFast(updated, target);
		}

		@Override
		@Transactional
		@PostAuthorize("@ggceSec.actionAllowed('Request', 'ADMINISTRATION', returnObject.orderRequest.site)")
		public OrderRequestAction remove(OrderRequestAction target) {
			return super.remove(target);
		}

		@Override
		@Transactional
		@PostAuthorize("@ggceSec.actionAllowed('Request', 'ADMINISTRATION', returnObject.![orderRequest.site])")
		public List<OrderRequestAction> assignActions(Map<Long, String> actionAssignee) {
			return super.assignActions(actionAssignee);
		}

		@Override
		@Transactional
		@PostAuthorize("@ggceSec.actionAllowed('Request', 'ADMINISTRATION', returnObject.![orderRequest.site])")
		public List<OrderRequestAction> startWorkflow(long workflowId, Set<Long> owningEntities) {
			return super.startWorkflow(workflowId, owningEntities);
		}

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

		@Override
		@Transactional
		@PreAuthorize("@ggceSec.actionAllowed('Request', 'ADMINISTRATION', #orderRequest.site)")
		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
		@PreAuthorize("@ggceSec.actionAllowed('Request', 'READ', #orderRequest.site)")
		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
		public Path createRepositoryPath(OrderRequest orderRequest) {
			orderRequest = owningEntityRepository.getReferenceById(orderRequest.getId());
			return Paths.get("/ORA/" + orderRequest.getId());
		}

		@Override
		@PostAuthorize("hasAuthority('GROUP_ADMINS') or @ggceSec.actionAllowed('Request', 'WRITE', returnObject.orderRequest.site)")
		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
		@Transactional
		@PostAuthorize("hasAuthority('GROUP_ADMINS') or @ggceSec.actionAllowed('Request', 'WRITE', returnObject.orderRequest.site)")
		public OrderRequestAttach create(OrderRequestAttach source) {
			return _lazyLoad(createFast(source));
		}

		@Override
		@Transactional
		@PostAuthorize("hasAuthority('GROUP_ADMINS') or @ggceSec.actionAllowed('Request', 'WRITE', returnObject.orderRequest.site)")
		public OrderRequestAttach createFast(OrderRequestAttach source) {
			var owningEntity = owningEntityRepository.getReferenceById(source.getOrderRequest().getId());
			var attach = createAttach(owningEntity, source);
			return repository.save(attach);
		}

		@Override
		@Transactional
		@PostAuthorize("hasAuthority('GROUP_ADMINS') or @ggceSec.actionAllowed('Request', 'WRITE', returnObject.orderRequest.site)")
		public OrderRequestAttach update(OrderRequestAttach updated, OrderRequestAttach target) {
			return _lazyLoad(updateFast(updated, target));
		}

		@Override
		@Transactional
		@PostAuthorize("hasAuthority('GROUP_ADMINS') or @ggceSec.actionAllowed('Request', 'WRITE', returnObject.orderRequest.site)")
		public OrderRequestAttach updateFast(OrderRequestAttach updated, OrderRequestAttach target) {
			target.apply(updated);
			updateRepositoryFileMetadata(updated, target);
			return repository.save(target);
		}

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

		@Override
		@PostAuthorize("hasAuthority('GROUP_ADMINS') or @ggceSec.actionAllowed('Request', 'WRITE', returnObject.orderRequest.site)")
		@Transactional(propagation = Propagation.REQUIRES_NEW)
		public OrderRequestAttach uploadFile(OrderRequest entity, MultipartFile file, OrderRequestAttachmentRequest metadata) throws IOException, InvalidRepositoryPathException, InvalidRepositoryFileDataException {
			return super.uploadFile(entity, file, metadata);
		}
	}

	@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()
			// authorizedOfficial
			.leftJoin(QOrderRequest.orderRequest.authorizedOfficialCooperator()).fetchJoin()
			// owner
			.join(QOrderRequest.orderRequest.ownedBy()).fetchJoin();
	}

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

	@Override
	protected OrderRequestFilter adjustFilter(OrderRequestFilter filter) {
		var siteIds = ggceSec.getSiteIds(SecurityAction.Request.name(), "READ");
		if (siteIds != null) {
			log.debug("Forcing Request Site IDs: {}", siteIds);
			var siteFilter = new OrderRequestFilter();
			siteFilter.site = new SiteFilter();
			siteFilter.site.id(siteIds);
			siteFilter.AND = filter;
			if (filter != null) {
				siteFilter._text(filter._text());
			}
			return siteFilter;
		}
		return filter;
	}

	@Override
	@PostAuthorize("@ggceSec.actionAllowed('Request', 'READ', returnObject.site)")
	public OrderRequest get(long id) {
		var orderRequest = super.get(id);
		return orderRequest;
	}

	@Override
	@PostAuthorize("@ggceSec.actionAllowed('Request', 'READ', returnObject.site)")
	public OrderRequest load(long id) {
		var orderRequest = super.load(id);
		return orderRequest;
	}

	/**
	 * Validates that all active items of the order request belong to the same site as the request.
	 *
	 * @param orderRequest the order request
	 */
	private void validateRequestSiteMatchesItemSites(OrderRequest orderRequest) {
		validateRequestSiteMatchesItemSites(orderRequest, orderRequest.getOrderRequestItems());
	}

	private void validateRequestSiteMatchesItemSites(OrderRequest orderRequest, List<OrderRequestItem> items) {
		if (orderRequest.getSite() == null || items == null) {
			return;
		}
		Long requestSiteId = orderRequest.getSite().getId();
		for (OrderRequestItem item : items) {
			if (CommunityCodeValues.ORDER_REQUEST_ITEM_STATUS_CANCEL.value.equals(item.getStatusCode())
					|| CommunityCodeValues.ORDER_REQUEST_ITEM_STATUS_SPLIT.value.equals(item.getStatusCode())) {
				continue;
			}
			Inventory inventory = item.getInventory();
			if (inventory == null || inventory.getSite() == null || Objects.equals(Inventory.SYSTEM_INVENTORY_FTC, inventory.getFormTypeCode())) {
				continue;
			}
			if (!Objects.equals(inventory.getSite().getId(), requestSiteId)) {
				throw new InvalidApiUsageException(
						"Inventory from a different site cannot be used in this request.");
			}
		}
	}

	@Override
	@PreAuthorize("@ggceSec.actionAllowed('Request', 'READ', #orderRequest.site)")
	public Page<OrderRequestItem> filterItems(final OrderRequest orderRequest, OrderRequestItemFilter filter, Pageable page) throws SearchException {
		filter.orderRequest(new OrderRequestFilter());
		filter.orderRequest.id(Sets.newHashSet(orderRequest.getId()));
		var items = itemService.list(filter, page);
		items.getContent().forEach(item -> {
			if (item.getWithdrawnInventory() != null) {
				item.getWithdrawnInventory().getId();
			}
		});
		return items;
	}

	@Override
	@PostAuthorize("@ggceSec.actionAllowed('Request', 'READ', returnObject.orderRequest.site)")
	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('RequestItem', 'CREATE', #orderRequest.site)")
	public List<OrderRequestItem> addInventories(OrderRequest orderRequest, List<OrderRequestedInventory> inventories) {
		final var finalOrderRequest = get(orderRequest);

		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 = (List<OrderRequestItem>) itemRepository.findAll(QOrderRequestItem.orderRequestItem.orderRequest().id.eq(finalOrderRequest.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();
		}

		var inventoryIds = new HashSet<Long>(toAdd.size());
		toAdd.forEach(ri -> {
			inventoryIds.add(ri.inventoryId);
		});

		var loadedInventories = inventoryRepository.findAllById(inventoryIds);
		Long requestSiteId = finalOrderRequest.getSite() == null ? null : finalOrderRequest.getSite().getId();
		if (requestSiteId != null) {
			for (Inventory inventory : loadedInventories) {
				if (inventory.getSite() == null || !Objects.equals(inventory.getSite().getId(), requestSiteId)) {
					throw new InvalidApiUsageException(
							"Inventory from a different site cannot be added to this request. Split the request by site first.");
				}
			}
		}

		var items = toAdd.stream().map(orderRequestedInventory -> {
			var inventory = loadedInventories.stream().filter(inv -> Objects.equals(inv.getId(), orderRequestedInventory.inventoryId)).findFirst().orElseThrow();
			OrderRequestItem item = new OrderRequestItem();
			item.setSequenceNumber(sequenceNumber.incrementAndGet());
			item.setOrderRequest(finalOrderRequest);
			item.setInventory(inventory);
			if (! inventory.isSystemInventory()) {
				item.setDistributionFormCode(inventory.getDistributionDefaultFormCode());
				item.setQuantityRequested(inventory.getDistributionDefaultQuantity());
				item.setQuantityRequestedUnitCode(inventory.getDistributionUnitCode());
			}

			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.", toAdd.size());
		return items;
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('RequestItem', 'WRITE', #orderRequest.site)")
	public List<OrderRequestItem> setDefaultInventories(OrderRequest orderRequest, Set<Long> itemIds) {
		final var finalOrderRequest = get(orderRequest);
		List<OrderRequestItem> existingItems = Lists.newArrayList(itemRepository.findAll(QOrderRequestItem.orderRequestItem.orderRequest().id.eq(finalOrderRequest.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());
		Long requestSiteId = finalOrderRequest.getSite() == null ? null : finalOrderRequest.getSite().getId();
		var basePredicate = QInventory.inventory.accession().id.in(itemsAccessionIds);
		if (requestSiteId != null) {
			basePredicate = basePredicate.and(QInventory.inventory.site().id.eq(requestSiteId));
		}

		var accessionInventories = StreamSupport
			.stream(inventoryRepository.findAll(basePredicate).spliterator(), false)
			.collect(Collectors.groupingBy(inventory -> inventory.getAccession().getId()));

		for (OrderRequestItem item : toAssign) {
			var inventories = accessionInventories.get(item.getInventory().getAccession().getId());
			if (inventories == null || inventories.isEmpty()) {
				throw new InvalidApiUsageException(
						"No inventory at the request site is available for accession " + 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.setQuantityRequested(firstDistributableInventory.getDistributionDefaultQuantity());
				item.setQuantityRequestedUnitCode(firstDistributableInventory.getDistributionUnitCode());
			} else {
				item.setDistributionFormCode(null);
				item.setQuantityRequested(null);
				item.setQuantityRequestedUnitCode(null);
			}
			item.setStatusCode(CommunityCodeValues.ORDER_REQUEST_ITEM_STATUS_NEW.value);
		}

		return itemRepository.saveAllAndFlush(toAssign);
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('RequestItem', 'DELETE', #orderRequest.site)")
	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', 'WRITE', #target.site)")
	@PostAuthorize("@ggceSec.actionAllowed('Request', 'WRITE', returnObject.site)")
	public OrderRequest updateFast(@NotNull @Valid OrderRequest updated, OrderRequest target) {
		log.debug("Update order request. Input data {}", updated);
		target.apply(updated);
		validateRequestSiteMatchesItemSites(target);
		return repository.save(target);
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('Request', 'WRITE', #target.site)")
	@PostAuthorize("@ggceSec.actionAllowed('Request', 'WRITE', returnObject.site)")
	public OrderRequest update(final OrderRequest updated, OrderRequest target) {
		return _lazyLoad(updateFast(updated, target));
	}

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

	@Override
	@Transactional
	@PostAuthorize("@ggceSec.actionAllowed('Request', 'CREATE', returnObject.site)")
	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
	@PostAuthorize("@ggceSec.actionAllowed('Request', 'CREATE', returnObject.site)")
	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);
		validateRequestSiteMatchesItemSites(saved, orderRequestItems);

		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', #orderRequest.site)")
	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
	@Transactional
	@PreAuthorize("hasAuthority('GROUP_ADMINS') or @ggceSec.actionAllowed('Request', 'ADMINISTRATION', #orderRequest.site)")
	public void shareAttachmentsToInventories(OrderRequest orderRequest, Set<Long> attachmentIds) {
		orderRequest = get(orderRequest);

		var attachPredicate = QOrderRequestAttach.orderRequestAttach.orderRequest().eq(orderRequest);
		if (attachmentIds != null) {
			attachPredicate = attachPredicate.and(QOrderRequestAttach.orderRequestAttach.id.in(attachmentIds));
		}
		List<OrderRequestAttach> attachments = (List<OrderRequestAttach>) orderRequestAttachRepository.findAll(attachPredicate);
		if (attachments.isEmpty()) {
			return;
		}

		var siteIds = ggceSec.getSiteIds(SecurityAction.InventoryAttachment.name(), "CREATE");

		var qRequestItem = QOrderRequestItem.orderRequestItem;
		var wherePredicate = qRequestItem.orderRequest().id.eq(orderRequest.getId())
			.and(qRequestItem.statusCode.notIn(
				CommunityCodeValues.ORDER_REQUEST_ITEM_STATUS_CANCEL.value, CommunityCodeValues.ORDER_REQUEST_ITEM_STATUS_SPLIT.value)
			);
		if (siteIds != null) {
			if (!siteIds.isEmpty()) {
				wherePredicate = wherePredicate.and(qRequestItem.inventory().site().id.in(siteIds));
			} else {
				// TODO Throw exception?
				return;
			}
		}

		Set<Inventory> inventories = jpaQueryFactory.select(qRequestItem.inventory(), qRequestItem.withdrawnInventory())
			.from(qRequestItem)
			.leftJoin(qRequestItem.withdrawnInventory())
			.leftJoin(qRequestItem.inventory())
			.where(wherePredicate)
			.fetch().stream()
			.flatMap(t -> Stream.of(
				t.get(qRequestItem.inventory()),
				t.get(qRequestItem.withdrawnInventory())
			))
			.filter(Objects::nonNull)
			.collect(Collectors.toSet());

		if (inventories.isEmpty()) {
			return;
		}

		List<AccessionInvAttach> batch = new ArrayList<>(100);
		for (Inventory inventory : inventories) {
			for (OrderRequestAttach requestAttach : attachments) {
				AccessionInvAttach invAttach = new AccessionInvAttach();
				requestAttach.copyTo(invAttach);
				invAttach.setInventory(inventory);
				batch.add(invAttach);
				if (batch.size() >= 100) {
					accessionInvAttachRepository.saveAllAndFlush(batch);
					batch.clear();
				}
			}
		}
		if (!batch.isEmpty()) {
			accessionInvAttachRepository.saveAllAndFlush(batch);
		}
	}

	@Override
	// adjustFilter takes care of permissions, no need for @PreAuthorize("@ggceSec.actionAllowed('Request', 'READ')")
	public Page<OrderRequest> list(OrderRequestFilter filter, Pageable page) throws SearchException {
		return super.list(filter, page);
	}

	@Override
	@PostAuthorize("@ggceSec.actionAllowed('Request', 'DELETE', returnObject.site)")
	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('RequestItem', 'WRITE', #orderRequest.site)")
	public List<OrderRequestItem> updateItemStatus(OrderRequest orderRequest, String newStatus, Set<Long> itemIds) {
		return updateItemStatus(orderRequest, newStatus, itemRepository.findAllById(itemIds));
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('RequestItem', 'WRITE', #orderRequest.site)")
	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(Objects::nonNull).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)) {
			if (updatedItems.stream().anyMatch(item -> item.getQuantityShipped() == null || item.getQuantityShippedUnitCode() == null)) {
				throw new InvalidApiUsageException(
					"Cannot set status to " + CommunityCodeValues.ORDER_REQUEST_ITEM_STATUS_SHIPPED + " when the shipped quantity is missing"
				);
			}
			var invalidItems = updatedItems.stream().filter(item -> {
				if (shouldAutoDeductInventory(item)) {
					return item.getInventory().getQuantityOnHand() < inventoryService.convertQuantity(item.getQuantityShippedUnitCode(), item.getQuantityShipped(), item.getInventory().getQuantityOnHandUnitCode(), item.getInventory().getHundredSeedWeight());
				}
				return false;
			}).collect(Collectors.toList());
			if (!invalidItems.isEmpty()) {
				invalidItems.forEach(i -> {
					i.getInventory().getAccession().getId();
				});
				throw new InsufficientInventoryQuantityException(invalidItems);
			}
			// update inventory quantity if item status is updated to shipped
			updatedItems.forEach(item -> {
				var inventory = item.getInventory();
				if (shouldAutoDeductInventory(item)) {
					inventory.setQuantityOnHand(inventory.getQuantityOnHand() - inventoryService.convertQuantity(item.getQuantityShippedUnitCode(), item.getQuantityShipped(), inventory.getQuantityOnHandUnitCode(), inventory.getHundredSeedWeight()));
					updatedInventories.add(inventory);
				}
				var withdrawnInventory = item.getWithdrawnInventory();
				if (withdrawnInventory != null) {
					if (inventoryService.areCompatibleUnits(item.getQuantityShippedUnitCode(), withdrawnInventory.getQuantityOnHandUnitCode(), withdrawnInventory.getHundredSeedWeight())) {
						withdrawnInventory.setQuantityOnHand(inventoryService.convertQuantity(item.getQuantityShippedUnitCode(), item.getQuantityShipped(), withdrawnInventory.getQuantityOnHandUnitCode(), withdrawnInventory.getHundredSeedWeight()));
					} else {
						withdrawnInventory.setQuantityOnHand(item.getQuantityShipped());
						withdrawnInventory.setQuantityOnHandUnitCode(item.getQuantityShippedUnitCode());
						withdrawnInventory.setFormTypeCode(item.getDistributionFormCode());
					}
				}
			});
		} else if (!shippedItemsIds.isEmpty()) {
			// update inventory quantity if item status is updated from shipped to something else
			updatedItems.stream().filter(updatedItem -> shippedItemsIds.contains(updatedItem.getId())).forEach(item -> {
				var inventory = item.getInventory();
				if (shouldAutoDeductInventory(item)) {
					inventory.setQuantityOnHand(inventory.getQuantityOnHand() + inventoryService.convertQuantity(item.getQuantityShippedUnitCode(), item.getQuantityShipped(), inventory.getQuantityOnHandUnitCode(), inventory.getHundredSeedWeight()));
					updatedInventories.add(inventory);
				}
				var withdrawnInventory = item.getWithdrawnInventory();
				if (withdrawnInventory != null) {
					withdrawnInventory.setQuantityOnHand(null);
					updatedInventories.add(withdrawnInventory);
				}
			});
		}

		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
				Strings.CS.equals(inventory.getIsAutoDeducted(), "Y")
				// item form type code matches inventory distribtion form code
				&& Strings.CS.equals(item.getDistributionFormCode(), inventory.getFormTypeCode())
				// item units match inventory units
				&& inventoryService.areCompatibleUnits(item.getQuantityShippedUnitCode(), inventory.getQuantityOnHandUnitCode(), inventory.getHundredSeedWeight())
				;
	}

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

		var currentDateTime = Instant.now();

		orderRequest.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.applyInventoryMaintenancePolicy(withdrawnPolicy != null ? withdrawnPolicy : inventory.getInventoryMaintenancePolicy());

			withdrawnInventory.setGeneration(inventory.getGeneration());
			withdrawnInventory.setHundredSeedWeight(inventory.getHundredSeedWeight());

			withdrawnInventory.setFormTypeCode(item.getDistributionFormCode());
			withdrawnInventory.setQuantityOnHandUnitCode(item.getQuantityShippedUnitCode());
			withdrawnInventory.setQuantityOnHand(null);

			// ONLY IF item is shipped we set quantity
			if (Strings.CS.equals(item.getStatusCode(), CommunityCodeValues.ORDER_REQUEST_ITEM_STATUS_SHIPPED.value)) {
				if (inventoryService.areCompatibleUnits(item.getQuantityShippedUnitCode(), withdrawnInventory.getQuantityOnHandUnitCode(), withdrawnInventory.getHundredSeedWeight())) {
					// Convert
					withdrawnInventory.setQuantityOnHand(inventoryService.convertQuantity(item.getQuantityShippedUnitCode(), item.getQuantityShipped(), withdrawnInventory.getQuantityOnHandUnitCode(), withdrawnInventory.getHundredSeedWeight()));
				} else {
					// Unknown quantity
					withdrawnInventory.setQuantityOnHand(null);
				}
			}

			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.setPreservationMethod(inventory.getPreservationMethod());
			withdrawnInventory.setRegenerationMethod(inventory.getRegenerationMethod());
			withdrawnInventory.setAvailabilityStatusCode(CommunityCodeValues.INVENTORY_AVAILABILITY_NOTSET.value); // Determined on save
			withdrawnInventory.setNote("Created from order_request_id=" + orderRequestId + " order_request_item_id=" + item.getId());
			var saved = inventoryRepository.save(withdrawnInventory);
			inventoryService.assignBarcode(saved);

			var extra = inventory.getExtra();
			if (extra != null) {
				var withdrawnExtra = extra.copy();
				if (withdrawnExtra != null) {
					withdrawnExtra.setInventory(saved);
					inventoryExtraService.create(withdrawnExtra);
				}
			}

			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 orderRequest;
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('Request', 'ADMINISTRATION', #orderRequest.site)")
	public GlisSMTAReportingManager.GlisSMTAReportResponse reportSMTA(OrderRequest orderRequest) throws Exception {
		orderRequest = load(orderRequest.getId());
		OrderRequestActionFilter filter = (OrderRequestActionFilter) new OrderRequestActionFilter()
			.orderRequest(new OrderRequestFilter().id(Set.of(orderRequest.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=" + orderRequest.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(orderRequest.getId()))
					.build();
				actionSupport.startAction(actionRequest);
			}
		}
		return glisSMTAReportingManager.uploadOrderRequestReport(orderRequest);
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('Request', 'ADMINISTRATION', #orderRequest.site)")
	public void generateSMTA(OrderRequest orderRequest, OutputStream outputStream) throws Exception {
		orderRequest = load(orderRequest.getId());
		var response = glisSMTAReportingManager.generateSMTA(orderRequest);
		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));
		}
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('Request', 'ADMINISTRATION')")
	public List<OrderRequest> autoSplitOrder(OrderRequest orderRequest) {
		assert orderRequest != null && orderRequest.getId() != null;
		orderRequest = get(orderRequest);
		if (orderRequest.getSite() != null) {
			throw new InvalidApiUsageException("Cannot split a site-specific request");
		}

		// Collect active items grouped by their inventory site.
		var activeItems = Lists.newArrayList(itemRepository.findAll(QOrderRequestItem.orderRequestItem.orderRequest().id.eq(orderRequest.getId()))).stream()
			.filter(item -> !CommunityCodeValues.ORDER_REQUEST_ITEM_STATUS_CANCEL.is(item.getStatusCode())
					&& !CommunityCodeValues.ORDER_REQUEST_ITEM_STATUS_SHIPPED.is(item.getStatusCode())
					&& !CommunityCodeValues.ORDER_REQUEST_ITEM_STATUS_SPLIT.is(item.getStatusCode()))
			.collect(Collectors.toList());

		var itemsBySite = activeItems.stream()
			.collect(Collectors.groupingBy(item -> item.getInventory() != null ? item.getInventory().getSite() : null,
					LinkedHashMap::new, Collectors.toList()));

		if (itemsBySite.size() <= 1) {
			// Nothing to split; update the original request site if all active items share one site.
			if (itemsBySite.size() == 1) {
				var entry = itemsBySite.entrySet().iterator().next();
				if (entry.getKey() != null && entry.getValue().stream().allMatch(item -> entry.getKey().equals(item.getInventory().getSite()))) {
					orderRequest.setSite(entry.getKey());
					repository.save(orderRequest);
				}
			}
			return List.of(orderRequest);
		}

		// Ties are broken deterministically: larger count wins, then smaller site id.
		var keepEntry = itemsBySite.entrySet().stream()
			.max((e1, e2) -> {
				int cmp = Integer.compare(e1.getValue().size(), e2.getValue().size());
				if (cmp != 0) {
					return cmp;
				}
				Site s1 = e1.getKey();
				Site s2 = e2.getKey();
				if (s1 == null || s2 == null) {
					return Boolean.compare(s2 == null, s1 == null);
				}
				return Long.compare(s2.getId(), s1.getId());
			})
			.orElseThrow();

		List<OrderRequest> result = new ArrayList<>();
		result.add(orderRequest);

		for (Map.Entry<Site, List<OrderRequestItem>> entry : itemsBySite.entrySet()) {
			if (entry.equals(keepEntry)) {
				// Update original request site to reflect the kept group.
				orderRequest.setSite(entry.getKey());
			} else {
				// Mark the moved items as split in the original request before copying them.
				entry.getValue().forEach(item -> {
					item.setStatusCode(CommunityCodeValues.ORDER_REQUEST_ITEM_STATUS_SPLIT.value);
					item.setStatusDate(new Date());
				});
				OrderRequest splitRequest = splitOrder(orderRequest, entry.getValue());
				splitRequest.setSite(entry.getKey());
				result.add(splitRequest);
			}
		}

		// Save the original request with updated site and SPLIT items.
		repository.save(orderRequest);
		itemRepository.saveAll(activeItems);

		return result;
	}

	/**
	 * 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.setId(null);
		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.setId(null);
			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 (Strings.CS.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;
	}
}