Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
8 changes: 8 additions & 0 deletions descriptors/ModuleDescriptor-template.json
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,14 @@
{
"id": "instance-authority-links-statistics",
"version": "2.1 3.0"
},
{
"id": "entitlements",
"version": "1.0"
},
{
"id": "tenants",
"version": "1.0"
}
],
"provides": [
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 @@ -47,12 +48,15 @@
public class OrderEmailContextMapper {

private static final DateTimeFormatter CREATED_AT_FORMATTER = new DateTimeFormatterBuilder().appendInstant(3).toFormatter();
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 @@ -138,6 +142,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 @@ -179,6 +184,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