Skip to content

MODEXPW-635 - Expose template tokens in EDIFACT email export#737

Open
markusweigelt wants to merge 81 commits into
masterfrom
MODEXPW-635
Open

MODEXPW-635 - Expose template tokens in EDIFACT email export#737
markusweigelt wants to merge 81 commits into
masterfrom
MODEXPW-635

Conversation

@markusweigelt

@markusweigelt markusweigelt commented May 27, 2026

Copy link
Copy Markdown
Contributor

MODEXPW-635
- Expose template tokens in EDIFACT email export

Purpose

Make order, order-line, and vendor-organization data available as {{token}} placeholders in the email templates rendered for the EDIFACT email export. Before this change, SendToEmailTasklet invoked TemplateEngineClient.processTemplate with templateId + lang + outputFormat but no context payload, so templates couldn't reference any order data — only the static template body reached the recipient.

Approach

Ship the worker a structured context object that mod-template-engine substitutes against the configured template.

1. Context payload shape

  {
    "createdAt": "...",
    "organization": { name, primaryAddress: { addressLine1, city, zipCode, country } },
    "orders": [
      {
        "order": {
          poNumber, orderType,
          metadata: { createdByUser: { id, firstName, lastName, fullName } },
          shipTo: { id, address },
          billTo: { id, address }
        },
        "orderLines": [
          { "orderLine": { poLineNumber, titleOrPackage, publisher, publicationDate, edition, rush,
                           contributors: [ { contributor, contributorNameType: { id, name } } ],
                           details: { productIds: [ { productId, qualifier, productIdType: { id, name } } ] },
                           cost: { listUnitPrice, listUnitPriceElectronic,
                                   quantityPhysical, quantityElectronic,
                                   poLineEstimatedPrice, currency },
                           fundDistribution: [ { code } ],
                           vendorDetail: { instructions } } }
        ]
      }
    ]
  }

2. Token surface exposed to templates

  • createdAt
  • organization.*: name, primaryAddress.{addressLine1, city, zipCode, country}
  • order.*: poNumber, orderType, metadata.createdByUser.{id, firstName, lastName, fullName}, shipTo.{id, address}, billTo.{id, address}
  • orderLine.*: poLineNumber, titleOrPackage, publisher, publicationDate, edition, rush, contributors[].{contributor, contributorNameType.{id, name}}, details.productIds[].{productId, qualifier, productIdType.{id, name}}, cost.{listUnitPrice, listUnitPriceElectronic, quantityPhysical, quantityElectronic, poLineEstimatedPrice, currency}, fundDistribution[].code, vendorDetail.instructions

3. Resolutions performed by context mapper

OrderEmailContextMapper.buildContext(orders) is the single entry point. It walks each CompositePurchaseOrder, pairs each PoLine with its own parent order (so multi-order exports don't leak the first order's poNumber onto later lines), and resolves the following FOLIO lookups before handoff:

  • createdAt — generated at build time in ISO-8601 UTC.
  • order.metadata.createdByUser — resolved from metadata.createdByUserId via new UserService.getUserContext(...) into a UserContext {id, firstName, lastName, fullName}; fullName is "firstName lastName" trimmed, falling back to username.
  • order.shipTo / order.billTo — address UUID resolved to a TenantAddressContext {id, address} via ConfigurationService.getTenantAddress(...).
  • orderLine.contributors[].contributorNameType.name — UUID → type name (e.g. Personal name) via cached ContributorNameTypeService.getContributorNameTypeName(uuid).
  • orderLine.details.productIds[].productIdType.name — UUID → type name (e.g. ISBN) via cached IdentifierTypeService.getIdentifierTypeName(uuid).
  • orderLine.fundDistribution[] — exposes only the fund code, taken as-is from the PO line (fundId is not resolved; expenseClassId, distributionType, and value are not exposed).
  • organization — resolved from the order vendor via OrganizationsService; primary address picked with a strict isPrimary == true filter.
  • Null-safe throughout: missing leaf strings fall back to "", quantities to 0, lists to [], and unresolved sub-objects (Cost, VendorDetail, metadata, vendor, addresses) to empty contexts.

Additional information

Example plain-text template body of mod-template-engine exercising every token:

Created at: {{createdAt}}

Organization: {{organization.name}}
  {{organization.primaryAddress.addressLine1}}
  {{organization.primaryAddress.city}}, {{organization.primaryAddress.zipCode}}
  {{organization.primaryAddress.country}}

{{#orders}}
==================================================
PO Number:  {{order.poNumber}}
Order Type: {{order.orderType}}
Created by: {{order.metadata.createdByUser.fullName}} ({{order.metadata.createdByUser.firstName}} {{order.metadata.createdByUser.lastName}}, id={{order.metadata.createdByUser.id}})

Ship To: {{order.shipTo.address}}   (id={{order.shipTo.id}})
Bill To: {{order.billTo.address}}   (id={{order.billTo.id}})

Order Lines:
{{#orderLines}}
  --------------------------------------------------
  PO Line:        {{orderLine.poLineNumber}}
  Title/Package:  {{orderLine.titleOrPackage}}
  Publisher:      {{orderLine.publisher}}
  Publication:    {{orderLine.publicationDate}}
  Edition:        {{orderLine.edition}}
  Rush:           {{orderLine.rush}}

  Contributors:
  {{#orderLine.contributors}}
    - {{contributor}} [{{contributorNameType.name}} / {{contributorNameType.id}}]
  {{/orderLine.contributors}}

  Product IDs:
  {{#orderLine.details.productIds}}
    - {{productId}} {{qualifier}} [{{productIdType.name}} / {{productIdType.id}}]
  {{/orderLine.details.productIds}}

  Cost:
    List Unit Price:            {{orderLine.cost.listUnitPrice}}
    List Unit Price (Electr.):  {{orderLine.cost.listUnitPriceElectronic}}
    Quantity Physical:          {{orderLine.cost.quantityPhysical}}
    Quantity Electronic:        {{orderLine.cost.quantityElectronic}}
    PO Line Estimated Price:    {{orderLine.cost.poLineEstimatedPrice}}
    Currency:                   {{orderLine.cost.currency}}

  Fund Distribution:
  {{#orderLine.fundDistribution}}
    - {{code}}
  {{/orderLine.fundDistribution}}

  Vendor Instructions: {{orderLine.vendorDetail.instructions}}
{{/orderLines}}
{{/orders}}

Depends on

PR #732 - MODEXPW-625 - Enable Email Delivery for EDIFACT Export Jobs

mod-template-engine returns 400 when outputFormat does not match
the format registered in the template. The claims template requires
text/html.
Read outputFormat from template-engine response meta and forward it
to EmailEntity so the email is sent in the same format the template
was rendered in (e.g. text/html).
Wrap templateEngineClient and emailClient calls in EdifactException,
derive attachment content type from fileFormat, extract buildAttachment
and sendEmail helper methods, and add unit/integration tests for the
tasklet and its decider.
Remove template context from TemplateProcessingRequest for now since
required template variables are not yet known; mod-template-engine
treats a missing context as an empty object. Add ORDERING + EMAIL
test coverage.
Migrate EmailClient and TemplateEngineClient from @FeignClient to Spring 6
@HttpExchange. Update Spring Batch import paths to new package structure.
Fix SendToEmailTaskletTest to use JobOperatorTestUtils and correct mock types.
…iguration

Both clients use @HttpExchange and require explicit @bean registration via
HttpServiceProxyFactory — they were missing, causing a NoSuchBeanDefinitionException
when sendToEmailStep attempted to inject EmailClient.
Spring HttpServiceProxyFactory requires @RequestBody on POST parameters
to resolve them as the request body — unlike Feign which inferred this
from unannotated arguments.
…lization

RestClient uses Jackson 3 (tools.jackson) which cannot deserialize into
the abstract com.fasterxml.jackson.databind.JsonNode (Jackson 2). Return
String instead and parse with ediObjectMapper in the tasklet.
…sponse DTO

Jackson 3 (RestClient) cannot deserialize JSON into String or the abstract
JsonNode (Jackson 2). A concrete DTO is the correct and consistent approach,
matching how all other clients in this module handle responses.
Removed the erroneous CLAIMING integration type check; email delivery
is driven exclusively by transmissionMethod == EMAIL regardless of
integration type. Also added a comment in EdifactExportJobConfig
summarising the decider conditions for all three optional steps.
Wrap organization, contributor-name-type, and identifier-type lookups in
try/catch so a failed enrichment call no longer aborts the whole email
export, and add unless="#result == null" on those plus tenantAddress so
transient failures are not pinned in the cache.
Revert OrganizationsService and IdentifierTypeService to propagate
lookup failures so the shared EDIFACT path keeps failing loud instead of
NPEing, and degrade gracefully only in OrderEmailContextMapper via
resolveOrganization/resolveIdentifierTypeName. Add tests for the throwing
lookup paths.
Remove the order-email estimatedPrice cost token, which duplicated
poLineEstimatedPrice. Resolve ediEmail once in SendToEmailTasklet with a
clear "no email configuration" failure before use, so the template-id
guard stays reachable and a missing config no longer NPEs.
Add an Order Email context-payload reference under Additional
information: an annotated structure tree and an example JSON payload
for the EDIFACT_ORDERS_EXPORT email transmission.
The cost context exposed a quantity equal to quantityPhysical plus
quantityElectronic, which is not a real PO line field. Remove it from
the DTO, mapper, tests, and README, and clarify that fundDistribution
code is taken verbatim from the PO line without resolving fundId.
Drop the unused JsonNode import and ADDRESS constant, and route both
cacheable methods through a private fetchTenantAddress helper so the
@Cacheable getTenantAddress is no longer self-invoked via this.
Cover getUserContext: resolved name, username fallback when names are
blank or personal is absent, and empty context on null response or
client exception.
@markusweigelt

Copy link
Copy Markdown
Contributor Author

@markusweigelt please fix the merge conflicts first before we continue reviewing.

@SerhiiNosko I hope you're getting through the heat well. 🥵 Besides merging master, we also optimized the actual changes.

Comment thread src/main/java/org/folio/dew/batch/acquisitions/services/UserService.java Outdated
Comment thread src/main/java/org/folio/dew/client/ContributorNameTypeClient.java
Skip null source entries before mapping in the shared list helper to
avoid NPEs. Also drop the obsolete estimatedPrice field from the
documented order-line payload in README.
Replace the manual LogManager.getLogger() field with Lombok @log4j2 to
match the other acquisitions services in this branch.
Instant.now().toString() emits a variable number of fractional digits
depending on clock resolution, breaking the required millisecond format.
Truncate to millis and add a regression test guarding the pattern.
Let ContributorNameTypeService return the raw cached result and handle
lookup failures in OrderEmailContextMapper, so a failed type resolution
degrades to an empty name without breaking the email context build.
@markusweigelt

Copy link
Copy Markdown
Contributor Author

@SerhiiNosko Thx for the review of this hugh PR and your approval! Can I merge this PR? There was still a question regarding caching. I would merge the Spring PR in the same step.

@SerhiiNosko

Copy link
Copy Markdown
Contributor

@SerhiiNosko Thx for the review of this hugh PR and your approval! Can I merge this PR? There was still a question regarding caching. I would merge the Spring PR in the same step.

by the way, locally did you check functionality with eureka-cli?

@markusweigelt

markusweigelt commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

by the way, locally did you check functionality with eureka-cli?

Yes, we have a development system based on the Eureka CLI, and we have already tested the placeholder replacement there. We haven’t checked the permissions in the PR folio-org/mod-data-export-spring#380 yet - I’ll take care of that right away. But this should only be a formality.

@SerhiiNosko

Copy link
Copy Markdown
Contributor

by the way, locally did you check functionality with eureka-cli?

Yes, we have a development system based on the Eureka CLI, and we have already tested the placeholder replacement there. We haven’t checked the permissions in the PR folio-org/mod-data-export-spring#380 yet - I’ll take care of that right away. But this should only be a formality.

lets merge then, also for future you can create story to cover this functionality wit karate tests, karate tests are executed every night and do real testing of services without any mocks and always help us to find new bugs. In this case we will make sure that this newly implemented functionality always work as expected.
Here is folder where our existing karate tests live: https://github.com/folio-org/folio-integration-tests/tree/master/acquisitions/src/main/resources/thunderjet/mod-data-export-spring

@sonarqubecloud

sonarqubecloud Bot commented Jul 2, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants