InventoryTriggers.java

/*
 * Copyright 2021 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.triggers;

import java.lang.reflect.Field;
import java.time.Duration;
import java.util.Collection;
import java.util.Comparator;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.gringlobal.api.exception.InvalidApiUsageException;
import org.gringlobal.component.GGCE;
import org.gringlobal.model.Inventory;
import org.gringlobal.model.InventoryMaintenancePolicy;
import org.gringlobal.model.InventoryViability;
import org.gringlobal.model.QInventoryExtra;
import org.gringlobal.model.QInventoryViability;
import org.gringlobal.model.SeedInventoryExtra;
import org.gringlobal.model.community.CommunityCodeValues;
import org.gringlobal.persistence.InventoryExtraRepository;
import org.gringlobal.persistence.InventoryRepository;
import org.gringlobal.persistence.InventoryViabilityRepository;
import org.gringlobal.service.AccessionService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.util.ReflectionUtils;
import org.springframework.stereotype.Component;

@Aspect
@Component("inventoryTriggers")
@Slf4j
public class InventoryTriggers {

	private final Field FIELD_INVENTORY_NUMBER;

	public InventoryTriggers() {
		FIELD_INVENTORY_NUMBER = ReflectionUtils.findRequiredField(Inventory.class, "inventoryNumber");
	}

	@Autowired
	private InventoryRepository inventoryRepository;


	@Around(value = "(execution(* org.gringlobal.persistence.InventoryRepository.save(..)) || execution(* org.gringlobal.persistence.InventoryRepository.saveAndFlush(..))) && args(inventory)")
	public Object aroundInventorySave(final ProceedingJoinPoint joinPoint, final Inventory inventory) throws Throwable {
		checkSystemForAccession(inventory);
		assignInventoryNumberPart2IfNeeded(inventory);
		ReflectionUtils.setField(FIELD_INVENTORY_NUMBER, inventory, GGCE.inventoryNumber(inventory));

		if (inventory.isNew()) {
			fillMaintenancePolicy(inventory);
		}

		// update status
		updateInventoryStatus(inventory);

		return joinPoint.proceed();
	}

	@Around(value = "(execution(* org.gringlobal.persistence.InventoryRepository.saveAll(Iterable))) && args(inventories)")
	public Object aroundInventoriesSave(final ProceedingJoinPoint joinPoint, final Collection<Inventory> inventories) throws Throwable {
		log.debug("Many inventories are being saved! {}", inventories);

		assignInventoryNumberPart2IfNeeded(inventories);

		inventories.forEach(inventory -> ReflectionUtils.setField(FIELD_INVENTORY_NUMBER, inventory, GGCE.inventoryNumber(inventory)));

		inventories.stream().filter(Inventory::isNew).forEach(this::fillMaintenancePolicy);

		// update status
		inventories.forEach(this::updateInventoryStatus);

		return joinPoint.proceed();
	}

	private void checkSystemForAccession(Inventory inventory) {
		if (inventory.isSystemInventory()) {
			var systemInvForAcce = inventoryRepository.getSystemInventory(inventory.getAccession());
			if (systemInvForAcce != null && !Objects.equals(systemInvForAcce.getId(), inventory.getId())) {
				throw new InvalidApiUsageException("Accession must have only one system inventory.");
			}
		}
	}
	
	/**
	 * Assign the next within inventoryNumberPart1
	 */
	private void assignInventoryNumberPart2IfNeeded(Inventory inventory) {
		if (AccessionService.AUTO_GENERATE_VALUE.equals(inventory.getInventoryNumberPart2())) {
			long maxPart2 = inventoryRepository.findMaxNumberPart2(inventory.getAccession(), inventory.getInventoryNumberPart1(), inventory.getInventoryNumberPart3(), inventory.getFormTypeCode());
			log.debug("Using next numberPart2 {}+1 for inventory {}", maxPart2, inventory.getInventoryNumberPart1());
			inventory.setInventoryNumberPart2(1 + maxPart2);
		}
	}

	/**
	 * Assign the next within inventoryNumberPart1 for collection
	 */
	private void assignInventoryNumberPart2IfNeeded(Collection<Inventory> inventories) {
		inventories.forEach(inventory -> {
			if (AccessionService.AUTO_GENERATE_VALUE.equals(inventory.getInventoryNumberPart2())) {
				long maxPart2 = inventoryRepository.findMaxNumberPart2(inventory.getAccession(), inventory.getInventoryNumberPart1(), inventory.getInventoryNumberPart3(), inventory.getFormTypeCode());
				inventory.setInventoryNumberPart2(1 + maxPart2);
				var isNumberTaken = inventories.stream()
					.anyMatch(i -> !Objects.equals(inventory, i)
						&& Objects.equals(inventory.getInventoryNumberPart1(), i.getInventoryNumberPart1())
						&& Objects.equals(inventory.getInventoryNumberPart2(), i.getInventoryNumberPart2())
						&& Objects.equals(inventory.getInventoryNumberPart3(), i.getInventoryNumberPart3())
						&& Objects.equals(inventory.getFormTypeCode(), i.getFormTypeCode()));
				if (isNumberTaken) {
					maxPart2 = inventories.stream()
						.filter(i -> !Objects.equals(inventory, i)
							&& Objects.equals(inventory.getInventoryNumberPart1(), i.getInventoryNumberPart1())
							&& Objects.equals(inventory.getInventoryNumberPart3(), i.getInventoryNumberPart3())
							&& Objects.equals(inventory.getFormTypeCode(), i.getFormTypeCode()))
						.max(Comparator.comparing(Inventory::getInventoryNumberPart2))
						.get().getInventoryNumberPart2();
					inventory.setInventoryNumberPart2(1 + maxPart2);
				}
				log.debug("Using next numberPart2 {}+1 for inventory {}", maxPart2, inventory.getInventoryNumberPart1());
			}
		});
	}

	private void updateInventoryStatus(final Inventory i) {
		if (Inventory.SYSTEM_INVENTORY_FTC.equals(i.getFormTypeCode())) return;
		if (i.getIsAutoDeducted().equals("N")) return;
		
		if (i.getQuantityOnHand() == null) {
			i.setIsAvailable("N");
			// i.setAvailabilityStatusCode(CommunityCodeValues.INVENTORY_AVAILABILITY_LOWINVENTORY.value); // Maybe?
			return;
		}

		if (i.getDistributionCriticalQuantity() == null) {
			if (i.getQuantityOnHand().doubleValue() > 0) {
				i.setIsAvailable("Y"); // Has quantity but no limits
			} else {
				i.setIsAvailable("N"); // Has 0 quantity
			}
			return;
		}

		double quantityOnHand = i.getQuantityOnHand().doubleValue();
		double distributionCriticalQuantity = i.getDistributionCriticalQuantity().doubleValue();

		if (quantityOnHand < distributionCriticalQuantity) {
			i.setIsAvailable("N");
			if (Set.of(CommunityCodeValues.INVENTORY_AVAILABILITY_AVAILABLE.value, CommunityCodeValues.INVENTORY_AVAILABILITY_NOTSET.value).contains(i.getAvailabilityStatusCode().toUpperCase())) {
				i.setAvailabilityStatusCode(CommunityCodeValues.INVENTORY_AVAILABILITY_LOWINVENTORY.value);
			}
		} else {
			i.setIsAvailable("Y");
			if (Set.of(CommunityCodeValues.INVENTORY_AVAILABILITY_LOWINVENTORY.value, CommunityCodeValues.INVENTORY_AVAILABILITY_NOTSET.value).contains(i.getAvailabilityStatusCode().toUpperCase())) {
				i.setAvailabilityStatusCode(CommunityCodeValues.INVENTORY_AVAILABILITY_AVAILABLE.value);
			}
		}

	}

	/**
	 * Fill maintenance policy values
	 *
	 * @param inventory The inventory to insert
	 */
	public void fillMaintenancePolicy(final Inventory inventory) {
		final InventoryMaintenancePolicy maintenancePolicy = inventory.getInventoryMaintenancePolicy();

		if (StringUtils.isBlank(inventory.getFormTypeCode())) {
			inventory.setFormTypeCode(maintenancePolicy.getFormTypeCode());
		}
		if (StringUtils.isBlank(inventory.getQuantityOnHandUnitCode())) {
			inventory.setQuantityOnHandUnitCode(maintenancePolicy.getQuantityOnHandUnitCode());
		}
		if (StringUtils.isBlank(inventory.getWebAvailabilityNote())) {
			inventory.setWebAvailabilityNote(maintenancePolicy.getWebAvailabilityNote());
		}
		if (StringUtils.isBlank(inventory.getIsAutoDeducted())) {
			inventory.setIsAutoDeducted(maintenancePolicy.getIsAutoDeducted());
		}
		if (StringUtils.isBlank(inventory.getDistributionDefaultFormCode())) {
			inventory.setDistributionDefaultFormCode(maintenancePolicy.getDistributionDefaultFormCode());
		}
		if (inventory.getDistributionDefaultQuantity() == null) {
			inventory.setDistributionDefaultQuantity(maintenancePolicy.getDistributionDefaultQuantity());
		}
		if (StringUtils.isBlank(inventory.getDistributionUnitCode())) {
			inventory.setDistributionUnitCode(maintenancePolicy.getDistributionUnitCode());
		}
		if (inventory.getDistributionCriticalQuantity() == null) {
			inventory.setDistributionCriticalQuantity(maintenancePolicy.getDistributionCriticalQuantity());
		}
		if (inventory.getRegenerationCriticalQuantity() == null) {
			inventory.setRegenerationCriticalQuantity(maintenancePolicy.getRegenerationCriticalQuantity());
		}
	}

	@Autowired
	private InventoryViabilityRepository inventoryViabilityRepository;

	@Autowired
	private InventoryExtraRepository inventoryExtraRepository;

	/**
	 * Max number of days between propagation and viability test to consider it as "initial seed viability"
	 */
	@Value("${initial.viability.max.days:365}")
	private long maxDaysForInitialViability;


	@After(value = "(execution(* org.gringlobal.persistence.InventoryViabilityRepository.save(..)) || execution(* org.gringlobal.persistence.InventoryViabilityRepository.saveAndFlush(..))) && args(inventoryViability)")
	public void afterInventoryViabilitySave(final InventoryViability inventoryViability) {
		if (inventoryViability.getId() == null) return; // Avoid Hibernate AssertionFailure "null id in org.gringlobal.model.InventoryViability"
		upsertSeedInventoryExtraWithLatestViability(inventoryViability.getInventory(), null);
	}

	@After(value = "(execution(* org.gringlobal.persistence.InventoryViabilityRepository.saveAll(Iterable))) && args(viabilityCollection)")
	public void afterInventoryViabilityCollectionSave(final Collection<InventoryViability> viabilityCollection) {
		var viabilityInventories = viabilityCollection.stream().map(InventoryViability::getInventory).collect(Collectors.toSet());
		viabilityInventories.forEach(i -> {
			if (i.getId() == null) return; // Avoid Hibernate AssertionFailure "null id in org.gringlobal.model.InventoryViability"
			upsertSeedInventoryExtraWithLatestViability(i, null);
		});
	}

	/**
	 * Find the latest (by {@code testedDate}) completed (with {@code percentViable}) viability result and
	 * apply it to {@code inventory.extra.lastViability}.
	 *
	 * @param inventory The inventory to update
	 */
	private void upsertSeedInventoryExtraWithLatestViability(Inventory inventory, Set<Long> excludeInventoryViabilityIds) {
		QInventoryViability qViability = QInventoryViability.inventoryViability;
		var q = qViability.inventory().eq(inventory)
			.and(qViability.percentViable.isNotNull())
			.and(qViability.testedDate.isNotNull())
			.and(qViability.status.eq(InventoryViability.ViabilityStatus.CONCLUSIVE));

		if (CollectionUtils.isNotEmpty(excludeInventoryViabilityIds)) {
			q = q.and(qViability.id.notIn(excludeInventoryViabilityIds));
		}

		var viabilityAfterSaved = inventoryViabilityRepository.findAll(
			q,
			PageRequest.of(0, 1, Sort.by(Sort.Direction.DESC, "testedDate")) // get latest completed test
		);
		var viabilityForExtra = viabilityAfterSaved.get().findFirst().orElse(null);

		var existingExtra = inventoryExtraRepository.findOne(QInventoryExtra.inventoryExtra.inventory().eq(inventory));
		if (existingExtra.isPresent()) {
			var extra = (SeedInventoryExtra) existingExtra.get();
			extra.setLastViability(viabilityForExtra);
			if (viabilityForExtra != null) { // It's possible that viabilityForExtra is null
				extra.setLastViabilityResult(viabilityForExtra.getPercentViable());
				extra.setLastViabilityDate(viabilityForExtra.getTestedDate());
				extra.setLastViabilityDateCode(viabilityForExtra.getTestedDateCode());
				updateInitialViability(extra, viabilityForExtra);
			}
			inventoryExtraRepository.save(extra);

		} else if (viabilityForExtra != null) {
			SeedInventoryExtra extra = new SeedInventoryExtra();
			extra.setInventory(inventory);
			extra.setLastViability(viabilityForExtra);
			extra.setLastViabilityResult(viabilityForExtra.getPercentViable());
			extra.setLastViabilityDate(viabilityForExtra.getTestedDate());
			extra.setLastViabilityDateCode(viabilityForExtra.getTestedDateCode());
			updateInitialViability(extra, viabilityForExtra);
			inventoryExtraRepository.save(extra);
		}
	}

	/**
	 * Update {@code initialViability} if not yet set.
	 * @param extra the extra
	 * @param inventoryViability the latest viability test, can be null in which case no update happens
	 */
	private void updateInitialViability(SeedInventoryExtra extra, InventoryViability inventoryViability) {
		if (inventoryViability == null) {
			return; // Nothing to do here
		}
		if (extra.getInitialViability() != null) {
			return; // Do not change anything
		}
		if (inventoryViability.getTestedDate() == null) {
			return; // We don't have the date when the test was completed
		}
		if (extra.getInventory().getPropagationDate() == null) {
			return; // We don't have the date when this seed inventory came to life
		}

		var timeDiff = Duration.between(extra.getInventory().getPropagationDate().toInstant(), inventoryViability.getTestedDate().toInstant());
		log.debug("Difference between viability test date and propagation date is: {}", timeDiff);
		if (timeDiff.compareTo(Duration.ofDays(maxDaysForInitialViability)) <= 0) {
			log.info("{} days passed between propagation and viability test, this is initial seed viability.", timeDiff.toDays());
			extra.setInitialViability(inventoryViability.getPercentViable());
			extra.setInitialViabilityDate(inventoryViability.getTestedDate());
			extra.setInitialViabilityDateCode(inventoryViability.getTestedDateCode());
		} else {
			log.debug("{} days passed between propagation and viability test, this is NOT initial seed viability.", timeDiff.toDays());
		}
	}

	@Around(value = "(execution(* org.gringlobal.persistence.InventoryViabilityRepository.delete(..)) || execution(* org.gringlobal.persistence.InventoryViabilityRepository.deleteInBatch(..))) && args(inventoryViability)")
	public Object aroundInventoryViabilityDelete(final ProceedingJoinPoint joinPoint, final InventoryViability inventoryViability) throws Throwable {
		upsertSeedInventoryExtraWithLatestViability(inventoryViability.getInventory(), Set.of(inventoryViability.getId()));
		return joinPoint.proceed();
	}

	@Around(value = "(execution(* org.gringlobal.persistence.InventoryViabilityRepository.deleteAll(..)) || execution(* org.gringlobal.persistence.InventoryViabilityRepository.deleteAllInBatch(..))) && args(viabilityCollection)")
	public Object aroundInventoryViabilityDeleteAll(final ProceedingJoinPoint joinPoint, final Collection<InventoryViability> viabilityCollection) throws Throwable {
		var deletedIds = viabilityCollection.stream().map(InventoryViability::getId).collect(Collectors.toSet());
		viabilityCollection.forEach(iv -> upsertSeedInventoryExtraWithLatestViability(iv.getInventory(), deletedIds));
		return joinPoint.proceed();
	}

}