Repository: Sundhedsdatastyrelsen/ehdsi
Pull request #198: Dispensation drug information
Author: login: hansbugge
Created: 2025-04-07T09:50:14Z | Merged: 2025-04-07T12:38:44Z



Diff:
diff --git a/country-a-service/epps-service/src/main/java/dk/sundhedsdatastyrelsen/ncpeh/service/PrescriptionService.java b/country-a-service/epps-service/src/main/java/dk/sundhedsdatastyrelsen/ncpeh/service/PrescriptionService.java
index e79a40cf..5e9f3f8f 100644
--- a/country-a-service/epps-service/src/main/java/dk/sundhedsdatastyrelsen/ncpeh/service/PrescriptionService.java
+++ b/country-a-service/epps-service/src/main/java/dk/sundhedsdatastyrelsen/ncpeh/service/PrescriptionService.java
@@ -193,17 +193,16 @@ private Stream<EPrescriptionL3Input> assembleEPrescriptionInput(String patientId
 
     public void submitDispensation(@NonNull String patientId, @NonNull Document dispensationCda, Identity caller) {
         StartEffectuationResponseType response;
-        var dispensationMapper = new DispensationMapper();
         String eDispensationCdaId;
         try {
-            eDispensationCdaId = dispensationMapper.cdaId(dispensationCda);
+            eDispensationCdaId = DispensationMapper.cdaId(dispensationCda);
         } catch (MapperException e) {
             throw new DataRequirementException("Invalid CDA ID value", e);
         }
         try {
             log.info("Start FMK effectuation");
             response = fmkClient.startEffectuation(
-                dispensationMapper.startEffectuationRequest(patientId, dispensationCda),
+                DispensationMapper.startEffectuationRequest(patientId, dispensationCda),
                 caller);
         } catch (JAXBException e) {
             throw new CountryAException(HttpStatus.INTERNAL_SERVER_ERROR, "StartEffectuation failed", e);
@@ -215,7 +214,7 @@ public void submitDispensation(@NonNull String patientId, @NonNull Document disp
         try {
             log.info("Create FMK pharmacy effectuation");
             effectuationResponse = fmkClient.createPharmacyEffectuation(
-                dispensationMapper.createPharmacyEffectuationRequest(
+                DispensationMapper.createPharmacyEffectuationRequest(
                     patientId,
                     dispensationCda,
                     response),
@@ -244,10 +243,9 @@ public void submitDispensation(@NonNull String patientId, @NonNull Document disp
     }
 
     public void undoDispensation(@NonNull String patientId, Document cdaToDiscard, Identity caller) {
-        var dispensationMapper = new DispensationMapper();
         String eDispensationCdaId;
         try {
-            eDispensationCdaId = dispensationMapper.cdaId(cdaToDiscard);
+            eDispensationCdaId = DispensationMapper.cdaId(cdaToDiscard);
         } catch (MapperException e) {
             throw new DataRequirementException("Invalid CDA ID value", e);
         }
@@ -259,7 +257,7 @@ public void undoDispensation(@NonNull String patientId, Document cdaToDiscard, I
 
         UndoEffectuationRequestType undoEffectuationRequest;
         try {
-            undoEffectuationRequest = dispensationMapper.createUndoEffectuationRequest(
+            undoEffectuationRequest = DispensationMapper.createUndoEffectuationRequest(
                 patientId,
                 cdaToDiscard,
                 undoInfo.orderId(),
diff --git a/country-a-service/epps-service/src/main/java/dk/sundhedsdatastyrelsen/ncpeh/service/mapping/DispensationMapper.java b/country-a-service/epps-service/src/main/java/dk/sundhedsdatastyrelsen/ncpeh/service/mapping/DispensationMapper.java
index 12af71a0..54071251 100644
--- a/country-a-service/epps-service/src/main/java/dk/sundhedsdatastyrelsen/ncpeh/service/mapping/DispensationMapper.java
+++ b/country-a-service/epps-service/src/main/java/dk/sundhedsdatastyrelsen/ncpeh/service/mapping/DispensationMapper.java
@@ -1,9 +1,12 @@
 package dk.sundhedsdatastyrelsen.ncpeh.service.mapping;
 
+import dk.dkma.medicinecard.xml_schema._2015._06._01.DrugType;
 import dk.dkma.medicinecard.xml_schema._2015._06._01.ModificatorPersonType;
 import dk.dkma.medicinecard.xml_schema._2015._06._01.ObjectFactory;
+import dk.dkma.medicinecard.xml_schema._2015._06._01.OrganisationIdentifierPredefinedSourceType;
 import dk.dkma.medicinecard.xml_schema._2015._06._01.OrganisationIdentifierType;
 import dk.dkma.medicinecard.xml_schema._2015._06._01.OrganisationType;
+import dk.dkma.medicinecard.xml_schema._2015._06._01.PackageNumberType;
 import dk.dkma.medicinecard.xml_schema._2015._06._01.e2.CreatePharmacyEffectuationOnPrescriptionType;
 import dk.dkma.medicinecard.xml_schema._2015._06._01.e2.CreatePharmacyEffectuationRequestType;
 import dk.dkma.medicinecard.xml_schema._2015._06._01.e2.CreatePharmacyEffectuationType;
@@ -34,11 +37,13 @@
 
 /**
  * Class for mapping eDispensation CDAs to FMK requests.
- * Not threadsafe due to xpath, so construct a new instance for each thread.
  */
 public class DispensationMapper {
     private static final Logger log = org.slf4j.LoggerFactory.getLogger(DispensationMapper.class);
 
+    private DispensationMapper() {
+    }
+
     // ART DECOR template for eDispensation CDA:
     // https://art-decor.ehdsi.eu/publication/epSOS/epsos-html-20240126T203601/tmp-2.16.840.1.113883.3.1937.777.11.10.111-2020-10-07T094007.html
     // Example FMK request:
@@ -172,25 +177,34 @@ private XPaths() {
             "/hl7:ClinicalDocument/hl7:effectiveTime/@value";
         static final String packageQuantity =
             "/hl7:ClinicalDocument/hl7:component/hl7:structuredBody/hl7:component/hl7:section/hl7:entry/hl7:supply/hl7:quantity";
-        static final String manufacturedMaterialCode =
+        static final String containerPackagedProductCode =
             "/hl7:ClinicalDocument/hl7:component/hl7:structuredBody/hl7:component/hl7:section/hl7:entry/hl7:supply/hl7:product/hl7:manufacturedProduct/hl7:manufacturedMaterial/pharm:asContent/pharm:containerPackagedProduct/pharm:code";
+        static final String manufacturedMaterialName =
+            "/hl7:ClinicalDocument/hl7:component/hl7:structuredBody/hl7:component/hl7:section/hl7:entry/hl7:supply/hl7:product/hl7:manufacturedProduct/hl7:manufacturedMaterial/hl7:name";
+        static final String manufacturedMaterialCode =
+            "/hl7:ClinicalDocument/hl7:component/hl7:structuredBody/hl7:component/hl7:section/hl7:entry/hl7:supply/hl7:product/hl7:manufacturedProduct/hl7:manufacturedMaterial/hl7:code";
         static final String substitution =
             "/hl7:ClinicalDocument/hl7:component/hl7:structuredBody/hl7:component/hl7:section/hl7:entry/hl7:supply/hl7:entryRelationship[@typeCode = 'COMP']/hl7:act/hl7:code[@code = 'SUBST']";
+        static final String atcCode =
+            "/hl7:ClinicalDocument/hl7:component/hl7:structuredBody/hl7:component/hl7:section/hl7:entry/hl7:supply/hl7:product/hl7:manufacturedProduct/hl7:manufacturedMaterial/pharm:asSpecializedKind[@classCode='GRIC']/pharm:generalizedMaterialKind/pharm:code";
         static final String cdaId =
             "/hl7:ClinicalDocument/hl7:id";
     }
 
-    private final XPath xpath;
-
-    {
-        xpath = XPathFactory.newInstance().newXPath();
+    /**
+     * Construct a new XPath object.
+     * XPath is not thread-safe, so we construct a new instance each time instead of reusing.
+     */
+    private static XPath xpath() {
+        var xpath = XPathFactory.newInstance().newXPath();
         var nsCtx = new SimpleNamespaceContext();
         nsCtx.bindNamespaceUri("hl7", "urn:hl7-org:v3");
         nsCtx.bindNamespaceUri("pharm", "urn:hl7-org:pharm");
         xpath.setNamespaceContext(nsCtx);
+        return xpath;
     }
 
-    private List<String> evalMany(Document cda, String xpathExpression) throws XPathExpressionException {
+    private static List<String> evalMany(XPath xpath, Document cda, String xpathExpression) throws XPathExpressionException {
         var nodeList = (DTMNodeList) xpath.evaluate(xpathExpression, cda, XPathConstants.NODESET);
         var l = nodeList.getLength();
         var result = new ArrayList<String>();
@@ -200,7 +214,8 @@ private List<String> evalMany(Document cda, String xpathExpression) throws XPath
         return Collections.unmodifiableList(result);
     }
 
-    private List<String> evalMany(
+    private static List<String> evalMany(
+        XPath xpath,
         Document cda,
         String xpathExpression,
         String attrName
@@ -217,17 +232,10 @@ private List<String> evalMany(
         return Collections.unmodifiableList(result);
     }
 
-    private String eval(Document cda, String xpathExpression) throws XPathExpressionException {
-        return (String) xpath.evaluate(xpathExpression, cda, XPathConstants.STRING);
-    }
-
-    private Node evalNode(Document cda, String xpathExpression) throws XPathExpressionException {
-        return (Node) xpath.evaluate(xpathExpression, cda, XPathConstants.NODE);
-    }
-
-    ModificatorPersonType authorPerson(Document cda) throws XPathExpressionException {
-        var familyNames = evalMany(cda, XPaths.authorFamilyName);
-        var givenNames = evalMany(cda, XPaths.authorGivenName);
+    static ModificatorPersonType authorPerson(Document cda) throws XPathExpressionException {
+        var xpath = xpath();
+        var familyNames = evalMany(xpath, cda, XPaths.authorFamilyName);
+        var givenNames = evalMany(xpath, cda, XPaths.authorGivenName);
         var allButLastName = Stream.concat(
                 givenNames.stream(),
                 familyNames.subList(0, familyNames.size() - 1).stream())
@@ -240,9 +248,10 @@ ModificatorPersonType authorPerson(Document cda) throws XPathExpressionException
             .build();
     }
 
-    String authorRole(Document cda) throws XPathExpressionException {
-        var functionCode = eval(cda, XPaths.authorFunctionCode);
-        var functionCodeSystem = eval(cda, XPaths.authorFunctionCodeSystem);
+    static String authorRole(Document cda) throws XPathExpressionException {
+        var xpath = xpath();
+        var functionCode = (String) xpath.evaluate(XPaths.authorFunctionCode, cda, XPathConstants.STRING);
+        var functionCodeSystem = (String) xpath.evaluate(XPaths.authorFunctionCodeSystem, cda, XPathConstants.STRING);
         if ("2262".equals(functionCode) && "2.16.840.1.113883.2.9.6.2.7".equals(functionCodeSystem)) {
             //This is the "official" translation of "Pharmacists" from ISCO.
             // It has implications in FMK, who validates these
@@ -256,51 +265,31 @@ String authorRole(Document cda) throws XPathExpressionException {
         throw new IllegalArgumentException("Unexpected function code: " + functionCode);
     }
 
-    private boolean notBlank(String s) {
+    private static boolean notBlank(String s) {
         return s != null && !s.isBlank();
     }
 
-    /**
-     * TODO FMK Improvement
-     * FMK is supposed to support "Udenlandsk" OrganisationIdenfier, according to the documentation
-     * When called, it responds with an error that it is not supported yet
-     * <p>
-     * Two options going forward:
-     * - FMK start supporting Udenlandsk, and we reimplement forwarding the identifier from Country-B
-     * - We agree with FMK to "proxy" all requests through another Organisation, like we do in the code right now.
-     * <p>
-     * (2024/09/10)
-     */
-    private OrganisationIdentifierType identifier(Node id) {
-
-//        var attrs = id.getAttributes();
-//        var root = attrs.getNamedItem("root");
-//        var ext = attrs.getNamedItem("extension");
-//        if (root == null) {
-//            throw new IllegalArgumentException("Id nodes without root attributes are unsupported");
-//        }
-//        return OrganisationIdentifierType.builder()
-//            .withSource(OrganisationIdentifierPredefinedSourceType.UDENLANDSK.value())
-//            .withValue(ext == null
-//                ? root.getTextContent()
-//                : String.format("%s.%s", root.getTextContent(), ext.getTextContent()))
-//            .build();
-        return TestIdentities.skanderborgApotek;
+    private static OrganisationIdentifierType placeholderPharmacyId() {
+        return OrganisationIdentifierType.builder()
+            .withSource(OrganisationIdentifierPredefinedSourceType.EAN_LOKATIONSNUMMER.value())
+            .withValue("5790001392277") // NSI, Udenlandsk Apotek via epSOS ( SOR-nummer: 397941000016003 )
+            .build();
     }
 
-    OrganisationType authorOrganization(Document cda) throws XPathExpressionException {
-        var addressLines = new ArrayList<>(evalMany(cda, XPaths.authorOrgAddressLine));
-        var postalCode = eval(cda, XPaths.authorOrgPostalCode);
-        var city = eval(cda, XPaths.authorOrgCity);
-        var state = eval(cda, XPaths.authorOrgState);
-        var country = eval(cda, XPaths.authorOrgCountry);
+    static OrganisationType authorOrganization(Document cda) throws XPathExpressionException {
+        var xpath = xpath();
+        var addressLines = new ArrayList<>(evalMany(xpath, cda, XPaths.authorOrgAddressLine));
+        var postalCode = (String) xpath.evaluate(XPaths.authorOrgPostalCode, cda, XPathConstants.STRING);
+        var city = (String) xpath.evaluate(XPaths.authorOrgCity, cda, XPathConstants.STRING);
+        var state = (String) xpath.evaluate(XPaths.authorOrgState, cda, XPathConstants.STRING);
+        var country = (String) xpath.evaluate(XPaths.authorOrgCountry, cda, XPathConstants.STRING);
         if (notBlank(postalCode)) addressLines.add(postalCode);
         if (notBlank(city)) addressLines.add(city);
         if (notBlank(state)) addressLines.add(state);
         if (notBlank(country)) addressLines.add(country);
 
         String email = null, telephone = null;
-        var telecoms = evalMany(cda, XPaths.authorOrgTelecom, "value");
+        var telecoms = evalMany(xpath, cda, XPaths.authorOrgTelecom, "value");
         for (var t : telecoms) {
             if (t == null) continue;
             if (t.startsWith("tel:")) telephone = t.substring(4);
@@ -308,9 +297,9 @@ OrganisationType authorOrganization(Document cda) throws XPathExpressionExceptio
         }
 
         var b = OrganisationType.builder()
-            .withIdentifier(identifier(evalNode(cda, XPaths.authorOrgId)))
-            .withName(eval(cda, XPaths.authorOrgName))
-            .withType("Apotek") // TODO: How can we determine this?
+            .withIdentifier(placeholderPharmacyId())
+            .withName((String) xpath.evaluate(XPaths.authorOrgName, cda, XPathConstants.STRING))
+            .withType("Apotek")
             .addAddressLine(addressLines);
 
         if (email != null) b.withEmailAddress(email);
@@ -319,12 +308,13 @@ OrganisationType authorOrganization(Document cda) throws XPathExpressionExceptio
         return b.build();
     }
 
-    OrganisationType orderedAtPharmacy(Document cda) throws XPathExpressionException {
+    static OrganisationType orderedAtPharmacy(Document cda) throws XPathExpressionException {
         return authorOrganization(cda);
     }
 
-    public long prescriptionId(Document cda) throws XPathExpressionException, MapperException {
-        var id = evalNode(cda, XPaths.inFulfillmentOfId);
+    public static long prescriptionId(Document cda) throws XPathExpressionException, MapperException {
+        var xpath = xpath();
+        var id = (Node) xpath.evaluate(XPaths.inFulfillmentOfId, cda, XPathConstants.NODE);
         var root = xpath.evaluate("@root", id);
         if (!Oid.DK_FMK_PRESCRIPTION.value.equals(root)) {
             throw new MapperException("Unknown prescription id type: " + root);
@@ -337,14 +327,14 @@ public long prescriptionId(Document cda) throws XPathExpressionException, Mapper
         }
     }
 
-    StartEffectuationRequestType.Prescription startEffectuationRequestPrescription(Document cda) throws XPathExpressionException,
+    static StartEffectuationRequestType.Prescription startEffectuationRequestPrescription(Document cda) throws XPathExpressionException,
         MapperException {
         return StartEffectuationRequestType.Prescription.builder()
             .withIdentifier(prescriptionId(cda))
             .build();
     }
 
-    CreatePharmacyEffectuationOnPrescriptionType prescription(
+    static CreatePharmacyEffectuationOnPrescriptionType prescription(
         Document cda,
         @NonNull StartEffectuationResponseType startEffectuationResponse
     ) throws XPathExpressionException, MapperException {
@@ -359,8 +349,9 @@ CreatePharmacyEffectuationOnPrescriptionType prescription(
             .build();
     }
 
-    Integer packageQuantity(Document cda) throws XPathExpressionException, MapperException {
-        var node = evalNode(cda, XPaths.packageQuantity);
+    static Integer packageQuantity(Document cda) throws XPathExpressionException, MapperException {
+        var xpath = xpath();
+        var node = (Node) xpath.evaluate(XPaths.packageQuantity, cda, XPathConstants.NODE);
         var unit = xpath.evaluate("@unit", node);
         if (!"1".equals(unit)) {
             throw new MapperException("Unsupported quantity unit: " + unit);
@@ -368,62 +359,87 @@ Integer packageQuantity(Document cda) throws XPathExpressionException, MapperExc
         return Integer.parseInt(xpath.evaluate("@value", node));
     }
 
-    CreatePharmacyEffectuationType effectuation(
+    static CreatePharmacyEffectuationType effectuation(
         Document cda,
         StartEffectuationResponseType startEffectuationResponse
     ) throws XPathExpressionException, MapperException {
-        var effectiveTime = eval(cda, XPaths.effectiveTime);
+        var xpath = xpath();
+        var effectiveTime = (String) xpath.evaluate(XPaths.effectiveTime, cda, XPathConstants.STRING);
         var packageRestriction = startEffectuationResponse.getPrescription().getFirst().getPackageRestriction();
 
-        // how do we handle substitutions? for now, disallow
-        if (evalNode(cda, XPaths.substitution) != null) {
-            log.warn("Substitutions are not supported. No conversion was made.");
-        }
-        // verify that package number matches prescription
-        var packageNumber = packageNumber(cda);
-        if (packageNumber != null && !packageRestriction.getPackageNumber().getValue().equals(packageNumber)) {
-            throw new MapperException(String.format(
-                "Package number in dispensation (%s) does not match prescription (%s).",
-                packageNumber(cda),
-                packageRestriction.getPackageNumber().getValue()));
-        }
+        var drug = drug(cda);
 
         return CreatePharmacyEffectuationType.builder()
             .withDateTime(Utils.parseEpsosTime(effectiveTime))
             .withPackageDispensed()
             .withPackageQuantity(packageQuantity(cda))
-            .withPackageNumber(packageRestriction.getPackageNumber())
-            .withPackageSize(packageRestriction.getPackageSize())
+            .withPackageNumber(packageNumber(cda))
+            .withPackageSize(packageRestriction.getPackageSize()) // TODO #200 get package size from CDA
+            .withSubstitutedDrug(drug)
             .end()
-            .withDeliverySite(TestIdentities.deliverySiteRyApotek)
+            .withDeliverySite(TestIdentities.deliverySiteRyApotek) // TODO #190
+            .build();
+    }
+
+    static DrugType drug(Document cda) {
+        return DrugType.builder()
+            .withDetailedDrugText(detailedDrugText(cda))
+            // TODO #201: Strength, ATC code, substances
             .build();
     }
 
-    String packageNumber(Document cda) {
+    static String detailedDrugText(Document cda) {
+        var xpath = xpath();
+        String drugName;
         try {
-            var node = evalNode(cda, XPaths.manufacturedMaterialCode);
-            if (node == null) {
-                return null; //The field is 0..1, and we cannot require it to be there. If it is missing, we return null.
-            }
-            var codeSystem = xpath.evaluate("@codeSystem", node);
-            if (!Oid.DK_VARENUMRE.value.equals(codeSystem)) {
-                // throw?
-                log.warn(
-                    "Expected LMS02 ({}) code system, for {}. Got: {}",
-                    Oid.DK_VARENUMRE.value,
-                    XPaths.manufacturedMaterialCode,
-                    codeSystem
-                );
-            }
-            return xpath.evaluate("@code", node);
+            drugName = (String) xpath.evaluate(XPaths.manufacturedMaterialName, cda, XPathConstants.STRING);
+        } catch (XPathExpressionException e) {
+            throw new DataRequirementException(String.format("Could not find data at path: %s", XPaths.manufacturedMaterialName));
+        }
+        String drugId;
+        try {
+            var node = (Node) xpath.evaluate(XPaths.manufacturedMaterialCode, cda, XPathConstants.NODE);
+            var system = xpath.evaluate("@codeSystem", node);
+            var id = xpath.evaluate("@code", node);
+            drugId = String.format("%s^^^%s", system, id);
         } catch (XPathExpressionException e) {
-            throw new DataRequirementException(String.format("Could not find find data at path: %s", XPaths.manufacturedMaterialCode));
+            drugId = "unknown";
         }
+        return String.format("%s - id: %s", drugName, drugId);
+    }
+
+    static PackageNumberType packageNumber(Document cda) {
+        //        try {
+//            var xpath = xpath();
+//            var node = (Node) xpath.evaluate(XPaths.containerPackagedProductCode, cda, XPathConstants.NODE);
+//            if (node == null) {
+//                return null; //The field is 0..1, and we cannot require it to be there. If it is missing, we return null.
+//            }
+//            var codeSystem = xpath.evaluate("@codeSystem", node);
+//            var code = xpath.evaluate("@code", node);
+//
+//            var packageNumber = codeSystem + "^^^" + code;
+//            return PackageNumberType.builder()
+//                .withSource("Local")
+//                .withValue(packageNumber)
+//                .build();
+//        } catch (XPathExpressionException e) {
+//            log.warn("Could not find find data at path: {}", XPaths.manufacturedMaterialCode);
+//            return null;
+//        }
+
+        // TODO #199: If the container packaged product code system is Oid.DK_VARENUMRE then we can provide it to FMK.
+
+        return PackageNumberType.builder()
+            .withSource("Local")
+            .withValue("720000") // "Ukendt" https://wiki.fmk-teknik.dk/doku.php?id=fmk:generel:varenumre
+            .build();
     }
 
-    public String cdaId(Document cda) throws MapperException {
+    public static String cdaId(Document cda) throws MapperException {
         try {
-            var node = evalNode(cda, XPaths.cdaId);
+            var xpath = xpath();
+            var node = (Node) xpath.evaluate(XPaths.cdaId, cda, XPathConstants.NODE);
             var root = xpath.evaluate("@root", node);
             var ext = xpath.evaluate("@extension", node);
             return ext == null
@@ -434,7 +450,7 @@ public String cdaId(Document cda) throws MapperException {
         }
     }
 
-    public StartEffectuationRequestType startEffectuationRequest(
+    public static StartEffectuationRequestType startEffectuationRequest(
         @NonNull String patientId,
         @NonNull Document cda
     ) throws MapperException {
@@ -455,7 +471,7 @@ public StartEffectuationRequestType startEffectuationRequest(
         }
     }
 
-    public CreatePharmacyEffectuationRequestType createPharmacyEffectuationRequest(
+    public static CreatePharmacyEffectuationRequestType createPharmacyEffectuationRequest(
         @NonNull String patientId,
         @NonNull Document cda,
         @NonNull StartEffectuationResponseType startEffectuationResponse
@@ -476,7 +492,7 @@ public CreatePharmacyEffectuationRequestType createPharmacyEffectuationRequest(
         }
     }
 
-    public UndoEffectuationRequestType createUndoEffectuationRequest(
+    public static UndoEffectuationRequestType createUndoEffectuationRequest(
         @NonNull String patientId,
         @NonNull Document cda,
         long orderId,
diff --git a/country-a-service/epps-service/src/test/java/dk/sundhedsdatastyrelsen/ncpeh/service/mapping/DispensationMapperTest.java b/country-a-service/epps-service/src/test/java/dk/sundhedsdatastyrelsen/ncpeh/service/mapping/DispensationMapperTest.java
index 57de3384..05ffa171 100644
--- a/country-a-service/epps-service/src/test/java/dk/sundhedsdatastyrelsen/ncpeh/service/mapping/DispensationMapperTest.java
+++ b/country-a-service/epps-service/src/test/java/dk/sundhedsdatastyrelsen/ncpeh/service/mapping/DispensationMapperTest.java
@@ -33,7 +33,7 @@ static Stream<Arguments> testDispensationCdas() {
         //Input format:
         // - Filename
         // - String of expected error message from mapping the CDA (null means no error is expected)
-        String missingPharmCodeErrorMessage = String.format("Could not find find data at path: %s", DispensationMapper.XPaths.manufacturedMaterialCode);
+        String missingPharmCodeErrorMessage = String.format("Could not find find data at path: %s", DispensationMapper.XPaths.containerPackagedProductCode);
         return Stream.of(
             Arguments.of("dispensation2.xml", null), //Our own constructed test dispensation
             Arguments.of("CzRequest1.xml", null), // One of the requests the CZ team sent us during the Fall 2024 test
@@ -46,9 +46,8 @@ static Stream<Arguments> testDispensationCdas() {
     }
 
     StartEffectuationResponseType testStartEffectuationResponse(Document cda) {
-        String packageNumber = new DispensationMapper().packageNumber(cda);
         var packageRestriction = PackageRestrictionType.builder()
-            .withPackageNumber().withSource("Medicinpriser").withValue(packageNumber).withDate("20240725").end()
+            .withPackageNumber(DispensationMapper.packageNumber(cda))
             .withPackageQuantity(2)
             .build();
         return StartEffectuationResponseType.builder()
@@ -62,9 +61,8 @@ StartEffectuationResponseType testStartEffectuationResponse(Document cda) {
     @ParameterizedTest
     @MethodSource("testDispensationCdas")
     void startEffectuationRequestTest(String xmlFileName, String mappingErrorMessageExpected) throws MapperException {
-        var sut = new DispensationMapper();
         var cda = testDispensationCda(xmlFileName);
-        var req = sut.startEffectuationRequest("1111111118^^^&1.2.208.176.1.2&ISO", cda);
+        var req = DispensationMapper.startEffectuationRequest("1111111118^^^&1.2.208.176.1.2&ISO", cda);
 
         assertThat(req.getPrescription().size(), is(equalTo(1)));
         Assertions.assertTrue(req.getPrescription().getFirst().getIdentifier() > 0L);
@@ -74,9 +72,8 @@ void startEffectuationRequestTest(String xmlFileName, String mappingErrorMessage
     @Test
     void authorPersonTest() throws XPathExpressionException {
         var xmlFileName = "CzRequest1.xml";
-        var sut = new DispensationMapper();
         var cda = testDispensationCda(xmlFileName);
-        var person = sut.authorPerson(cda);
+        var person = DispensationMapper.authorPerson(cda);
 
         Assertions.assertNotNull(person.getPersonIdentifier());
         Assertions.assertEquals("CPR", person.getPersonIdentifier().getSource());
@@ -95,9 +92,8 @@ void authorPersonTest() throws XPathExpressionException {
     void authorRoleTest() throws XPathExpressionException {
         var xmlFileName = "CzRequest1.xml";
 
-        var sut = new DispensationMapper();
         var cda = testDispensationCda(xmlFileName);
-        var role = sut.authorRole(cda);
+        var role = DispensationMapper.authorRole(cda);
 
         Assertions.assertFalse(role.isBlank());
         Assertions.assertEquals("Apoteker", role);
@@ -107,9 +103,8 @@ void authorRoleTest() throws XPathExpressionException {
     void authorOrganizationTest() throws XPathExpressionException {
         var xmlFileName = "CzRequest1.xml";
 
-        var sut = new DispensationMapper();
         var cda = testDispensationCda(xmlFileName);
-        var org = sut.authorOrganization(cda);
+        var org = DispensationMapper.authorOrganization(cda);
 
         Assertions.assertFalse(org.getIdentifier().getSource().isBlank());
         Assertions.assertFalse(org.getIdentifier().getValue().isBlank());
@@ -118,7 +113,7 @@ void authorOrganizationTest() throws XPathExpressionException {
 
         // Assertions
         Assertions.assertEquals("EAN-Lokationsnummer", org.getIdentifier().getSource(), "Source does not match");
-        Assertions.assertEquals("5790000170609", org.getIdentifier().getValue(), "Value does not match");
+        Assertions.assertEquals("5790001392277", org.getIdentifier().getValue(), "Value does not match");
         Assertions.assertEquals("Apotek", org.getType(), "Type does not match");
         Assertions.assertEquals("NCP-B-CZ Portal", org.getName(), "Name does not match");
         Assertions.assertEquals("invalid@email.test", org.getEmailAddress(), "Email does not match");
@@ -132,9 +127,8 @@ void authorOrganizationTest() throws XPathExpressionException {
     void startEffectuationRequestPrescriptionTest() throws XPathExpressionException, MapperException {
         var xmlFileName = "CzRequest1.xml";
 
-        var sut = new DispensationMapper();
         var cda = testDispensationCda(xmlFileName);
-        var p = sut.startEffectuationRequestPrescription(cda);
+        var p = DispensationMapper.startEffectuationRequestPrescription(cda);
 
         Assertions.assertEquals(469854419288102L, p.getIdentifier());
     }
@@ -143,12 +137,11 @@ void startEffectuationRequestPrescriptionTest() throws XPathExpressionException,
     void effectuationTest() throws Exception {
         var xmlFileName = "CzRequest1.xml";
 
-        var sut = new DispensationMapper();
         var cda = testDispensationCda(xmlFileName);
 
         var startEffectuationResponse = testStartEffectuationResponse(cda);
 
-        var e = sut.effectuation(cda, startEffectuationResponse);
+        var e = DispensationMapper.effectuation(cda, startEffectuationResponse);
 
         Assertions.assertTrue(e.getDateTime().isValid());
 
@@ -163,9 +156,8 @@ void effectuationTest() throws Exception {
         Assertions.assertTrue(packageDispensed.getPackageQuantity() > 0);
         Assertions.assertEquals(1, packageDispensed.getPackageQuantity());
 
-        Assertions.assertEquals("56232", packageDispensed.getPackageNumber().getValue());
-        Assertions.assertEquals("20240725", packageDispensed.getPackageNumber().getDate());
-        Assertions.assertEquals("Medicinpriser", packageDispensed.getPackageNumber().getSource());
+        Assertions.assertEquals("720000", packageDispensed.getPackageNumber().getValue());
+        Assertions.assertEquals("Local", packageDispensed.getPackageNumber().getSource());
 
     }
 
@@ -174,10 +166,9 @@ void packageQuantityTest() throws Exception {
 
         var xmlFileName = "CzRequest1.xml";
 
-        var sut = new DispensationMapper();
         var cda = testDispensationCda(xmlFileName);
 
-        var q = sut.packageQuantity(cda);
+        var q = DispensationMapper.packageQuantity(cda);
 
         Assertions.assertEquals(1, q);
     }
@@ -185,7 +176,6 @@ void packageQuantityTest() throws Exception {
     @ParameterizedTest
     @MethodSource("testDispensationCdas")
     void createPharmacyEffectuationRequestTest(String xmlFileName, String mappingErrorMessageExpected) throws MapperException, XPathExpressionException {
-        var sut = new DispensationMapper();
         var cda = testDispensationCda(xmlFileName);
         if (mappingErrorMessageExpected != null) {
             var exception = Assertions.assertThrows(DataRequirementException.class, () -> testStartEffectuationResponse(cda));
@@ -195,7 +185,7 @@ void createPharmacyEffectuationRequestTest(String xmlFileName, String mappingErr
         var startEffectuationResponse = testStartEffectuationResponse(cda);
 
 
-        var result = sut.createPharmacyEffectuationRequest(
+        var result = DispensationMapper.createPharmacyEffectuationRequest(
             "1111111118^^^&1.2.208.176.1.2&ISO",
             cda,
             startEffectuationResponse
diff --git a/country-a-service/integration-tests/src/test/java/dk/sundhedsdatastyrelsen/ncpeh/FmkIT.java b/country-a-service/integration-tests/src/test/java/dk/sundhedsdatastyrelsen/ncpeh/FmkIT.java
index 2030215a..5f0d9dcf 100644
--- a/country-a-service/integration-tests/src/test/java/dk/sundhedsdatastyrelsen/ncpeh/FmkIT.java
+++ b/country-a-service/integration-tests/src/test/java/dk/sundhedsdatastyrelsen/ncpeh/FmkIT.java
@@ -140,5 +140,11 @@ public void submitDispensationTest() throws Exception {
 
         // shouldn't throw:
         prescriptionService.undoDispensation(patientId(cpr), eDispensation, TestIdentities.apotekerChrisChristoffersen);
+
+        // we perform the dispensation again to clean up after ourselves:
+        prescriptionService.submitDispensation(
+            patientId(cpr),
+            eDispensation,
+            TestIdentities.apotekerChrisChristoffersen);
     }
 }


Reviews:
user: login: jakobvase
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T12:01:48Z
user: login: CBrams
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-07T12:03:26Z
user: login: jakobvase
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T12:09:43Z
user: login: jakobvase
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T12:10:11Z
user: login: jakobvase
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-07T12:10:27Z
user: login: hansbugge
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T12:15:52Z
user: login: hansbugge
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T12:19:20Z
user: login: hansbugge
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T12:21:06Z
user: login: hansbugge
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T12:33:38Z

Review comments:
diff_hunk: @@ -140,5 +140,11 @@ public void submitDispensationTest() throws Exception {
 
         // shouldn't throw:
         prescriptionService.undoDispensation(patientId(cpr), eDispensation, TestIdentities.apotekerChrisChristoffersen);
+
+        // we perform the dispensation again to clean up after ourselves:
path: country-a-service/integration-tests/src/test/java/dk/sundhedsdatastyrelsen/ncpeh/FmkIT.java
user: login: CBrams
body: ![text](https://github.com/cheesits456/ReactionPics/blob/master/images/again.jpeg?raw=true)
created_at: 2025-04-07T11:58:14Z
author_association: COLLABORATOR
diff_hunk: @@ -140,5 +140,11 @@ public void submitDispensationTest() throws Exception {
 
         // shouldn't throw:
         prescriptionService.undoDispensation(patientId(cpr), eDispensation, TestIdentities.apotekerChrisChristoffersen);
+
+        // we perform the dispensation again to clean up after ourselves:
path: country-a-service/integration-tests/src/test/java/dk/sundhedsdatastyrelsen/ncpeh/FmkIT.java
user: login: CBrams
body: Also, great change.
created_at: 2025-04-07T11:58:58Z
author_association: COLLABORATOR
diff_hunk: @@ -359,71 +349,96 @@ CreatePharmacyEffectuationOnPrescriptionType prescription(
             .build();
     }
 
-    Integer packageQuantity(Document cda) throws XPathExpressionException, MapperException {
-        var node = evalNode(cda, XPaths.packageQuantity);
+    static Integer packageQuantity(Document cda) throws XPathExpressionException, MapperException {
+        var xpath = xpath();
+        var node = (Node) xpath.evaluate(XPaths.packageQuantity, cda, XPathConstants.NODE);
         var unit = xpath.evaluate("@unit", node);
         if (!"1".equals(unit)) {
             throw new MapperException("Unsupported quantity unit: " + unit);
         }
         return Integer.parseInt(xpath.evaluate("@value", node));
     }
 
-    CreatePharmacyEffectuationType effectuation(
+    static CreatePharmacyEffectuationType effectuation(
         Document cda,
         StartEffectuationResponseType startEffectuationResponse
     ) throws XPathExpressionException, MapperException {
-        var effectiveTime = eval(cda, XPaths.effectiveTime);
+        var xpath = xpath();
+        var effectiveTime = (String) xpath.evaluate(XPaths.effectiveTime, cda, XPathConstants.STRING);
         var packageRestriction = startEffectuationResponse.getPrescription().getFirst().getPackageRestriction();
 
-        // how do we handle substitutions? for now, disallow
-        if (evalNode(cda, XPaths.substitution) != null) {
-            log.warn("Substitutions are not supported. No conversion was made.");
-        }
-        // verify that package number matches prescription
-        var packageNumber = packageNumber(cda);
-        if (packageNumber != null && !packageRestriction.getPackageNumber().getValue().equals(packageNumber)) {
-            throw new MapperException(String.format(
-                "Package number in dispensation (%s) does not match prescription (%s).",
-                packageNumber(cda),
-                packageRestriction.getPackageNumber().getValue()));
-        }
+        var drug = drug(cda);
 
         return CreatePharmacyEffectuationType.builder()
             .withDateTime(Utils.parseEpsosTime(effectiveTime))
             .withPackageDispensed()
             .withPackageQuantity(packageQuantity(cda))
-            .withPackageNumber(packageRestriction.getPackageNumber())
-            .withPackageSize(packageRestriction.getPackageSize())
+            .withPackageNumber(packageNumber(cda))
+            .withPackageSize(packageRestriction.getPackageSize()) // TODO get package size from CDA
+            .withSubstitutedDrug(drug)
             .end()
-            .withDeliverySite(TestIdentities.deliverySiteRyApotek)
+            .withDeliverySite(TestIdentities.deliverySiteRyApotek) // TODO #190
+            .build();
+    }
+
+    static DrugType drug(Document cda) {
+        return DrugType.builder()
+            .withDetailedDrugText(detailedDrugText(cda))
+            // TODO: Strength
+            // TODO: ATC code
+            // TODO: substances
             .build();
     }
 
-    String packageNumber(Document cda) {
+    static String detailedDrugText(Document cda) {
+        var xpath = xpath();
+        String drugName;
         try {
-            var node = evalNode(cda, XPaths.manufacturedMaterialCode);
-            if (node == null) {
-                return null; //The field is 0..1, and we cannot require it to be there. If it is missing, we return null.
-            }
-            var codeSystem = xpath.evaluate("@codeSystem", node);
-            if (!Oid.DK_VARENUMRE.value.equals(codeSystem)) {
-                // throw?
-                log.warn(
-                    "Expected LMS02 ({}) code system, for {}. Got: {}",
-                    Oid.DK_VARENUMRE.value,
-                    XPaths.manufacturedMaterialCode,
-                    codeSystem
-                );
-            }
-            return xpath.evaluate("@code", node);
+            drugName = (String) xpath.evaluate(XPaths.manufacturedMaterialName, cda, XPathConstants.STRING);
         } catch (XPathExpressionException e) {
-            throw new DataRequirementException(String.format("Could not find find data at path: %s", XPaths.manufacturedMaterialCode));
+            throw new DataRequirementException(String.format("Could not find data at path: %s", XPaths.manufacturedMaterialName));
         }
+        String drugId;
+        try {
+            var node = (Node) xpath.evaluate(XPaths.manufacturedMaterialCode, cda, XPathConstants.NODE);
+            var system = xpath.evaluate("@codeSystem", node);
+            var id = xpath.evaluate("@code", node);
+            drugId = String.format("%s^^^%s", system, id);
+        } catch (XPathExpressionException e) {
+            drugId = "unknown";
+        }
+        return String.format("%s - id: %s", drugName, drugId);
+    }
+
+    static PackageNumberType packageNumber(Document cda) {
+        //        try {
path: country-a-service/epps-service/src/main/java/dk/sundhedsdatastyrelsen/ncpeh/service/mapping/DispensationMapper.java
user: login: CBrams
body: Vi skal nok have lavet et issue på at finde de rigtige varenumre igen, hvis vi kan (hvis der er andre, ligesom Polen, der sender os vores egne tilbage)

Selvom vi vist også bare ignorerede substitutioner, men det var ikke alle deres kald der havde dem med, så dem hvor de ikke substituerede, der udleverede de nok de danske varer ;)
created_at: 2025-04-07T12:01:36Z
author_association: COLLABORATOR
diff_hunk: @@ -378,51 +387,72 @@ static CreatePharmacyEffectuationType effectuation(
         var effectiveTime = (String) xpath.evaluate(XPaths.effectiveTime, cda, XPathConstants.STRING);
         var packageRestriction = startEffectuationResponse.getPrescription().getFirst().getPackageRestriction();
 
-        // how do we handle substitutions? for now, disallow
-        if ((Node) xpath.evaluate(XPaths.substitution, cda, XPathConstants.NODE) != null) {
-            log.warn("Substitutions are not supported. No conversion was made.");
-        }
-        // verify that package number matches prescription
-        var packageNumber = packageNumber(cda);
-        if (packageNumber != null && !packageRestriction.getPackageNumber().getValue().equals(packageNumber)) {
-            throw new MapperException(String.format(
-                "Package number in dispensation (%s) does not match prescription (%s).",
-                packageNumber(cda),
-                packageRestriction.getPackageNumber().getValue()));
-        }
+        var drug = drug(cda);
 
         return CreatePharmacyEffectuationType.builder()
             .withDateTime(Utils.parseEpsosTime(effectiveTime))
             .withPackageDispensed()
             .withPackageQuantity(packageQuantity(cda))
-            .withPackageNumber(packageRestriction.getPackageNumber())
-            .withPackageSize(packageRestriction.getPackageSize())
+            .withPackageNumber(packageNumber(cda))
+            .withPackageSize(packageRestriction.getPackageSize()) // TODO get package size from CDA
+            .withSubstitutedDrug(drug)
             .end()
-            .withDeliverySite(TestIdentities.deliverySiteRyApotek)
+            .withDeliverySite(TestIdentities.deliverySiteRyApotek) // TODO #190
             .build();
     }
 
-    static String packageNumber(Document cda) {
+    static DrugType drug(Document cda) {
+        return DrugType.builder()
+            .withDetailedDrugText(detailedDrugText(cda))
+            // TODO: Strength
+            // TODO: ATC code
+            // TODO: substances
+            .build();
+    }
+
+    static String detailedDrugText(Document cda) {
+        var xpath = xpath();
+        String drugName;
+        try {
+            drugName = (String) xpath.evaluate(XPaths.manufacturedMaterialName, cda, XPathConstants.STRING);
+        } catch (XPathExpressionException e) {
+            throw new DataRequirementException(String.format("Could not find data at path: %s", XPaths.manufacturedMaterialName));
path: country-a-service/epps-service/src/main/java/dk/sundhedsdatastyrelsen/ncpeh/service/mapping/DispensationMapper.java
user: login: jakobvase
body: Are we sure this is always there? If they just dispensed the same thing as we asked them to, they might just send the Danish varenummer?
created_at: 2025-04-07T12:01:48Z
author_association: COLLABORATOR
diff_hunk: @@ -256,61 +265,41 @@ String authorRole(Document cda) throws XPathExpressionException {
         throw new IllegalArgumentException("Unexpected function code: " + functionCode);
     }
 
-    private boolean notBlank(String s) {
+    private static boolean notBlank(String s) {
         return s != null && !s.isBlank();
     }
 
-    /**
-     * TODO FMK Improvement
-     * FMK is supposed to support "Udenlandsk" OrganisationIdenfier, according to the documentation
-     * When called, it responds with an error that it is not supported yet
-     * <p>
-     * Two options going forward:
-     * - FMK start supporting Udenlandsk, and we reimplement forwarding the identifier from Country-B
-     * - We agree with FMK to "proxy" all requests through another Organisation, like we do in the code right now.
-     * <p>
-     * (2024/09/10)
-     */
-    private OrganisationIdentifierType identifier(Node id) {
-
-//        var attrs = id.getAttributes();
-//        var root = attrs.getNamedItem("root");
-//        var ext = attrs.getNamedItem("extension");
-//        if (root == null) {
-//            throw new IllegalArgumentException("Id nodes without root attributes are unsupported");
-//        }
-//        return OrganisationIdentifierType.builder()
-//            .withSource(OrganisationIdentifierPredefinedSourceType.UDENLANDSK.value())
-//            .withValue(ext == null
-//                ? root.getTextContent()
-//                : String.format("%s.%s", root.getTextContent(), ext.getTextContent()))
-//            .build();
-        return TestIdentities.skanderborgApotek;
+    private static OrganisationIdentifierType placeholdePharmacyId() {
path: country-a-service/epps-service/src/main/java/dk/sundhedsdatastyrelsen/ncpeh/service/mapping/DispensationMapper.java
user: login: jakobvase
body: ```suggestion
    private static OrganisationIdentifierType placeholderPharmacyId() {
```

But why is it called placeholder when it's the final one?

Also, we should probably use the same organisation identifier in the calls to the authorization registry, unless I'm mixing something up.
created_at: 2025-04-07T12:09:43Z
author_association: COLLABORATOR
diff_hunk: @@ -256,61 +265,41 @@ String authorRole(Document cda) throws XPathExpressionException {
         throw new IllegalArgumentException("Unexpected function code: " + functionCode);
     }
 
-    private boolean notBlank(String s) {
+    private static boolean notBlank(String s) {
         return s != null && !s.isBlank();
     }
 
-    /**
-     * TODO FMK Improvement
-     * FMK is supposed to support "Udenlandsk" OrganisationIdenfier, according to the documentation
-     * When called, it responds with an error that it is not supported yet
-     * <p>
-     * Two options going forward:
-     * - FMK start supporting Udenlandsk, and we reimplement forwarding the identifier from Country-B
-     * - We agree with FMK to "proxy" all requests through another Organisation, like we do in the code right now.
-     * <p>
-     * (2024/09/10)
-     */
-    private OrganisationIdentifierType identifier(Node id) {
-
-//        var attrs = id.getAttributes();
-//        var root = attrs.getNamedItem("root");
-//        var ext = attrs.getNamedItem("extension");
-//        if (root == null) {
-//            throw new IllegalArgumentException("Id nodes without root attributes are unsupported");
-//        }
-//        return OrganisationIdentifierType.builder()
-//            .withSource(OrganisationIdentifierPredefinedSourceType.UDENLANDSK.value())
-//            .withValue(ext == null
-//                ? root.getTextContent()
-//                : String.format("%s.%s", root.getTextContent(), ext.getTextContent()))
-//            .build();
-        return TestIdentities.skanderborgApotek;
+    private static OrganisationIdentifierType placeholdePharmacyId() {
+        return OrganisationIdentifierType.builder()
+            .withSource(OrganisationIdentifierPredefinedSourceType.EAN_LOKATIONSNUMMER.value())
+            .withValue("5790001392277") // NSI, Udenlandsk Apotek via epSOS ( SOR-nummer: 397941000016003 )
+            .build();
     }
 
-    OrganisationType authorOrganization(Document cda) throws XPathExpressionException {
-        var addressLines = new ArrayList<>(evalMany(cda, XPaths.authorOrgAddressLine));
-        var postalCode = eval(cda, XPaths.authorOrgPostalCode);
-        var city = eval(cda, XPaths.authorOrgCity);
-        var state = eval(cda, XPaths.authorOrgState);
-        var country = eval(cda, XPaths.authorOrgCountry);
+    static OrganisationType authorOrganization(Document cda) throws XPathExpressionException {
+        var xpath = xpath();
+        var addressLines = new ArrayList<>(evalMany(xpath, cda, XPaths.authorOrgAddressLine));
+        var postalCode = (String) xpath.evaluate(XPaths.authorOrgPostalCode, cda, XPathConstants.STRING);
+        var city = (String) xpath.evaluate(XPaths.authorOrgCity, cda, XPathConstants.STRING);
+        var state = (String) xpath.evaluate(XPaths.authorOrgState, cda, XPathConstants.STRING);
+        var country = (String) xpath.evaluate(XPaths.authorOrgCountry, cda, XPathConstants.STRING);
         if (notBlank(postalCode)) addressLines.add(postalCode);
         if (notBlank(city)) addressLines.add(city);
         if (notBlank(state)) addressLines.add(state);
         if (notBlank(country)) addressLines.add(country);
 
         String email = null, telephone = null;
-        var telecoms = evalMany(cda, XPaths.authorOrgTelecom, "value");
+        var telecoms = evalMany(xpath, cda, XPaths.authorOrgTelecom, "value");
         for (var t : telecoms) {
             if (t == null) continue;
             if (t.startsWith("tel:")) telephone = t.substring(4);
             if (t.startsWith("mailto:")) email = t.substring(7);
         }
 
         var b = OrganisationType.builder()
-            .withIdentifier(identifier(evalNode(cda, XPaths.authorOrgId)))
-            .withName(eval(cda, XPaths.authorOrgName))
-            .withType("Apotek") // TODO: How can we determine this?
+            .withIdentifier(placeholdePharmacyId())
path: country-a-service/epps-service/src/main/java/dk/sundhedsdatastyrelsen/ncpeh/service/mapping/DispensationMapper.java
user: login: jakobvase
body: ```suggestion
            .withIdentifier(placeholderPharmacyId())
```
created_at: 2025-04-07T12:10:11Z
author_association: COLLABORATOR
diff_hunk: @@ -378,51 +387,72 @@ static CreatePharmacyEffectuationType effectuation(
         var effectiveTime = (String) xpath.evaluate(XPaths.effectiveTime, cda, XPathConstants.STRING);
         var packageRestriction = startEffectuationResponse.getPrescription().getFirst().getPackageRestriction();
 
-        // how do we handle substitutions? for now, disallow
-        if ((Node) xpath.evaluate(XPaths.substitution, cda, XPathConstants.NODE) != null) {
-            log.warn("Substitutions are not supported. No conversion was made.");
-        }
-        // verify that package number matches prescription
-        var packageNumber = packageNumber(cda);
-        if (packageNumber != null && !packageRestriction.getPackageNumber().getValue().equals(packageNumber)) {
-            throw new MapperException(String.format(
-                "Package number in dispensation (%s) does not match prescription (%s).",
-                packageNumber(cda),
-                packageRestriction.getPackageNumber().getValue()));
-        }
+        var drug = drug(cda);
 
         return CreatePharmacyEffectuationType.builder()
             .withDateTime(Utils.parseEpsosTime(effectiveTime))
             .withPackageDispensed()
             .withPackageQuantity(packageQuantity(cda))
-            .withPackageNumber(packageRestriction.getPackageNumber())
-            .withPackageSize(packageRestriction.getPackageSize())
+            .withPackageNumber(packageNumber(cda))
+            .withPackageSize(packageRestriction.getPackageSize()) // TODO get package size from CDA
+            .withSubstitutedDrug(drug)
             .end()
-            .withDeliverySite(TestIdentities.deliverySiteRyApotek)
+            .withDeliverySite(TestIdentities.deliverySiteRyApotek) // TODO #190
             .build();
     }
 
-    static String packageNumber(Document cda) {
+    static DrugType drug(Document cda) {
+        return DrugType.builder()
+            .withDetailedDrugText(detailedDrugText(cda))
+            // TODO: Strength
+            // TODO: ATC code
+            // TODO: substances
+            .build();
+    }
+
+    static String detailedDrugText(Document cda) {
+        var xpath = xpath();
+        String drugName;
+        try {
+            drugName = (String) xpath.evaluate(XPaths.manufacturedMaterialName, cda, XPathConstants.STRING);
+        } catch (XPathExpressionException e) {
+            throw new DataRequirementException(String.format("Could not find data at path: %s", XPaths.manufacturedMaterialName));
path: country-a-service/epps-service/src/main/java/dk/sundhedsdatastyrelsen/ncpeh/service/mapping/DispensationMapper.java
user: login: hansbugge
body: It's mandatory: 
https://webgate.ec.europa.eu/fpfis/wikis/spaces/EHDSI/pages/888398314/07.01.+Create+the+MyHealth+EU+eDispensation+content

created_at: 2025-04-07T12:15:52Z
author_association: COLLABORATOR
diff_hunk: @@ -256,61 +265,41 @@ String authorRole(Document cda) throws XPathExpressionException {
         throw new IllegalArgumentException("Unexpected function code: " + functionCode);
     }
 
-    private boolean notBlank(String s) {
+    private static boolean notBlank(String s) {
         return s != null && !s.isBlank();
     }
 
-    /**
-     * TODO FMK Improvement
-     * FMK is supposed to support "Udenlandsk" OrganisationIdenfier, according to the documentation
-     * When called, it responds with an error that it is not supported yet
-     * <p>
-     * Two options going forward:
-     * - FMK start supporting Udenlandsk, and we reimplement forwarding the identifier from Country-B
-     * - We agree with FMK to "proxy" all requests through another Organisation, like we do in the code right now.
-     * <p>
-     * (2024/09/10)
-     */
-    private OrganisationIdentifierType identifier(Node id) {
-
-//        var attrs = id.getAttributes();
-//        var root = attrs.getNamedItem("root");
-//        var ext = attrs.getNamedItem("extension");
-//        if (root == null) {
-//            throw new IllegalArgumentException("Id nodes without root attributes are unsupported");
-//        }
-//        return OrganisationIdentifierType.builder()
-//            .withSource(OrganisationIdentifierPredefinedSourceType.UDENLANDSK.value())
-//            .withValue(ext == null
-//                ? root.getTextContent()
-//                : String.format("%s.%s", root.getTextContent(), ext.getTextContent()))
-//            .build();
-        return TestIdentities.skanderborgApotek;
+    private static OrganisationIdentifierType placeholdePharmacyId() {
path: country-a-service/epps-service/src/main/java/dk/sundhedsdatastyrelsen/ncpeh/service/mapping/DispensationMapper.java
user: login: hansbugge
body: It's a placeholder because we don't have an id for the actual pharmacy, so we use "Udenlandsk Apotek" instead.  
created_at: 2025-04-07T12:19:20Z
author_association: COLLABORATOR
diff_hunk: @@ -256,61 +265,41 @@ String authorRole(Document cda) throws XPathExpressionException {
         throw new IllegalArgumentException("Unexpected function code: " + functionCode);
     }
 
-    private boolean notBlank(String s) {
+    private static boolean notBlank(String s) {
         return s != null && !s.isBlank();
     }
 
-    /**
-     * TODO FMK Improvement
-     * FMK is supposed to support "Udenlandsk" OrganisationIdenfier, according to the documentation
-     * When called, it responds with an error that it is not supported yet
-     * <p>
-     * Two options going forward:
-     * - FMK start supporting Udenlandsk, and we reimplement forwarding the identifier from Country-B
-     * - We agree with FMK to "proxy" all requests through another Organisation, like we do in the code right now.
-     * <p>
-     * (2024/09/10)
-     */
-    private OrganisationIdentifierType identifier(Node id) {
-
-//        var attrs = id.getAttributes();
-//        var root = attrs.getNamedItem("root");
-//        var ext = attrs.getNamedItem("extension");
-//        if (root == null) {
-//            throw new IllegalArgumentException("Id nodes without root attributes are unsupported");
-//        }
-//        return OrganisationIdentifierType.builder()
-//            .withSource(OrganisationIdentifierPredefinedSourceType.UDENLANDSK.value())
-//            .withValue(ext == null
-//                ? root.getTextContent()
-//                : String.format("%s.%s", root.getTextContent(), ext.getTextContent()))
-//            .build();
-        return TestIdentities.skanderborgApotek;
+    private static OrganisationIdentifierType placeholdePharmacyId() {
path: country-a-service/epps-service/src/main/java/dk/sundhedsdatastyrelsen/ncpeh/service/mapping/DispensationMapper.java
user: login: hansbugge
body: The identities we use for system calls (like for the authorization registry) also has certificates.  So it's a different kind of issue.  We need to request certificates for NCPeH that we can use.
created_at: 2025-04-07T12:21:06Z
author_association: COLLABORATOR
diff_hunk: @@ -359,71 +349,96 @@ CreatePharmacyEffectuationOnPrescriptionType prescription(
             .build();
     }
 
-    Integer packageQuantity(Document cda) throws XPathExpressionException, MapperException {
-        var node = evalNode(cda, XPaths.packageQuantity);
+    static Integer packageQuantity(Document cda) throws XPathExpressionException, MapperException {
+        var xpath = xpath();
+        var node = (Node) xpath.evaluate(XPaths.packageQuantity, cda, XPathConstants.NODE);
         var unit = xpath.evaluate("@unit", node);
         if (!"1".equals(unit)) {
             throw new MapperException("Unsupported quantity unit: " + unit);
         }
         return Integer.parseInt(xpath.evaluate("@value", node));
     }
 
-    CreatePharmacyEffectuationType effectuation(
+    static CreatePharmacyEffectuationType effectuation(
         Document cda,
         StartEffectuationResponseType startEffectuationResponse
     ) throws XPathExpressionException, MapperException {
-        var effectiveTime = eval(cda, XPaths.effectiveTime);
+        var xpath = xpath();
+        var effectiveTime = (String) xpath.evaluate(XPaths.effectiveTime, cda, XPathConstants.STRING);
         var packageRestriction = startEffectuationResponse.getPrescription().getFirst().getPackageRestriction();
 
-        // how do we handle substitutions? for now, disallow
-        if (evalNode(cda, XPaths.substitution) != null) {
-            log.warn("Substitutions are not supported. No conversion was made.");
-        }
-        // verify that package number matches prescription
-        var packageNumber = packageNumber(cda);
-        if (packageNumber != null && !packageRestriction.getPackageNumber().getValue().equals(packageNumber)) {
-            throw new MapperException(String.format(
-                "Package number in dispensation (%s) does not match prescription (%s).",
-                packageNumber(cda),
-                packageRestriction.getPackageNumber().getValue()));
-        }
+        var drug = drug(cda);
 
         return CreatePharmacyEffectuationType.builder()
             .withDateTime(Utils.parseEpsosTime(effectiveTime))
             .withPackageDispensed()
             .withPackageQuantity(packageQuantity(cda))
-            .withPackageNumber(packageRestriction.getPackageNumber())
-            .withPackageSize(packageRestriction.getPackageSize())
+            .withPackageNumber(packageNumber(cda))
+            .withPackageSize(packageRestriction.getPackageSize()) // TODO get package size from CDA
+            .withSubstitutedDrug(drug)
             .end()
-            .withDeliverySite(TestIdentities.deliverySiteRyApotek)
+            .withDeliverySite(TestIdentities.deliverySiteRyApotek) // TODO #190
+            .build();
+    }
+
+    static DrugType drug(Document cda) {
+        return DrugType.builder()
+            .withDetailedDrugText(detailedDrugText(cda))
+            // TODO: Strength
+            // TODO: ATC code
+            // TODO: substances
             .build();
     }
 
-    String packageNumber(Document cda) {
+    static String detailedDrugText(Document cda) {
+        var xpath = xpath();
+        String drugName;
         try {
-            var node = evalNode(cda, XPaths.manufacturedMaterialCode);
-            if (node == null) {
-                return null; //The field is 0..1, and we cannot require it to be there. If it is missing, we return null.
-            }
-            var codeSystem = xpath.evaluate("@codeSystem", node);
-            if (!Oid.DK_VARENUMRE.value.equals(codeSystem)) {
-                // throw?
-                log.warn(
-                    "Expected LMS02 ({}) code system, for {}. Got: {}",
-                    Oid.DK_VARENUMRE.value,
-                    XPaths.manufacturedMaterialCode,
-                    codeSystem
-                );
-            }
-            return xpath.evaluate("@code", node);
+            drugName = (String) xpath.evaluate(XPaths.manufacturedMaterialName, cda, XPathConstants.STRING);
         } catch (XPathExpressionException e) {
-            throw new DataRequirementException(String.format("Could not find find data at path: %s", XPaths.manufacturedMaterialCode));
+            throw new DataRequirementException(String.format("Could not find data at path: %s", XPaths.manufacturedMaterialName));
         }
+        String drugId;
+        try {
+            var node = (Node) xpath.evaluate(XPaths.manufacturedMaterialCode, cda, XPathConstants.NODE);
+            var system = xpath.evaluate("@codeSystem", node);
+            var id = xpath.evaluate("@code", node);
+            drugId = String.format("%s^^^%s", system, id);
+        } catch (XPathExpressionException e) {
+            drugId = "unknown";
+        }
+        return String.format("%s - id: %s", drugName, drugId);
+    }
+
+    static PackageNumberType packageNumber(Document cda) {
+        //        try {
path: country-a-service/epps-service/src/main/java/dk/sundhedsdatastyrelsen/ncpeh/service/mapping/DispensationMapper.java
user: login: hansbugge
body: Done: #199 
created_at: 2025-04-07T12:33:38Z
author_association: COLLABORATOR

PR comments:
user: login: jakobvase
created_at: 2025-04-07T09:54:37Z
body: Love the commit title.
author_association: COLLABORATOR
user: login: sonarqubecloud[bot]
created_at: 2025-04-07T12:35:53Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=Sundhedsdatastyrelsen_ehdsi&pullRequest=198) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [10 New issues](https://sonarcloud.io/project/issues?id=Sundhedsdatastyrelsen_ehdsi&pullRequest=198&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=Sundhedsdatastyrelsen_ehdsi&pullRequest=198&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=Sundhedsdatastyrelsen_ehdsi&pullRequest=198&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [85.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=Sundhedsdatastyrelsen_ehdsi&pullRequest=198&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=Sundhedsdatastyrelsen_ehdsi&pullRequest=198&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=Sundhedsdatastyrelsen_ehdsi&pullRequest=198)


author_association: NONE