From 4f5ddfd0f1bacbe7a28fe288ad8ceb887819a2bc Mon Sep 17 00:00:00 2001 From: Markus Weigelt Date: Mon, 6 Jul 2026 14:17:57 +0200 Subject: [PATCH 1/7] Resolve custom fields into the order email export context Turn the raw refId-to-value maps on purchase orders and PO lines into template-ready custom-field tokens, resolving select option-ids to labels via a cached definitions lookup targeted at mod-orders-storage. Degrade gracefully to an unresolved-token-free email when the interface is absent. --- descriptors/ModuleDescriptor-template.json | 3 +- .../mapper/OrderEmailContextMapper.java | 7 + .../CustomFieldDefinitionService.java | 52 +++++ .../services/CustomFieldsService.java | 108 ++++++++++ .../folio/dew/client/CustomFieldsClient.java | 23 ++ .../dew/config/HttpClientConfiguration.java | 6 + .../customfields/CustomField.java | 14 ++ .../customfields/CustomFieldCollection.java | 12 ++ .../customfields/SelectField.java | 10 + .../customfields/SelectFieldOption.java | 11 + .../customfields/SelectFieldOptions.java | 12 ++ .../context/CustomFieldContext.java | 28 +++ .../context/CustomFieldOptionValue.java | 17 ++ .../templateengine/context/OrderContext.java | 3 + .../context/OrderLineContext.java | 2 + .../mapper/OrderEmailContextMapperTest.java | 24 ++- .../CustomFieldDefinitionServiceTest.java | 82 +++++++ .../services/CustomFieldsServiceTest.java | 203 ++++++++++++++++++ 18 files changed, 615 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/folio/dew/batch/acquisitions/services/CustomFieldDefinitionService.java create mode 100644 src/main/java/org/folio/dew/batch/acquisitions/services/CustomFieldsService.java create mode 100644 src/main/java/org/folio/dew/client/CustomFieldsClient.java create mode 100644 src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/CustomField.java create mode 100644 src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/CustomFieldCollection.java create mode 100644 src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/SelectField.java create mode 100644 src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/SelectFieldOption.java create mode 100644 src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/SelectFieldOptions.java create mode 100644 src/main/java/org/folio/dew/domain/dto/templateengine/context/CustomFieldContext.java create mode 100644 src/main/java/org/folio/dew/domain/dto/templateengine/context/CustomFieldOptionValue.java create mode 100644 src/test/java/org/folio/dew/batch/acquisitions/services/CustomFieldDefinitionServiceTest.java create mode 100644 src/test/java/org/folio/dew/batch/acquisitions/services/CustomFieldsServiceTest.java diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index b08b304e9..c5df5b345 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -265,7 +265,8 @@ "users.collection.get", "transfers.collection.get", "inventory-storage.service-points.collection.get", - "instance-authority-links.authority-statistics.collection.get" + "instance-authority-links.authority-statistics.collection.get", + "custom-fields.collection.get" ] }, { diff --git a/src/main/java/org/folio/dew/batch/acquisitions/mapper/OrderEmailContextMapper.java b/src/main/java/org/folio/dew/batch/acquisitions/mapper/OrderEmailContextMapper.java index 060e73b5d..8a97594d9 100644 --- a/src/main/java/org/folio/dew/batch/acquisitions/mapper/OrderEmailContextMapper.java +++ b/src/main/java/org/folio/dew/batch/acquisitions/mapper/OrderEmailContextMapper.java @@ -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; @@ -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 orders) { var orderWrappers = orders.stream() @@ -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(); } @@ -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(); } diff --git a/src/main/java/org/folio/dew/batch/acquisitions/services/CustomFieldDefinitionService.java b/src/main/java/org/folio/dew/batch/acquisitions/services/CustomFieldDefinitionService.java new file mode 100644 index 000000000..4f1c90d7a --- /dev/null +++ b/src/main/java/org/folio/dew/batch/acquisitions/services/CustomFieldDefinitionService.java @@ -0,0 +1,52 @@ +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. The definitions live on {@code mod-orders-storage}, so the + * {@code custom-fields} ({@code interfaceType: multiple}) call is targeted with an explicit module id. + * + *

Degrades gracefully: if the interface is unavailable (e.g. 404 at the gateway) the export still + * goes out, just without resolved custom-field tokens. + */ +@Service +@Log4j2 +@RequiredArgsConstructor +public class CustomFieldDefinitionService { + + private static final int LIMIT = 1000; + private static final String MODULE_ID = "mod-orders-storage"; + + private final CustomFieldsClient customFieldsClient; + + @Cacheable(cacheNames = "customFieldDefinitions", key = "#entityType") + public Map getDefinitionsByRefId(String entityType) { + Map byRefId = new LinkedHashMap<>(); + try { + var collection = customFieldsClient.getCustomFields("entityType==" + entityType, LIMIT, MODULE_ID); + 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; + } +} \ No newline at end of file diff --git a/src/main/java/org/folio/dew/batch/acquisitions/services/CustomFieldsService.java b/src/main/java/org/folio/dew/batch/acquisitions/services/CustomFieldsService.java new file mode 100644 index 000000000..e5212c76a --- /dev/null +++ b/src/main/java/org/folio/dew/batch/acquisitions/services/CustomFieldsService.java @@ -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. + * + *

Value shape: + *

+ */ +@Service +@RequiredArgsConstructor +public class CustomFieldsService { + + private static final String TYPE_SINGLE_CHECKBOX = "SINGLE_CHECKBOX"; + + private final CustomFieldDefinitionService definitionService; + + public Map resolve(Map raw, String entityType) { + if (MapUtils.isEmpty(raw)) { + return Map.of(); + } + var definitions = definitionService.getDefinitionsByRefId(entityType); + Map 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); + } +} \ No newline at end of file diff --git a/src/main/java/org/folio/dew/client/CustomFieldsClient.java b/src/main/java/org/folio/dew/client/CustomFieldsClient.java new file mode 100644 index 000000000..af22c8c33 --- /dev/null +++ b/src/main/java/org/folio/dew/client/CustomFieldsClient.java @@ -0,0 +1,23 @@ +package org.folio.dew.client; + +import org.folio.dew.domain.dto.acquisitions.customfields.CustomFieldCollection; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.HttpExchange; + +/** + * Client for the {@code custom-fields} interface. Because {@code custom-fields} is declared as an + * {@code interfaceType: multiple} interface (provided by several modules), Kong cannot route the + * request on path alone — the {@code X-Okapi-Module-Id} header must name the target module + * (e.g. {@code mod-orders-storage}). + */ +@HttpExchange(url = "custom-fields", accept = MediaType.APPLICATION_JSON_VALUE) +public interface CustomFieldsClient { + + @GetExchange + CustomFieldCollection getCustomFields(@RequestParam("query") String query, + @RequestParam("limit") int limit, + @RequestHeader("X-Okapi-Module-Id") String moduleId); +} \ No newline at end of file diff --git a/src/main/java/org/folio/dew/config/HttpClientConfiguration.java b/src/main/java/org/folio/dew/config/HttpClientConfiguration.java index af5eb7db1..1cce6a7ca 100644 --- a/src/main/java/org/folio/dew/config/HttpClientConfiguration.java +++ b/src/main/java/org/folio/dew/config/HttpClientConfiguration.java @@ -7,6 +7,7 @@ import org.folio.dew.client.AgreementClient; import org.folio.dew.client.AuditClient; import org.folio.dew.client.ContributorNameTypeClient; +import org.folio.dew.client.CustomFieldsClient; import org.folio.dew.client.DataExportSpringClient; import org.folio.dew.client.EmailClient; import org.folio.dew.client.EntitiesLinksStatsClient; @@ -50,6 +51,11 @@ public NotesClient notesClient(HttpServiceProxyFactory factory) { return factory.createClient(NotesClient.class); } + @Bean + public CustomFieldsClient customFieldsClient(HttpServiceProxyFactory factory) { + return factory.createClient(CustomFieldsClient.class); + } + @Bean public UserTenantsClient userTenantsClient(HttpServiceProxyFactory factory) { return factory.createClient(UserTenantsClient.class); diff --git a/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/CustomField.java b/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/CustomField.java new file mode 100644 index 000000000..121b6e018 --- /dev/null +++ b/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/CustomField.java @@ -0,0 +1,14 @@ +package org.folio.dew.domain.dto.acquisitions.customfields; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class CustomField { + private String refId; + private String name; + private String type; + private Boolean visible; + private SelectField selectField; +} \ No newline at end of file diff --git a/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/CustomFieldCollection.java b/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/CustomFieldCollection.java new file mode 100644 index 000000000..a54478cf6 --- /dev/null +++ b/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/CustomFieldCollection.java @@ -0,0 +1,12 @@ +package org.folio.dew.domain.dto.acquisitions.customfields; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +import java.util.List; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class CustomFieldCollection { + private List customFields; +} \ No newline at end of file diff --git a/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/SelectField.java b/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/SelectField.java new file mode 100644 index 000000000..c6c6cbf86 --- /dev/null +++ b/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/SelectField.java @@ -0,0 +1,10 @@ +package org.folio.dew.domain.dto.acquisitions.customfields; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SelectField { + private SelectFieldOptions options; +} \ No newline at end of file diff --git a/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/SelectFieldOption.java b/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/SelectFieldOption.java new file mode 100644 index 000000000..bc617c121 --- /dev/null +++ b/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/SelectFieldOption.java @@ -0,0 +1,11 @@ +package org.folio.dew.domain.dto.acquisitions.customfields; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SelectFieldOption { + private String id; + private String value; +} \ No newline at end of file diff --git a/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/SelectFieldOptions.java b/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/SelectFieldOptions.java new file mode 100644 index 000000000..10b7671cb --- /dev/null +++ b/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/SelectFieldOptions.java @@ -0,0 +1,12 @@ +package org.folio.dew.domain.dto.acquisitions.customfields; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +import java.util.List; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SelectFieldOptions { + private List values; +} \ No newline at end of file diff --git a/src/main/java/org/folio/dew/domain/dto/templateengine/context/CustomFieldContext.java b/src/main/java/org/folio/dew/domain/dto/templateengine/context/CustomFieldContext.java new file mode 100644 index 000000000..aaf5dbc94 --- /dev/null +++ b/src/main/java/org/folio/dew/domain/dto/templateengine/context/CustomFieldContext.java @@ -0,0 +1,28 @@ +package org.folio.dew.domain.dto.templateengine.context; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +/** + * Template-ready representation of a single custom-field value on a purchase order or PO line. + *
    + *
  • {@code value} carries a scalar: {@link CustomFieldOptionValue} for a single-select, + * {@code Boolean} for a checkbox, or {@code String} for textbox/date/number fields.
  • + *
  • {@code values} carries an array: {@link CustomFieldOptionValue} elements for + * multi-select / repeatable select fields, or plain {@code String} elements for + * repeatable text fields.
  • + *
+ * Exactly one of {@code value} / {@code values} is populated per field. + */ +@Data +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CustomFieldContext { + private String name; // display name from definition.name + private String type; // definition type, e.g. SINGLE_SELECT_DROPDOWN / SINGLE_CHECKBOX / TEXTBOX_LONG + private Object value; // scalar: CustomFieldOptionValue | Boolean | String + private List values; // array: CustomFieldOptionValue elements or plain String elements +} \ No newline at end of file diff --git a/src/main/java/org/folio/dew/domain/dto/templateengine/context/CustomFieldOptionValue.java b/src/main/java/org/folio/dew/domain/dto/templateengine/context/CustomFieldOptionValue.java new file mode 100644 index 000000000..44bbb5c33 --- /dev/null +++ b/src/main/java/org/folio/dew/domain/dto/templateengine/context/CustomFieldOptionValue.java @@ -0,0 +1,17 @@ +package org.folio.dew.domain.dto.templateengine.context; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; + +/** + * A resolved select-field option: the stored option-id together with its human-readable label. + * Built for select custom fields only. + */ +@Data +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CustomFieldOptionValue { + private String id; // stored option-id (e.g. opt_1) + private String label; // resolved option label, falling back to the raw option-id when unknown +} \ No newline at end of file diff --git a/src/main/java/org/folio/dew/domain/dto/templateengine/context/OrderContext.java b/src/main/java/org/folio/dew/domain/dto/templateengine/context/OrderContext.java index c646fd96b..d1b3da42d 100644 --- a/src/main/java/org/folio/dew/domain/dto/templateengine/context/OrderContext.java +++ b/src/main/java/org/folio/dew/domain/dto/templateengine/context/OrderContext.java @@ -3,6 +3,8 @@ import lombok.Builder; import lombok.Data; +import java.util.Map; + @Data @Builder public class OrderContext { @@ -11,4 +13,5 @@ public class OrderContext { private OrderMetadataContext metadata; private TenantAddressContext shipTo; private TenantAddressContext billTo; + private Map customFields; } diff --git a/src/main/java/org/folio/dew/domain/dto/templateengine/context/OrderLineContext.java b/src/main/java/org/folio/dew/domain/dto/templateengine/context/OrderLineContext.java index 45ab2a84b..d630f2709 100644 --- a/src/main/java/org/folio/dew/domain/dto/templateengine/context/OrderLineContext.java +++ b/src/main/java/org/folio/dew/domain/dto/templateengine/context/OrderLineContext.java @@ -4,6 +4,7 @@ import lombok.Data; import java.util.List; +import java.util.Map; @Data @Builder @@ -19,4 +20,5 @@ public class OrderLineContext { private CostContext cost; private List fundDistribution; private VendorDetailContext vendorDetail; + private Map customFields; } diff --git a/src/test/java/org/folio/dew/batch/acquisitions/mapper/OrderEmailContextMapperTest.java b/src/test/java/org/folio/dew/batch/acquisitions/mapper/OrderEmailContextMapperTest.java index e27a49e0e..48de90a13 100644 --- a/src/test/java/org/folio/dew/batch/acquisitions/mapper/OrderEmailContextMapperTest.java +++ b/src/test/java/org/folio/dew/batch/acquisitions/mapper/OrderEmailContextMapperTest.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; 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; @@ -10,6 +11,7 @@ import org.folio.dew.domain.dto.acquisitions.edifact.Organization; import org.folio.dew.domain.dto.acquisitions.edifact.OrganizationAddress; import org.folio.dew.domain.dto.acquisitions.edifact.TenantAddress; +import org.folio.dew.domain.dto.templateengine.context.CustomFieldContext; import org.folio.dew.domain.dto.templateengine.context.OrderEmailContext; import org.folio.dew.domain.dto.templateengine.context.OrderLineContext; import org.folio.dew.domain.dto.templateengine.context.UserContext; @@ -21,12 +23,14 @@ import java.io.IOException; import java.util.List; +import java.util.Map; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.folio.dew.utils.TestUtils.getMockData; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.when; @@ -49,19 +53,22 @@ class OrderEmailContextMapperTest { private UserService userService; @Mock private OrganizationsService organizationsService; + @Mock + private CustomFieldsService customFieldsService; private OrderEmailContextMapper mapper; private ObjectMapper objectMapper; @BeforeEach void setUp() { - mapper = new OrderEmailContextMapper(identifierTypeService, contributorNameTypeService, configurationService, userService, organizationsService); + mapper = new OrderEmailContextMapper(identifierTypeService, contributorNameTypeService, configurationService, userService, organizationsService, customFieldsService); objectMapper = new ObjectMapper(); lenient().when(identifierTypeService.getIdentifierTypeName(anyString())).thenReturn("ISBN"); lenient().when(contributorNameTypeService.getContributorNameTypeName(anyString())).thenReturn("Personal name"); lenient().when(configurationService.getTenantAddress(any())).thenReturn(null); lenient().when(userService.getUserContext(anyString())).thenReturn(UserContext.builder().build()); lenient().when(organizationsService.getOrganizationById(anyString())).thenReturn(new Organization()); + lenient().when(customFieldsService.resolve(any(), anyString())).thenReturn(Map.of()); } @Test @@ -150,6 +157,21 @@ void buildContext_mapsOrderLineFields() throws IOException { assertThat(line.getVendorDetail().getInstructions()).isEqualTo("Handle with care"); } + @Test + void buildContext_resolvesCustomFieldsForOrderAndLine() throws IOException { + var orderCf = Map.of("po_cf", CustomFieldContext.builder().name("PO field").type("TEXTBOX_SHORT").value("po-value").build()); + var lineCf = Map.of("line_cf", CustomFieldContext.builder().name("Line field").type("TEXTBOX_SHORT").value("line-value").build()); + when(customFieldsService.resolve(any(), eq("purchase_order"))).thenReturn(orderCf); + when(customFieldsService.resolve(any(), eq("po_line"))).thenReturn(lineCf); + var order = loadOrder("edifact/acquisitions/composite_purchase_order_email_context.json"); + + OrderEmailContext ctx = mapper.buildContext(List.of(order)); + + var wrapper = ctx.getOrders().get(0); + assertThat(wrapper.order().getCustomFields()).isEqualTo(orderCf); + assertThat(wrapper.orderLines().get(0).orderLine().getCustomFields()).isEqualTo(lineCf); + } + @Test void buildContext_multipleOrders_producesOneWrapperPerOrder() throws IOException { var order1 = loadOrder("edifact/acquisitions/composite_purchase_order_email_context.json"); diff --git a/src/test/java/org/folio/dew/batch/acquisitions/services/CustomFieldDefinitionServiceTest.java b/src/test/java/org/folio/dew/batch/acquisitions/services/CustomFieldDefinitionServiceTest.java new file mode 100644 index 000000000..7f396b8af --- /dev/null +++ b/src/test/java/org/folio/dew/batch/acquisitions/services/CustomFieldDefinitionServiceTest.java @@ -0,0 +1,82 @@ +package org.folio.dew.batch.acquisitions.services; + +import org.folio.dew.client.CustomFieldsClient; +import org.folio.dew.domain.dto.acquisitions.customfields.CustomField; +import org.folio.dew.domain.dto.acquisitions.customfields.CustomFieldCollection; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.client.HttpClientErrorException; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +@ExtendWith(MockitoExtension.class) +class CustomFieldDefinitionServiceTest { + + @Mock + private CustomFieldsClient customFieldsClient; + + @InjectMocks + private CustomFieldDefinitionService service; + + @Test + void getDefinitionsByRefId_indexesByRefId_andQueriesTargetModule() { + var collection = new CustomFieldCollection(); + collection.setCustomFields(List.of(customField("a"), customField("b"))); + when(customFieldsClient.getCustomFields(anyString(), anyInt(), anyString())).thenReturn(collection); + + var result = service.getDefinitionsByRefId("po_line"); + + assertThat(result).containsOnlyKeys("a", "b"); + + var queryCaptor = ArgumentCaptor.forClass(String.class); + var moduleCaptor = ArgumentCaptor.forClass(String.class); + org.mockito.Mockito.verify(customFieldsClient).getCustomFields(queryCaptor.capture(), anyInt(), moduleCaptor.capture()); + assertThat(queryCaptor.getValue()).isEqualTo("entityType==po_line"); + assertThat(moduleCaptor.getValue()).isEqualTo("mod-orders-storage"); + } + + @Test + void getDefinitionsByRefId_skipsBlankRefIds() { + var collection = new CustomFieldCollection(); + collection.setCustomFields(new ArrayList<>(List.of(customField("a"), customField(""), customField(null)))); + when(customFieldsClient.getCustomFields(anyString(), anyInt(), anyString())).thenReturn(collection); + + var result = service.getDefinitionsByRefId("po_line"); + + assertThat(result).containsOnlyKeys("a"); + } + + @Test + void getDefinitionsByRefId_nullCollection_returnsEmpty() { + when(customFieldsClient.getCustomFields(anyString(), anyInt(), anyString())).thenReturn(new CustomFieldCollection()); + + assertThat(service.getDefinitionsByRefId("po_line")).isEmpty(); + } + + @Test + void getDefinitionsByRefId_clientThrows_degradesToEmptyMap() { + when(customFieldsClient.getCustomFields(anyString(), anyInt(), eq("mod-orders-storage"))) + .thenThrow(HttpClientErrorException.create(NOT_FOUND, "Not Found", null, null, null)); + + assertThat(service.getDefinitionsByRefId("po_line")).isEmpty(); + } + + private static CustomField customField(String refId) { + var cf = new CustomField(); + cf.setRefId(refId); + cf.setName("name-" + refId); + return cf; + } +} \ No newline at end of file diff --git a/src/test/java/org/folio/dew/batch/acquisitions/services/CustomFieldsServiceTest.java b/src/test/java/org/folio/dew/batch/acquisitions/services/CustomFieldsServiceTest.java new file mode 100644 index 000000000..4a0dfc64a --- /dev/null +++ b/src/test/java/org/folio/dew/batch/acquisitions/services/CustomFieldsServiceTest.java @@ -0,0 +1,203 @@ +package org.folio.dew.batch.acquisitions.services; + +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.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CustomFieldsServiceTest { + + private static final String ENTITY_TYPE = "po_line"; + + @Mock + private CustomFieldDefinitionService definitionService; + + @InjectMocks + private CustomFieldsService service; + + @Test + void resolve_nullOrEmptyRaw_returnsEmptyMap() { + assertThat(service.resolve(null, ENTITY_TYPE)).isEmpty(); + assertThat(service.resolve(Map.of(), ENTITY_TYPE)).isEmpty(); + } + + @Test + void resolve_singleSelect_scalarValueIsOptionWithLabel() { + when(definitionService.getDefinitionsByRefId(ENTITY_TYPE)) + .thenReturn(Map.of("format", select("format", "Format", "opt_1", "Hardcover", "opt_2", "Paperback"))); + + var result = service.resolve(raw("format", "opt_2"), ENTITY_TYPE); + + var ctx = result.get("format"); + assertThat(ctx.getName()).isEqualTo("Format"); + assertThat(ctx.getType()).isEqualTo("SINGLE_SELECT_DROPDOWN"); + assertThat(ctx.getValues()).isNull(); + assertThat(ctx.getValue()).isInstanceOf(CustomFieldOptionValue.class); + var option = (CustomFieldOptionValue) ctx.getValue(); + assertThat(option.getId()).isEqualTo("opt_2"); + assertThat(option.getLabel()).isEqualTo("Paperback"); + } + + @Test + void resolve_singleSelect_unknownOptionId_fallsBackToId() { + when(definitionService.getDefinitionsByRefId(ENTITY_TYPE)) + .thenReturn(Map.of("format", select("format", "Format", "opt_1", "Hardcover"))); + + var result = service.resolve(raw("format", "opt_9"), ENTITY_TYPE); + + var option = (CustomFieldOptionValue) result.get("format").getValue(); + assertThat(option.getId()).isEqualTo("opt_9"); + assertThat(option.getLabel()).isEqualTo("opt_9"); + } + + @Test + void resolve_multiSelect_valuesAreOptionsWithLabels() { + when(definitionService.getDefinitionsByRefId(ENTITY_TYPE)) + .thenReturn(Map.of("langs", select("langs", "Languages", "opt_1", "English", "opt_2", "German"))); + + var result = service.resolve(raw("langs", List.of("opt_1", "opt_2")), ENTITY_TYPE); + + var ctx = result.get("langs"); + assertThat(ctx.getValue()).isNull(); + assertThat(ctx.getValues()).hasSize(2); + assertThat(ctx.getValues()).allMatch(CustomFieldOptionValue.class::isInstance); + var first = (CustomFieldOptionValue) ctx.getValues().get(0); + assertThat(first.getId()).isEqualTo("opt_1"); + assertThat(first.getLabel()).isEqualTo("English"); + var second = (CustomFieldOptionValue) ctx.getValues().get(1); + assertThat(second.getLabel()).isEqualTo("German"); + } + + @Test + void resolve_repeatableText_valuesArePlainStrings() { + when(definitionService.getDefinitionsByRefId(ENTITY_TYPE)) + .thenReturn(Map.of("keywords", text("keywords", "Keywords", "TEXTBOX_SHORT"))); + + var result = service.resolve(raw("keywords", List.of("alpha", "beta")), ENTITY_TYPE); + + var ctx = result.get("keywords"); + assertThat(ctx.getValue()).isNull(); + assertThat(ctx.getValues()).containsExactly("alpha", "beta"); + } + + @Test + void resolve_checkbox_valueIsBoolean() { + when(definitionService.getDefinitionsByRefId(ENTITY_TYPE)) + .thenReturn(Map.of("urgent", text("urgent", "Urgent", "SINGLE_CHECKBOX"))); + + var result = service.resolve(raw("urgent", Boolean.TRUE), ENTITY_TYPE); + + assertThat(result.get("urgent").getValue()).isEqualTo(Boolean.TRUE); + } + + @Test + void resolve_textbox_valueIsString() { + when(definitionService.getDefinitionsByRefId(ENTITY_TYPE)) + .thenReturn(Map.of("note", text("note", "Note", "TEXTBOX_LONG"))); + + var result = service.resolve(raw("note", "Line 1\nLine 2"), ENTITY_TYPE); + + assertThat(result.get("note").getValue()).isEqualTo("Line 1\nLine 2"); + } + + @Test + void resolve_dropsUnknownRefId_nullValue_andHiddenFields() { + var hidden = text("hidden", "Hidden", "TEXTBOX_SHORT"); + hidden.setVisible(false); + var visible = text("kept", "Kept", "TEXTBOX_SHORT"); + when(definitionService.getDefinitionsByRefId(ENTITY_TYPE)) + .thenReturn(Map.of("hidden", hidden, "kept", visible)); + + Map raw = new HashMap<>(); + raw.put("hidden", "secret"); // definition hidden → dropped + raw.put("kept", "shown"); // kept + raw.put("unknown", "orphan"); // no definition → dropped + raw.put("nullValue", null); // null → dropped + + var result = service.resolve(raw, ENTITY_TYPE); + + assertThat(result).containsOnlyKeys("kept"); + assertThat(result.get("kept").getValue()).isEqualTo("shown"); + } + + @Test + void resolve_visibleUnsetOrTrue_areRendered() { + var unset = text("unset", "Unset", "TEXTBOX_SHORT"); // visible == null + var explicit = text("explicit", "Explicit", "TEXTBOX_SHORT"); + explicit.setVisible(true); + when(definitionService.getDefinitionsByRefId(ENTITY_TYPE)) + .thenReturn(Map.of("unset", unset, "explicit", explicit)); + + var result = service.resolve(raw2("unset", "a", "explicit", "b"), ENTITY_TYPE); + + assertThat(result).containsOnlyKeys("unset", "explicit"); + } + + @Test + void resolve_returnsUnmodifiableMap() { + when(definitionService.getDefinitionsByRefId(ENTITY_TYPE)) + .thenReturn(Map.of("kept", text("kept", "Kept", "TEXTBOX_SHORT"))); + + var result = service.resolve(raw("kept", "shown"), ENTITY_TYPE); + + assertThatThrownBy(() -> result.put("x", CustomFieldContext.builder().build())) + .isInstanceOf(UnsupportedOperationException.class); + } + + // ---- helpers ---- + + private static Map raw(String key, Object value) { + Map map = new LinkedHashMap<>(); + map.put(key, value); + return map; + } + + private static Map raw2(String k1, Object v1, String k2, Object v2) { + Map map = new LinkedHashMap<>(); + map.put(k1, v1); + map.put(k2, v2); + return map; + } + + private static CustomField text(String refId, String name, String type) { + var cf = new CustomField(); + cf.setRefId(refId); + cf.setName(name); + cf.setType(type); + return cf; + } + + private static CustomField select(String refId, String name, String... idLabelPairs) { + var cf = text(refId, name, "SINGLE_SELECT_DROPDOWN"); + var options = new SelectFieldOptions(); + var values = new java.util.ArrayList(); + for (int i = 0; i < idLabelPairs.length; i += 2) { + var option = new SelectFieldOption(); + option.setId(idLabelPairs[i]); + option.setValue(idLabelPairs[i + 1]); + values.add(option); + } + options.setValues(values); + var selectField = new SelectField(); + selectField.setOptions(options); + cf.setSelectField(selectField); + return cf; + } +} \ No newline at end of file From b49eae1b3de74b6d5ea539b4410a79ea5c827ee0 Mon Sep 17 00:00:00 2001 From: Markus Weigelt Date: Mon, 6 Jul 2026 14:34:18 +0200 Subject: [PATCH 2/7] Bump folio-export-common to customFields schema change Point the submodule at the commit that exposes customFields on the purchase order and PO line schemas, and track branch MODEXPW-638. --- .gitmodules | 1 + folio-export-common | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitmodules b/.gitmodules index 214ef69b3..48f981054 100644 --- a/.gitmodules +++ b/.gitmodules @@ -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 diff --git a/folio-export-common b/folio-export-common index 982ad7a55..f5f4be627 160000 --- a/folio-export-common +++ b/folio-export-common @@ -1 +1 @@ -Subproject commit 982ad7a5552f27a3e836c435b47ac8da1991196a +Subproject commit f5f4be627c6e7b464ea61ce07dc8d60b436738a1 From f7327b6606a81da75e20904933adf61d25339445 Mon Sep 17 00:00:00 2001 From: Markus Weigelt Date: Mon, 6 Jul 2026 17:02:52 +0200 Subject: [PATCH 3/7] Resolve mod-orders-storage module id dynamically for custom fields The custom-fields interface is interfaceType:multiple, so the gateway routes it on the exact versioned module id. Resolve that id per tenant from entitlements (scoping cross-tenant responses via the tenant name) instead of a hardcoded value, and make the definitions cache tenant-aware. --- README.md | 69 ++++++++++- .../CustomFieldDefinitionService.java | 25 ++-- .../OrdersStorageModuleIdResolver.java | 117 ++++++++++++++++++ .../folio/dew/client/EntitlementsClient.java | 31 +++++ .../org/folio/dew/client/TenantsClient.java | 28 +++++ .../dew/config/HttpClientConfiguration.java | 12 ++ .../CustomFieldDefinitionServiceTest.java | 31 +++-- .../OrdersStorageModuleIdResolverTest.java | 117 ++++++++++++++++++ 8 files changed, 409 insertions(+), 21 deletions(-) create mode 100644 src/main/java/org/folio/dew/batch/acquisitions/services/OrdersStorageModuleIdResolver.java create mode 100644 src/main/java/org/folio/dew/client/EntitlementsClient.java create mode 100644 src/main/java/org/folio/dew/client/TenantsClient.java create mode 100644 src/test/java/org/folio/dew/batch/acquisitions/services/OrdersStorageModuleIdResolverTest.java diff --git a/README.md b/README.md index d41b892da..3601ef5fc 100644 --- a/README.md +++ b/README.md @@ -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) + │ └── + │ ├── 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 @@ -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) + └── + ├── 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 @@ -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": [ @@ -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" + } } } } diff --git a/src/main/java/org/folio/dew/batch/acquisitions/services/CustomFieldDefinitionService.java b/src/main/java/org/folio/dew/batch/acquisitions/services/CustomFieldDefinitionService.java index 4f1c90d7a..05ca918f7 100644 --- a/src/main/java/org/folio/dew/batch/acquisitions/services/CustomFieldDefinitionService.java +++ b/src/main/java/org/folio/dew/batch/acquisitions/services/CustomFieldDefinitionService.java @@ -15,12 +15,15 @@ import java.util.Optional; /** - * Thin cached wrapper over {@link CustomFieldsClient} that returns the custom-field definitions - * for an entity type, indexed by refId. The definitions live on {@code mod-orders-storage}, so the - * {@code custom-fields} ({@code interfaceType: multiple}) call is targeted with an explicit module id. + * 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}. * - *

Degrades gracefully: if the interface is unavailable (e.g. 404 at the gateway) the export still - * goes out, just without resolved custom-field tokens. + *

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 @@ -28,15 +31,21 @@ public class CustomFieldDefinitionService { private static final int LIMIT = 1000; - private static final String MODULE_ID = "mod-orders-storage"; private final CustomFieldsClient customFieldsClient; + private final OrdersStorageModuleIdResolver ordersStorageModuleIdResolver; - @Cacheable(cacheNames = "customFieldDefinitions", key = "#entityType") + @Cacheable(cacheNames = "customFieldDefinitions", key = "@folioExecutionContext.tenantId + ':' + #entityType") public Map getDefinitionsByRefId(String entityType) { Map 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, MODULE_ID); + 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())) { diff --git a/src/main/java/org/folio/dew/batch/acquisitions/services/OrdersStorageModuleIdResolver.java b/src/main/java/org/folio/dew/batch/acquisitions/services/OrdersStorageModuleIdResolver.java new file mode 100644 index 000000000..0f765823f --- /dev/null +++ b/src/main/java/org/folio/dew/batch/acquisitions/services/OrdersStorageModuleIdResolver.java @@ -0,0 +1,117 @@ +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.EntitlementsClient; +import org.folio.dew.client.EntitlementsClient.Entitlement; +import org.folio.dew.client.TenantsClient; +import org.folio.dew.client.TenantsClient.Tenant; +import org.folio.spring.FolioExecutionContext; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClientException; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Resolves the full, versioned module id of the {@code mod-orders-storage} module the current tenant + * is entitled to (e.g. {@code mod-orders-storage-14.0.0-SNAPSHOT.498}). That id is required as the + * {@code X-Okapi-Module-Id} header when calling the {@code interfaceType: multiple} + * {@code custom-fields} interface, because the gateway routes such interfaces on the exact module id. + * + *

Multi-tenant safe: {@code /entitlements} is a cross-tenant manager endpoint, so the response may + * carry entitlements for several tenants. When it does, the current tenant's id (UUID) is resolved by + * name via {@code /tenants} and used to filter. When the response already contains a single tenant + * (auto-scoped or single-tenant deployment) that step is skipped. + * + *

Degrades gracefully: any failure returns {@code null}, letting the caller send the email without + * resolved custom-field tokens rather than failing the export. + */ +@Service +@Log4j2 +@RequiredArgsConstructor +public class OrdersStorageModuleIdResolver { + + private static final String MODULE_NAME = "mod-orders-storage"; + private static final String MODULE_ID_PREFIX = MODULE_NAME + "-"; + private static final int ENTITLEMENTS_LIMIT = 500; + + private final EntitlementsClient entitlementsClient; + private final TenantsClient tenantsClient; + private final FolioExecutionContext folioExecutionContext; + + @Cacheable(cacheNames = "ordersStorageModuleId", key = "@folioExecutionContext.tenantId", unless = "#result == null") + public String resolve() { + try { + var entitlements = Optional.ofNullable(entitlementsClient.getEntitlements(true, ENTITLEMENTS_LIMIT)) + .map(EntitlementsClient.EntitlementCollection::entitlements) + .orElseGet(List::of); + if (entitlements.isEmpty()) { + log.warn("resolve:: No entitlements returned - cannot resolve {} module id", MODULE_NAME); + return null; + } + var tenantId = resolveTargetTenantId(entitlements); + if (tenantId == null) { + return null; + } + var moduleId = entitlements.stream() + .filter(e -> tenantId.equals(e.tenantId())) + .map(Entitlement::modules) + .filter(Objects::nonNull) + .flatMap(List::stream) + .filter(Objects::nonNull) + .filter(id -> id.startsWith(MODULE_ID_PREFIX)) + .findFirst() + .orElse(null); + if (moduleId == null) { + log.warn("resolve:: Tenant '{}' has no entitled {} module", tenantId, MODULE_NAME); + } + return moduleId; + } catch (RestClientException e) { + log.warn("resolve:: Failed to resolve {} module id from entitlements", MODULE_NAME, e); + return null; + } + } + + private String resolveTargetTenantId(List entitlements) { + var distinctTenantIds = entitlements.stream() + .map(Entitlement::tenantId) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (distinctTenantIds.size() == 1) { + // Auto-scoped by X-Okapi-Tenant, or a single-tenant deployment. + return distinctTenantIds.get(0); + } + // Cross-tenant response: narrow to the current tenant by resolving its UUID from its name. + return resolveCurrentTenantId(); + } + + private String resolveCurrentTenantId() { + var tenantName = folioExecutionContext.getTenantId(); + if (StringUtils.isBlank(tenantName)) { + log.warn("resolveCurrentTenantId:: No tenant in execution context"); + return null; + } + try { + var tenantId = Optional.ofNullable(tenantsClient.getTenants("name==" + tenantName, 1)) + .map(TenantsClient.TenantCollection::tenants) + .orElseGet(List::of).stream() + .filter(t -> tenantName.equals(t.name())) + .map(Tenant::id) + .filter(StringUtils::isNotBlank) + .findFirst() + .orElse(null); + if (tenantId == null) { + log.warn("resolveCurrentTenantId:: Could not resolve id for tenant '{}'", tenantName); + } + return tenantId; + } catch (RestClientException e) { + log.warn("resolveCurrentTenantId:: Failed to resolve id for tenant '{}'", tenantName, e); + return null; + } + } +} \ No newline at end of file diff --git a/src/main/java/org/folio/dew/client/EntitlementsClient.java b/src/main/java/org/folio/dew/client/EntitlementsClient.java new file mode 100644 index 000000000..4eb7b16e7 --- /dev/null +++ b/src/main/java/org/folio/dew/client/EntitlementsClient.java @@ -0,0 +1,31 @@ +package org.folio.dew.client; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.HttpExchange; + +import java.util.List; + +/** + * Client for {@code mgr-tenant-entitlements}. Used to discover which application/modules a tenant is + * entitled to — in particular the currently deployed {@code mod-orders-storage} module id, which is + * needed as the {@code X-Okapi-Module-Id} when calling the {@code interfaceType: multiple} + * {@code custom-fields} interface. + */ +@HttpExchange(url = "entitlements", accept = MediaType.APPLICATION_JSON_VALUE) +public interface EntitlementsClient { + + @GetExchange + EntitlementCollection getEntitlements(@RequestParam("includeModules") boolean includeModules, + @RequestParam("limit") int limit); + + @JsonIgnoreProperties(ignoreUnknown = true) + record EntitlementCollection(List entitlements) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + record Entitlement(String tenantId, String applicationId, List modules) { + } +} \ No newline at end of file diff --git a/src/main/java/org/folio/dew/client/TenantsClient.java b/src/main/java/org/folio/dew/client/TenantsClient.java new file mode 100644 index 000000000..33c8a96cc --- /dev/null +++ b/src/main/java/org/folio/dew/client/TenantsClient.java @@ -0,0 +1,28 @@ +package org.folio.dew.client; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.HttpExchange; + +import java.util.List; + +/** + * Client for {@code mgr-tenants}. Resolves a tenant name to its id (UUID), used to scope the + * cross-tenant {@code /entitlements} response to the current tenant when more than one tenant exists. + */ +@HttpExchange(url = "tenants", accept = MediaType.APPLICATION_JSON_VALUE) +public interface TenantsClient { + + @GetExchange + TenantCollection getTenants(@RequestParam("query") String query, @RequestParam("limit") int limit); + + @JsonIgnoreProperties(ignoreUnknown = true) + record TenantCollection(List tenants) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + record Tenant(String id, String name) { + } +} \ No newline at end of file diff --git a/src/main/java/org/folio/dew/config/HttpClientConfiguration.java b/src/main/java/org/folio/dew/config/HttpClientConfiguration.java index 1cce6a7ca..486d742be 100644 --- a/src/main/java/org/folio/dew/config/HttpClientConfiguration.java +++ b/src/main/java/org/folio/dew/config/HttpClientConfiguration.java @@ -11,6 +11,8 @@ import org.folio.dew.client.DataExportSpringClient; import org.folio.dew.client.EmailClient; import org.folio.dew.client.EntitiesLinksStatsClient; +import org.folio.dew.client.EntitlementsClient; +import org.folio.dew.client.TenantsClient; import org.folio.dew.client.ExpenseClassClient; import org.folio.dew.client.HoldingClient; import org.folio.dew.client.IdentifierTypeClient; @@ -56,6 +58,16 @@ public CustomFieldsClient customFieldsClient(HttpServiceProxyFactory factory) { return factory.createClient(CustomFieldsClient.class); } + @Bean + public EntitlementsClient entitlementsClient(HttpServiceProxyFactory factory) { + return factory.createClient(EntitlementsClient.class); + } + + @Bean + public TenantsClient tenantsClient(HttpServiceProxyFactory factory) { + return factory.createClient(TenantsClient.class); + } + @Bean public UserTenantsClient userTenantsClient(HttpServiceProxyFactory factory) { return factory.createClient(UserTenantsClient.class); diff --git a/src/test/java/org/folio/dew/batch/acquisitions/services/CustomFieldDefinitionServiceTest.java b/src/test/java/org/folio/dew/batch/acquisitions/services/CustomFieldDefinitionServiceTest.java index 7f396b8af..dbb82e198 100644 --- a/src/test/java/org/folio/dew/batch/acquisitions/services/CustomFieldDefinitionServiceTest.java +++ b/src/test/java/org/folio/dew/batch/acquisitions/services/CustomFieldDefinitionServiceTest.java @@ -18,20 +18,27 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.http.HttpStatus.NOT_FOUND; @ExtendWith(MockitoExtension.class) class CustomFieldDefinitionServiceTest { + private static final String MODULE_ID = "mod-orders-storage-14.0.0-SNAPSHOT.498"; + @Mock private CustomFieldsClient customFieldsClient; + @Mock + private OrdersStorageModuleIdResolver ordersStorageModuleIdResolver; @InjectMocks private CustomFieldDefinitionService service; @Test - void getDefinitionsByRefId_indexesByRefId_andQueriesTargetModule() { + void getDefinitionsByRefId_indexesByRefId_andPassesResolvedModuleId() { + when(ordersStorageModuleIdResolver.resolve()).thenReturn(MODULE_ID); var collection = new CustomFieldCollection(); collection.setCustomFields(List.of(customField("a"), customField("b"))); when(customFieldsClient.getCustomFields(anyString(), anyInt(), anyString())).thenReturn(collection); @@ -39,35 +46,43 @@ void getDefinitionsByRefId_indexesByRefId_andQueriesTargetModule() { var result = service.getDefinitionsByRefId("po_line"); assertThat(result).containsOnlyKeys("a", "b"); - var queryCaptor = ArgumentCaptor.forClass(String.class); var moduleCaptor = ArgumentCaptor.forClass(String.class); - org.mockito.Mockito.verify(customFieldsClient).getCustomFields(queryCaptor.capture(), anyInt(), moduleCaptor.capture()); + verify(customFieldsClient).getCustomFields(queryCaptor.capture(), anyInt(), moduleCaptor.capture()); assertThat(queryCaptor.getValue()).isEqualTo("entityType==po_line"); - assertThat(moduleCaptor.getValue()).isEqualTo("mod-orders-storage"); + assertThat(moduleCaptor.getValue()).isEqualTo(MODULE_ID); } @Test void getDefinitionsByRefId_skipsBlankRefIds() { + when(ordersStorageModuleIdResolver.resolve()).thenReturn(MODULE_ID); var collection = new CustomFieldCollection(); collection.setCustomFields(new ArrayList<>(List.of(customField("a"), customField(""), customField(null)))); when(customFieldsClient.getCustomFields(anyString(), anyInt(), anyString())).thenReturn(collection); - var result = service.getDefinitionsByRefId("po_line"); - - assertThat(result).containsOnlyKeys("a"); + assertThat(service.getDefinitionsByRefId("po_line")).containsOnlyKeys("a"); } @Test void getDefinitionsByRefId_nullCollection_returnsEmpty() { + when(ordersStorageModuleIdResolver.resolve()).thenReturn(MODULE_ID); when(customFieldsClient.getCustomFields(anyString(), anyInt(), anyString())).thenReturn(new CustomFieldCollection()); assertThat(service.getDefinitionsByRefId("po_line")).isEmpty(); } + @Test + void getDefinitionsByRefId_moduleIdUnresolved_skipsCallAndReturnsEmpty() { + when(ordersStorageModuleIdResolver.resolve()).thenReturn(null); + + assertThat(service.getDefinitionsByRefId("po_line")).isEmpty(); + verify(customFieldsClient, never()).getCustomFields(anyString(), anyInt(), anyString()); + } + @Test void getDefinitionsByRefId_clientThrows_degradesToEmptyMap() { - when(customFieldsClient.getCustomFields(anyString(), anyInt(), eq("mod-orders-storage"))) + when(ordersStorageModuleIdResolver.resolve()).thenReturn(MODULE_ID); + when(customFieldsClient.getCustomFields(anyString(), anyInt(), eq(MODULE_ID))) .thenThrow(HttpClientErrorException.create(NOT_FOUND, "Not Found", null, null, null)); assertThat(service.getDefinitionsByRefId("po_line")).isEmpty(); diff --git a/src/test/java/org/folio/dew/batch/acquisitions/services/OrdersStorageModuleIdResolverTest.java b/src/test/java/org/folio/dew/batch/acquisitions/services/OrdersStorageModuleIdResolverTest.java new file mode 100644 index 000000000..71c099a33 --- /dev/null +++ b/src/test/java/org/folio/dew/batch/acquisitions/services/OrdersStorageModuleIdResolverTest.java @@ -0,0 +1,117 @@ +package org.folio.dew.batch.acquisitions.services; + +import org.folio.dew.client.EntitlementsClient; +import org.folio.dew.client.EntitlementsClient.Entitlement; +import org.folio.dew.client.EntitlementsClient.EntitlementCollection; +import org.folio.dew.client.TenantsClient; +import org.folio.dew.client.TenantsClient.Tenant; +import org.folio.dew.client.TenantsClient.TenantCollection; +import org.folio.spring.FolioExecutionContext; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.client.HttpServerErrorException; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; + +@ExtendWith(MockitoExtension.class) +class OrdersStorageModuleIdResolverTest { + + private static final String TENANT_NAME = "diku"; + private static final String TENANT_A = "0665fa8b-529e-4d5d-9cb3-74d5cfd6c8c2"; + private static final String TENANT_B = "11111111-1111-1111-1111-111111111111"; + private static final String OS_MODULE_ID = "mod-orders-storage-14.0.0-SNAPSHOT.498"; + + @Mock + private EntitlementsClient entitlementsClient; + @Mock + private TenantsClient tenantsClient; + @Mock + private FolioExecutionContext folioExecutionContext; + + @InjectMocks + private OrdersStorageModuleIdResolver resolver; + + @Test + void resolve_singleTenantResponse_picksModuleWithoutTenantLookup() { + when(entitlementsClient.getEntitlements(true, 500)).thenReturn(new EntitlementCollection(List.of( + entitlement(TENANT_A, "app-acq-1.0.0", List.of("mod-orders-13.1.0-SNAPSHOT.1121", OS_MODULE_ID)), + entitlement(TENANT_A, "app-users-1.0.0", List.of("mod-users-19.6.0-SNAPSHOT.379"))))); + + assertThat(resolver.resolve()).isEqualTo(OS_MODULE_ID); + verify(tenantsClient, never()).getTenants(anyString(), anyInt()); + } + + @Test + void resolve_crossTenantResponse_filtersToCurrentTenantByName() { + when(entitlementsClient.getEntitlements(true, 500)).thenReturn(new EntitlementCollection(List.of( + entitlement(TENANT_B, "app-acq-1.0.0", List.of("mod-orders-storage-99.0.0-OTHER.1")), + entitlement(TENANT_A, "app-acq-1.0.0", List.of(OS_MODULE_ID))))); + when(folioExecutionContext.getTenantId()).thenReturn(TENANT_NAME); + when(tenantsClient.getTenants("name==" + TENANT_NAME, 1)) + .thenReturn(new TenantCollection(List.of(new Tenant(TENANT_A, TENANT_NAME)))); + + assertThat(resolver.resolve()).isEqualTo(OS_MODULE_ID); + } + + @Test + void resolve_crossTenant_tenantLookupEmpty_returnsNull() { + when(entitlementsClient.getEntitlements(true, 500)).thenReturn(new EntitlementCollection(List.of( + entitlement(TENANT_B, "app", List.of("mod-orders-storage-99.0.0-OTHER.1")), + entitlement(TENANT_A, "app", List.of(OS_MODULE_ID))))); + when(folioExecutionContext.getTenantId()).thenReturn(TENANT_NAME); + when(tenantsClient.getTenants(anyString(), anyInt())).thenReturn(new TenantCollection(List.of())); + + assertThat(resolver.resolve()).isNull(); + } + + @Test + void resolve_noOrdersStorageModule_returnsNull() { + when(entitlementsClient.getEntitlements(true, 500)).thenReturn(new EntitlementCollection(List.of( + entitlement(TENANT_A, "app", List.of("mod-users-19.6.0-SNAPSHOT.379"))))); + + assertThat(resolver.resolve()).isNull(); + } + + @Test + void resolve_emptyEntitlements_returnsNull() { + when(entitlementsClient.getEntitlements(true, 500)).thenReturn(new EntitlementCollection(List.of())); + + assertThat(resolver.resolve()).isNull(); + } + + @Test + void resolve_entitlementsClientThrows_returnsNull() { + when(entitlementsClient.getEntitlements(true, 500)) + .thenThrow(HttpServerErrorException.create(INTERNAL_SERVER_ERROR, "boom", null, null, null)); + + assertThat(resolver.resolve()).isNull(); + } + + @Test + void resolve_tenantClientThrows_returnsNull() { + when(entitlementsClient.getEntitlements(true, 500)).thenReturn(new EntitlementCollection(List.of( + entitlement(TENANT_B, "app", List.of("mod-orders-storage-99.0.0-OTHER.1")), + entitlement(TENANT_A, "app", List.of(OS_MODULE_ID))))); + lenient().when(folioExecutionContext.getTenantId()).thenReturn(TENANT_NAME); + when(tenantsClient.getTenants(anyString(), anyInt())) + .thenThrow(HttpServerErrorException.create(INTERNAL_SERVER_ERROR, "boom", null, null, null)); + + assertThat(resolver.resolve()).isNull(); + } + + private static Entitlement entitlement(String tenantId, String applicationId, List modules) { + return new Entitlement(tenantId, applicationId, modules); + } +} \ No newline at end of file From 0d653e36814cfb3b0cf86cb61ef234ccc380a642 Mon Sep 17 00:00:00 2001 From: Markus Weigelt Date: Thu, 6 Aug 2026 18:29:13 +0200 Subject: [PATCH 4/7] Add missing final newline to custom fields sources The custom fields services, clients, DTOs and their tests ended without a trailing newline, producing "No newline at end of file" markers in every diff. Whitespace only, no behaviour change. --- .../acquisitions/services/CustomFieldDefinitionService.java | 2 +- .../dew/batch/acquisitions/services/CustomFieldsService.java | 2 +- .../acquisitions/services/OrdersStorageModuleIdResolver.java | 2 +- src/main/java/org/folio/dew/client/CustomFieldsClient.java | 2 +- src/main/java/org/folio/dew/client/EntitlementsClient.java | 2 +- src/main/java/org/folio/dew/client/TenantsClient.java | 2 +- .../dew/domain/dto/acquisitions/customfields/CustomField.java | 2 +- .../dto/acquisitions/customfields/CustomFieldCollection.java | 2 +- .../dew/domain/dto/acquisitions/customfields/SelectField.java | 2 +- .../domain/dto/acquisitions/customfields/SelectFieldOption.java | 2 +- .../dto/acquisitions/customfields/SelectFieldOptions.java | 2 +- .../domain/dto/templateengine/context/CustomFieldContext.java | 2 +- .../dto/templateengine/context/CustomFieldOptionValue.java | 2 +- .../acquisitions/services/CustomFieldDefinitionServiceTest.java | 2 +- .../batch/acquisitions/services/CustomFieldsServiceTest.java | 2 +- .../services/OrdersStorageModuleIdResolverTest.java | 2 +- 16 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/main/java/org/folio/dew/batch/acquisitions/services/CustomFieldDefinitionService.java b/src/main/java/org/folio/dew/batch/acquisitions/services/CustomFieldDefinitionService.java index 05ca918f7..33ab97827 100644 --- a/src/main/java/org/folio/dew/batch/acquisitions/services/CustomFieldDefinitionService.java +++ b/src/main/java/org/folio/dew/batch/acquisitions/services/CustomFieldDefinitionService.java @@ -58,4 +58,4 @@ public Map getDefinitionsByRefId(String entityType) { } return byRefId; } -} \ No newline at end of file +} diff --git a/src/main/java/org/folio/dew/batch/acquisitions/services/CustomFieldsService.java b/src/main/java/org/folio/dew/batch/acquisitions/services/CustomFieldsService.java index e5212c76a..34e8e4902 100644 --- a/src/main/java/org/folio/dew/batch/acquisitions/services/CustomFieldsService.java +++ b/src/main/java/org/folio/dew/batch/acquisitions/services/CustomFieldsService.java @@ -105,4 +105,4 @@ private String resolveOptionLabel(CustomField definition, String optionId) { .findFirst() .orElse(optionId); } -} \ No newline at end of file +} diff --git a/src/main/java/org/folio/dew/batch/acquisitions/services/OrdersStorageModuleIdResolver.java b/src/main/java/org/folio/dew/batch/acquisitions/services/OrdersStorageModuleIdResolver.java index 0f765823f..1199f0be4 100644 --- a/src/main/java/org/folio/dew/batch/acquisitions/services/OrdersStorageModuleIdResolver.java +++ b/src/main/java/org/folio/dew/batch/acquisitions/services/OrdersStorageModuleIdResolver.java @@ -114,4 +114,4 @@ private String resolveCurrentTenantId() { return null; } } -} \ No newline at end of file +} diff --git a/src/main/java/org/folio/dew/client/CustomFieldsClient.java b/src/main/java/org/folio/dew/client/CustomFieldsClient.java index af22c8c33..d5a505afc 100644 --- a/src/main/java/org/folio/dew/client/CustomFieldsClient.java +++ b/src/main/java/org/folio/dew/client/CustomFieldsClient.java @@ -20,4 +20,4 @@ public interface CustomFieldsClient { CustomFieldCollection getCustomFields(@RequestParam("query") String query, @RequestParam("limit") int limit, @RequestHeader("X-Okapi-Module-Id") String moduleId); -} \ No newline at end of file +} diff --git a/src/main/java/org/folio/dew/client/EntitlementsClient.java b/src/main/java/org/folio/dew/client/EntitlementsClient.java index 4eb7b16e7..6efa5e4af 100644 --- a/src/main/java/org/folio/dew/client/EntitlementsClient.java +++ b/src/main/java/org/folio/dew/client/EntitlementsClient.java @@ -28,4 +28,4 @@ record EntitlementCollection(List entitlements) { @JsonIgnoreProperties(ignoreUnknown = true) record Entitlement(String tenantId, String applicationId, List modules) { } -} \ No newline at end of file +} diff --git a/src/main/java/org/folio/dew/client/TenantsClient.java b/src/main/java/org/folio/dew/client/TenantsClient.java index 33c8a96cc..37a224a71 100644 --- a/src/main/java/org/folio/dew/client/TenantsClient.java +++ b/src/main/java/org/folio/dew/client/TenantsClient.java @@ -25,4 +25,4 @@ record TenantCollection(List tenants) { @JsonIgnoreProperties(ignoreUnknown = true) record Tenant(String id, String name) { } -} \ No newline at end of file +} diff --git a/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/CustomField.java b/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/CustomField.java index 121b6e018..8d0326463 100644 --- a/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/CustomField.java +++ b/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/CustomField.java @@ -11,4 +11,4 @@ public class CustomField { private String type; private Boolean visible; private SelectField selectField; -} \ No newline at end of file +} diff --git a/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/CustomFieldCollection.java b/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/CustomFieldCollection.java index a54478cf6..837c9a466 100644 --- a/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/CustomFieldCollection.java +++ b/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/CustomFieldCollection.java @@ -9,4 +9,4 @@ @JsonIgnoreProperties(ignoreUnknown = true) public class CustomFieldCollection { private List customFields; -} \ No newline at end of file +} diff --git a/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/SelectField.java b/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/SelectField.java index c6c6cbf86..f7a2be6f1 100644 --- a/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/SelectField.java +++ b/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/SelectField.java @@ -7,4 +7,4 @@ @JsonIgnoreProperties(ignoreUnknown = true) public class SelectField { private SelectFieldOptions options; -} \ No newline at end of file +} diff --git a/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/SelectFieldOption.java b/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/SelectFieldOption.java index bc617c121..8455ebc96 100644 --- a/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/SelectFieldOption.java +++ b/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/SelectFieldOption.java @@ -8,4 +8,4 @@ public class SelectFieldOption { private String id; private String value; -} \ No newline at end of file +} diff --git a/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/SelectFieldOptions.java b/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/SelectFieldOptions.java index 10b7671cb..5f5df10b2 100644 --- a/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/SelectFieldOptions.java +++ b/src/main/java/org/folio/dew/domain/dto/acquisitions/customfields/SelectFieldOptions.java @@ -9,4 +9,4 @@ @JsonIgnoreProperties(ignoreUnknown = true) public class SelectFieldOptions { private List values; -} \ No newline at end of file +} diff --git a/src/main/java/org/folio/dew/domain/dto/templateengine/context/CustomFieldContext.java b/src/main/java/org/folio/dew/domain/dto/templateengine/context/CustomFieldContext.java index aaf5dbc94..f0d075e52 100644 --- a/src/main/java/org/folio/dew/domain/dto/templateengine/context/CustomFieldContext.java +++ b/src/main/java/org/folio/dew/domain/dto/templateengine/context/CustomFieldContext.java @@ -25,4 +25,4 @@ public class CustomFieldContext { private String type; // definition type, e.g. SINGLE_SELECT_DROPDOWN / SINGLE_CHECKBOX / TEXTBOX_LONG private Object value; // scalar: CustomFieldOptionValue | Boolean | String private List values; // array: CustomFieldOptionValue elements or plain String elements -} \ No newline at end of file +} diff --git a/src/main/java/org/folio/dew/domain/dto/templateengine/context/CustomFieldOptionValue.java b/src/main/java/org/folio/dew/domain/dto/templateengine/context/CustomFieldOptionValue.java index 44bbb5c33..081e04ec8 100644 --- a/src/main/java/org/folio/dew/domain/dto/templateengine/context/CustomFieldOptionValue.java +++ b/src/main/java/org/folio/dew/domain/dto/templateengine/context/CustomFieldOptionValue.java @@ -14,4 +14,4 @@ public class CustomFieldOptionValue { private String id; // stored option-id (e.g. opt_1) private String label; // resolved option label, falling back to the raw option-id when unknown -} \ No newline at end of file +} diff --git a/src/test/java/org/folio/dew/batch/acquisitions/services/CustomFieldDefinitionServiceTest.java b/src/test/java/org/folio/dew/batch/acquisitions/services/CustomFieldDefinitionServiceTest.java index dbb82e198..11dd20068 100644 --- a/src/test/java/org/folio/dew/batch/acquisitions/services/CustomFieldDefinitionServiceTest.java +++ b/src/test/java/org/folio/dew/batch/acquisitions/services/CustomFieldDefinitionServiceTest.java @@ -94,4 +94,4 @@ private static CustomField customField(String refId) { cf.setName("name-" + refId); return cf; } -} \ No newline at end of file +} diff --git a/src/test/java/org/folio/dew/batch/acquisitions/services/CustomFieldsServiceTest.java b/src/test/java/org/folio/dew/batch/acquisitions/services/CustomFieldsServiceTest.java index 4a0dfc64a..93ce092ac 100644 --- a/src/test/java/org/folio/dew/batch/acquisitions/services/CustomFieldsServiceTest.java +++ b/src/test/java/org/folio/dew/batch/acquisitions/services/CustomFieldsServiceTest.java @@ -200,4 +200,4 @@ private static CustomField select(String refId, String name, String... idLabelPa cf.setSelectField(selectField); return cf; } -} \ No newline at end of file +} diff --git a/src/test/java/org/folio/dew/batch/acquisitions/services/OrdersStorageModuleIdResolverTest.java b/src/test/java/org/folio/dew/batch/acquisitions/services/OrdersStorageModuleIdResolverTest.java index 71c099a33..f3390d8e4 100644 --- a/src/test/java/org/folio/dew/batch/acquisitions/services/OrdersStorageModuleIdResolverTest.java +++ b/src/test/java/org/folio/dew/batch/acquisitions/services/OrdersStorageModuleIdResolverTest.java @@ -114,4 +114,4 @@ void resolve_tenantClientThrows_returnsNull() { private static Entitlement entitlement(String tenantId, String applicationId, List modules) { return new Entitlement(tenantId, applicationId, modules); } -} \ No newline at end of file +} From 24edbc0b6958b27dbf8513aaba9c8d8530ec7b48 Mon Sep 17 00:00:00 2001 From: Markus Weigelt Date: Thu, 6 Aug 2026 18:56:02 +0200 Subject: [PATCH 5/7] Bump folio-export-common to the customFields order schemas The merge from master had reset the submodule pointer to a commit without customFields, so OrderEmailContextMapper no longer compiled. Point it back at the branch commit that exposes the map on purchase order, composite purchase order and PO line. --- folio-export-common | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/folio-export-common b/folio-export-common index 28dd89f31..3b27ef179 160000 --- a/folio-export-common +++ b/folio-export-common @@ -1 +1 @@ -Subproject commit 28dd89f318e8958d59c7d2362a610e432a3be389 +Subproject commit 3b27ef179305c39b45a3802a2472183766c38d24 From 3b05425098eaf5cdf300968983bdeef9843e9bf6 Mon Sep 17 00:00:00 2001 From: Markus Weigelt Date: Thu, 6 Aug 2026 19:17:15 +0200 Subject: [PATCH 6/7] Drop the ineffective custom-fields module permission The entry named a permission mod-orders-storage does not define (orders-storage.custom-fields.collection.get) and sat on the _tenant handler, which does not cover Kafka-triggered jobs. The permission belongs to the token minted by mod-data-export-spring. --- descriptors/ModuleDescriptor-template.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index 8b675db67..7043e6048 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -265,8 +265,7 @@ "users.collection.get", "transfers.collection.get", "inventory-storage.service-points.collection.get", - "instance-authority-links.authority-statistics.collection.get", - "custom-fields.collection.get" + "instance-authority-links.authority-statistics.collection.get" ] }, { From 57ccf35cda3de05dcb747ee85abff02326584417 Mon Sep 17 00:00:00 2001 From: Markus Weigelt Date: Mon, 10 Aug 2026 11:02:21 +0200 Subject: [PATCH 7/7] Hoist test fixture out of the assertThatThrownBy lambda The lambda held three invocations that could throw, so a failure would not prove result.put was the source of the UnsupportedOperationException. --- .../batch/acquisitions/services/CustomFieldsServiceTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/java/org/folio/dew/batch/acquisitions/services/CustomFieldsServiceTest.java b/src/test/java/org/folio/dew/batch/acquisitions/services/CustomFieldsServiceTest.java index 93ce092ac..f1490791c 100644 --- a/src/test/java/org/folio/dew/batch/acquisitions/services/CustomFieldsServiceTest.java +++ b/src/test/java/org/folio/dew/batch/acquisitions/services/CustomFieldsServiceTest.java @@ -156,8 +156,9 @@ void resolve_returnsUnmodifiableMap() { .thenReturn(Map.of("kept", text("kept", "Kept", "TEXTBOX_SHORT"))); var result = service.resolve(raw("kept", "shown"), ENTITY_TYPE); + var context = CustomFieldContext.builder().build(); - assertThatThrownBy(() -> result.put("x", CustomFieldContext.builder().build())) + assertThatThrownBy(() -> result.put("x", context)) .isInstanceOf(UnsupportedOperationException.class); }