Broadleaf Microservices
  • v1.0.0-latest-prod

Adding New Fulfillment Statuses

Out of the box, we have these default fulfillment statuses that signify which stage the fulfillment is currently at. There may be use cases where additional statuses are needed. For example, you may want a status to indicate that the fulfillment is in transit, and another to indicate that it is delivered.

In this tutorial, we will cover how to add custom fulfillment statuses to support the in transit and delivered stages of the fulfillment.

Metadata Extension - Adding Metadata Actions

First, we will add the extension to the metadata component.

Enums

@AllArgsConstructor
public enum DemoOrderFulfillmentStatuses implements SelectOption.SelectOptionEnum {

    // (1)
    IN_TRANSIT("order-fulfillment.enums.status.in-transit", "orange"),
    DELIVERED("order-fulfillment.enums.status.delivered", "green"),

    // (2)
    NEW("order-fulfillment.enums.status.new", "blue"),
    CAPTURING_PAYMENT("order-fulfillment.enums.status.capturing-payment", "green"),
    PAYMENT_CAPTURED("order-fulfillment.enums.status.payment-captured", "green"),
    PAYMENT_CAPTURE_FAILED("order-fulfillment.enums.status.payment-capture-failed", "red"),
    FULFILLING("order-fulfillment.enums.status.fulfilling", "green"),
    FULFILLED("order-fulfillment.enums.status.fulfilled", "green"),
    FULFILL_FAILED("order-fulfillment.enums.status.fulfill-failed", "red"),
    CANCELLED("order-fulfillment.enums.status.cancelled", "red");

    private final String label;

    private final String color;

    @Override
    @NonNull
    public String label() {
        return label;
    }

    @Override
    @NonNull
    public String color() {
        return color;
    }

    public static List<SelectOption> toOptions() {
        return SelectOption.fromEnums(values());
    }
}
  1. Our custom statuses to indicate the fulfillment is in transit or delivered

  2. Below our custom statuses, we will copy over the default statuses out of the box

Adding Metadata Components

Then, we will add metadata components to add new status change actions in the admin:

@Configuration
@RequiredArgsConstructor
@EnableConfigurationProperties(OrderMetadataProperties.class)
public class DemoOrderServicesMetadataAutoConfiguration {

    private final OrderMetadataProperties orderMetadataProperties;

    @Bean
    public ComponentSource demoOrderMetadataComponents() {
        return registry -> {
            OrderView<?> orderDetailsView =
                    registry.getComponent(OrderIds.DETAILS, OrderView.class);
            // (1)
            orderDetailsView.fulfillmentStatuses(DemoOrderFulfillmentStatuses.toOptions());

            // (2)
            orderDetailsView.getItemsSection().statusChangeAction(
                    DemoOrderFulfillmentStatuses.IN_TRANSIT.name(),
                    fulfillAction -> fulfillAction
                            .allowedStatuses(List.of(DemoOrderFulfillmentStatuses.FULFILLED.name())) // (3)
                            .label("order.sections.items.actions.set-to-in-transit")
                            .submitLabel("order.sections.items.actions.set-to-in-transit")
                            .statusChangeEndpoint(this::configureStatusChangeEndpoint));

            orderDetailsView.getItemsSection().statusChangeAction(
                    DemoOrderFulfillmentStatuses.DELIVERED.name(),
                    fulfillAction -> fulfillAction
                            .allowedStatuses(
                                    List.of(DemoOrderFulfillmentStatuses.IN_TRANSIT.name()))
                            .label("order.sections.items.actions.mark-as-delivered")
                            .submitLabel("order.sections.items.actions.mark-as-delivered")
                            .statusChangeEndpoint(this::configureStatusChangeEndpoint));

            // (4)
            if (isEnableSimpleReturns()) {
                List<String> createReturnAllowedStatuses =
                        List.of(
                                DemoOrderFulfillmentStatuses.DELIVERED.name());
                orderDetailsView.getCreateReturnAction()
                        .allowedStatuses(createReturnAllowedStatuses);

                orderDetailsView.getItemsSection().getCreateReturnAction()
                        .allowedStatuses(createReturnAllowedStatuses);
            }
        };
    }

    // (5)
    @Bean
    public ComponentSource demoFulfillmentMetadataComponents() {
        return registry -> {
            FulfillmentView<?> fulfillmentDetailsView =
                    registry.getComponent(OrderIds.FULFILLMENT_DETAILS, FulfillmentView.class);
            fulfillmentDetailsView.fulfillmentStatuses(DemoOrderFulfillmentStatuses.toOptions());

            fulfillmentDetailsView.statusChangeAction(
                    DemoOrderFulfillmentStatuses.IN_TRANSIT.name(),
                    fulfillAction -> fulfillAction
                            .allowedStatuses(List.of(DemoOrderFulfillmentStatuses.FULFILLED.name()))
                            .label("order-fulfillment.actions.set-to-in-transit")
                            .submitLabel("order-fulfillment.actions.set-to-in-transit")
                            .statusChangeEndpoint(this::configureStatusChangeEndpoint));

            fulfillmentDetailsView.statusChangeAction(DemoOrderFulfillmentStatuses.DELIVERED.name(),
                    fulfillAction -> fulfillAction
                            .allowedStatuses(
                                    List.of(DemoOrderFulfillmentStatuses.IN_TRANSIT.name()))
                            .label("order-fulfillment.actions.mark-as-delivered")
                            .submitLabel("order-fulfillment.actions.mark-as-delivered")
                            .statusChangeEndpoint(this::configureStatusChangeEndpoint));
        };
    }

    protected boolean isEnableSimpleReturns() {
        return orderMetadataProperties.isEnableSimpleReturns();
    }

    protected Endpoint<?> configureStatusChangeEndpoint(Endpoint<?> statusChangeEndpoint) {
        return statusChangeEndpoint
                .uri(OrderPaths.OrderFulfillmentOperations.CHANGE_STATUS)
                .scope(OrderScopes.ORDER_FULFILLMENT);
    }
}
  1. We need to set the OrderView’s fulfillment statuses to use our custom ones rather than the default ones

  2. Add status change actions, which would make the status change buttons to show up in the admin

  3. Note the allowed statuses here defines what statuses are allowed to transition into this status. In this case, only the FULFILLED status can transition to the IN_TRANSIT status

  4. If out of box return management is used, we also need to define what statuses are allowed to create a return. In this case, we are setting it to only allowed DELIVERED fulfillment to be returned

  5. We also need to do the same thing for the standalone fulfillment view

Messages

Then, we need to add the messages to support the metadata labels that we defined. Create a /messages/demo.properties file under the resources directory:

order-fulfillment.enums.status.in-transit=IN_TRANSIT
order-fulfillment.enums.status.delivered=DELIVERED

order.sections.items.actions.set-to-in-transit=Set to In Transit
order.sections.items.actions.mark-as-delivered=Set to Delivered

order-fulfillment.actions.set-to-in-transit=Set to In Transit
order-fulfillment.actions.mark-as-delivered=Set to Delivered

Then register the messages file:

public class DemoMetadataMessages implements MetadataMessagesBasename {

    @Override
    public String getBasename() {
        return "messages/demo";
    }
}
Important
The message properties file does not have to be named demo, it can be named to whatever makese sense, as long as the properties file name matches the getBasename() in the java class.

Registering the Configurations

Lastly, we just need to add the configuration classes to a /META-INF/spring.factories:

Note
Create a new spring-factories under the resources/META-INF directory if it doesn’t already exist
org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.example.microservices.metadata.order.DemoOrderServicesMetadataAutoConfiguration

com.broadleafcommerce.metadata.i18n.MetadataMessagesBasename=\
  com.example.microservices.metadata.i18n.DemoMetadataMessages

Then we’re done with the metadata! The admin OMS would look like this if the fulfillments had the IN_TRANSIT and DELIVERED statuses:

new fulfillment statuses in admin

Order Operation Services Extension - Supporting Status Transitions

To support status transitions for order fulfillments, we need to add an AbstractFulfillmentStatusChangeHandler implementation for each new status transition. In this tutorial, we just need to support the following new status transitions:

  • FULFILLEDIN_TRANSIT

  • IN_TRANSITDELIVERED

Enums

First, we will create the enums to include our new statuses:

public enum DemoOrderFulfillmentStatus {
    /**
     * Indicates that the fulfillment is in transit.
     */
    IN_TRANSIT,

    /**
     * Indicates that the fulfillment is delivered.
     */
    DELIVERED;

    // .... omitting the out of the box statuses

    public static boolean isInTrasit(String status) {
        return StringUtils.equals(status, IN_TRANSIT.name());
    }

    public static boolean isDelivered(String status) {
        return StringUtils.equals(status, DELIVERED.name());
    }

    // .... omitting the out of the box status helpers
}

Status Change Handlers

We will create an InTransitStatusChangeHandler to support the FULFILLEDIN_TRANSIT transition:

@Component
public class InTransitStatusChangeHandler extends AbstractFulfillmentStatusChangeHandler {

    private static final SecureRandom SECURE_RANDOM = new SecureRandom();

    @Getter(AccessLevel.PROTECTED)
    private final FulfillmentInTransitProducer messageProducer;

    public InTransitStatusChangeHandler(
            FulfillmentSplittingService<OrderFulfillment> splittingService,
            FulfillmentProvider<OrderFulfillment> fulfillmentProvider,
            OrderProvider<Order> orderProvider,
            FulfillmentInTransitProducer messageProducer,
            TypeFactory typeFactory) {
        super(splittingService, fulfillmentProvider, orderProvider, typeFactory);
        this.messageProducer = messageProducer;
    }

    // (1)
    @Override
    protected String getValidTargetStatus() {
        return IN_TRANSIT.name();
    }

    // (2)
    @Override
    protected Set<String> getValidOriginatingStatuses() {
        return new HashSet<>(Arrays.asList(FULFILLED.name()));
    }

    // Send message
    @Override
    protected OrderFulfillment postProcessStatusChange(
            FulfillmentStatusChangeRequest request,
            String originalStatus,
            List<OrderFulfillment> splitFulfillments,
            Order order,
            @Nullable ContextInfo contextInfo) {
        OrderFulfillment inTransit = getFulfillmentWithChangedStatus(request, splitFulfillments);
        // (3)
        sendFulfillmentStatusChangeEvent(messageProducer.fulfillmentInTransitOutput(), request,
                originalStatus, inTransit, order, contextInfo);
        return inTransit;
    }

    @Override
    protected void sendFulfillmentStatusChangeEvent(MessageChannel channel,
            FulfillmentStatusChangeRequest request,
            String originalStatus,
            OrderFulfillment changed,
            Order order,
            @Nullable ContextInfo contextInfo) {
        FulfillmentStatusChangeEvent event =
                getTypeFactory().get(FulfillmentStatusChangeEvent.class);
        event.setRequest(request);
        event.setOriginalStatus(originalStatus);
        event.setFulfillment(changed);
        event.setOrder(order);
        event.setContextInfo(contextInfo);

        // (4)
        if (getProperties() != null && !"none".equals(getProperties().getProvider())
                && getDetachedDurableMessageSender() != null) {
            getDetachedDurableMessageSender().send(event, FulfillmentInTransitProducer.TYPE,
                    changed.getId(),
                    RouteConstants.Persistence.ORDER_OP_ROUTE_KEY);
        } else {
            channel.send(MessageBuilder.withPayload(event)
                    .setHeaderIfAbsent(MESSAGE_IDEMPOTENCY_KEY, ULID.random(SECURE_RANDOM))
                    .build());
        }
    }
}
  1. Define the target status

  2. Define the statuses that are allowed to transition into the target status

  3. After the fulfillment has changed status, we will send an event notifying other systems that the fulfillment’s status has changed

  4. Use the durable message sender if available

Same thing to support the IN_TRANSITDELIVERED transition:

@Component
public class DeliveredStatusChangeHandler extends AbstractFulfillmentStatusChangeHandler {

    private static final SecureRandom SECURE_RANDOM = new SecureRandom();

    @Getter(AccessLevel.PROTECTED)
    private final FulfillmentDeliveredProducer messageProducer;

    public DeliveredStatusChangeHandler(
            FulfillmentSplittingService<OrderFulfillment> splittingService,
            FulfillmentProvider<OrderFulfillment> fulfillmentProvider,
            OrderProvider<Order> orderProvider,
            FulfillmentDeliveredProducer messageProducer,
            TypeFactory typeFactory) {
        super(splittingService, fulfillmentProvider, orderProvider, typeFactory);
        this.messageProducer = messageProducer;
    }

    @Override
    protected String getValidTargetStatus() {
        return DELIVERED.name();
    }

    @Override
    protected Set<String> getValidOriginatingStatuses() {
        return new HashSet<>(Arrays.asList(IN_TRANSIT.name()));
    }

    // Send message
    @Override
    protected OrderFulfillment postProcessStatusChange(
            FulfillmentStatusChangeRequest request,
            String originalStatus,
            List<OrderFulfillment> splitFulfillments,
            Order order,
            @Nullable ContextInfo contextInfo) {
        OrderFulfillment delivered = getFulfillmentWithChangedStatus(request, splitFulfillments);
        sendFulfillmentStatusChangeEvent(messageProducer.fulfillmentDeliveredOutput(), request,
                originalStatus, delivered, order, contextInfo);
        return delivered;
    }

    @Override
    protected void sendFulfillmentStatusChangeEvent(MessageChannel channel,
            FulfillmentStatusChangeRequest request,
            String originalStatus,
            OrderFulfillment changed,
            Order order,
            @Nullable ContextInfo contextInfo) {
        FulfillmentStatusChangeEvent event =
                getTypeFactory().get(FulfillmentStatusChangeEvent.class);
        event.setRequest(request);
        event.setOriginalStatus(originalStatus);
        event.setFulfillment(changed);
        event.setOrder(order);
        event.setContextInfo(contextInfo);
        if (getProperties() != null && !"none".equals(getProperties().getProvider())
                && getDetachedDurableMessageSender() != null) {
            getDetachedDurableMessageSender().send(event, FulfillmentDeliveredProducer.TYPE,
                    changed.getId(),
                    RouteConstants.Persistence.ORDER_OP_ROUTE_KEY);
        } else {
            channel.send(MessageBuilder.withPayload(event)
                    .setHeaderIfAbsent(MESSAGE_IDEMPOTENCY_KEY, ULID.random(SECURE_RANDOM))
                    .build());
        }
    }
}

Spring Cloud Message Outputs

Then, we need to create the spring cloud message outputs for each event:

FULFILLEDIN_TRANSIT:

public interface FulfillmentInTransitProducer {

    String TYPE = "FULFILLMENT_IN_TRANSIT";
    String CHANNEL = "fulfillmentInTransitOutput";

    @Output(CHANNEL)
    MessageChannel fulfillmentInTransitOutput();
}

IN_TRANSITDELIVERED:

public interface FulfillmentDeliveredProducer {

    String TYPE = "FULFILLMENT_DELIVERED";
    String CHANNEL = "fulfillmentDeliveredOutput";

    @Output(CHANNEL)
    MessageChannel fulfillmentDeliveredOutput();
}

Then we just need to add the spring message bindings to our application properties file:

spring:
  cloud:
    stream:
      bindings:
        fulfillmentInTransitOutput:
          destination: fulfillmentInTransit
        fulfillmentDeliveredOutput:
          destination: fulfillmentDelivered

Return Request Validator

Since we are only allowing fulfillments with the DELIVERED status to be returned, we need to override DefaultReturnRequestValidator:

@Component
public class DemoReturnRequestValidator extends DefaultReturnRequestValidator {

    protected static final String NOT_FULFILLED_NOR_DELIVERED_ITEM_CODE =
            prefixWithEntityValidationMessageKey("notFulfilledNorDeliveredItem");
    protected static final String NOT_FULFILLED_NOR_DELIVERED_ITEM_MESSAGE =
            "Cannot create return for item {} which cannot be returned because it is not fulfilled nor delivered";

    protected void validateFulfilled(ReturnRequest returnRequest,
            List<OrderFulfillment> fulfillments,
            Errors errors) {
        List<String> fulfillmentItemIdsRequested = returnRequest.getItems().stream()
                .map(ReturnItemRequest::getOrderFulfillmentItemId)
                .collect(Collectors.toList());
        List<String> notFulfilledNorDeliveredFulfillmentItemIds = fulfillments.stream()
                .filter(fulfillment -> !DemoOrderFulfillmentStatus
                        .isFulfilled(fulfillment.getStatus())
                        && !DemoOrderFulfillmentStatus.isDelivered(fulfillment.getStatus()))
                .map(OrderFulfillment::getFulfillmentItems)
                .flatMap(Collection::stream)
                .map(OrderFulfillmentItem::getId)
                .collect(Collectors.toList());

        for (int itemIndex = 0; itemIndex < fulfillmentItemIdsRequested.size(); itemIndex++) {
            String fulfillmentItemIdRequested = fulfillmentItemIdsRequested.get(itemIndex);
            if (notFulfilledNorDeliveredFulfillmentItemIds.contains(fulfillmentItemIdRequested)) {
                errors.rejectValue(orderFulfillmentItemIdFieldForIndex(itemIndex),
                        NOT_FULFILLED_NOR_DELIVERED_ITEM_CODE,
                        ArrayUtils.toArray(fulfillmentItemIdRequested),
                        NOT_FULFILLED_NOR_DELIVERED_ITEM_MESSAGE);
            }
        }
    }
}

AutoConfiguration

Lastly, we just need to add the configurations for our extensions:

@Configuration
@AutoConfigureBefore(OrderOperationServiceAutoConfiguration.class)
@EnableBinding({FulfillmentInTransitProducer.class, FulfillmentDeliveredProducer.class})
@ComponentScan(
        basePackageClasses = {InTransitStatusChangeHandler.class, DemoReturnRequestValidator.class})
public class DemoOrderOpsServiceAutoConfiguration {}

Add it to spring.factories:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.example.microservices.orderoperation.autoconfigure.DemoOrderOpsServiceAutoConfiguration

Order Services Extension - Supporting Returns for the New Status

In Order Services, we just need to override DefaultOrderFulfillmentItemService to ensure the right returnable items are returned by the OrderEndpoint and OrderOwnedFulfillmentEndpoint:

@Component
public class DemoOrderFulfillmentItemService<P extends OrderFulfillmentItem>
        extends DefaultOrderFulfillmentItemService<P> {

    public DemoOrderFulfillmentItemService(
            OrderFulfillmentService<OrderFulfillment> orderFulfillmentService,
            ReturnAuthorizationService<ReturnAuthorization> returnAuthorizationService) {
        super(orderFulfillmentService, returnAuthorizationService);
    }

    @Override
    public List<P> readAllReturnableFulfillmentItemsForOrder(String orderId,
            @Nullable ContextInfo contextInfo) {
        Assert.hasText(orderId, "OrderId cannot be blank");

        Map<String, Integer> itemsAlreadyInReturn = getItemsAlreadyInReturn(orderId, contextInfo);

        List<OrderFulfillment> fulfillments =
                getOrderFulfillmentService().readAllByOrderIdAndStatus(orderId,
                        "DELIVERED", // (1)
                        contextInfo);
        Stream<P> items = fulfillments.stream()
                .flatMap(fg -> fg.getFulfillmentItems().stream())
                .map(fi -> (P) fi);

        return findReturnableItems(itemsAlreadyInReturn, items);
    }

    @Override
    public List<P> readAllReturnableFulfillmentItemsForGroup(String orderId,
            String orderFulfillmentId,
            @Nullable ContextInfo contextInfo) {
        Assert.hasText(orderId, "OrderId cannot be blank");
        Assert.hasText(orderFulfillmentId, "OrderFulfillmentId cannot be blank");

        Map<String, Integer> itemsAlreadyInReturn = getItemsAlreadyInReturn(orderId, contextInfo);

        Optional<OrderFulfillment> fulfillment =
                getOrderFulfillmentService().readByContextIdAndStatus(orderFulfillmentId,
                        "DELIVERED",
                        contextInfo);

        if (fulfillment.isEmpty()) {
            return Collections.emptyList();
        }

        Stream<P> items = fulfillment.get().getFulfillmentItems()
                .stream()
                .map(fi -> (P) fi);

        return findReturnableItems(itemsAlreadyInReturn, items);
    }
}
  1. Use the new DELIVERED status

Then, we just need to add the AutoConfiguration class and add it to the spring.factories:

@Configuration
@ComponentScan(basePackageClasses = DemoOrderFulfillmentItemService.class)
@AutoConfigureBefore(OrderServiceAutoConfiguration.class)
public class DemoOrderServiceAutoConfiguration {}

spring.factories:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.example.microservices.order.service.DemoOrderServiceAutoConfiguration