package com.commercetools.sync.inventories; import com.commercetools.sync.commons.BaseSync; import com.commercetools.sync.inventories.helpers.InventoryEntryIdentifier; import com.commercetools.sync.inventories.helpers.InventoryReferenceResolver; import com.commercetools.sync.inventories.helpers.InventorySyncStatistics; import com.commercetools.sync.services.ChannelService; import com.commercetools.sync.services.InventoryService; import com.commercetools.sync.services.TypeService; import com.commercetools.sync.services.impl.ChannelServiceImpl; import com.commercetools.sync.services.impl.InventoryServiceImpl; import com.commercetools.sync.services.impl.TypeServiceImpl; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import io.sphere.sdk.channels.Channel; import io.sphere.sdk.channels.ChannelRole; import io.sphere.sdk.commands.UpdateAction; import io.sphere.sdk.inventory.InventoryEntry; import io.sphere.sdk.inventory.InventoryEntryDraft; import io.sphere.sdk.models.ResourceIdentifier; import org.apache.commons.lang3.tuple.ImmutablePair; import javax.annotation.Nonnull; import javax.annotation.Nullable; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.stream.Collectors; import static com.commercetools.sync.commons.utils.SyncUtils.batchElements; import static com.commercetools.sync.inventories.utils.InventorySyncUtils.buildActions; import static java.lang.String.format; import static java.util.Optional.ofNullable; import static java.util.concurrent.CompletableFuture.completedFuture; import static java.util.function.Function.identity; import static java.util.stream.Collectors.toList; import static java.util.stream.Collectors.toMap; import static org.apache.commons.lang3.StringUtils.isBlank; /** * Default implementation of inventories sync process. */ public final class InventorySync extends BaseSync<InventoryEntryDraft, InventorySyncStatistics, InventorySyncOptions> { private static final String CTP_INVENTORY_FETCH_FAILED = "Failed to fetch existing inventory entries of SKUs %s."; private static final String CTP_INVENTORY_ENTRY_UPDATE_FAILED = "Failed to update inventory entry of SKU '%s' and " + "supply channel id '%s'."; private static final String INVENTORY_DRAFT_HAS_NO_SKU = "Failed to process inventory entry without SKU."; private static final String INVENTORY_DRAFT_IS_NULL = "Failed to process null inventory draft."; private static final String FAILED_TO_PROCESS = "Failed to process the InventoryEntryDraft with SKU:'%s'. " + "Reason: %s"; private final InventoryService inventoryService; private final InventoryReferenceResolver referenceResolver; /** * Takes a {@link InventorySyncOptions} instance to instantiate a new {@link InventorySync} instance that could be * used to sync inventory drafts with the given inventory entries in the CTP project specified in the injected * {@link InventorySyncOptions} instance. * * @param syncOptions the container of all the options of the sync process including the CTP project client and/or * configuration and other sync-specific options. */ public InventorySync(@Nonnull final InventorySyncOptions syncOptions) { this(syncOptions, new InventoryServiceImpl(syncOptions), new ChannelServiceImpl(syncOptions, Collections.singleton(ChannelRole.INVENTORY_SUPPLY)), new TypeServiceImpl(syncOptions)); } InventorySync(@Nonnull final InventorySyncOptions syncOptions, @Nonnull final InventoryService inventoryService, @Nonnull final ChannelService channelService, @Nonnull final TypeService typeService) { super(new InventorySyncStatistics(), syncOptions); this.inventoryService = inventoryService; this.referenceResolver = new InventoryReferenceResolver(syncOptions, typeService, channelService); } /** * Iterates through the whole {@code inventories} list and accumulates its valid drafts to batches. Every batch * is then processed by {@link InventorySync#processBatch(List)}. * * <p><strong>Inherited doc:</strong> * {@inheritDoc} * * @param inventoryEntryDrafts {@link List} of {@link InventoryEntryDraft} resources that would be synced into CTP * project. * @return {@link CompletionStage} with {@link InventorySyncStatistics} holding statistics of all sync * processes performed by this sync instance */ @Nonnull @Override protected CompletionStage<InventorySyncStatistics> process( @Nonnull final List<InventoryEntryDraft> inventoryEntryDrafts) { final List<List<InventoryEntryDraft>> batches = batchElements(inventoryEntryDrafts, syncOptions.getBatchSize()); return syncBatches(batches, CompletableFuture.completedFuture(statistics)); } /** * Fetches existing {@link InventoryEntry} objects from CTP project that correspond to passed {@code batchOfDrafts}. * Having existing inventory entries fetched, {@code batchOfDrafts} is compared and synced with fetched objects by * {@link InventorySync#syncBatch(Set, List)} function. When fetching existing inventory entries results in * an empty optional then {@code batchOfDrafts} isn't processed. * * @param batch batch of drafts that need to be synced * @return {@link CompletionStage} of {@link Void} that indicates method progress. */ protected CompletionStage<InventorySyncStatistics> processBatch(@Nonnull final List<InventoryEntryDraft> batch) { final List<InventoryEntryDraft> validDrafts = batch .stream() .filter(this::validateDraft) .collect(toList()); if (validDrafts.isEmpty()) { statistics.incrementProcessed(batch.size()); return completedFuture(statistics); } final Set<String> skus = validDrafts.stream().map(InventoryEntryDraft::getSku).collect(Collectors.toSet()); return inventoryService.fetchInventoryEntriesBySkus(skus) .handle(ImmutablePair::new) .thenCompose(fetchResponse -> { final Set<InventoryEntry> fetchedInventoryEntries = fetchResponse.getKey(); final Throwable exception = fetchResponse.getValue(); if (exception != null) { final String errorMessage = format(CTP_INVENTORY_FETCH_FAILED, skus); handleError(errorMessage, exception, skus.size()); return CompletableFuture.completedFuture(null); } else { return syncBatch(fetchedInventoryEntries, validDrafts); } }) .thenApply(ignored -> { statistics.incrementProcessed(batch.size()); return statistics; }); } /** * Checks if a draft is valid for further processing. If so, then returns {@code true}. Otherwise handles an error * and returns {@code false}. A valid draft is a {@link InventoryEntryDraft} object that is not {@code null} and its * SKU is not empty. * * @param draft nullable draft * @return boolean that indicate if given {@code draft} is valid for sync */ private boolean validateDraft(@Nullable final InventoryEntryDraft draft) { if (draft == null) { handleError(INVENTORY_DRAFT_IS_NULL, null, 1); } else if (isBlank(draft.getSku())) { handleError(INVENTORY_DRAFT_HAS_NO_SKU, null, 1); } else { return true; } return false; } /** * Given a list of inventory entry {@code drafts}, this method resolves the references of each entry and attempts to * sync it to the CTP project depending whether the references resolution was successful. In addition the given * {@code oldInventories} list is converted to a {@link Map} of an identifier to an inventory entry, for a resources * comparison reason. * * @param oldInventories inventory entries from CTP * @param inventoryEntryDrafts drafts that need to be synced * @return a future which contains an empty result after execution of the update */ private CompletionStage<Void> syncBatch( @Nonnull final Set<InventoryEntry> oldInventories, @Nonnull final List<InventoryEntryDraft> inventoryEntryDrafts) { final Map<InventoryEntryIdentifier , InventoryEntry> oldInventoryMap = oldInventories.stream().collect(toMap(InventoryEntryIdentifier::of, identity())); return CompletableFuture.allOf(inventoryEntryDrafts .stream() .map(newInventoryEntry -> referenceResolver .resolveReferences(newInventoryEntry) .thenCompose(resolvedDraft -> syncDraft(oldInventoryMap, resolvedDraft)) .exceptionally(completionException -> { final String errorMessage = format(FAILED_TO_PROCESS, newInventoryEntry.getSku(), completionException.getMessage()); handleError(errorMessage, completionException, 1); return Optional.empty(); }) ) .map(CompletionStage::toCompletableFuture) .toArray(CompletableFuture[]::new)); } /** * Checks if the {@code resolvedDraft} matches with an old existing inventory entry. If it does, it tries to update * it. If it doesn't, it creates it. * * @param oldInventories map of {@link InventoryEntryIdentifier} to old {@link InventoryEntry} instances * @param resolvedDraft inventory entry draft which has its references resolved * @return a future which contains an empty result after execution of the update */ private CompletionStage<Optional<InventoryEntry>> syncDraft( @Nonnull final Map<InventoryEntryIdentifier , InventoryEntry> oldInventories, @Nonnull final InventoryEntryDraft resolvedDraft) { final InventoryEntry oldInventory = oldInventories.get(InventoryEntryIdentifier.of(resolvedDraft)); return ofNullable(oldInventory) .map(type -> buildActionsAndUpdate(oldInventory, resolvedDraft)) .orElseGet(() -> applyCallbackAndCreate(resolvedDraft)); } /** * Given an existing {@link InventoryEntry} and a new {@link InventoryEntryDraft}, the method calculates all the * update actions required to synchronize the existing entry to be the same as the new one. If there are update * actions found, a request is made to CTP to update the existing entry, otherwise it doesn't issue a request. * * <p>The {@code statistics} instance is updated accordingly to whether the CTP request was carried * out successfully or not. If an exception was thrown on executing the request to CTP, the error handling method * is called. * * @param entry existing inventory entry that could be updated. * @param draft draft containing data that could differ from data in {@code entry}. * <strong>Sku isn't compared</strong> * @return a future which contains an empty result after execution of the update. */ @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") // https://github.com/findbugsproject/findbugs/issues/79 private CompletionStage<Optional<InventoryEntry>> buildActionsAndUpdate( @Nonnull final InventoryEntry entry, @Nonnull final InventoryEntryDraft draft) { final List<UpdateAction<InventoryEntry>> updateActions = buildActions(entry, draft, syncOptions); final List<UpdateAction<InventoryEntry>> beforeUpdateCallBackApplied = syncOptions.applyBeforeUpdateCallBack(updateActions, draft, entry); if (!beforeUpdateCallBackApplied.isEmpty()) { return inventoryService .updateInventoryEntry(entry, beforeUpdateCallBackApplied) .handle(ImmutablePair::new) .thenCompose(updateResponse -> { final InventoryEntry updatedInventoryEntry = updateResponse.getKey(); final Throwable sphereException = updateResponse.getValue(); if (sphereException != null) { final ResourceIdentifier<Channel> supplyChannel = draft.getSupplyChannel(); final String errorMessage = format(CTP_INVENTORY_ENTRY_UPDATE_FAILED, draft.getSku(), supplyChannel != null ? supplyChannel.getId() : null); handleError(errorMessage, sphereException, 1); return CompletableFuture.completedFuture(Optional.empty()); } else { statistics.incrementUpdated(); return CompletableFuture.completedFuture(Optional.of(updatedInventoryEntry)); } }); } return completedFuture(null); } /** * Given an inventory entry {@code draft}, issues a request to the CTP project to create a corresponding Inventory * Entry. * * <p>The {@code statistics} instance is updated accordingly to whether the CTP request was carried * out successfully or not. If an exception was thrown on executing the request to CTP, the error handling method * is called. * * @param inventoryEntryDraft the inventory entry draft to create the inventory entry from. * @return a future which contains an empty result after execution of the create. */ private CompletionStage<Optional<InventoryEntry>> applyCallbackAndCreate( @Nonnull final InventoryEntryDraft inventoryEntryDraft) { return syncOptions .applyBeforeCreateCallBack(inventoryEntryDraft) .map(draft -> inventoryService .createInventoryEntry(draft) .thenApply(inventoryEntryOptional -> { if (inventoryEntryOptional.isPresent()) { statistics.incrementCreated(); } else { statistics.incrementFailed(); } return inventoryEntryOptional; }) ) .orElse(CompletableFuture.completedFuture(Optional.empty())); } /** * Given a {@link String} {@code errorMessage} and a {@link Throwable} {@code exception}, this method calls the * optional error callback specified in the {@code syncOptions} and updates the {@code statistics} instance by * incrementing the total number of failed categories to sync. * * @param errorMessage The error message describing the reason(s) of failure. * @param exception The exception that called caused the failure, if any. */ private void handleError(@Nonnull final String errorMessage, @Nullable final Throwable exception, final int failedTimes) { syncOptions.applyErrorCallback(errorMessage, exception); statistics.incrementFailed(failedTimes); } }