Skip to content
1 change: 1 addition & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
[submodule "folio-export-common"]
path = folio-export-common
url = https://github.com/folio-org/folio-export-common.git
branch = MODEXPW-638
69 changes: 64 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,15 @@ OrderEmailContext
│ ├── shipTo # resolved from shipTo UUID via tenant-addresses
│ │ ├── id
│ │ └── address
│ └── billTo # resolved from billTo UUID via tenant-addresses
│ ├── id
│ └── address
│ ├── billTo # resolved from billTo UUID via tenant-addresses
│ │ ├── id
│ │ └── address
│ └── customFields{} # map keyed by custom-field refId — email export only (omitted when none)
│ └── <refId>
│ ├── name # custom-field display name
│ ├── type # definition type (e.g. SINGLE_SELECT_DROPDOWN, SINGLE_CHECKBOX, TEXTBOX_LONG)
│ ├── value # scalar value — see Custom fields note below
│ └── values[] # array value — see Custom fields note below
└── orderLines[] # multiple entries
└── orderLine
├── poLineNumber
Expand Down Expand Up @@ -123,11 +129,27 @@ OrderEmailContext
│ └── currency
├── fundDistribution[] # multiple entries
│ └── code # code taken as-is from the PO line; fundId is not resolved
└── vendorDetail
└── instructions # vendor instructions
├── vendorDetail
│ └── instructions # vendor instructions
└── customFields{} # map keyed by custom-field refId — email export only (omitted when none)
└── <refId>
├── name # custom-field display name
├── type # definition type (e.g. SINGLE_SELECT_DROPDOWN, SINGLE_CHECKBOX, TEXTBOX_LONG)
├── value # scalar value — see Custom fields note below
└── values[] # array value — see Custom fields note below
```
> **Null/empty policy:** missing values are rendered as safe defaults rather than
> `null`, so templates can reference any field without null checks.
>
> **Custom fields:** `customFields` is a map keyed by the field's `refId`, populated only for
> the email export (the whole map is omitted when the record has no custom-field values). Each
> entry carries `name`, `type`, and exactly one of `value` (single-value fields) or `values[]`
> (multi-value fields):
> - single-select → `value` = `{ id, label }` (`id` = stored option-id, `label` = resolved option label)
> - checkbox → `value` = boolean; textbox / date / number → `value` = string
> - multi-select → `values[]` of `{ id, label }`; repeatable text → `values[]` of strings
>
> Hidden custom fields (definition `visible: false`) and fields whose definition cannot be resolved are omitted.

#### Example payload

Expand Down Expand Up @@ -163,6 +185,13 @@ OrderEmailContext
"billTo": {
"id": "22222222-2222-2222-2222-222222222222",
"address": "Accounts Payable, PO Box 42, Springfield IL"
},
"customFields": {
"order_channel": {
"name": "Order channel",
"type": "SINGLE_SELECT_DROPDOWN",
"value": { "id": "opt_1", "label": "Web" }
}
}
},
"orderLines": [
Expand Down Expand Up @@ -210,6 +239,36 @@ OrderEmailContext
],
"vendorDetail": {
"instructions": "Deliver to loading dock, ring bell on arrival"
},
"customFields": {
"binding": {
"name": "Binding",
"type": "SINGLE_SELECT_DROPDOWN",
"value": { "id": "opt_2", "label": "Paperback" }
},
"genres": {
"name": "Genres",
"type": "MULTI_SELECT_DROPDOWN",
"values": [
{ "id": "opt_1", "label": "Fiction" },
{ "id": "opt_3", "label": "Reference" }
]
},
"keywords": {
"name": "Keywords",
"type": "TEXTBOX_SHORT",
"values": ["folio", "library"]
},
"urgent": {
"name": "Urgent",
"type": "SINGLE_CHECKBOX",
"value": true
},
"expected_release": {
"name": "Expected release",
"type": "DATE_PICKER",
"value": "2026-07-06"
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.apache.commons.lang3.StringUtils;
import org.folio.dew.batch.acquisitions.services.ConfigurationService;
import org.folio.dew.batch.acquisitions.services.ContributorNameTypeService;
import org.folio.dew.batch.acquisitions.services.CustomFieldsService;
import org.folio.dew.batch.acquisitions.services.IdentifierTypeService;
import org.folio.dew.batch.acquisitions.services.OrganizationsService;
import org.folio.dew.batch.acquisitions.services.UserService;
Expand Down Expand Up @@ -45,11 +46,15 @@
@Log4j2
public class OrderEmailContextMapper {

private static final String ENTITY_TYPE_PURCHASE_ORDER = "purchase_order";
private static final String ENTITY_TYPE_PO_LINE = "po_line";

private final IdentifierTypeService identifierTypeService;
private final ContributorNameTypeService contributorNameTypeService;
private final ConfigurationService configurationService;
private final UserService userService;
private final OrganizationsService organizationsService;
private final CustomFieldsService customFieldsService;

public OrderEmailContext buildContext(List<CompositePurchaseOrder> orders) {
var orderWrappers = orders.stream()
Expand Down Expand Up @@ -131,6 +136,7 @@ private OrderContext mapOrder(CompositePurchaseOrder order) {
.metadata(mapOrderMetadata(order.getMetadata()))
.shipTo(mapTenantAddress(order.getShipTo()))
.billTo(mapTenantAddress(order.getBillTo()))
.customFields(customFieldsService.resolve(order.getCustomFields(), ENTITY_TYPE_PURCHASE_ORDER))
.build();
}

Expand Down Expand Up @@ -172,6 +178,7 @@ private OrderLineContext mapOrderLine(PoLine line) {
.cost(mapCost(line.getCost()))
.fundDistribution(mapList(line.getFundDistribution(), this::mapFundDistribution))
.vendorDetail(mapVendorDetail(line.getVendorDetail()))
.customFields(customFieldsService.resolve(line.getCustomFields(), ENTITY_TYPE_PO_LINE))
.build();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package org.folio.dew.batch.acquisitions.services;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.apache.commons.lang3.StringUtils;
import org.folio.dew.client.CustomFieldsClient;
import org.folio.dew.domain.dto.acquisitions.customfields.CustomField;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientException;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

/**
* Thin cached wrapper over {@link CustomFieldsClient} that returns the custom-field definitions for
* an entity type, indexed by refId. Definitions live on {@code mod-orders-storage}, exposed through
* the {@code interfaceType: multiple} {@code custom-fields} interface, so the call must carry the
* target module id in {@code X-Okapi-Module-Id}. That id is resolved per tenant by
* {@link OrdersStorageModuleIdResolver}.
*
* <p>Degrades gracefully: if the module id can't be resolved, or the interface is unavailable
* (e.g. 404/403 at the gateway), an empty map is returned so the export still goes out — just
* without resolved custom-field tokens.
*/
@Service
@Log4j2
@RequiredArgsConstructor
public class CustomFieldDefinitionService {

private static final int LIMIT = 1000;

private final CustomFieldsClient customFieldsClient;
private final OrdersStorageModuleIdResolver ordersStorageModuleIdResolver;

@Cacheable(cacheNames = "customFieldDefinitions", key = "@folioExecutionContext.tenantId + ':' + #entityType")
public Map<String, CustomField> getDefinitionsByRefId(String entityType) {
Map<String, CustomField> byRefId = new LinkedHashMap<>();
var moduleId = ordersStorageModuleIdResolver.resolve();
if (StringUtils.isBlank(moduleId)) {
log.warn("getDefinitionsByRefId:: Could not resolve mod-orders-storage module id "
+ "- email will be sent without resolved custom-field tokens");
return byRefId;
}
try {
var collection = customFieldsClient.getCustomFields("entityType==" + entityType, LIMIT, moduleId);
var definitions = Optional.ofNullable(collection.getCustomFields()).orElseGet(List::of);
for (CustomField definition : definitions) {
if (StringUtils.isNotBlank(definition.getRefId())) {
byRefId.put(definition.getRefId(), definition);
}
}
} catch (RestClientException e) {
log.warn("getDefinitionsByRefId:: Cannot resolve custom-field definitions for entityType '{}' "
+ "- email will be sent without resolved custom-field tokens", entityType, e);
}
return byRefId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package org.folio.dew.batch.acquisitions.services;

import lombok.RequiredArgsConstructor;
import org.apache.commons.collections4.MapUtils;
import org.apache.commons.lang3.StringUtils;
import org.folio.dew.domain.dto.acquisitions.customfields.CustomField;
import org.folio.dew.domain.dto.acquisitions.customfields.SelectField;
import org.folio.dew.domain.dto.acquisitions.customfields.SelectFieldOption;
import org.folio.dew.domain.dto.acquisitions.customfields.SelectFieldOptions;
import org.folio.dew.domain.dto.templateengine.context.CustomFieldContext;
import org.folio.dew.domain.dto.templateengine.context.CustomFieldOptionValue;
import org.springframework.stereotype.Service;

import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;

/**
* Turns the raw {@code refId -> value} custom-fields map carried on a purchase order or PO line
* into template-ready {@link CustomFieldContext} entries, resolving select option-ids to labels.
*
* <p>Value shape:
* <ul>
* <li>single-select scalar → {@code value} = {@link CustomFieldOptionValue}{@code {id, label}}</li>
* <li>multi-select / repeatable select array → {@code values[]} of {@link CustomFieldOptionValue}</li>
* <li>checkbox scalar → {@code value} = {@code Boolean}</li>
* <li>textbox/date/number scalar → {@code value} = {@code String}</li>
* <li>repeatable text array → {@code values[]} of plain {@code String}</li>
* </ul>
*/
@Service
@RequiredArgsConstructor
public class CustomFieldsService {

private static final String TYPE_SINGLE_CHECKBOX = "SINGLE_CHECKBOX";

private final CustomFieldDefinitionService definitionService;

public Map<String, CustomFieldContext> resolve(Map<String, Object> raw, String entityType) {
if (MapUtils.isEmpty(raw)) {
return Map.of();
}
var definitions = definitionService.getDefinitionsByRefId(entityType);
Map<String, CustomFieldContext> result = new LinkedHashMap<>();
raw.forEach((refId, rawValue) -> {
var definition = definitions.get(refId);
if (rawValue == null || definition == null || Boolean.FALSE.equals(definition.getVisible())) {
return;
}
var builder = CustomFieldContext.builder()
.name(definition.getName())
.type(definition.getType());
if (rawValue instanceof List<?> list) {
builder.values(list.stream()
.filter(Objects::nonNull)
.map(element -> toElement(definition, element))
.toList());
} else {
builder.value(toScalar(definition, rawValue));
}
result.put(refId, builder.build());
});
return Collections.unmodifiableMap(result);
}

private Object toScalar(CustomField definition, Object rawValue) {
if (isSelect(definition)) {
return toOptionValue(definition, String.valueOf(rawValue));
}
if (TYPE_SINGLE_CHECKBOX.equals(definition.getType())) {
return rawValue; // keep the boolean as-is
}
return String.valueOf(rawValue);
}

private Object toElement(CustomField definition, Object element) {
if (isSelect(definition)) {
return toOptionValue(definition, String.valueOf(element));
}
return String.valueOf(element); // repeatable text → plain String, no wrapper
}

private boolean isSelect(CustomField definition) {
return definition.getSelectField() != null;
}

private CustomFieldOptionValue toOptionValue(CustomField definition, String optionId) {
return CustomFieldOptionValue.builder()
.id(optionId)
.label(resolveOptionLabel(definition, optionId))
.build();
}

private String resolveOptionLabel(CustomField definition, String optionId) {
return Optional.ofNullable(definition.getSelectField())
.map(SelectField::getOptions)
.map(SelectFieldOptions::getValues)
.orElseGet(List::of).stream()
.filter(option -> optionId.equals(option.getId()))
.map(SelectFieldOption::getValue)
.filter(StringUtils::isNotBlank)
.findFirst()
.orElse(optionId);
}
}
Loading
Loading