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.Objects;
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.blocks.security.SecurityContextUtil;
import org.genesys.filerepository.InvalidRepositoryFileDataException;
import org.genesys.filerepository.InvalidRepositoryPathException;
import org.gringlobal.api.FilteredPage;
import org.gringlobal.api.Pagination;
import org.gringlobal.api.exception.InvalidApiUsageException;
import org.gringlobal.api.exception.NotFoundElement;
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.Geography;
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.QAccessionInvAnnotation;
import org.gringlobal.model.QAccessionInvAttach;
import org.gringlobal.model.QAccessionInvGroupMap;
import org.gringlobal.model.QAccessionInvName;
import org.gringlobal.model.QCropTraitObservation;
import org.gringlobal.model.QCropTraitObservationData;
import org.gringlobal.model.QInventory;
import org.gringlobal.model.QInventoryAction;
import org.gringlobal.model.QInventoryViability;
import org.gringlobal.model.QInventoryViabilityAction;
import org.gringlobal.model.QInventoryViabilityAttach;
import org.gringlobal.model.QInventoryViabilityData;
import org.gringlobal.model.QInventoryViabilityDataEnvironmentMap;
import org.gringlobal.model.QOrderRequestItem;
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.AbstractAction.ActionState;
import org.gringlobal.model.community.CommunityCodeValues;
import org.gringlobal.model.community.SecurityAction;
import org.gringlobal.model.community.CommunityCodeValues.CodeValueDef;
import org.gringlobal.model.community.InventoryQualityStatusAttach;
import org.gringlobal.model.community.QInventoryQualityStatusAttach;
import org.gringlobal.model.workflow.WorkflowActionStep;
import org.gringlobal.persistence.AccessionInvAnnotationRepository;
import org.gringlobal.persistence.AccessionInvAttachRepository;
import org.gringlobal.persistence.AccessionInvGroupMapRepository;
import org.gringlobal.persistence.AccessionInvNameRepository;
import org.gringlobal.persistence.AccessionRepository;
import org.gringlobal.persistence.CropTraitObservationDataRepository;
import org.gringlobal.persistence.CropTraitObservationRepository;
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.InventoryViabilityActionRepository;
import org.gringlobal.persistence.InventoryViabilityAttachRepository;
import org.gringlobal.persistence.InventoryViabilityDataEnvMapRepository;
import org.gringlobal.persistence.InventoryViabilityDataRepository;
import org.gringlobal.persistence.InventoryViabilityRepository;
import org.gringlobal.persistence.OrderRequestItemRepository;
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.InventoryQualityStatusAttachmentService;
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.AccessionInvAttachFilter;
import org.gringlobal.service.filter.InventoryActionFilter;
import org.gringlobal.service.filter.InventoryFilter;
import org.gringlobal.service.filter.InventoryQualityStatusFilter;
import org.gringlobal.service.filter.SiteFilter;
import org.genesys.blocks.util.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 TransactionHelper transactionHelper;

	@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;

	@Autowired
	private InventoryQualityStatusRepository inventoryQualityStatusRepository;

	@Autowired
	private AccessionInvGroupMapRepository accessionInvGroupMapRepository;

	@Autowired
	private AccessionInvAnnotationRepository accessionInvAnnotationRepository;

	@Autowired
	private CropTraitObservationDataRepository cropTraitObservationDataRepository;

	@Autowired
	private CropTraitObservationRepository cropTraitObservationRepository;

	@Autowired
	private InventoryViabilityRepository inventoryViabilityRepository;

	@Autowired
	private InventoryViabilityDataRepository inventoryViabilityDataRepository;

	@Autowired
	private InventoryViabilityActionRepository inventoryViabilityActionRepository;

	@Autowired
	private InventoryViabilityAttachRepository inventoryViabilityAttachRepository;

	@Autowired
	private InventoryViabilityDataEnvMapRepository viabilityDataEnvMapRepository;

	@Autowired
	private OrderRequestItemRepository orderRequestItemRepository;

	@Autowired
	private AccessionInvAttachRepository accessionInvAttachRepository;

	/** Units of measure representing weight in grams */
	public static final Set<String> GRAM_UNITS = Set.of(CommunityCodeValues.UNIT_OF_QUANTITY_GRAM.value);
	/** Units of measure representing seed */
	public static final Set<String> SEED_UNITS = Set.of(CommunityCodeValues.UNIT_OF_QUANTITY_SEED.value, CommunityCodeValues.UNIT_OF_QUANTITY_COUNT.value);

	@Component
	protected static class AttachmentSupport extends BaseAttachmentFilteredSupport<Inventory, AccessionInvAttach, InventoryAttachmentRequest, AccessionInvAttachFilter> implements InventoryAttachmentService {

		@Autowired
		protected GGCESecurityConfig.GgceSec ggceSec;

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

		// Limit to sites with READ permission
		@Override
		protected AccessionInvAttachFilter adjustFilter(AccessionInvAttachFilter filter) {
			var siteIds = ggceSec.getSiteIds(SecurityAction.InventoryAttachment.name(), "READ");
			if (siteIds != null) {
				log.debug("Forcing Site IDs: {}", siteIds);
				var attachFilter = new AccessionInvAttachFilter();
				attachFilter.inventory = new InventoryFilter().includeSystem(null);
				attachFilter.inventory.site(new SiteFilter());
				attachFilter.inventory.site.id(siteIds);
				attachFilter.AND = filter;
				return attachFilter;
			} else {
				// No change
				return filter;
			}
		}

		/**
		 * 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
		public 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
		@Transactional
		@PostAuthorize("hasAuthority('GROUP_ADMINS') or @ggceSec.actionAllowed('InventoryAttachment', 'CREATE', returnObject.inventory.site)")
		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(propagation = Propagation.REQUIRES_NEW)
		@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) {
			return _lazyLoad(createFast(source));
		}

		@Override
		@Transactional
		@PostAuthorize("hasAuthority('GROUP_ADMINS') or @ggceSec.actionAllowed('InventoryAttachment', 'CREATE', returnObject.inventory.site)")
		public AccessionInvAttach createFast(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 savedAttach;
		}

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

		@Override
		@Transactional
		@PreAuthorize("hasAuthority('GROUP_ADMINS') or @ggceSec.actionAllowed('InventoryAttachment', 'WRITE', #target.inventory.site)")
		public AccessionInvAttach updateFast(AccessionInvAttach updated, AccessionInvAttach target) {
			target.apply(updated);
			updateRepositoryFileMetadata(updated, target);
			return 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();
			});
		}

		protected InventoryFilter adjustFilter(InventoryFilter filter) {
			var siteIds = ggceSec.getSiteIds(SecurityAction.InventoryData.name(), "READ");
			if (siteIds != null) {
				log.debug("Forcing Site IDs: {}", siteIds);
				var siteFilter = new InventoryFilter().includeSystem(null);
				siteFilter.site(new SiteFilter());
				siteFilter.site.id(siteIds);
				siteFilter.AND = filter;
				return siteFilter;
			} else {
				// No change
				return filter;
			}
		}

		@Override
		protected InventoryActionFilter adjustFilter(InventoryActionFilter filter) {
			var siteIds = ggceSec.getSiteIds(SecurityAction.InventoryData.name(), "READ");
			if (siteIds != null) {
				log.debug("Forcing Site IDs: {}", siteIds);
				var siteFilter = new InventoryActionFilter();
				siteFilter.inventory = new InventoryFilter().includeSystem(null);
				siteFilter.inventory.site(new SiteFilter());
				siteFilter.inventory.site.id(siteIds);
				siteFilter.AND = filter;
				return siteFilter;
			} else {
				// No change
				return filter;
			}
		}

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

		@Override
		protected InventoryAction createAction(Inventory owningEntity) {
			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
		protected InventoryAction prepareNextWorkflowStepAction(WorkflowActionStep nextStep, InventoryAction completedAction) {
			InventoryAction nextAction = new InventoryAction();
			nextAction.setInventory(new Inventory(completedAction.getInventory().getId()));
			return nextAction;
		}

		@Override
		@Transactional
		@PostAuthorize("@ggceSec.actionAllowed('InventoryData', 'ADMINISTRATION', returnObject.inventory.site)")
		public InventoryAction createFast(InventoryAction source) {
			log.debug("Create InventoryAction. Input data {}", source);
			InventoryAction inventoryAction = new InventoryAction();
			inventoryAction.apply(source);
			InventoryAction saved = repository.save(inventoryAction);

			if (saved.getState() == ActionState.COMPLETED) {
				if (CommunityCodeValues.INVENTORY_ACTION_WITHDRAW.value.equals(saved.getActionNameCode())) {
					// Automatically reduce inventory quantity
					var updatedInventory = reduceInventory(saved, inventoryRepository.getReferenceById(saved.getInventory().getId()));
					saved.setInventory(updatedInventory);
				} else if (CommunityCodeValues.INVENTORY_ACTION_HARVEST.value.equals(saved.getActionNameCode())) {
					// Set quantity on hand to 0 when HARVEST is completed
					var plantedInventory = inventoryRepository.getReferenceById(saved.getInventory().getId());
					if (Objects.equals("Y", plantedInventory.getIsAutoDeducted())) {
						// If auto-deducted, then set quantity to 0 to mark it as finished
						plantedInventory.setQuantityOnHand(0.0d);
						plantedInventory = inventoryRepository.save(plantedInventory);
					}
					saved.setInventory(plantedInventory);
				}
			}

			return saved;
		}

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

		@Override
		@Transactional
		@PostAuthorize("@ggceSec.actionAllowed('InventoryData', 'ADMINISTRATION', returnObject.inventory.site)")
		public InventoryAction update(InventoryAction updated) {
			return super.update(updated);
		}

		@Override
		@Transactional
		@PostAuthorize("@ggceSec.actionAllowed('InventoryData', 'ADMINISTRATION', returnObject.inventory.site)")
		public InventoryAction remove(InventoryAction entity) {
			return super.remove(entity);
		}

		@Override
		@Transactional
		@PostAuthorize("@ggceSec.actionAllowed('InventoryData', 'ADMINISTRATION', returnObject.inventory.site)")
		public InventoryAction updateFast(InventoryAction updated, InventoryAction target) {
			var canBeClosed = target.getState() != ActionState.COMPLETED;
			var saved = super.update(updated, target);

			if (canBeClosed && saved.getState() == ActionState.COMPLETED) {
				if (CommunityCodeValues.INVENTORY_ACTION_WITHDRAW.value.equals(saved.getActionNameCode())) {
					// Automatically reduce inventory quantity
					var updatedInventory = reduceInventory(saved, inventoryRepository.getReferenceById(saved.getInventory().getId()));
					saved.setInventory(updatedInventory);
				} else if (CommunityCodeValues.INVENTORY_ACTION_HARVEST.value.equals(saved.getActionNameCode())) {
					// Set quantity on hand to 0 when HARVEST is completed
					var plantedInventory = inventoryRepository.getReferenceById(saved.getInventory().getId());
					if (Objects.equals("Y", plantedInventory.getIsAutoDeducted())) {
						// If auto-deducted, then set quantity to 0 to mark it as finished
						plantedInventory.setQuantityOnHand(0.0d);
						plantedInventory = inventoryRepository.save(plantedInventory);
					}
					saved.setInventory(plantedInventory);
				}
			}

			return saved;
		}

		@Override
		@Transactional
		@PostAuthorize("@ggceSec.actionAllowed('InventoryData', 'ADMINISTRATION', returnObject.inventory.site)")
		public InventoryAction update(InventoryAction updated, InventoryAction target) {
			return _lazyLoad(updateFast(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 FilteredCRUDService2Impl<InventoryQualityStatus, InventoryQualityStatusFilter, InventoryQualityStatusRepository>
		implements InventoryQualityStatusService {

		@Override
		@Transactional
		@PostAuthorize("hasAuthority('GROUP_ADMINS') or @ggceSec.actionAllowed('InventoryData', 'WRITE', returnObject.inventory.site)")
		public InventoryQualityStatus create(InventoryQualityStatus source) {
			return _lazyLoad(createFast(source));
		}

		@Override
		@Transactional
		@PostAuthorize("hasAuthority('GROUP_ADMINS') or @ggceSec.actionAllowed('InventoryData', 'WRITE', returnObject.inventory.site)")
		public InventoryQualityStatus createFast(InventoryQualityStatus source) {
			InventoryQualityStatus statusForSave = new InventoryQualityStatus();
			statusForSave.apply(source);
			return repository.save(statusForSave);
		}

		@Override
		@Transactional
		@PreAuthorize("hasAuthority('GROUP_ADMINS') or @ggceSec.actionAllowed('InventoryData', 'WRITE', #target.inventory.site)")
		public InventoryQualityStatus update(InventoryQualityStatus updated, InventoryQualityStatus target) {
			return _lazyLoad(updateFast(updated, target));
		}

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

		@Component
		protected static class AttachmentSupport extends BaseAttachmentSupport<InventoryQualityStatus, InventoryQualityStatusAttach, InventoryQualityStatusAttachmentService.InventoryQualityStatusAttachmentRequest>
			implements InventoryQualityStatusAttachmentService {

			public AttachmentSupport() {
				super(QInventoryQualityStatusAttach.inventoryQualityStatusAttach.inventoryQualityStatus().id, QInventoryQualityStatusAttach.inventoryQualityStatusAttach.id);
			}

			@Override
			@PreAuthorize("hasAuthority('GROUP_ADMINS') or @ggceSec.actionAllowed('InventoryData', 'WRITE', #entity.inventory.site)")
			@Transactional
			public InventoryQualityStatusAttach uploadFile(InventoryQualityStatus entity, MultipartFile file, InventoryQualityStatusAttachmentService.InventoryQualityStatusAttachmentRequest metadata) throws IOException, InvalidRepositoryPathException, InvalidRepositoryFileDataException {
				return super.uploadFile(entity, file, metadata);
			}

			@Override
			@PreAuthorize("hasAuthority('GROUP_ADMINS') or @ggceSec.actionAllowed('InventoryData', 'WRITE', #entity.inventory.site)")
			@Transactional
			public InventoryQualityStatusAttach removeFile(InventoryQualityStatus entity, Long attachmentId) {
				return super.removeFile(entity, attachmentId);
			}

			@Override
			public Path createRepositoryPath(InventoryQualityStatus qualityStatus) {
				qualityStatus = owningEntityRepository.getReferenceById(qualityStatus.getId());
				return Paths.get("/quality-status/" + qualityStatus.getId());
			}

			@Override
			@PreAuthorize("hasAuthority('GROUP_ADMINS') or @ggceSec.actionAllowed('InventoryData', 'WRITE', #entity.inventory.site)")
			protected InventoryQualityStatusAttach createAttach(InventoryQualityStatus entity, InventoryQualityStatusAttach source) {
				InventoryQualityStatusAttach attach = new InventoryQualityStatusAttach();
				attach.apply(source);
				attach.setInventoryQualityStatus(entity);
				return attach;
			}

			@Override
			public InventoryQualityStatusAttach create(InventoryQualityStatusAttach source) {
				throw new UnsupportedOperationException("Create attachments by uploading a file.");
			}

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

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

	@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
	@PostAuthorize("@ggceSec.actionAllowed('InventoryData', 'READ', returnObject.site)")
	public Inventory get(long id) {
		return super.get(id);
	}

	@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) {

		if (Objects.nonNull(source.getDoi()) && !SecurityContextUtil.hasRole("ADMINISTRATOR")) {
			throw new AccessDeniedException("Only administrators can change the DOI of an inventory.");
		}

		Inventory saved = new Inventory();
		saved.apply(source);
		saved = repository.save(saved);

		if (saved.getDoi() != null && saved.getDoi().equals(saved.getAccession().getDoi())) {
			throw new InvalidApiUsageException("Inventory DOI must be not the same as accession DOI");
		}

		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.");
		}

		if (!Objects.equals(target.getDoi(), input.getDoi()) && !SecurityContextUtil.hasRole("ADMINISTRATOR")) {
			throw new AccessDeniedException("Only administrators can change the DOI of an inventory.");
		}

		target.apply(input);
		if (target.getDoi() != null && target.getDoi().equals(target.getAccession().getDoi())) {
			throw new InvalidApiUsageException("Inventory DOI must be not the same as accession DOI");
		}

		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.");
		}

		if (!Objects.equals(target.getDoi(), updated.getDoi()) && !SecurityContextUtil.hasRole("ADMINISTRATOR")) {
			throw new AccessDeniedException("Only administrators can change the DOI of an inventory.");
		}

		target.apply(updated);

		if (target.getDoi() != null && target.getDoi().equals(target.getAccession().getDoi())) {
			throw new InvalidApiUsageException("Inventory DOI must be not the same as accession DOI");
		}

		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) {
		entity = reload(entity);
		if (entity == null) {
			return null;
		}
		if (entity.isSystemInventory()) {
			throw new InvalidApiUsageException("System inventory cannot be removed");
		}
		inventoryQualityStatusRepository.deleteAll(entity.getQuality());
		inventoryActionRepository.deleteAll(entity.getActions());
		accessionInvGroupMapRepository.deleteAll(accessionInvGroupMapRepository.findAll(QAccessionInvGroupMap.accessionInvGroupMap.inventory().eq(entity)));
		accessionInvNameRepository.deleteAll(accessionInvNameRepository.findAll(QAccessionInvName.accessionInvName.inventory().eq(entity)));
		accessionInvAnnotationRepository.deleteAll(accessionInvAnnotationRepository.findAll(QAccessionInvAnnotation.accessionInvAnnotation.inventory().eq(entity)));
		accessionInvAttachRepository.deleteAll(entity.getAttachments());
		cropTraitObservationDataRepository.deleteAll(cropTraitObservationDataRepository.findAll(QCropTraitObservationData.cropTraitObservationData.inventory().eq(entity)));
		var observations = Lists.newArrayList(cropTraitObservationRepository.findAll(QCropTraitObservation.cropTraitObservation.inventory().eq(entity)));
		if (!observations.isEmpty()) {
			cropTraitObservationDataRepository.deleteAll(cropTraitObservationDataRepository.findAll(QCropTraitObservationData.cropTraitObservationData.cropTraitObservation().in(observations)));
			cropTraitObservationRepository.deleteAll(observations);
		}
		var viabilityList = Lists.newArrayList(inventoryViabilityRepository.findAll(QInventoryViability.inventoryViability.inventory().eq(entity)));
		if (!viabilityList.isEmpty()) {
			inventoryViabilityActionRepository.deleteAll(inventoryViabilityActionRepository.findAll(QInventoryViabilityAction.inventoryViabilityAction.inventoryViability().in(viabilityList)));
			var viabilityDatas = Lists.newArrayList(inventoryViabilityDataRepository.findAll(QInventoryViabilityData.inventoryViabilityData.inventoryViability().in(viabilityList)));
			if (!viabilityDatas.isEmpty()) {
				viabilityDataEnvMapRepository.deleteAll(
					viabilityDataEnvMapRepository.findAll(QInventoryViabilityDataEnvironmentMap.inventoryViabilityDataEnvironmentMap.inventoryViabilityData().in(viabilityDatas))
				);
				inventoryViabilityDataRepository.deleteAll(viabilityDatas);
			}
			inventoryViabilityAttachRepository.deleteAll(
				inventoryViabilityAttachRepository.findAll(QInventoryViabilityAttach.inventoryViabilityAttach.inventoryViability().in(viabilityList))
			);
			inventoryViabilityRepository.deleteAll(viabilityList);
		}
		var orderRequestItems = Lists.newArrayList(orderRequestItemRepository.findAll(QOrderRequestItem.orderRequestItem.inventory().eq(entity)));
		if (!orderRequestItems.isEmpty()) {
			throw new InvalidApiUsageException("Inventory " + entity.getId() + " is used in an order request item and cannot be deleted.");
//			orderRequestItemActionRepository.deleteAll(orderRequestItemActionRepository.findAll(QOrderRequestItemAction.orderRequestItemAction.orderRequestItem().in(orderRequestItems)));
//			orderRequestItemRepository.deleteAll(orderRequestItems);
		}
		var itemsWithdrawnInv = orderRequestItemRepository.findAll(QOrderRequestItem.orderRequestItem.withdrawnInventory().eq(entity));
		itemsWithdrawnInv.forEach(orderItem -> orderItem.setWithdrawnInventory(null));
		orderRequestItemRepository.saveAllAndFlush(itemsWithdrawnInv);
		entity = repository.getReferenceById(entity.getId());
		repository.delete(entity);
		return entity;
	}

	@Override
	@Transactional
	@PostAuthorize("@ggceSec.actionAllowed('InventoryData', 'WRITE', returnObject.site)")
	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 (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) {
		if (inventory.isSystemInventory()) {
			throw new InvalidApiUsageException("System inventories do not have barcodes");
		}

		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(adjustFilter(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 (!ggceSec.actionAllowed(SecurityAction.InventoryData.name(), "WRITE", source.getSite())) {
			throw new AccessDeniedException("Don't have permission to split inventory on this site");
		}
		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
	@PostAuthorize("@ggceSec.actionAllowed('InventoryData', 'READ', returnObject.site)")
	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 = pendingActions.stream().map(inventoryActionService::update).collect(Collectors.toList());
		if (result.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
	protected InventoryFilter adjustFilter(InventoryFilter filter) {
		var siteIds = ggceSec.getSiteIds(SecurityAction.InventoryData.name(), "READ");
		if (siteIds != null) {
			log.debug("Forcing Site IDs: {}", siteIds);
			var siteFilter = new InventoryFilter().includeSystem(null);
			siteFilter.site(new SiteFilter());
			siteFilter.site.id(siteIds);
			siteFilter.AND = filter;
			if (filter != null) {
				siteFilter._text(filter._text()); // A workaround for triggering ES querying
			}
			return siteFilter;
		} else {
			// No change
			return filter;
		}
	}

	@Override
	public Page<Inventory> list(InventoryFilter filter, Pageable page) throws SearchException {
		return list(Inventory.class, adjustFilter(filter), page, BOOST_FIELDS); // This is just to include the boosted 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
	public Inventory getSystemInventory(String instituteCode, String accessionNumber) {
		return repository.getSystemInventory(instituteCode, accessionNumber);
	}

	@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
	@PostAuthorize("@ggceSec.actionAllowed('InventoryData', 'READ', returnObject.inventory.site)")
	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();
				if (viab.getMedium() != null) {
					viab.getMedium().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, adjustFilter(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 (!Objects.equals("Y", request.sourceInventory.getIsAutoDeducted())) {
			// Do not deduct from source (grin-global/grin-global-server#488)
		} else if (request.sourceInventory.getQuantityOnHand() == null || request.sourceInventory.getQuantityOnHand() == 0 || request.sourceInventory.getQuantityOnHand() < request.quantityToDeduct) {
			throw new InvalidApiUsageException("Source inventory does not have sufficient quantity"); // Require 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.setIsAutoDeducted(StringUtils.defaultIfBlank(item.isAutoDeducted, "Y")); // Make IV inventory auto-deducted when doing first introduction
			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.setAvailabilityReasonCode(item.availabilityReasonCode);
			inv.setNote(item.note);

			inv.setPathogenStatusCode(item.pathogenStatusCode != null ? item.pathogenStatusCode : request.sourceInventory.getPathogenStatusCode()); // Inherit from the source if not present
			inv.setGeneration(request.sourceInventory.nextGeneration()); // Increment generation
			if (inv.getGeneration() == null) inv.setGeneration(1L); // Set 1st generation

			var created = create(inv);
			applyTCExtraFromRequest(created, item, null /* Do not copy from source */);

			createdList.add(created);
		}

		if (Objects.equals("Y", request.sourceInventory.getIsAutoDeducted())) {
			// Consume 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());

		if (!Objects.equals("Y", request.sourceInventory.getIsAutoDeducted())) {
			// Do not deduct from source (grin-global/grin-global-server#488)
		} else if (request.sourceInventory.getQuantityOnHand() == null || request.sourceInventory.getQuantityOnHand() == 0 || request.sourceInventory.getQuantityOnHand() < request.quantityToDeduct) {
			throw new InvalidApiUsageException("Source inventory does not have sufficient quantity"); // Require 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.setIsAutoDeducted(StringUtils.defaultIfBlank(item.isAutoDeducted, request.sourceInventory.getIsAutoDeducted())); // Use parent auto-deducted in multiplication
			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.setAvailabilityReasonCode(item.availabilityReasonCode);
			inv.setNote(item.note);

			inv.setPathogenStatusCode(item.pathogenStatusCode != null ? item.pathogenStatusCode : request.sourceInventory.getPathogenStatusCode()); // Inherit from the source if not present
			inv.setGeneration(request.sourceInventory.nextGeneration()); // Increment generation

			var created = create(inv);
			applyTCExtraFromRequest(created, item, request.sourceInventory);

			createdList.add(created);
		}

		if (Objects.equals("Y", request.sourceInventory.getIsAutoDeducted())) {
			// Consume source inventory
			request.sourceInventory.setQuantityOnHand(request.sourceInventory.getQuantityOnHand() - request.quantityToDeduct);
			request.sourceInventory = update(request.sourceInventory);
		}

		return createdList;
	}

	/**
	 * Create and populate {@link TissueCultureExtra}
	 *
	 * @param inventory
	 * @param request
	 * @param sourceInventory {@code null} for introduction, {@code not-null} for multiplication
	 */
	private void applyTCExtraFromRequest(Inventory inventory, IVMultiplicationItem request, Inventory sourceInventory) {
		TissueCultureExtra extra = new TissueCultureExtra();

		// Copy introduction date
		if (sourceInventory != null && sourceInventory.getExtra() instanceof TissueCultureExtra) {
			TissueCultureExtra sourceExtra = (TissueCultureExtra) sourceInventory.getExtra();
			if (sourceExtra.getIntroductionDate() != null) {
				extra.setIntroductionDate(sourceExtra.getIntroductionDate());
				extra.setIntroductionDateCode(sourceExtra.getIntroductionDateCode());
			}
		}

		// Apply medium
		if (request.medium != null) {
			extra.setMedium(request.medium);
		}
		// Apply introduction date
		if (request.introductionDate != null) {
			extra.setIntroductionDate(request.introductionDate);
			extra.setIntroductionDateCode(request.introductionDateCode);
		} else if (sourceInventory == null) {
			// Set current date as introduction date
			extra.setIntroductionDate(new Date());
			extra.setIntroductionDateCode(CommunityCodeValues.DATE_FORMAT_DATE.value);
		}

		extra.setInventory(inventory);
		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());

		// Filter for accessions on my sites
		var readSiteIds = ggceSec.getSiteIds(SecurityAction.PassportData.name(), "READ");
		if (readSiteIds != null) {
			bigQuery.where(accession.site().id.in(readSiteIds));
		}

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

		for (var site : siteAndFilters) {
			if (!ggceSec.actionAllowed(SecurityAction.InventoryData.name(), "READ", site.getKey())) {
				throw new AccessDeniedException("Missing read permission for InventoryData on site");
			}

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

	@Override
	protected void prepareLabelContext(Map<String, Object> context, Inventory entity) {
		context.put("inventory", entity);
		context.put("accession", entity.getAccession());
		context.put("taxon", entity.getAccession().getTaxonomySpecies().getName());
	}

	@Override
	@Transactional(readOnly = true)
	public FilteredPage<InventoryHarvest, InventoryActionFilter> listInventoryHarvest(
		InventoryActionFilter filter,
		Pageable page
	) {

		if (filter == null) {
			filter = new InventoryActionFilter();
			filter.states = Set.of(ActionState.INPROGRESS, ActionState.PENDING, ActionState.SCHEDULED);
		}
		filter.actionNameCode = Set.of(CommunityCodeValues.INVENTORY_ACTION_HARVEST.value);

		var effectiveFilter = new InventoryActionFilter();
		// Limit to accessible sites
		effectiveFilter.inventory = adjustFilter(null);
		// Limit to HARVEST action
		effectiveFilter.actionNameCode = Set.of(CommunityCodeValues.INVENTORY_ACTION_HARVEST.value);
		effectiveFilter.AND = filter;

		var inventoryActions = inventoryActionService.listActions(effectiveFilter, page);
		var data = inventoryActions.stream().map(InventoryHarvest::new).collect(Collectors.toList());
		// Get all child inventories
		var allChildInventories = (List<Inventory>) repository.findAll(
			QInventory.inventory.parentInventory().in(data.stream().map(InventoryHarvest::getPlantedInventory).collect(Collectors.toSet()))
		);
		// Find the harvested inventory for each planted inventory
		data.forEach(inventoryHarvest -> {
			var plantedInventory = inventoryHarvest.getPlantedInventory();
			var harvested = allChildInventories.stream()
				// Find child inventories
				.filter(candidate -> Objects.equals(plantedInventory.getId(), candidate.getParentInventory().getId()))
				// Find the ones with generation = generation+1 OR generation=1 if parent has no generation
				.filter(candidate -> candidate.getGeneration() != null
					&& (plantedInventory.getGeneration() == null ? candidate.getGeneration().equals(2L) : Objects.equals(plantedInventory.getGeneration() + 1, candidate.getGeneration())))
				// Get first one
				.findFirst();

			inventoryHarvest.setHarvestedInventory(harvested.orElse(null));
		});
		return new FilteredPage<>(filter, new PageImpl<>(data, page, inventoryActions.getTotalElements()));
	}

	@Override
	@Transactional
	@PreAuthorize("@ggceSec.actionAllowed('InventoryData', 'CREATE', #site)")
	public InventoryHarvest createHarvestedInventory(
		Inventory plantedInventory,
		Site site,
		InventoryMaintenancePolicy harvestedInventoryMaintenancePolicy,
		String inventoryNumberPart1,
		String inventoryNumberPart3,
		Double harvestedQuantity,
		String harvestedQuantityUnitCode,
		String containerTypeCode,
		Geography productionLocation,
		String storageLocationPart1, String storageLocationPart2, String storageLocationPart3, String storageLocationPart4,
		boolean isFinalBatch
	) {

		var harvestFilter = new InventoryActionFilter();
		harvestFilter.actionNameCode = Set.of(CommunityCodeValues.INVENTORY_ACTION_HARVEST.value);
		harvestFilter.states = Set.of(ActionState.INPROGRESS, ActionState.PENDING, ActionState.SCHEDULED);
		harvestFilter.inventory(new InventoryFilter());
		harvestFilter.inventory.id(Set.of(plantedInventory.getId()));
		var inventoryHarvest = listInventoryHarvest(harvestFilter, Pageable.ofSize(1));
		if (inventoryHarvest.page.getTotalElements() != 1) {
			throw new InvalidApiUsageException("No such inventory ready for harvest");
		}

		var plantedInventoryForHarvest = inventoryHarvest.page.getContent().get(0);
		var harvestAction = plantedInventoryForHarvest.getHarvestAction();
		assert (plantedInventory.getQuantityOnHand() == null || 0d != plantedInventory.getQuantityOnHand().doubleValue());
		assert (plantedInventory.getId() != null);
		plantedInventory = get(plantedInventoryForHarvest.getPlantedInventory().getId());
		plantedInventory = Hibernate.unproxy(plantedInventory, Inventory.class);
		var harvestedInventory = plantedInventoryForHarvest.getHarvestedInventory();

		if (harvestedInventory != null) { // We allow for updating the harvested inventory

			harvestedInventory.setContainerTypeCode(containerTypeCode);
			harvestedInventory.setProductionLocationGeography(productionLocation);
			// Add to existing quantity
			Double newQuantityOnHand = harvestedInventory.getQuantityOnHand();
			newQuantityOnHand = newQuantityOnHand == null ? harvestedQuantity : harvestedQuantity == null ? newQuantityOnHand : newQuantityOnHand + harvestedQuantity;
			harvestedInventory.setQuantityOnHand(newQuantityOnHand);
			harvestedInventory.setQuantityOnHandUnitCode(harvestedQuantityUnitCode);
			// Set inventory location
			harvestedInventory.setStorageLocationPart1(storageLocationPart1);
			harvestedInventory.setStorageLocationPart2(storageLocationPart2);
			harvestedInventory.setStorageLocationPart3(storageLocationPart3);
			harvestedInventory.setStorageLocationPart4(storageLocationPart4);
			harvestedInventory = repository.save(harvestedInventory);

		} else { // Register a new inventory!

			harvestedInventoryMaintenancePolicy = inventoryMaintPolicyRepo.getReferenceById(harvestedInventoryMaintenancePolicy.getId());

			harvestedInventory = new Inventory();
			harvestedInventory.apply(plantedInventory);
			// Clear
			harvestedInventory.setId(null);
			harvestedInventory.setBarcode(null);
			harvestedInventory.setPathogenStatusCode(null);
			harvestedInventory.setBackupInventory(null);
			harvestedInventory.setNote(null);
			harvestedInventory.setWebAvailabilityNote(null);
			// Set
			harvestedInventory.setParentInventory(plantedInventory);
			harvestedInventory.setSite(site);
			harvestedInventory.setGeneration(plantedInventory.getGeneration() == null ? 2L : plantedInventory.getGeneration() + 1);
			harvestedInventory.setAvailabilityStatusCode(CommunityCodeValues.INVENTORY_AVAILABILITY_NOTSET.value);
			harvestedInventory.applyInventoryMaintenancePolicy(harvestedInventoryMaintenancePolicy);
			harvestedInventory.setQuantityOnHand(harvestedQuantity);
			harvestedInventory.setQuantityOnHandUnitCode(harvestedQuantityUnitCode);
			// Name the inventory
			if (StringUtils.isNotBlank(inventoryNumberPart1)) {
				harvestedInventory.setInventoryNumberPart1(inventoryNumberPart1); // Apply custom prefix
			}
			if (StringUtils.isNotBlank(inventoryNumberPart3)) {
				harvestedInventory.setInventoryNumberPart3(inventoryNumberPart3); // Apply custom suffix
			}
			harvestedInventory.setInventoryNumberPart2(-1L); // Auto-renumber using the same name
			harvestedInventory.setContainerTypeCode(containerTypeCode);
			// Propagation date
			harvestedInventory.setPropagationDate(new Date());
			harvestedInventory.setPropagationDateCode(CommunityCodeValues.DATE_FORMAT_DATE.value);
			// Set inventory location
			harvestedInventory.setStorageLocationPart1(storageLocationPart1);
			harvestedInventory.setStorageLocationPart2(storageLocationPart2);
			harvestedInventory.setStorageLocationPart3(storageLocationPart3);
			harvestedInventory.setStorageLocationPart4(storageLocationPart4);
			// Set production geography
			harvestedInventory.setProductionLocationGeography(productionLocation);

			// Barcode the thing!
			harvestedInventory.setBarcode(null);
			harvestedInventory = repository.save(harvestedInventory);
			mintBarcode(harvestedInventory);
			harvestedInventory = repository.save(harvestedInventory);
		}

		var now = Instant.now();

		{
			// Log information about this harvested batch as action!
			var hia = new InventoryAction();
			hia.setInventory(harvestedInventory);
			hia.setIsDone("Y");
			hia.setCompletedDate(now);
			hia.setCompletedDateCode(CommunityCodeValues.DATE_FORMAT_DATETIME.value);
			hia.setActionNameCode(CommunityCodeValues.INVENTORY_ACTION_LOG.value);
			hia.setQuantity(harvestedQuantity);
			hia.setQuantityUnitCode(harvestedQuantityUnitCode);
			hia.setFormCode(harvestedInventory.getFormTypeCode());
			hia.setNote("Increased quantity through harvest");
			inventoryActionService.createFast(hia);
		}

		// Start the action if necessary
		if (harvestAction.getStartedDate() == null) {
			harvestAction.setStartedDate(now);
			harvestAction.setStartedDateCode(CommunityCodeValues.DATE_FORMAT_DATETIME.value);
		}

		if (isFinalBatch) {
			// This will set the quantity of planted material to 0
			harvestAction.setIsDone("Y");
			harvestAction.setCompletedDate(now);
			harvestAction.setCompletedDateCode(CommunityCodeValues.DATE_FORMAT_DATETIME.value);
		} else {
			// The action is IN PROGRESS and must be closed separately
		}
		harvestAction.setQuantity(harvestedInventory.getQuantityOnHand());
		harvestAction.setQuantityUnitCode(harvestedInventory.getQuantityOnHandUnitCode());
		harvestAction = inventoryActionService.updateFast(harvestAction);
		plantedInventory = harvestAction.getInventory(); // Action update may update the inventory

		// Return planted inventory with harvested inventory and action
		plantedInventoryForHarvest.setPlantedInventory(plantedInventory);
		plantedInventoryForHarvest.setHarvestedInventory(harvestedInventory);
		plantedInventoryForHarvest.setHarvestAction(harvestAction);
		return plantedInventoryForHarvest;
	}

	/**
	 * Checks if automatic conversion is possible between unitCode1 and unitCode2
	 */
	@Override
	public boolean areCompatibleUnits(String unitCode1, String unitCode2, Double hundredSeedWeight) {
		if (unitCode1 == null || unitCode2 == null) return false;
		if (StringUtils.equals(unitCode1, unitCode2)) return true;

		if (SEED_UNITS.contains(unitCode1)) {
			if (SEED_UNITS.contains(unitCode2)) return true;
			if (GRAM_UNITS.contains(unitCode2) && hundredSeedWeight != null && hundredSeedWeight != 0d) return true;
		} else if (GRAM_UNITS.contains(unitCode1)) {
			if (GRAM_UNITS.contains(unitCode2)) return true;
			if (SEED_UNITS.contains(unitCode2) && hundredSeedWeight != null && hundredSeedWeight != 0d) return true;
		}
		return false;
	}

	/**
	 * Checks if automatic conversion is possible between unitCode1 and unitCode2
	 */
	@Override
	public Double convertQuantity(String fromUnit, Double quantity, String toUnit, Double hundredSeedWeight) {
		if (quantity == null) return null;
		if (fromUnit == null || toUnit == null) throw new RuntimeException("Units not specified");
		if (StringUtils.equals(fromUnit, toUnit)) return quantity;

		if (SEED_UNITS.contains(fromUnit)) { // have seed
			if (SEED_UNITS.contains(toUnit)) return quantity; // same
			if (GRAM_UNITS.contains(toUnit)) { // want grams
				if (hundredSeedWeight == null || hundredSeedWeight == 0d) throw new RuntimeException("Hundred seed weight is not available");
				return quantity * hundredSeedWeight / 100d;
			}
		} else if (GRAM_UNITS.contains(fromUnit)) {
			if (SEED_UNITS.contains(toUnit)) {
				if (hundredSeedWeight == null || hundredSeedWeight == 0d) throw new RuntimeException("Hundred seed weight is not available");
				return (double) Math.round(quantity * 100d / hundredSeedWeight); // Generate round number
			}
		}
		throw new RuntimeException("Cannot convert from " + fromUnit + " to " + toUnit);
	}
}