InventoryServiceImpl.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 static org.gringlobal.model.community.CommunityAppSettings.BARCODE_INVENTORY;

import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;

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

import com.blazebit.persistence.CriteriaBuilderFactory;
import com.blazebit.persistence.querydsl.BlazeJPAQuery;
import com.querydsl.core.Tuple;
import com.querydsl.core.types.Expression;
import com.querydsl.core.types.OrderSpecifier;
import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.core.types.dsl.PathBuilder;
import com.querydsl.jpa.JPQLQuery;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.genesys.blocks.model.filters.NumberFilter;
import org.genesys.filerepository.InvalidRepositoryFileDataException;
import org.genesys.filerepository.InvalidRepositoryPathException;
import org.gringlobal.api.exception.InvalidApiUsageException;
import org.gringlobal.api.exception.NotFoundElement;
import org.gringlobal.api.v1.Pagination;
import org.gringlobal.application.config.GGCESecurityConfig;
import org.gringlobal.component.GGCE;
import org.gringlobal.custom.elasticsearch.SearchException;
import org.gringlobal.model.Accession;
import org.gringlobal.model.AccessionInvAttach;
import org.gringlobal.model.AppSetting;
import org.gringlobal.model.Cooperator;
import org.gringlobal.model.DateVersionEntityId.EntityIdAndModifiedDate;
import org.gringlobal.model.Inventory;
import org.gringlobal.model.InventoryAction;
import org.gringlobal.model.InventoryExtra;
import org.gringlobal.model.InventoryMaintenancePolicy;
import org.gringlobal.model.InventoryQualityStatus;
import org.gringlobal.model.LazyLoading;
import org.gringlobal.model.OrderRequest;
import org.gringlobal.model.OrderRequestItem;
import org.gringlobal.model.QAccession;
import org.gringlobal.model.QAccessionInvAttach;
import org.gringlobal.model.QInventory;
import org.gringlobal.model.QInventoryAction;
import org.gringlobal.model.QSiteCounter;
import org.gringlobal.model.SeedInventoryExtra;
import org.gringlobal.model.Site;
import org.gringlobal.model.TaxonomyGenus;
import org.gringlobal.model.TaxonomySpecies;
import org.gringlobal.model.TissueCultureExtra;
import org.gringlobal.model.community.CommunityCodeValues;
import org.gringlobal.model.community.SecurityAction;
import org.gringlobal.model.community.CommunityCodeValues.CodeValueDef;
import org.gringlobal.persistence.AccessionInvAttachRepository;
import org.gringlobal.persistence.AccessionInvNameRepository;
import org.gringlobal.persistence.AccessionRepository;
import org.gringlobal.persistence.InventoryActionRepository;
import org.gringlobal.persistence.InventoryExtraRepository;
import org.gringlobal.persistence.InventoryMaintenancePolicyRepository;
import org.gringlobal.persistence.InventoryQualityStatusRepository;
import org.gringlobal.persistence.InventoryRepository;
import org.gringlobal.persistence.InventoryRepositoryCustom;
import org.gringlobal.persistence.SiteRepository;
import org.gringlobal.service.AccessionInvGroupService;
import org.gringlobal.service.AppSettingsService;
import org.gringlobal.service.InventoryActionService;
import org.gringlobal.service.InventoryActionService.InventoryActionRequest;
import org.gringlobal.service.InventoryActionService.InventoryActionScheduleFilter;
import org.gringlobal.service.InventoryAttachmentService;
import org.gringlobal.service.InventoryAttachmentService.InventoryAttachmentRequest;
import org.gringlobal.service.InventoryExtraService;
import org.gringlobal.service.InventoryQualityStatusService;
import org.gringlobal.service.InventoryService;
import org.gringlobal.service.InvitroInventoryService;
import org.gringlobal.service.MethodService;
import org.gringlobal.service.OrderRequestService;
import org.gringlobal.service.TemplatingService;
import org.gringlobal.service.filter.AccessionFilter;
import org.gringlobal.service.filter.InventoryActionFilter;
import org.gringlobal.service.filter.InventoryFilter;
import org.gringlobal.service.filter.InventoryQualityStatusFilter;
import org.gringlobal.spring.TransactionHelper;
import org.hibernate.Hibernate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.repository.support.Querydsl;
import org.springframework.data.querydsl.QuerydslPredicateExecutor;
import org.springframework.data.support.PageableExecutionUtils;
import org.springframework.security.access.AccessDeniedException;
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.validation.annotation.Validated;
import org.springframework.web.multipart.MultipartFile;

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

@Service
@Transactional(readOnly = true)
@Validated
@Slf4j
public class InventoryServiceImpl extends FilteredCRUDService2Impl<Inventory, InventoryFilter, InventoryRepository> implements InventoryService, InvitroInventoryService {

	private static final String[] BOOST_FIELDS = { "inventoryNumber", "names.plantName", "accession.accessionNumber", "accession.taxonomySpecies.name" };

	@Autowired
	private InventoryMaintenancePolicyRepository inventoryMaintPolicyRepo;

	@Autowired
	private AccessionInvNameRepository accessionInvNameRepository;

	@Autowired
	private AccessionInvGroupService accessionInvGroupService;

	@Autowired
	private AccessionInvAttachRepository attachRepository;

	@Autowired
	private InventoryActionRepository inventoryActionRepository;

	@Autowired
	private AccessionRepository accessionRepository;

	@Autowired
	private OverviewHelper overviewHelper;

	@Autowired
	private AppSettingsService appSettingsService;

	@Autowired
	private TemplatingService templatingService;

	@Autowired
	private InventoryActionService inventoryActionService;

	@Autowired
	QuerydslPredicateExecutor<InventoryAction> actionFinder;

	@Autowired
	private EntityManager em;

	@Autowired
	private InventoryExtraService inventoryExtraService;
	
	@Autowired
	@Lazy
	private OrderRequestService orderRequestService;
	
	@Autowired
	private GGCESecurityConfig.GgceSec ggceSec;
	
	@Autowired
	private InventoryAttachmentService attachmentService;
	
	@Autowired
	private SiteRepository siteRepository;

	@Autowired
	private CriteriaBuilderFactory criteriaBuilderFactory;
	
	@Component
	protected static class AttachmentSupport extends BaseAttachmentSupport<Inventory, AccessionInvAttach, InventoryAttachmentRequest> implements InventoryAttachmentService {

		public AttachmentSupport() {
			super(QAccessionInvAttach.accessionInvAttach.inventory().id, QAccessionInvAttach.accessionInvAttach.id);
		}

		/**
		 * Based on the original `inventory_attach_wizard_get_filepath` dataview.
		 *
		 * <ul>
		 * <li>AIA/</li>
		 * <li>${accession.taxonomySpecies.taxonomyGenus.genusName}</li>
		 * <li>Conditional: last 2 digits of accession ID: ${accession.id % 100} if there are over 2000 accessions of that `TaxonomyGenus`</li>
		 * <li>${accession.id}</li>
		 * </ul>
		 *
		 * @param inventory
		 * @return repository path for attachments
		 */
		@Override
		protected Path createRepositoryPath(Inventory inventory) {
			inventory = owningEntityRepository.getReferenceById(inventory.getId());

			Hibernate.initialize(inventory.getAccession());
			Accession accession = inventory.getAccession();

			Hibernate.initialize(accession.getTaxonomySpecies());
			TaxonomySpecies taxonomySpecies = accession.getTaxonomySpecies();

			Hibernate.initialize(taxonomySpecies.getTaxonomyGenus());
			TaxonomyGenus taxonomyGenus = taxonomySpecies.getTaxonomyGenus();

			StringBuilder builder = new StringBuilder("/").append("AIA").append("/")
					.append(taxonomyGenus.getGenusName()).append("/")
					.append(accession.getId() % 100).append("/") // last 2 digits
					.append(accession.getId()).append("/")
					.append(inventory.getId());

			return Paths.get(builder.toString());
		}

		@Override
		protected AccessionInvAttach createAttach(Inventory entity, AccessionInvAttach source) {
			AccessionInvAttach attach = new AccessionInvAttach();
			attach.apply(source);
			attach.setVirtualPath(source.getVirtualPath()); // SOAP uses this to create the record
			attach.setInventory(entity);
			return attach;
		}

		@Override
		@Transactional
		@PreAuthorize("hasAuthority('GROUP_ADMINS') or @ggceSec.actionAllowed('InventoryAttachment', 'CREATE', #entity.site)")
		public AccessionInvAttach uploadFile(Inventory entity, MultipartFile file, InventoryAttachmentRequest metadata) throws IOException, InvalidRepositoryPathException,
				InvalidRepositoryFileDataException {
			return super.uploadFile(entity, file, metadata);
		}

		@Override
		@Transactional
		@PreAuthorize("hasAuthority('GROUP_ADMINS') or @ggceSec.actionAllowed('InventoryAttachment', 'DELETE', #entity.site)")
		public AccessionInvAttach removeFile(Inventory entity, Long attachmentId) {
			return super.removeFile(entity, attachmentId);
		}

		@Override
		@Transactional
		@PostAuthorize("hasAuthority('GROUP_ADMINS') or @ggceSec.actionAllowed('InventoryAttachment', 'CREATE', returnObject.inventory.site)")
		public AccessionInvAttach create(AccessionInvAttach source) {
			var inventory = owningEntityRepository.getReferenceById(source.getInventory().getId());
			Hibernate.initialize(inventory.getSite()); // For permissions

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

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

		@Override
		@Transactional
		@PreAuthorize("hasAuthority('GROUP_ADMINS') or @ggceSec.actionAllowed('InventoryAttachment', 'DELETE', #entity.inventory.site)")
		public AccessionInvAttach remove(AccessionInvAttach entity) {
			return super.remove(entity);
		}

		/**
		 * Utility to find attachments belonging to an inventory.
		 * Used by SOAP FilesEndpoint.
		 */
		@Override
		public AccessionInvAttach findAttachment(Inventory inventory, Path filePath) {
			return attachFinder.findOne(
				QAccessionInvAttach.accessionInvAttach.inventory().eq(inventory)
				.and(QAccessionInvAttach.accessionInvAttach.virtualPath.eq(filePath.toString()))
			).orElse(null);
		}

		@Override
		@Transactional
		@PreAuthorize("@ggceSec.actionAllowed('InventoryAttachment', 'WRITE', #source.inventory.site)")
		public void shareAttachment(AccessionInvAttach source, List<Inventory> inventories) {
			List<AccessionInvAttach> createdAttachments = new ArrayList<>(inventories.size());
			for (Inventory inventory : inventories) {
				var attach = createAttach(inventory, source);
				attach.setContentType(source.getContentType());
				attach.setTitle(source.getTitle());
				attach.setVirtualPath(source.getVirtualPath());
				attach.setRepositoryFile(source.getRepositoryFile());
				createdAttachments.add(attach);
			}
			attachRepository.saveAll(createdAttachments);
		}
	}

	@Component
	protected static class ActionSupport extends BaseActionSupport<Inventory, InventoryAction, InventoryActionFilter, InventoryActionRepository, InventoryActionRequest, InventoryActionScheduleFilter>
			implements InventoryActionService {

		@Autowired
		private InventoryRepository inventoryRepository;

		@Autowired
		private MethodService methodService;

		@Override
		protected EntityPath<Inventory> getOwningEntityPath() {
			return QInventoryAction.inventoryAction.inventory();
		}

		@Override
		protected void initializeActionDetails(List<InventoryAction> actions) {
			actions.forEach(action -> {
				Hibernate.initialize(action.getInventory());
				action.getInventory().lazyLoad();
			});
		}

		@Override
		protected void applyOwningEntityFilter(InventoryActionScheduleFilter filter, String owningEntityAlias, List<Predicate> predicates) {
			QInventory qInventory = new QInventory(owningEntityAlias);
			if (predicates != null && filter.inventory != null) {
				predicates.addAll(filter.inventory.collectPredicates(qInventory));
			}
		}

		@Override
		protected InventoryAction createAction(Inventory owningEntity, InventoryActionRequest requestData) {
			InventoryAction action = new InventoryAction();
			action.setInventory(owningEntity);
			return action;
		}

		@Override
		protected void updateAction(InventoryAction action, InventoryActionRequest request) {
			action.setQuantity(request.quantity);
			action.setQuantityUnitCode(request.quantityUnitCode);
			action.setFormCode(request.formCode);
			if (request.method != null && !request.method.isNew()) {
				action.setMethod(methodService.get(request.method.getId()));
			}
		}

		@Override
		@Transactional
		public InventoryAction create(InventoryAction source) {
			log.debug("Create InventoryAction. Input data {}", source);
			InventoryAction inventoryAction = new InventoryAction();
			inventoryAction.apply(source);
			InventoryAction saved = repository.save(inventoryAction);

			if (saved.getCompletedDate() != null) {
				if (CommunityCodeValues.INVENTORY_ACTION_WITHDRAW.value.equals(saved.getActionNameCode())) {
					var updatedInventory = reduceInventory(saved, inventoryRepository.getReferenceById(saved.getInventory().getId()));
					saved.setInventory(updatedInventory);
				}
			}

			return _lazyLoad(saved);
		}

		@Override
		public InventoryAction update(InventoryAction updated, InventoryAction target) {
			if (target.getCompletedDate() == null && updated.getCompletedDate() != null) {
				if (CommunityCodeValues.INVENTORY_ACTION_WITHDRAW.value.equals(updated.getActionNameCode())) {
					var updatedInventory = reduceInventory(updated, target.getInventory());
					updated.setInventory(updatedInventory);
				}
			}

			return super.update(updated, target);
		}

		@Override
		protected Iterable<Inventory> findOwningEntities(Set<Long> id) {
			return inventoryRepository.findAll(QInventory.inventory.id.in(id));
		}

		private Inventory reduceInventory(InventoryAction inventoryAction, Inventory inventory) {
			assert(CommunityCodeValues.INVENTORY_ACTION_WITHDRAW.value.equals(inventoryAction.getActionNameCode()));

			// if flagged as auto-deducted, reduce quantity on hand
			if (!"Y".equals(inventory.getIsAutoDeducted())) {
				return inventory;
			} else {
				if (!inventory.getQuantityOnHandUnitCode().equals(inventoryAction.getQuantityUnitCode())) {
					throw new InvalidApiUsageException("Quantity Unit Code of the action does not match the unit code of inventory item.");
				}
				if (inventory.getQuantityOnHand() < inventoryAction.getQuantity()) {
					throw new InvalidApiUsageException("Insufficient quantity on hand.");
				} else {
					// update quantity
					inventory.setQuantityOnHand(inventory.getQuantityOnHand() - inventoryAction.getQuantity());
					return inventoryRepository.save(inventory);
				}
			}
		}

	}

	@Service
	@Transactional(readOnly = true)
	@Validated
	protected static class InventoryQualityStatusServiceImpl extends FilteredCRUDServiceImpl<InventoryQualityStatus, InventoryQualityStatusFilter, InventoryQualityStatusRepository>
		implements InventoryQualityStatusService {

		@Override
		public InventoryQualityStatus create(InventoryQualityStatus source) {
			InventoryQualityStatus statusForSave = new InventoryQualityStatus();
			statusForSave.apply(source);
			return _lazyLoad(repository.save(statusForSave));
		}

		@Override
		public InventoryQualityStatus update(InventoryQualityStatus updated, InventoryQualityStatus target) {
			target.apply(updated);
			return _lazyLoad(repository.save(target));
		}

	}

	@Transactional
	@PreAuthorize("hasAuthority('GROUP_ADMINS')")
	@Override
	public int ensureSystemInventories() {
		var accessionPath = QAccession.accession;

		var subQuery = jpaQueryFactory.selectDistinct(accessionPath.id).from(accessionPath)
			.where(accessionPath.inventories.any().formTypeCode.eq(Inventory.SYSTEM_INVENTORY_FTC));
		
		var accessionsWithoutSystemInv = jpaQueryFactory.select(accessionPath.id).from(accessionPath)
			.where(accessionPath.id.notIn(subQuery))
			.fetch();

		accessionRepository.findAllById(accessionsWithoutSystemInv).forEach(this::assureSystemInventory);

		return accessionsWithoutSystemInv.size();
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('InventoryData', 'CREATE', #source.site)")
	public Inventory createFast(Inventory source) {
		return create(source);
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('InventoryData', 'CREATE', #source.site)")
	public Inventory create(Inventory source) {
		Inventory saved = new Inventory();
		saved.apply(source);
		saved = repository.save(saved);

		if (!saved.isSystemInventory() && StringUtils.isBlank(saved.getBarcode())) {
			mintBarcode(saved);
			saved = repository.save(saved);
		}
		
		if (source.getExtra() != null) {
			var extra = source.getExtra();
			extra.setInventory(saved);
			var createdExtra = inventoryExtraService.create(extra);
			saved.setExtra(createdExtra);
		}
		// Save 
		return saved;
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('InventoryData', 'WRITE', #target.site)")
	public Inventory update(Inventory input, Inventory target) {

		if (target.isSystemInventory()) {
			throw new InvalidApiUsageException("The system inventory cannot be modified.");
		}
		
		if (target.equals(input.getParentInventory())) {
			throw new InvalidApiUsageException("Cannot set a parent inventory, a cyclical dependency was detected.");
		}

		target.apply(input);

		if (input.getExtra() != null) {
			if (target.getExtra() != null) {
				target.getExtra().getId();
				target.setExtra(inventoryExtraService.update(target.getExtra().apply(input.getExtra())));
			} else {
				var inputExtra = input.getExtra();
				inputExtra.setInventory(target);
				target.setExtra(inventoryExtraService.create(inputExtra));
			};
		}

		return _lazyLoad(repository.save(target));
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('InventoryData', 'WRITE', #target.site)")
	public Inventory updateFast(@NotNull @Valid Inventory updated, Inventory target) {
		if (target.isSystemInventory()) {
			throw new InvalidApiUsageException("The system inventory cannot be modified.");
		}
		
		if (target.equals(updated.getParentInventory())) {
			throw new InvalidApiUsageException("Cannot set a parent inventory, a cyclical dependency was detected.");
		}

		target.apply(updated);

		if (updated.getExtra() != null) {
			if (target.getExtra() != null) {
				target.getExtra().getId();
				target.setExtra(inventoryExtraService.update(target.getExtra().apply(updated.getExtra())));
			} else {
				var inputExtra = updated.getExtra();
				inputExtra.setInventory(target);
				target.setExtra(inventoryExtraService.create(inputExtra));
			};
		}

		return repository.save(target);
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('InventoryData', 'DELETE', #entity.site)")
	public Inventory remove(Inventory entity) {
		if (entity.isSystemInventory()) {
			throw new InvalidApiUsageException("System inventory cannot be removed");
		}
		return super.remove(entity);
	}

	@Override
	@Transactional
	// FIXME @PreAuthorize
	public Inventory setInventoryQuantity(InventoryQuantityRequest inventoryQuantity) {
		Inventory inventory = get(inventoryQuantity.id);

		if (inventory.isSystemInventory()) {
			throw new InvalidApiUsageException("Cannot set quantity for a SYSTEM inventory");
		}

		// Only update 100 seed weight if not null
		if (inventoryQuantity.hundredSeedWeight != null) {
			if (inventoryQuantity.hundredSeedWeight == 0 && inventory.getHundredSeedWeight() != null) {
				// If 0, clear it
				completeInventoryAction(inventory, CommunityCodeValues.INVENTORY_ACTION_100SEEDWEIGHT, "Cleared. Was " + inventory.getHundredSeedWeight());
				inventory.setHundredSeedWeight(null);

			} else if (inventoryQuantity.hundredSeedWeight != 0 && (inventory.getHundredSeedWeight() == null || BigDecimal.valueOf(inventory.getHundredSeedWeight()).compareTo(BigDecimal.valueOf(inventoryQuantity.hundredSeedWeight)) != 0)) {
				// Otherwise update
				completeInventoryAction(inventory, CommunityCodeValues.INVENTORY_ACTION_100SEEDWEIGHT, "100 seed weight set to " + inventoryQuantity.hundredSeedWeight + ". Was " + inventory.getHundredSeedWeight());
				inventory.setHundredSeedWeight(inventoryQuantity.hundredSeedWeight);
			}
		}

		inventory.setQuantityOnHand(inventoryQuantity.quantityOnHand);
		inventory.setQuantityOnHandUnitCode(inventoryQuantity.quantityOnHandUnitCode);

		inventory = repository.save(inventory);
		completeInventoryAction(inventory, CommunityCodeValues.INVENTORY_ACTION_QUANTITYSET, inventoryQuantity.note);

		return _lazyLoad(inventory);
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('InventoryData', 'WRITE', #inventory.site)")
	public String assignBarcode(Inventory inventory) {
		inventory = get(inventory);

		if (inventory.isSystemInventory()) {
			throw new InvalidApiUsageException("System inventories do not have barcodes");
		}

		if (StringUtils.isNotBlank(inventory.getBarcode()))
			return inventory.getBarcode(); // return existing barcode

		mintBarcode(inventory);
		repository.save(inventory);
		return inventory.getBarcode();
	}

	/**
	 * Utility to mint barcode without loading or saving the inventory
	 */
	private void mintBarcode(Inventory inventory) {
		AppSetting setting = appSettingsService.getSetting(BARCODE_INVENTORY.categoryTag, BARCODE_INVENTORY.name);
		inventory.setBarcode(StringUtils.stripToNull(templatingService.fillTemplate(setting.getValue(), Map.of("inventory", inventory, "randomUUID", UUID.randomUUID()))));
	}

	@Override
	@Transactional
	public void discardMaterial(Map<Long, Integer> discardQuantities) {
		for (var entry : discardQuantities.entrySet()) {
			Inventory inventory = get(entry.getKey());
			if (inventory.isSystemInventory())
				throw new InvalidApiUsageException("Cannot update quantity for a SYSTEM inventory");

			int discardQuantity = entry.getValue();
			if (inventory.getQuantityOnHand() < discardQuantity)
				throw new InvalidApiUsageException("Refusing to discard " + discardQuantity + " items, the current quantity on hand is " + inventory.getQuantityOnHand());

			inventory.setQuantityOnHand(inventory.getQuantityOnHand() - discardQuantity);
			var savedInventory = repository.save(inventory);
			addCompletedInventoryAction(savedInventory, CommunityCodeValues.INVENTORY_ACTION_DISCARD, (action) -> {
				action.setQuantity((double) discardQuantity);
				action.setQuantityUnitCode(savedInventory.getQuantityOnHandUnitCode());
				action.setFormCode(savedInventory.getFormTypeCode());
				action.setNote("Discarded " + discardQuantity + " " + inventory.getQuantityOnHandUnitCode());
			});
		}
	}

	@Override
	public Page<InventoryRepositoryCustom.AggregatedInventoryQuantity> aggregateQuantity(InventoryFilter filter, Pageable page) {
		if (filter.isFulltextQuery())
			throw new InvalidApiUsageException("Elasticsearch filters not supported.");

		var resultPage = repository.aggregateQuantity(filter.buildPredicate(), page);
		resultPage.stream().forEach(e -> e.accession = accessionRepository.findById(e.accession.getId()).orElseThrow(() -> {
			// should not happen
			throw new NotFoundElement("No such accession");
		}));

		return resultPage;
	}

	@Override
	@Transactional
	public List<Inventory> splitInventory(SplitInventoryRequest splitInventoryRequest) {
		var splitSource = splitInventoryRequest.sourceSplitInventory;
		Inventory source = get(splitSource.id, splitSource.modifiedDate);
		if (source.isSystemInventory()) {
			throw new InvalidApiUsageException("Cannot split a system inventory");
		}
		if (splitSource.containerTypeCode != null) {
			source.setContainerTypeCode(splitSource.containerTypeCode);
			source = update(source); // updated source inventory
		}
		if (splitSource.quantityOnHand != null) {
			var quantityUpdate = new InventoryQuantityRequest();
			quantityUpdate.id = source.getId();
			quantityUpdate.quantityOnHand = splitSource.quantityOnHand.doubleValue();
			quantityUpdate.quantityOnHandUnitCode = splitSource.quantityOnHandUnitCode;
			quantityUpdate.note = splitSource.note;
			source = setInventoryQuantity(quantityUpdate);
		}

		// New inventories
		var parentInventory = source;
		var parentExtra = parentInventory.getExtra();
		List<Inventory> inventoriesForSave = new ArrayList<>();
		splitInventoryRequest.splits.forEach(splitRequest -> {
			var splitInventory = new Inventory();
			splitInventory.apply(parentInventory); // Copy from source
			splitInventory.setBarcode(null);
			splitInventory.setStorageLocationPart1(null);
			splitInventory.setStorageLocationPart2(null);
			splitInventory.setStorageLocationPart3(null);
			splitInventory.setStorageLocationPart4(null);
			splitInventory.setBackupInventory(null);

			if (splitRequest.inventoryMaintenancePolicy != null) {
				if (splitRequest.inventoryMaintenancePolicy.id == null) {
					throw new InvalidApiUsageException("InventoryMaintenancePolicy.id not specified");
				}
				splitInventory.applyInventoryMaintenancePolicy(inventoryMaintPolicyRepo.getReferenceById(splitRequest.inventoryMaintenancePolicy.id));
			} else {
				splitInventory.applyInventoryMaintenancePolicy(parentInventory.getInventoryMaintenancePolicy());
			}
			splitInventory.setParentInventory(parentInventory); // Set parent
			splitInventory.setQuantityOnHand(splitRequest.quantityOnHand); // Optional quantity
			splitInventory.setQuantityOnHandUnitCode(StringUtils.defaultIfBlank(splitRequest.quantityOnHandUnitCode, parentInventory.getQuantityOnHandUnitCode())); // Set unit code
			splitInventory.setContainerTypeCode(splitRequest.containerTypeCode);
			splitInventory.setInventoryNumberPart1(splitRequest.inventoryNumberPart1);
			splitInventory.setInventoryNumberPart2(splitRequest.inventoryNumberPart2);
			splitInventory.setInventoryNumberPart3(splitRequest.inventoryNumberPart3);
			splitInventory.setNote(splitRequest.note);
			splitInventory.setAvailabilityStatusCode(CommunityCodeValues.INVENTORY_AVAILABILITY_NOTSET.value);

			var savedSplit = repository.save(splitInventory);

			if (parentExtra != null) {
				var splitInventoryExtra = parentExtra.copy();
				if (splitInventoryExtra != null) { // We have a copy
					splitInventoryExtra.setInventory(splitInventory);
					inventoryExtraService.create(splitInventoryExtra);
				}
			}

			inventoriesForSave.add(savedSplit);
		});

		// Assign barcodes to new inventories
		inventoriesForSave.forEach(this::mintBarcode);

		// Record the SPLIT action on source inventory
		completeInventoryAction(parentInventory, CommunityCodeValues.INVENTORY_ACTION_SPLIT, splitSource.note);

		return repository.saveAll(inventoriesForSave); // Do a final save (for barcodes)
	}

	@Override
	@Transactional
	public List<Inventory> assignLocation(AssignLocationRequest assignLocationRequest) {
		var location = assignLocationRequest.location;

		if (location == null || location.siteId == null) {
			throw new InvalidApiUsageException("Location must be provided");
		}

		if (CollectionUtils.isEmpty(assignLocationRequest.inventories)) {
			throw new InvalidApiUsageException("Inventories must be provided");
		}

		var site = siteRepository.findById(location.siteId).orElseThrow(() -> new NotFoundElement("No site with id=" + location.siteId));
		if (!ggceSec.actionAllowed(SecurityAction.InventoryData.name(), "WRITE", site)) {
			throw new AccessDeniedException("Don't have permission to assign inventories to this site");
		}
		var inventoriesForAssign = findByIdAndVersion(assignLocationRequest.inventories);
		if (inventoriesForAssign.size() < assignLocationRequest.inventories.size()) {
			throw new NotFoundElement("Some inventories not found");
		}
		var hasUnavailableSite = getHasUnavailableSite(inventoriesForAssign, SecurityAction.InventoryData, "WRITE");
		if (hasUnavailableSite) {
			throw new AccessDeniedException("Don't have permission to change location of the selected inventories");
		}
		for (Inventory inventory : inventoriesForAssign) {
			inventory.setSite(site);
			inventory.setStorageLocationPart1(location.storageLocationPart1);
			inventory.setStorageLocationPart2(location.storageLocationPart2);
			inventory.setStorageLocationPart3(location.storageLocationPart3);
			inventory.setStorageLocationPart4(location.storageLocationPart4);
		}

		return repository.saveAll(inventoriesForAssign);
	}

	@Override
	@Transactional
	public List<Inventory> assignLocations(List<AssignLocationRequest> assignLocationRequests) {
		return assignLocationRequests.stream()
			.map(this::assignLocation)
			.flatMap(Collection::stream)
			.collect(Collectors.toList());
	}

	@Override
	public Inventory getByBarcode(String barcode) {
		return repository.findOne(QInventory.inventory.barcode.eq(barcode)).orElseThrow(() -> new NotFoundElement("Inventory not found by barcode: " + barcode));
	}

	private Collection<Inventory> findByIdAndVersion(Set<EntityIdAndModifiedDate> inventories) {
		assert(CollectionUtils.isNotEmpty(inventories));
		BooleanBuilder builder = new BooleanBuilder();
		for (var i : inventories) {
			builder.orAllOf(QInventory.inventory.id.eq(i.id), QInventory.inventory.modifiedDate.eq(i.modifiedDate));
		}
		return (Collection<Inventory>) repository.findAll(builder);
	}

	private void completeInventoryAction(Inventory inventory, CodeValueDef actionCodeValue, String note) {
		addCompletedInventoryAction(inventory, actionCodeValue, (action) -> {
			action.setNote(note);
			// Update action with inventory data
			action.setFormCode(inventory.getFormTypeCode());
			action.setQuantity(inventory.getQuantityOnHand());
			action.setQuantityUnitCode(inventory.getQuantityOnHandUnitCode());
		});
	}

	private void addCompletedInventoryAction(Inventory inventory, CodeValueDef actionCodeValue, Consumer<InventoryAction> customizer) {
		var nowDate = Instant.now();
		BooleanExpression expression = QInventoryAction.inventoryAction.inventory().id.in(inventory.getId())
			.and(QInventoryAction.inventoryAction.completedDate.isNull())
			.and(QInventoryAction.inventoryAction.notBeforeDate.isNull().or(QInventoryAction.inventoryAction.notBeforeDate.loe(nowDate)))
			.and(QInventoryAction.inventoryAction.actionNameCode.eq(actionCodeValue.value));
		var pendingActions = StreamSupport.stream(actionFinder.findAll(expression).spliterator(), false)
			.peek(action -> {
				action.setCompletedDate(nowDate);
				action.setCompletedDateCode(CommunityCodeValues.DATE_FORMAT_DATETIME.value);
				if (customizer != null) {
					customizer.accept(action);
				}
			}).collect(Collectors.toList());
		var result = inventoryActionService.update(pendingActions);
		if (result.success.size() > 0) {
			return;
		}

		InventoryAction quantityAction = new InventoryAction();
		quantityAction.setActionNameCode(actionCodeValue.value);
		quantityAction.setInventory(inventory);

		quantityAction.setCompletedDate(nowDate);
		quantityAction.setCompletedDateCode(CommunityCodeValues.DATE_FORMAT_DATETIME.value);
		quantityAction.setStartedDate(quantityAction.getCompletedDate());
		quantityAction.setStartedDateCode(quantityAction.getCompletedDateCode());

		if (customizer != null) {
			customizer.accept(quantityAction);
		}

		inventoryActionRepository.save(quantityAction);
	}
//
//	@Override
//	public Page<InventoryAction> listInventoriesWithAction(InventoryActionFilter actionFilter, InventoryFilter inventoryFilter, Pageable page) {
//
//		var qInvA = QInventoryAction.inventoryAction;
//		QInventory qInv = new QInventory("inventory");
//		BooleanBuilder predicate = new BooleanBuilder();
//
//		if (actionFilter != null) {
//			predicate.and(ExpressionUtils.allOf(actionFilter.collectPredicates(qInvA)));
//		}
//		if (inventoryFilter != null) {
//			predicate.and(ExpressionUtils.allOf(inventoryFilter.collectPredicates(qInv)));
//		}
//
//		var query = jpaQueryFactory.selectFrom(qInvA)
//				// join
//				.innerJoin(qInvA.inventory, qInv).fetchJoin();
//		
//		query = joinDetails(query, qInv)
//				// where
//				.where(predicate);
//
//		query.select(qInvA);
//		
//		// get total elements
//		var totalElements = query.fetchCount();
//
//		// apply pagination
//		Querydsl querydsl = new Querydsl(entityManager, new PathBuilder<>(qInvA.getType(), qInvA.getMetadata()));
//		querydsl.applyPagination(page, query);
//
//		var content = query.fetch();
//
//		return new PageImpl<>(content, page, totalElements);
//	}

	@Override
	public Page<Inventory> list(InventoryFilter filter, Pageable page) throws SearchException {
		return list(Inventory.class, filter, page, BOOST_FIELDS);
	}

	@Override
	protected Page<Inventory> list(Class<Inventory> clazz, InventoryFilter filter, Pageable page, String... boostFields) throws SearchException {
		page = Pagination.addSortByParams(page, idSortParams);
		
			// if (ftf.isFulltextQuery() && elasticsearchService != null) { // This could be quietly ignoring full-test search
		if (filter != null && filter.isFulltextQuery()) {
			return elasticsearchService.findAll(clazz, filter, null, page, (entityIds) -> list(entityIds), boostFields);
		}

		BooleanBuilder predicate = new BooleanBuilder();
		if (filter != null) {
			predicate.and(filter.buildPredicate());
		}

		if (entityListQuery() != null) {
			JPAQuery<Inventory> query = entityListQuery().where(predicate);
			var inventoryExtraProp = QInventory.inventory.extra().getMetadata().getName().concat(".");
			var hasExtraSorts = page.getSort().stream().anyMatch(sort -> sort.getProperty().startsWith(inventoryExtraProp));
			if (hasExtraSorts) {
				var queryDsl = new Querydsl(entityManager, new PathBuilder<Inventory>(QInventory.inventory.getType(), QInventory.inventory.getMetadata()));
				query.offset(page.getOffset());
				query.limit(page.getPageSize());

				applyOrderWithExtra(query, page, inventoryExtraProp, queryDsl);

				Long total = query.fetchCount();
				return PageableExecutionUtils.getPage(query.fetch(), page, total::longValue);
			}
			return repository.findAll(query, page);
		} else {
			// default implementation without custom loading
			return repository.findAll(predicate, page);
		}
	}
	
	private void applyOrderWithExtra(JPQLQuery<Inventory> query, Pageable page, String inventoryExtraProp, Querydsl queryDsl) {
		for (Sort.Order order : page.getSort()) {
			if (order.getProperty().startsWith(inventoryExtraProp)) {
				var extraOrderPath = Expressions.path(Comparable.class, QInventory.inventory, order.getProperty());
				query.orderBy(new OrderSpecifier<Comparable>(
					order.isAscending() ? com.querydsl.core.types.Order.ASC : com.querydsl.core.types.Order.DESC, extraOrderPath)
				);
			} else {
				queryDsl.applySorting(Sort.by(order), query);
			}
		}
	}
	
	@Override
	protected JPAQuery<Inventory> entityListQuery() {
		return joinDetails(jpaQueryFactory.selectFrom(QInventory.inventory), QInventory.inventory);
	}

	private <T> JPAQuery<T> joinDetails(JPAQuery<T> query, QInventory inventory) {
		QAccession aliasAccession = new QAccession("a");
		return query
				// site
				.join(inventory.site()).fetchJoin()
				// acce
				.join(inventory.accession(), aliasAccession).fetchJoin() // Needs alias because of fetchJoin 
				// maint pol
				.join(inventory.inventoryMaintenancePolicy()).fetchJoin()
				// species
				.join(aliasAccession.taxonomySpecies()).fetchJoin()
				// extra
				.leftJoin(inventory.extra()).fetchJoin()
				// production location geography
				.leftJoin(inventory.productionLocationGeography()).fetchJoin()
				;
	}

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

	@Override
	@Transactional(propagation = Propagation.REQUIRED)
	@PreAuthorize("@ggceSec.actionAllowed('PassportData', 'CREATE', #accession.site)")
	public Inventory assureSystemInventory(Accession accession) {
		log.debug("Assuring a SYSTEM inventory for new accession {}", accession.getId());

		final Inventory inventory = new Inventory();
		inventory.setAccession(accession);
		inventory.setInventoryNumberPart1(accession.getAccessionNumberPart1());
		inventory.setInventoryNumberPart2(accession.getAccessionNumberPart2());
		inventory.setInventoryNumberPart3(accession.getAccessionNumberPart3());
		inventory.setFormTypeCode(Inventory.SYSTEM_INVENTORY_FTC);
		inventory.setSite(accession.getSite());
		final InventoryMaintenancePolicy inventoryMaintenancePolicy = inventoryMaintPolicyRepo.getSystemMaintenancePolicy();
		inventory.setInventoryMaintenancePolicy(inventoryMaintenancePolicy);
		inventory.setIsDistributable("N");
		inventory.setIsAvailable("N");
		inventory.setAvailabilityStatusCode(CommunityCodeValues.INVENTORY_AVAILABILITY_NOTSET.value);
		inventory.setIsAutoDeducted("N");
		inventory.setNote("Default Association Record for Accession -> Inventory");

		final var now = Instant.now();
		inventory.setOwnedBy(accession.getOwnedBy());
		inventory.setOwnedDate(now);
		inventory.setSite(accession.getSite());
		// inventory.setCreatedBy(accession.getCreatedBy());
		// inventory.setCreatedDate(now);

		return repository.save(inventory);
	}

	@Override
	public InventoryDetails getInventoryDetails(Inventory inventory) {
		if (inventory == null) {
			throw new NotFoundElement();
		}

		inventory = this.reload(inventory);
		inventory.lazyLoad();

		// initialize lazy data
		Hibernate.initialize(inventory.getActions());
		Hibernate.initialize(inventory.getViability());
		Hibernate.initialize(inventory.getQuality());
		Hibernate.initialize(inventory.getInventoryMaintenancePolicy());
		Hibernate.initialize(inventory.getParentInventory());
		Hibernate.initialize(inventory.getProductionLocationGeography());

		InventoryDetails inventoryDetails = new InventoryDetails();
		inventoryDetails.inventory = inventory;
		inventoryDetails.actions = inventory.getActions();
		inventoryDetails.viability = inventory.getViability();
		if (inventoryDetails.viability != null) {
			inventoryDetails.viability.forEach((viab) -> {
				if (viab.getInventoryViabilityRule() != null)
					viab.getInventoryViabilityRule().getId();
			});
		}
		inventoryDetails.qualityStatus = inventory.getQuality();
		if (inventoryDetails.qualityStatus != null) {
			inventoryDetails.qualityStatus.forEach(LazyLoading::lazyLoad);
		}

		inventoryDetails.names = accessionInvNameRepository.findInventoryNames(inventory);
		inventoryDetails.attachments = attachRepository.findInventoryAttachments(inventory);
		inventoryDetails.attachments.forEach(accessionInvAttach -> {
			if (accessionInvAttach.getAttachCooperator() != null) {
				accessionInvAttach.getAttachCooperator().getId();
			}
		});
		inventoryDetails.groups = accessionInvGroupService.listInventoryGroups(inventory);

		return inventoryDetails;
	}

	@Override
	public Map<Object, Number> inventoryOverview(String groupBy, InventoryFilter filter) {
		return overviewHelper.getOverview(Inventory.class, QInventory.inventory, QInventory.inventory.id.countDistinct(), groupBy, filter);
	}

	@Override
	@PreAuthorize("hasRole('ADMINISTRATOR')")
	public void recalculateAllInventoryNumbers() {
		boolean lastPage = false;
		int pageNumber = 0;

		do {
			Page<Inventory> page = repository.findAll(PageRequest.of(pageNumber, 1000));
			lastPage = page.isLast();
			pageNumber += 1;

			log.warn("Updating inventoryNumber for {} records, page {}", page.getNumberOfElements(), pageNumber);
			Lists.partition(page.getContent(), 100).forEach(batch -> TransactionHelper.executeInTransaction(false, () -> {
				batch.forEach(i -> repository.setInventoryNumber(i.getId(), GGCE.inventoryNumber(i)));
				return true;
			}));

			// Clear anything cached in the entity manager
			em.clear();
		} while (!lastPage);

		log.info("Done.");
	}

	@Override
	@PreAuthorize("hasRole('ADMINISTRATOR')")
	public void assignMissingInventoryNumbers() {
		boolean lastPage = false;
		int pageNumber = 0;

		do {
			Page<Inventory> page = repository.findAll(QInventory.inventory.inventoryNumber.isNull(), PageRequest.of(0, 1000));
			lastPage = page.isLast();
			pageNumber++;

			log.warn("Assigning inventoryNumber for {} records, page {}", page.getNumberOfElements(), pageNumber);
			Lists.partition(page.getContent(), 100).forEach(batch -> TransactionHelper.executeInTransaction(false, () -> {
				batch.forEach(a -> repository.setInventoryNumber(a.getId(), GGCE.inventoryNumber(a)));
				return true;
			}));

			// Clear anything cached in the entity manager
			em.clear();
		} while (!lastPage && pageNumber < 5000); // at most 5000 loops

		log.info("Done.");
	}

	@Service
	@Transactional(readOnly = true)
	@Validated
	protected static class InventoryExtraServiceImpl extends CRUDServiceImpl<InventoryExtra, InventoryExtraRepository>
		implements InventoryExtraService {

		@Override
		public InventoryExtra create(InventoryExtra source) {
			return make(source);
		}

		@Override
		public <T extends InventoryExtra> T make(T extra) {
			var inventory = extra.getInventory();
			if (inventory == null || inventory.getId() == null) {
				throw new InvalidApiUsageException("Inventory must be provided");
			}
			return repository.save(extra);
		}

		@Override
		public InventoryExtra update(InventoryExtra updated, InventoryExtra target) {
			return repository.save(target.apply(updated));
		}

	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('Invitro', 'CREATE', #request.sourceInventory.site)")
	public List<Inventory> invitroStart(IVMultiplicationRequest request) {
		assert (request.multiplicationItems != null && !request.multiplicationItems.isEmpty());
		request.sourceInventory = get(request.sourceInventory.getId());
		if (request.sourceInventory.getQuantityOnHand() == null || request.sourceInventory.getQuantityOnHand() < request.quantityToDeduct) {
			throw new InvalidApiUsageException("Source inventory does not have sufficient quantity");
		}

		var createdList = new ArrayList<Inventory>(request.multiplicationItems.size());

		for (IVMultiplicationItem item : request.multiplicationItems) {
			if (item.inventoryMaintenancePolicy == null || item.inventoryMaintenancePolicy.getId() == null) {
				throw new InvalidApiUsageException("Inventory maintenance policy id is empty");
			}
			item.inventoryMaintenancePolicy = inventoryMaintPolicyRepo.getReferenceById(item.inventoryMaintenancePolicy.getId());

			var inv = new Inventory();
			inv.setParentInventory(request.sourceInventory);
			inv.setAccession(request.sourceInventory.getAccession());
			inv.setFormTypeCode(StringUtils.defaultIfBlank(item.formTypeCode, CommunityCodeValues.GERMPLASM_FORM_INVITRO.value));
			inv.setInventoryMaintenancePolicy(item.inventoryMaintenancePolicy);
			inv.setInventoryNumberPart1(StringUtils.isBlank(item.inventoryNumberPart1) ? inv.getAccession().getAccessionNumber() : item.inventoryNumberPart1);
			inv.setInventoryNumberPart2(-1L); // Auto-increment
			inv.setInventoryNumberPart3(item.inventoryNumberPart3); // Not sure about this one...
			inv.setQuantityOnHand(item.quantityOnHand == null ? 1.0d : item.quantityOnHand);
			inv.setQuantityOnHandUnitCode(item.quantityOnHandUnitCode);
			inv.setContainerTypeCode(item.containerTypeCode);
			inv.setAvailabilityStatusCode(CommunityCodeValues.INVENTORY_AVAILABILITY_NOTSET.value);
			inv.setSite(request.sourceInventory.getSite());
			inv.setPropagationDate(new Date());
			inv.setPropagationDateCode(CommunityCodeValues.DATE_FORMAT_DATE.value);

			inv.setPathogenStatusCode(request.sourceInventory.getPathogenStatusCode()); // Inherit from source
			inv.setGeneration(request.sourceInventory.nextGeneration()); // Increment generation
			if (inv.getGeneration() == null) inv.setGeneration(1L); // Set 1st generation
			
			var created = create(inv);
			applyExtraFromRequest(created, item);
			
			createdList.add(created);
		}

		// Consume 1 of source inventory
		request.sourceInventory.setQuantityOnHand(request.sourceInventory.getQuantityOnHand() - request.quantityToDeduct);
		request.sourceInventory = update(request.sourceInventory);
		
		return createdList;
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('Invitro', 'CREATE', #request.sourceInventory.site)")
	public List<Inventory> invitroMultiply(IVMultiplicationRequest request) {
		assert (request.multiplicationItems != null && !request.multiplicationItems.isEmpty());
		request.sourceInventory = get(request.sourceInventory.getId());
		// Require quantity
		if (request.sourceInventory.getQuantityOnHand() == null || request.sourceInventory.getQuantityOnHand() < request.quantityToDeduct) {
			throw new InvalidApiUsageException("Source inventory does not have sufficient quantity");
		}

		var createdList = new ArrayList<Inventory>();
		
		for (IVMultiplicationItem item : request.multiplicationItems) {

			item.formTypeCode = item.formTypeCode != null ? item.formTypeCode : request.sourceInventory.getFormTypeCode();
			item.inventoryMaintenancePolicy = item.inventoryMaintenancePolicy != null  && item.inventoryMaintenancePolicy.getId() != null ?
				inventoryMaintPolicyRepo.getReferenceById(item.inventoryMaintenancePolicy.getId()) : request.sourceInventory.getInventoryMaintenancePolicy();
			item.inventoryNumberPart3 = item.inventoryNumberPart3 != null ? item.inventoryNumberPart3 : request.sourceInventory.getInventoryNumberPart3();
			item.quantityOnHandUnitCode = item.quantityOnHandUnitCode != null ? item.quantityOnHandUnitCode : request.sourceInventory.getQuantityOnHandUnitCode();
			item.containerTypeCode = item.containerTypeCode != null ? item.containerTypeCode : request.sourceInventory.getContainerTypeCode();
			
			var inv = new Inventory();
			inv.setParentInventory(request.sourceInventory);
			inv.setAccession(request.sourceInventory.getAccession());
			inv.setFormTypeCode(StringUtils.defaultIfBlank(item.formTypeCode, CommunityCodeValues.GERMPLASM_FORM_INVITRO.value));
			inv.setInventoryMaintenancePolicy(item.inventoryMaintenancePolicy);
			inv.setInventoryNumberPart1(StringUtils.isBlank(item.inventoryNumberPart1) ? 
				StringUtils.joinWith(".", request.sourceInventory.getInventoryNumberPart1(), request.sourceInventory.getInventoryNumberPart2()) 
				: item.inventoryNumberPart1);
			inv.setInventoryNumberPart2(-1L); // Auto-increment
			inv.setInventoryNumberPart3(item.inventoryNumberPart3); // Not sure about this one...
			inv.setQuantityOnHand(item.quantityOnHand == null ? 1.0d : item.quantityOnHand);
			inv.setQuantityOnHandUnitCode(item.quantityOnHandUnitCode);
			inv.setContainerTypeCode(item.containerTypeCode);
			inv.setAvailabilityStatusCode(CommunityCodeValues.INVENTORY_AVAILABILITY_NOTSET.value);
			inv.setSite(request.sourceInventory.getSite());
			inv.setPropagationDate(new Date());
			inv.setPropagationDateCode(CommunityCodeValues.DATE_FORMAT_DATE.value);

			inv.setPathogenStatusCode(request.sourceInventory.getPathogenStatusCode()); // Inherit from source
			inv.setGeneration(request.sourceInventory.nextGeneration()); // Increment generation
			
			var created = create(inv);
			applyExtraFromRequest(created, item);

			createdList.add(created);
		}

		request.sourceInventory.setQuantityOnHand(request.sourceInventory.getQuantityOnHand() - request.quantityToDeduct);
		request.sourceInventory = update(request.sourceInventory);

		return createdList;
	}
	
//	private List<Inventory> applyExtraFromRequest(List<Inventory> inventoryList, IVMultiplicationItem request) {
//		// Apply medium
//		if (request.medium != null) {
//			var newExtras = new ArrayList<InventoryExtra>();
//			inventoryList.forEach(inventory -> {
//				TissueCultureExtra extra = new TissueCultureExtra();
//				extra.setInventory(inventory);
//				extra.setMedium(request.medium);
//				newExtras.add(extra);
//			});
//			var createdExtras = inventoryExtraService.create(newExtras).success;
//			inventoryList = inventoryList.stream().peek(inventory -> {
//				var invExtra = createdExtras.stream().filter(extra -> Objects.equals(inventory.getId(), extra.getInventory().getId())).findFirst().orElse(null);
//				inventory.setExtra(invExtra);
//			}).collect(Collectors.toList());
//		}
//		return inventoryList;
//	}

	private void applyExtraFromRequest(Inventory inventory, IVMultiplicationItem request) {
		// Apply medium
		if (request.medium != null) {
			TissueCultureExtra extra = new TissueCultureExtra();
			extra.setInventory(inventory);
			extra.setMedium(request.medium);
			var createdExtra = inventoryExtraService.create(extra);
			inventory.setExtra(createdExtra);
		}
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('InventoryData', 'ADMINISTRATION', #site)")
	public OrderRequest multiplicationOrder(Site site, Set<Long> inventoryIds, Cooperator multiplicationCooperator, String orderType, String intendedUseCode) {
		var inventories = Lists.newArrayList(repository.findAll(QInventory.inventory.id.in(inventoryIds)));
		
		final var orderRequestItems = new ArrayList<OrderRequestItem>(inventories.size());

		var sequenceNumber = new AtomicInteger(1);
		inventories.forEach(inventory -> {
			OrderRequestItem ori = new OrderRequestItem();
			ori.setInventory(inventory);
			ori.setSequenceNumber(sequenceNumber.getAndIncrement());
			ori.setQuantityShippedUnitCode(inventory.getDistributionUnitCode());
			ori.setDistributionFormCode(inventory.getDistributionDefaultFormCode());
			ori.setQuantityShipped(inventory.getDistributionDefaultQuantity());
			orderRequestItems.add(ori);

			// Start the CommunityCodeValues.INVENTORY_ACTION_MULTIPLICATION action by setting the start date
			InventoryActionService.InventoryActionRequest iar = InventoryActionService.InventoryActionRequest.builder()
				.actionNameCode(CommunityCodeValues.INVENTORY_ACTION_MULTIPLICATION.value)
				.quantity(null)
				.quantityUnitCode(CommunityCodeValues.UNIT_OF_QUANTITY_SEED.value)
				.formCode(CommunityCodeValues.GERMPLASM_FORM_SEED.value)
				.id(Set.of(inventory.getId()))
				.build();
			var startedActions = inventoryActionService.startAction(iar);
			log.info("Started {} action(s) for {}", startedActions.size(), iar.actionNameCode);
		});

		var orderRequest = new OrderRequest();
		orderRequest.setOrderTypeCode(orderType);
		orderRequest.setIntendedUseCode(intendedUseCode);

		orderRequest.setRequestorCooperator(multiplicationCooperator);
		orderRequest.setShipToCooperator(multiplicationCooperator);
		orderRequest.setFinalRecipientCooperator(multiplicationCooperator);

		orderRequest = orderRequestService.create(orderRequest, orderRequestItems);

		return orderRequest;
	}

	@Override
	@Transactional
	public void shareAttachment(Long attachId, List<Long> inventoryIds) {
		var inventories = repository.findAll(QInventory.inventory.id.in(inventoryIds), Pageable.unpaged()).getContent();
		var hasUnavailableSite = getHasUnavailableSite(inventories, SecurityAction.InventoryAttachment, "CREATE");
		if (hasUnavailableSite) {
			throw new AccessDeniedException("Don't have permission to create attachments for the selected inventories");
		}
		var attachment = attachRepository.findById(attachId).orElseThrow(() -> new NotFoundElement("No such attach for provided id"));
		attachmentService.shareAttachment(attachment, inventories);
	}

	/**
	 * Find sites where permission is not granted.
	 */
	private boolean getHasUnavailableSite(Collection<Inventory> inventories, SecurityAction action, String permission) {
		var hasUnavailableSite = inventories.stream()
			.map(Inventory::getSite)
			.distinct()
			.anyMatch(site -> !ggceSec.actionAllowed(action.name(), permission, site));
		return hasUnavailableSite;
	}

	@Override
	@Transactional
	@PostAuthorize("@ggceSec.actionAllowed('InventoryData', 'WRITE', returnObject.inventory.site)")
	public SeedInventoryExtra updateMoistureContent(SeedInventoryExtra extra, MoistureContentRequest request) {
		assert (extra != null && extra.getId() != null);
		SeedInventoryExtra target = (SeedInventoryExtra) inventoryExtraService.get(extra.getId());
		target.setMoistureContent(request.moistureContent);
		target.setMoistureContentDate(request.moistureContentDate);
		target.setMoistureContentDateCode(request.moistureContentDateCode);
		return (SeedInventoryExtra) inventoryExtraService.update(target);
	}

	@Override
	public Page<ComparedSitesResponse> compareSites(AccessionFilter filter, Map<Long, NumberFilter<Long>> siteItems, Pageable page) {

		// (Site, NumberFilter) entries
		var siteAndFilters = siteRepository.findAllById(siteItems.keySet()).stream()
			.map(site -> Map.entry(site, siteItems.get(site.getId())))
			.collect(Collectors.toList());

		if (siteAndFilters.size() != siteItems.size()) {
			throw new IllegalArgumentException("Some of the provided sites are missing");
		}

		var accession = QAccession.accession;

		List<Expression<?>> selectExpressions = new ArrayList<>();
		selectExpressions.add(accession.id);
		selectExpressions.add(accession.accessionNumber);

		var bigQuery = new BlazeJPAQuery<>(entityManager, criteriaBuilderFactory).from(accession)
			.where(filter != null ? filter.buildPredicate() : new AccessionFilter().buildPredicate())
			.orderBy(accession.accessionNumberPart1.asc(), accession.accessionNumberPart2.asc(), accession.accessionNumberPart3.asc());

		var fetchCountQuery = new BlazeJPAQuery<>(entityManager, criteriaBuilderFactory)
			.from(accession)
			.select(accession.id.count())
			.where(filter != null ? filter.buildPredicate() : new AccessionFilter().buildPredicate());

		for (var site : siteAndFilters) {
			QSiteCounter siteCounter = new QSiteCounter("CTEFor" + site.getKey().getId()); // CTE (accessionId, count(inventories))

			var siteInv = new QInventory("subQuerySite" + site.getKey().getId());

			var siteCountQuery = new BlazeJPAQuery<>(entityManager, criteriaBuilderFactory).from(siteInv).select(siteCounter)
				.bind(siteCounter.siteCount, siteInv.id.count())
				.bind(siteCounter.accessionId, siteInv.accession().id)
				.where(
					siteInv.site().eq(site.getKey())
						.and(siteInv.accession().eq(accession))
						.and(siteInv.formTypeCode.ne(Inventory.SYSTEM_INVENTORY_FTC))
						.and(siteInv.quantityOnHand.isNull().or(siteInv.quantityOnHand.gt(0)))
				)
				.groupBy(siteInv.accession().id);

			bigQuery.leftJoin(siteCountQuery, siteCounter).on(siteCounter.accessionId.eq(accession.id));

			selectExpressions.add(siteCounter.siteCount.coalesce(0L));
			
			// support filtering by count at this site
			if (site.getValue() != null && !site.getValue().isEmpty()) {
				var siteCounterPredicate = site.getValue().buildQuery(siteCounter.siteCount.coalesce(0L)).getValue();
				bigQuery.where(siteCounterPredicate);
				
				// add join to the fetch count query only if it has filter
				fetchCountQuery.leftJoin(siteCountQuery, siteCounter).on(siteCounter.accessionId.eq(accession.id));
				fetchCountQuery.where(siteCounterPredicate);
			}
		}

		bigQuery.select(selectExpressions.toArray(new Expression[0]));
		
		var total = fetchCountQuery.fetchOne();
		if (total == 0) {
			return new PageImpl<>(new ArrayList<>(), page, total);
		}
		
		// Apply pagination
		bigQuery.offset(page.getOffset());
		bigQuery.limit(page.getPageSize());
		
		var responseItems = bigQuery.fetch().stream().map(result -> {
			var response = new ComparedSitesResponse();
			response.setAccessionNumber(((Tuple)result).get(accession.accessionNumber));
			response.setAccessionId(((Tuple)result).get(accession.id));
			Map<String, Long> siteInvCounter = new HashMap<>();
			for (int siteIndex = 0, resultIndex = siteIndex + 2; siteIndex < siteAndFilters.size(); siteIndex++, resultIndex++) {
				siteInvCounter.put(
					siteAndFilters.get(siteIndex).getKey().getSiteShortName(),
					((Tuple)result).get(resultIndex, Long.class)
				);
			}
			response.setSiteInventories(siteInvCounter);
			return response;
		}).collect(Collectors.toList());

		return new PageImpl<>(responseItems, page, total);
	}
}