diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/rest/api/Constants.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/rest/api/Constants.java index d5118a04182..8c315beba76 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/rest/api/Constants.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/rest/api/Constants.java @@ -201,6 +201,14 @@ public class Constants { public static final String PARAM_TAGS = "_tags"; public static final String PARAM_TEXT = "_text"; public static final String PARAM_VALIDATE = "_validate"; + + /** + * $member-match operation + */ + public static final String PARAM_MEMBER_PATIENT = "MemberPatient"; + public static final String PARAM_OLD_COVERAGE = "OldCoverage"; + public static final String PARAM_NEW_COVERAGE = "NewCoverage"; + public static final String PARAMQUALIFIER_MISSING = ":missing"; public static final String PARAMQUALIFIER_MISSING_FALSE = "false"; public static final String PARAMQUALIFIER_MISSING_TRUE = "true"; diff --git a/hapi-fhir-base/src/main/resources/ca/uhn/fhir/i18n/hapi-messages.properties b/hapi-fhir-base/src/main/resources/ca/uhn/fhir/i18n/hapi-messages.properties index 85461489845..42db4f1b737 100644 --- a/hapi-fhir-base/src/main/resources/ca/uhn/fhir/i18n/hapi-messages.properties +++ b/hapi-fhir-base/src/main/resources/ca/uhn/fhir/i18n/hapi-messages.properties @@ -183,3 +183,9 @@ ca.uhn.fhir.jpa.provider.DiffProvider.cantDiffDifferentTypes=Unable to diff two ca.uhn.fhir.jpa.interceptor.validation.RuleRequireProfileDeclaration.noMatchingProfile=Resource of type "{0}" does not declare conformance to profile from: {1} ca.uhn.fhir.jpa.interceptor.validation.RuleRequireProfileDeclaration.illegalProfile=Resource of type "{0}" must not declare conformance to profile: {1} + +operation.member.match.error.coverage.not.found=Could not find coverage for member based on coverage id or coverage identifier. +operation.member.match.error.beneficiary.not.found=Could not find beneficiary for coverage. +operation.member.match.error.missing.parameter=Parameter "{0}" is required. +operation.member.match.error.beneficiary.without.identifier=Coverage beneficiary does not have an identifier. + diff --git a/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/5_7_0/3172-implement-support-for-member-match-operation.yml b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/5_7_0/3172-implement-support-for-member-match-operation.yml new file mode 100644 index 00000000000..9f50313cdf3 --- /dev/null +++ b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/5_7_0/3172-implement-support-for-member-match-operation.yml @@ -0,0 +1,5 @@ +--- +type: add +issue: 3172 +title: "Implement support for $member-match operation by coverage-id or coverage-identifier. + (Beneficiary demographic matching not supported)" diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/BaseConfig.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/BaseConfig.java index 2438613eb34..f000ef4a1cd 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/BaseConfig.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/BaseConfig.java @@ -86,6 +86,7 @@ import ca.uhn.fhir.jpa.provider.DiffProvider; import ca.uhn.fhir.jpa.provider.SubscriptionTriggeringProvider; import ca.uhn.fhir.jpa.provider.TerminologyUploaderProvider; import ca.uhn.fhir.jpa.provider.ValueSetOperationProvider; +import ca.uhn.fhir.jpa.provider.r4.MemberMatcherR4Helper; import ca.uhn.fhir.jpa.reindex.ReindexJobSubmitterImpl; import ca.uhn.fhir.jpa.sched.AutowiringSpringBeanJobFactory; import ca.uhn.fhir.jpa.sched.HapiSchedulerServiceImpl; @@ -952,6 +953,14 @@ public abstract class BaseConfig { return new UnknownCodeSystemWarningValidationSupport(fhirContext()); } + @Lazy + @Bean + public MemberMatcherR4Helper memberMatcherR4Helper(FhirContext theFhirContext) { + return new MemberMatcherR4Helper(theFhirContext); + } + + + public static void configureEntityManagerFactory(LocalContainerEntityManagerFactoryBean theFactory, FhirContext theCtx) { theFactory.setJpaDialect(hibernateJpaDialect(theCtx.getLocalizer())); theFactory.setPackagesToScan("ca.uhn.fhir.jpa.model.entity", "ca.uhn.fhir.jpa.entity"); diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/provider/r4/BaseJpaResourceProviderPatientR4.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/provider/r4/BaseJpaResourceProviderPatientR4.java index 6f655f6ac86..bf703e80f2e 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/provider/r4/BaseJpaResourceProviderPatientR4.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/provider/r4/BaseJpaResourceProviderPatientR4.java @@ -3,6 +3,7 @@ package ca.uhn.fhir.jpa.provider.r4; import ca.uhn.fhir.jpa.api.dao.IFhirResourceDaoPatient; import ca.uhn.fhir.jpa.model.util.JpaConstants; import ca.uhn.fhir.model.api.annotation.Description; +import ca.uhn.fhir.model.primitive.StringDt; import ca.uhn.fhir.model.valueset.BundleTypeEnum; import ca.uhn.fhir.rest.annotation.IdParam; import ca.uhn.fhir.rest.annotation.Operation; @@ -18,13 +19,20 @@ import ca.uhn.fhir.rest.param.StringOrListParam; import ca.uhn.fhir.rest.param.StringParam; import ca.uhn.fhir.rest.param.TokenOrListParam; import ca.uhn.fhir.rest.param.TokenParam; +import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException; +import ca.uhn.fhir.rest.server.provider.ProviderConstants; +import org.hl7.fhir.r4.model.Coverage; import org.hl7.fhir.r4.model.IdType; +import org.hl7.fhir.r4.model.Parameters; import org.hl7.fhir.r4.model.Patient; import org.hl7.fhir.r4.model.StringType; import org.hl7.fhir.r4.model.UnsignedIntType; +import org.springframework.beans.factory.annotation.Autowired; +import javax.servlet.http.HttpServletRequest; import java.util.Arrays; import java.util.List; +import java.util.Optional; import static org.apache.commons.lang3.StringUtils.isNotBlank; @@ -50,6 +58,10 @@ import static org.apache.commons.lang3.StringUtils.isNotBlank; public class BaseJpaResourceProviderPatientR4 extends JpaResourceProviderR4 { + @Autowired + private MemberMatcherR4Helper myMemberMatcherR4Helper; + + /** * Patient/123/$everything */ @@ -150,6 +162,85 @@ public class BaseJpaResourceProviderPatientR4 extends JpaResourceProviderR4 coverageOpt = myMemberMatcherR4Helper.findMatchingCoverage(theCoverageToMatch); + if ( ! coverageOpt.isPresent()) { + String i18nMessage = getContext().getLocalizer().getMessage( + "operation.member.match.error.coverage.not.found"); + throw new UnprocessableEntityException(i18nMessage); + } + Coverage coverage = coverageOpt.get(); + + Optional patientOpt = myMemberMatcherR4Helper.getBeneficiaryPatient(coverage); + if (! patientOpt.isPresent()) { + String i18nMessage = getContext().getLocalizer().getMessage( + "operation.member.match.error.beneficiary.not.found"); + throw new UnprocessableEntityException(i18nMessage); + } + Patient patient = patientOpt.get(); + + if (patient.getIdentifier().isEmpty()) { + String i18nMessage = getContext().getLocalizer().getMessage( + "operation.member.match.error.beneficiary.without.identifier"); + throw new UnprocessableEntityException(i18nMessage); + } + + myMemberMatcherR4Helper.addMemberIdentifierToMemberPatient(theMemberPatient, patient.getIdentifierFirstRep()); + + return myMemberMatcherR4Helper.buildSuccessReturnParameters(theMemberPatient, theCoverageToLink); + } + + + private void validateParams(Patient theMemberPatient, Coverage theOldCoverage, Coverage theNewCoverage) { + validateParam(theMemberPatient, Constants.PARAM_MEMBER_PATIENT); + validateParam(theOldCoverage, Constants.PARAM_OLD_COVERAGE); + validateParam(theNewCoverage, Constants.PARAM_NEW_COVERAGE); + } + + + private void validateParam(Object theParam, String theParamName) { + if (theParam == null) { + String i18nMessage = getContext().getLocalizer().getMessage( + "operation.member.match.error.missing.parameter", theParamName); + throw new UnprocessableEntityException(i18nMessage); + } + } + + /** * Given a list of string types, return only the ID portions of any parameters passed in. */ diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/provider/r4/MemberMatcherR4Helper.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/provider/r4/MemberMatcherR4Helper.java new file mode 100644 index 00000000000..cdcabe4e2a2 --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/provider/r4/MemberMatcherR4Helper.java @@ -0,0 +1,172 @@ +package ca.uhn.fhir.jpa.provider.r4; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; +import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; +import ca.uhn.fhir.model.primitive.IdDt; +import ca.uhn.fhir.rest.param.StringParam; +import ca.uhn.fhir.rest.param.TokenOrListParam; +import ca.uhn.fhir.util.ParametersUtil; +import com.google.common.collect.Lists; +import org.hl7.fhir.instance.model.api.IBaseParameters; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.r4.model.CodeableConcept; +import org.hl7.fhir.r4.model.Coding; +import org.hl7.fhir.r4.model.Coverage; +import org.hl7.fhir.r4.model.Identifier; +import org.hl7.fhir.r4.model.Parameters; +import org.hl7.fhir.r4.model.Patient; +import org.hl7.fhir.r4.model.Reference; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.List; +import java.util.Optional; + +import static ca.uhn.fhir.rest.api.Constants.PARAM_MEMBER_PATIENT; +import static ca.uhn.fhir.rest.api.Constants.PARAM_NEW_COVERAGE; + +/* + * #%L + * HAPI FHIR JPA Server + * %% + * Copyright (C) 2014 - 2021 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +public class MemberMatcherR4Helper { + + private static final String OUT_COVERAGE_IDENTIFIER_CODE_SYSTEM = "http://terminology.hl7.org/CodeSystem/v2-0203"; + private static final String OUT_COVERAGE_IDENTIFIER_CODE = "UMB"; + private static final String OUT_COVERAGE_IDENTIFIER_TEXT = "Member Number"; + private static final String COVERAGE_TYPE = "Coverage"; + + private final FhirContext myFhirContext; + + @Autowired + private IFhirResourceDao myCoverageDao; + + @Autowired + private IFhirResourceDao myPatientDao; + + + public MemberMatcherR4Helper(FhirContext theContext) { + myFhirContext = theContext; + } + + /** + * Find Coverage matching the received member (Patient) by coverage id or by coverage identifier + * Matching by member patient demographics is not supported. + */ + public Optional findMatchingCoverage(Coverage theCoverageToMatch) { + // search by received old coverage id + List foundCoverages = findCoverageByCoverageId(theCoverageToMatch); + if (foundCoverages.size() == 1 && isCoverage(foundCoverages.get(0))) { + return Optional.of( (Coverage) foundCoverages.get(0) ); + } + + // search by received old coverage identifier + foundCoverages = findCoverageByCoverageIdentifier(theCoverageToMatch); + if (foundCoverages.size() == 1 && isCoverage(foundCoverages.get(0))) { + return Optional.of( (Coverage) foundCoverages.get(0) ); + } + + return Optional.empty(); + } + + + private List findCoverageByCoverageIdentifier(Coverage theCoverageToMatch) { + TokenOrListParam identifierParam = new TokenOrListParam(); + for (Identifier identifier : theCoverageToMatch.getIdentifier()) { + identifierParam.add(identifier.getSystem(), identifier.getValue()); + } + + SearchParameterMap paramMap = new SearchParameterMap() + .add("identifier", identifierParam); + ca.uhn.fhir.rest.api.server.IBundleProvider retVal = myCoverageDao.search(paramMap); + + return retVal.getAllResources(); + } + + + private boolean isCoverage(IBaseResource theIBaseResource) { + return theIBaseResource.fhirType().equals(COVERAGE_TYPE); + } + + + private List findCoverageByCoverageId(Coverage theCoverageToMatch) { + SearchParameterMap paramMap = new SearchParameterMap() + .add("_id", new StringParam(theCoverageToMatch.getId())); + ca.uhn.fhir.rest.api.server.IBundleProvider retVal = myCoverageDao.search(paramMap); + + return retVal.getAllResources(); + } + + + public Parameters buildSuccessReturnParameters(Patient theMemberPatient, Coverage theCoverage) { + IBaseParameters parameters = ParametersUtil.newInstance(myFhirContext); + ParametersUtil.addParameterToParameters(myFhirContext, parameters, PARAM_MEMBER_PATIENT, theMemberPatient); + ParametersUtil.addParameterToParameters(myFhirContext, parameters, PARAM_NEW_COVERAGE, theCoverage); + return (Parameters) parameters; + } + + + public void addMemberIdentifierToMemberPatient(Patient theMemberPatient, Identifier theNewIdentifier) { + Coding coding = new Coding() + .setSystem(OUT_COVERAGE_IDENTIFIER_CODE_SYSTEM) + .setCode(OUT_COVERAGE_IDENTIFIER_CODE) + .setDisplay(OUT_COVERAGE_IDENTIFIER_TEXT) + .setUserSelected(false); + + CodeableConcept concept = new CodeableConcept() + .setCoding(Lists.newArrayList(coding)) + .setText(OUT_COVERAGE_IDENTIFIER_TEXT); + + Identifier newIdentifier = new Identifier() + .setUse(Identifier.IdentifierUse.USUAL) + .setType(concept) + .setSystem(theNewIdentifier.getSystem()) + .setValue(theNewIdentifier.getValue()); + + theMemberPatient.addIdentifier(newIdentifier); + } + + + public Optional getBeneficiaryPatient(Coverage theCoverage) { + if (theCoverage.getBeneficiaryTarget() == null && theCoverage.getBeneficiary() == null) { + return Optional.empty(); + } + + if (theCoverage.getBeneficiaryTarget() != null + && ! theCoverage.getBeneficiaryTarget().getIdentifier().isEmpty()) { + return Optional.of(theCoverage.getBeneficiaryTarget()); + } + + Reference beneficiaryRef = theCoverage.getBeneficiary(); + if (beneficiaryRef == null) { + return Optional.empty(); + } + + if (beneficiaryRef.getResource() != null) { + return Optional.of((Patient) beneficiaryRef.getResource()); + } + + if (beneficiaryRef.getReference() == null) { + return Optional.empty(); + } + + Patient beneficiary = myPatientDao.read(new IdDt(beneficiaryRef.getReference())); + return Optional.ofNullable(beneficiary); + } +} diff --git a/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/provider/r4/MemberMatcherR4HelperTest.java b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/provider/r4/MemberMatcherR4HelperTest.java new file mode 100644 index 00000000000..91e0e8587a9 --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/provider/r4/MemberMatcherR4HelperTest.java @@ -0,0 +1,241 @@ +package ca.uhn.fhir.jpa.provider.r4; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; +import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; +import ca.uhn.fhir.model.api.IQueryParameterType; +import ca.uhn.fhir.model.primitive.IdDt; +import ca.uhn.fhir.rest.api.server.IBundleProvider; +import com.google.common.collect.Lists; +import org.hl7.fhir.r4.model.Coverage; +import org.hl7.fhir.r4.model.Identifier; +import org.hl7.fhir.r4.model.Parameters; +import org.hl7.fhir.r4.model.Patient; +import org.hl7.fhir.r4.model.Reference; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static ca.uhn.fhir.rest.api.Constants.PARAM_MEMBER_PATIENT; +import static ca.uhn.fhir.rest.api.Constants.PARAM_NEW_COVERAGE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MemberMatcherR4HelperTest { + + private final FhirContext myFhirContext = FhirContext.forR4(); + @Mock private IFhirResourceDao myCoverageDao; + @Mock private IFhirResourceDao myPatientDao; + + private MemberMatcherR4Helper myTestedHelper; + + @Mock private Coverage myCoverageToMatch; + @Mock private Patient myPatient; + @Mock private IBundleProvider myBundleProvider; + + private final Coverage myMatchedCoverage = new Coverage(); + private final Identifier myMatchingIdentifier = new Identifier() + .setSystem("identifier-system").setValue("identifier-value"); + + @Captor ArgumentCaptor mySearchParameterMapCaptor; + + @BeforeEach + public void beforeEach() { + myTestedHelper = new MemberMatcherR4Helper(myFhirContext); + + // @InjectMocks didn't work + ReflectionTestUtils.setField(myTestedHelper, "myCoverageDao", myCoverageDao); + ReflectionTestUtils.setField(myTestedHelper, "myPatientDao", myPatientDao); + } + + + @Test + void findMatchingCoverageMatchByIdReturnsMatched() { + when(myCoverageToMatch.getId()).thenReturn("cvg-to-match-id"); + when(myCoverageDao.search(isA(SearchParameterMap.class))).thenReturn(myBundleProvider); + when(myBundleProvider.getAllResources()).thenReturn(Collections.singletonList(myMatchedCoverage)); + + Optional result = myTestedHelper.findMatchingCoverage(myCoverageToMatch); + + assertEquals(Optional.of(myMatchedCoverage), result); + verify(myCoverageDao).search(mySearchParameterMapCaptor.capture()); + SearchParameterMap spMap = mySearchParameterMapCaptor.getValue(); + assertTrue(spMap.containsKey("_id")); + List> listListParams = spMap.get("_id"); + assertEquals(1, listListParams.size()); + assertEquals(1, listListParams.get(0).size()); + IQueryParameterType param = listListParams.get(0).get(0); + assertEquals("cvg-to-match-id", param.getValueAsQueryToken(myFhirContext)); + } + + + @Test + void findMatchingCoverageMatchByIdentifierReturnsMatched() { + when(myCoverageToMatch.getId()).thenReturn("non-matching-id"); + when(myCoverageToMatch.getIdentifier()).thenReturn(Collections.singletonList(myMatchingIdentifier)); + when(myCoverageDao.search(isA(SearchParameterMap.class))).thenReturn(myBundleProvider); + when(myBundleProvider.getAllResources()).thenReturn( + Collections.emptyList(), Collections.singletonList(myMatchedCoverage)); + + Optional result = myTestedHelper.findMatchingCoverage(myCoverageToMatch); + + assertEquals(Optional.of(myMatchedCoverage), result); + verify(myCoverageDao, times(2)).search(mySearchParameterMapCaptor.capture()); + List spMap = mySearchParameterMapCaptor.getAllValues(); + assertTrue(spMap.get(0).containsKey("_id")); + assertTrue(spMap.get(1).containsKey("identifier")); + List> listListParams = spMap.get(1).get("identifier"); + assertEquals(1, listListParams.size()); + assertEquals(1, listListParams.get(0).size()); + IQueryParameterType param = listListParams.get(0).get(0); + assertEquals(myMatchingIdentifier.getSystem() + "|" + myMatchingIdentifier.getValue(), + param.getValueAsQueryToken(myFhirContext)); + } + + + @Test + void findMatchingCoverageNoMatchReturnsEmpty() { + when(myCoverageToMatch.getId()).thenReturn("non-matching-id"); + when(myCoverageToMatch.getIdentifier()).thenReturn(Collections.singletonList(myMatchingIdentifier)); + when(myCoverageDao.search(isA(SearchParameterMap.class))).thenReturn(myBundleProvider); + when(myBundleProvider.getAllResources()).thenReturn(Collections.emptyList(), Collections.emptyList()); + + Optional result = myTestedHelper.findMatchingCoverage(myCoverageToMatch); + + assertFalse(result.isPresent()); + } + + + @Test + void buildSuccessReturnParameters() { + Patient patient = new Patient(); + Coverage coverage = new Coverage(); + + Parameters result = myTestedHelper.buildSuccessReturnParameters(patient, coverage); + + assertEquals(PARAM_MEMBER_PATIENT, result.getParameter().get(0).getName()); + assertEquals(patient, result.getParameter().get(0).getResource()); + + assertEquals(PARAM_NEW_COVERAGE, result.getParameter().get(1).getName()); + assertEquals(coverage, result.getParameter().get(1).getResource()); + } + + + @Test + void addMemberIdentifierToMemberPatient() { + Identifier originalIdentifier = new Identifier() + .setSystem("original-identifier-system").setValue("original-identifier-value"); + + Identifier newIdentifier = new Identifier() + .setSystem("new-identifier-system").setValue("new-identifier-value"); + + Patient patient = new Patient().setIdentifier(Lists.newArrayList(originalIdentifier)); + + myTestedHelper.addMemberIdentifierToMemberPatient(patient, newIdentifier); + + assertEquals(2, patient.getIdentifier().size()); + + assertEquals("original-identifier-system", patient.getIdentifier().get(0).getSystem()); + assertEquals("original-identifier-value", patient.getIdentifier().get(0).getValue()); + + assertEquals("new-identifier-system", patient.getIdentifier().get(1).getSystem()); + assertEquals("new-identifier-value", patient.getIdentifier().get(1).getValue()); + } + + @Nested + public class TestGetBeneficiaryPatient { + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private Coverage coverage; + + + @Test + void noBeneficiaryOrBeneficiaryTargetReturnsEmpty() { + when(coverage.getBeneficiaryTarget()).thenReturn(null); + when(coverage.getBeneficiary()).thenReturn(null); + + Optional result = myTestedHelper.getBeneficiaryPatient(coverage); + + assertFalse(result.isPresent()); + } + + + @Test + void beneficiaryTargetWithNoIdentifierReturnsEmpty() { + when(coverage.getBeneficiary()).thenReturn(null); + when(coverage.getBeneficiaryTarget()).thenReturn(new Patient()); + + Optional result = myTestedHelper.getBeneficiaryPatient(coverage); + + assertFalse(result.isPresent()); + } + + + @Test + void beneficiaryTargetWithIdentifierReturnsBeneficiary() { + Patient patient = new Patient().setIdentifier(Collections.singletonList(new Identifier())); + when(coverage.getBeneficiaryTarget()).thenReturn(patient); + + Optional result = myTestedHelper.getBeneficiaryPatient(coverage); + + assertTrue(result.isPresent()); + assertEquals(patient, result.get()); + } + + + @Test + void beneficiaryReferenceResourceReturnsBeneficiary() { + Patient patient = new Patient().setIdentifier(Collections.singletonList(new Identifier())); + when(coverage.getBeneficiaryTarget()).thenReturn(null); + when(coverage.getBeneficiary().getResource()).thenReturn(patient); + + Optional result = myTestedHelper.getBeneficiaryPatient(coverage); + + assertTrue(result.isPresent()); + assertEquals(patient, result.get()); + } + + + @Test + void beneficiaryReferenceNoResourceOrReferenceReturnsEmpty() { + when(coverage.getBeneficiaryTarget()).thenReturn(null); + when(coverage.getBeneficiary()).thenReturn(new Reference()); + + Optional result = myTestedHelper.getBeneficiaryPatient(coverage); + + assertFalse(result.isPresent()); + } + + + @Test + void beneficiaryReferenceReferenceReturnsReadPatient() { + when(coverage.getBeneficiaryTarget()).thenReturn(null); + when(coverage.getBeneficiary().getResource()).thenReturn(null); + when(coverage.getBeneficiary().getReference()).thenReturn("patient-id"); + + myTestedHelper.getBeneficiaryPatient(coverage); + + verify(myPatientDao).read(new IdDt("patient-id")); + } + + } + + +} diff --git a/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/provider/r4/PatientMemberMatchOperationR4Test.java b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/provider/r4/PatientMemberMatchOperationR4Test.java new file mode 100644 index 00000000000..ff4b7a12f4e --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/provider/r4/PatientMemberMatchOperationR4Test.java @@ -0,0 +1,388 @@ +package ca.uhn.fhir.jpa.provider.r4; + +import ca.uhn.fhir.jpa.api.config.DaoConfig; +import ca.uhn.fhir.parser.StrictErrorHandler; +import ca.uhn.fhir.rest.api.Constants; +import ca.uhn.fhir.rest.api.EncodingEnum; +import ca.uhn.fhir.rest.client.apache.ResourceEntity; +import ca.uhn.fhir.util.ParametersUtil; +import com.google.common.collect.Lists; +import org.apache.commons.io.IOUtils; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.hl7.fhir.instance.model.api.IBase; +import org.hl7.fhir.r4.model.CodeableConcept; +import org.hl7.fhir.r4.model.Coding; +import org.hl7.fhir.r4.model.Coverage; +import org.hl7.fhir.r4.model.Enumerations; +import org.hl7.fhir.r4.model.HumanName; +import org.hl7.fhir.r4.model.Identifier; +import org.hl7.fhir.r4.model.Organization; +import org.hl7.fhir.r4.model.Parameters; +import org.hl7.fhir.r4.model.Patient; +import org.hl7.fhir.r4.model.Reference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; + +import static ca.uhn.fhir.rest.api.Constants.PARAM_MEMBER_PATIENT; +import static ca.uhn.fhir.rest.api.Constants.PARAM_NEW_COVERAGE; +import static ca.uhn.fhir.rest.api.Constants.PARAM_OLD_COVERAGE; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@SuppressWarnings("Duplicates") +public class PatientMemberMatchOperationR4Test extends BaseResourceProviderR4Test { + private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(PatientMemberMatchOperationR4Test.class); + + private static final String ourQuery = "/Patient/$member-match?_format=json"; + private static final String EXISTING_COVERAGE_ID = "cov-id-123"; + private static final String EXISTING_COVERAGE_IDENT_SYSTEM = "http://centene.com/insurancePlanIds"; + private static final String EXISTING_COVERAGE_IDENT_VALUE = "U1234567890"; + private static final String EXISTING_COVERAGE_PATIENT_IDENT_SYSTEM = "http://oldhealthplan.example.com"; + private static final String EXISTING_COVERAGE_PATIENT_IDENT_VALUE = "DHU-55678"; + + private Identifier ourExistingCoverageIdentifier; + + + @BeforeEach + public void beforeDisableResultReuse() { + myDaoConfig.setReuseCachedSearchResultsForMillis(null); + } + + @Override + @AfterEach + public void after() throws Exception { + super.after(); + myDaoConfig.setReuseCachedSearchResultsForMillis(new DaoConfig().getReuseCachedSearchResultsForMillis()); + myDaoConfig.setEverythingIncludesFetchPageSize(new DaoConfig().getEverythingIncludesFetchPageSize()); + myDaoConfig.setSearchPreFetchThresholds(new DaoConfig().getSearchPreFetchThresholds()); + myDaoConfig.setAllowExternalReferences(new DaoConfig().isAllowExternalReferences()); + } + + + @Override + @BeforeEach + public void before() throws Exception { + super.before(); + myFhirCtx.setParserErrorHandler(new StrictErrorHandler()); + } + + + private void createCoverageWithBeneficiary( + boolean theAssociateBeneficiaryPatient, boolean includeBeneficiaryIdentifier) { + + Patient member = null; + if (theAssociateBeneficiaryPatient) { + // Patient + member = new Patient().setName(Lists.newArrayList(new HumanName() + .setUse(HumanName.NameUse.OFFICIAL).setFamily("Person").addGiven("Patricia").addGiven("Ann"))); + if (includeBeneficiaryIdentifier) { + member.setIdentifier(Collections.singletonList(new Identifier() + .setSystem(EXISTING_COVERAGE_PATIENT_IDENT_SYSTEM).setValue(EXISTING_COVERAGE_PATIENT_IDENT_VALUE))); + } + + myClient.create().resource(member).execute().getId().toUnqualifiedVersionless().getValue(); + } + + // Coverage + ourExistingCoverageIdentifier = new Identifier() + .setSystem(EXISTING_COVERAGE_IDENT_SYSTEM).setValue(EXISTING_COVERAGE_IDENT_VALUE); + Coverage ourExistingCoverage = new Coverage() + .setStatus(Coverage.CoverageStatus.ACTIVE) + .setIdentifier(Collections.singletonList(ourExistingCoverageIdentifier)); + + if (theAssociateBeneficiaryPatient) { + // this doesn't work + // myOldCoverage.setBeneficiaryTarget(patient) + ourExistingCoverage.setBeneficiary(new Reference(member)) + .setId(EXISTING_COVERAGE_ID); + } + + myClient.create().resource(ourExistingCoverage).execute().getId().toUnqualifiedVersionless().getValue(); + } + + + @Test + public void testMemberMatchByCoverageId() throws Exception { + createCoverageWithBeneficiary(true, true); + + // patient doesn't participate in match + Patient patient = new Patient().setGender(Enumerations.AdministrativeGender.FEMALE); + + // Old Coverage + Coverage oldCoverage = new Coverage(); + oldCoverage.setId(EXISTING_COVERAGE_ID); // must match field + + // New Coverage (must return unchanged) + Coverage newCoverage = new Coverage(); + newCoverage.setId("AA87654"); + newCoverage.setIdentifier(Lists.newArrayList( + new Identifier().setSystem("http://newealthplan.example.com").setValue("234567"))); + + Parameters inputParameters = buildInputParameters(patient, oldCoverage, newCoverage); + Parameters parametersResponse = performOperation(ourServerBase + ourQuery, + EncodingEnum.JSON, inputParameters); + + validateMemberPatient(parametersResponse); + validateNewCoverage(parametersResponse, newCoverage); + } + + + @Test + public void testCoverageNoBeneficiaryReturns422() throws Exception { + createCoverageWithBeneficiary(false, false); + + // patient doesn't participate in match + Patient patient = new Patient().setGender(Enumerations.AdministrativeGender.FEMALE); + + // Old Coverage + Coverage oldCoverage = new Coverage(); + oldCoverage.setId(EXISTING_COVERAGE_ID); // must match field + + // New Coverage (must return unchanged) + Coverage newCoverage = new Coverage(); + newCoverage.setId("AA87654"); + newCoverage.setIdentifier(Lists.newArrayList( + new Identifier().setSystem("http://newealthplan.example.com").setValue("234567"))); + + Parameters inputParameters = buildInputParameters(patient, oldCoverage, newCoverage); + performOperationExpecting422(ourServerBase + ourQuery, EncodingEnum.JSON, inputParameters, + "Could not find beneficiary for coverage."); + } + + + @Test + public void testCoverageBeneficiaryNoIdentifierReturns422() throws Exception { + createCoverageWithBeneficiary(true, false); + + // patient doesn't participate in match + Patient patient = new Patient().setGender(Enumerations.AdministrativeGender.FEMALE); + + // Old Coverage + Coverage oldCoverage = new Coverage(); + oldCoverage.setId(EXISTING_COVERAGE_ID); // must match field + + // New Coverage (must return unchanged) + Coverage newCoverage = new Coverage(); + newCoverage.setId("AA87654"); + newCoverage.setIdentifier(Lists.newArrayList( + new Identifier().setSystem("http://newealthplan.example.com").setValue("234567"))); + + Parameters inputParameters = buildInputParameters(patient, oldCoverage, newCoverage); + performOperationExpecting422(ourServerBase + ourQuery, EncodingEnum.JSON, inputParameters, + "Coverage beneficiary does not have an identifier."); + } + + + @Nested + public class ValidateParameterErrors { + private Patient ourPatient; + private Coverage ourOldCoverage; + private Coverage ourNewCoverage; + + @BeforeEach + public void beforeValidateParameterErrors() { + ourPatient = new Patient().setGender(Enumerations.AdministrativeGender.FEMALE); + + ourOldCoverage = new Coverage(); + ourOldCoverage.setId(EXISTING_COVERAGE_ID); + + ourNewCoverage = new Coverage(); + ourNewCoverage.setId("AA87654"); + ourNewCoverage.setIdentifier(Lists.newArrayList( + new Identifier().setSystem("http://newealthplan.example.com").setValue("234567"))); + } + + @Test + public void testInvalidPatient() throws Exception { + Parameters inputParameters = buildInputParameters(new Patient(), ourOldCoverage, ourNewCoverage); + performOperationExpecting422(ourServerBase + ourQuery, EncodingEnum.JSON, inputParameters, + "Parameter \\\"" + PARAM_MEMBER_PATIENT + "\\\" is required."); + } + + @Test + public void testInvalidOldCoverage() throws Exception { + Parameters inputParameters = buildInputParameters(ourPatient, new Coverage(), ourNewCoverage); + performOperationExpecting422(ourServerBase + ourQuery, EncodingEnum.JSON, inputParameters, + "Parameter \\\"" + PARAM_OLD_COVERAGE + "\\\" is required."); + } + + @Test + public void testInvalidNewCoverage() throws Exception { + Parameters inputParameters = buildInputParameters(ourPatient, ourOldCoverage, new Coverage()); + performOperationExpecting422(ourServerBase + ourQuery, EncodingEnum.JSON, inputParameters, + "Parameter \\\"" + PARAM_NEW_COVERAGE + "\\\" is required."); + } + } + + + @Test + public void testMemberMatchByCoverageIdentifier() throws Exception { + createCoverageWithBeneficiary(true, true); + + // patient doesn't participate in match + Patient patient = new Patient().setGender(Enumerations.AdministrativeGender.FEMALE); + + // Old Coverage + Coverage oldCoverage = new Coverage(); + oldCoverage.setId("9876B1"); + oldCoverage.setIdentifier(Lists.newArrayList(ourExistingCoverageIdentifier)); // must match field + + // New Coverage (must return unchanged) + Coverage newCoverage = new Coverage(); + newCoverage.setId("AA87654"); + newCoverage.setIdentifier(Lists.newArrayList( + new Identifier().setSystem("http://newealthplan.example.com").setValue("234567"))); + + Parameters inputParameters = buildInputParameters(patient, oldCoverage, newCoverage); + Parameters parametersResponse = performOperation(ourServerBase + ourQuery, EncodingEnum.JSON, inputParameters); + + validateMemberPatient(parametersResponse); + validateNewCoverage(parametersResponse, newCoverage); + } + + + /** + * Validates that second resource from the response is same as the received coverage + */ + private void validateNewCoverage(Parameters theResponse, Coverage theOriginalCoverage) { + List patientList = ParametersUtil.getNamedParameters(getContext(), theResponse, PARAM_NEW_COVERAGE); + assertEquals(1, patientList.size()); + Coverage respCoverage = (Coverage) theResponse.getParameter().get(1).getResource(); + + assertEquals("Coverage/" + theOriginalCoverage.getId(), respCoverage.getId()); + assertEquals(theOriginalCoverage.getIdentifierFirstRep().getSystem(), respCoverage.getIdentifierFirstRep().getSystem()); + assertEquals(theOriginalCoverage.getIdentifierFirstRep().getValue(), respCoverage.getIdentifierFirstRep().getValue()); + } + + + private void validateMemberPatient(Parameters response) { +// parameter MemberPatient must have a new identifier with: +// { +// "use": "usual", +// "type": { +// "coding": [ +// { +// "system": "http://terminology.hl7.org/CodeSystem/v2-0203", +// "code": "UMB", +// "display": "Member Number", +// "userSelected": false +// } +// ], +// "text": "Member Number" +// }, +// "system": COVERAGE_PATIENT_IDENT_SYSTEM, +// "value": COVERAGE_PATIENT_IDENT_VALUE +// } + List patientList = ParametersUtil.getNamedParameters(getContext(), response, PARAM_MEMBER_PATIENT); + assertEquals(1, patientList.size()); + Patient resultPatient = (Patient) response.getParameter().get(0).getResource(); + + assertNotNull(resultPatient.getIdentifier()); + assertEquals(1, resultPatient.getIdentifier().size()); + Identifier addedIdentifier = resultPatient.getIdentifier().get(0); + assertEquals(Identifier.IdentifierUse.USUAL, addedIdentifier.getUse()); + checkCoding(addedIdentifier.getType()); + assertEquals(EXISTING_COVERAGE_PATIENT_IDENT_SYSTEM, addedIdentifier.getSystem()); + assertEquals(EXISTING_COVERAGE_PATIENT_IDENT_VALUE, addedIdentifier.getValue()); + } + + + @Test + public void testNoCoverageMatchFound() throws Exception { + // Patient doesn't participate in match + Patient patient = new Patient().setGender(Enumerations.AdministrativeGender.FEMALE); + + // Old Coverage + Coverage oldCoverage = new Coverage(); + oldCoverage.setId("9876B1"); + oldCoverage.setIdentifier(Lists.newArrayList( + new Identifier().setSystem("http://oldhealthplan.example.com").setValue("DH10001235"))); + + // New Coverage + Organization newOrg = new Organization(); + newOrg.setId("Organization/ProviderOrg1"); + newOrg.setName("New Health Plan"); + + Coverage newCoverage = new Coverage(); + newCoverage.setId("AA87654"); + newCoverage.getContained().add(newOrg); + newCoverage.setIdentifier(Lists.newArrayList( + new Identifier().setSystem("http://newealthplan.example.com").setValue("234567"))); + + Parameters inputParameters = buildInputParameters(patient, oldCoverage, newCoverage); + performOperationExpecting422(ourServerBase + ourQuery, EncodingEnum.JSON, inputParameters, + "Could not find coverage for member"); + } + + + private Parameters buildInputParameters(Patient thePatient, Coverage theOldCoverage, Coverage theNewCoverage) { + Parameters p = new Parameters(); + ParametersUtil.addParameterToParameters(getContext(), p, PARAM_MEMBER_PATIENT, thePatient); + ParametersUtil.addParameterToParameters(getContext(), p, PARAM_OLD_COVERAGE, theOldCoverage); + ParametersUtil.addParameterToParameters(getContext(), p, PARAM_NEW_COVERAGE, theNewCoverage); + return p; + } + + + private Parameters performOperation(String theUrl, + EncodingEnum theEncoding, Parameters theInputParameters) throws Exception { + + HttpPost post = new HttpPost(theUrl); + post.addHeader(Constants.HEADER_ACCEPT_ENCODING, theEncoding.toString()); + post.setEntity(new ResourceEntity(getContext(), theInputParameters)); + ourLog.info("Request: {}", post); + try (CloseableHttpResponse response = ourHttpClient.execute(post)) { + assertEquals(200, response.getStatusLine().getStatusCode()); + + return theEncoding.newParser(myFhirCtx).parseResource(Parameters.class, + IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8)); + } + } + + + private void performOperationExpecting422(String theUrl, EncodingEnum theEncoding, + Parameters theInputParameters, String theExpectedErrorMsg) throws Exception { + + HttpPost post = new HttpPost(theUrl); + post.addHeader(Constants.HEADER_ACCEPT_ENCODING, theEncoding.toString()); + post.setEntity(new ResourceEntity(getContext(), theInputParameters)); + ourLog.info("Request: {}", post); + try (CloseableHttpResponse response = ourHttpClient.execute(post)) { + String responseString = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8); + ourLog.info("Response: {}", responseString); + assertEquals(422, response.getStatusLine().getStatusCode()); + assertThat(responseString, containsString(theExpectedErrorMsg)); + } + } + + + private void checkCoding(CodeableConcept theType) { + // must match: + // "coding": [ + // { + // "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + // "code": "UMB", + // "display": "Member Number", + // "userSelected": false + // } + // * ] + Coding coding = theType.getCoding().get(0); + assertEquals("http://terminology.hl7.org/CodeSystem/v2-0203", coding.getSystem()); + assertEquals("UMB", coding.getCode()); + assertEquals("Member Number", coding.getDisplay()); + assertFalse(coding.getUserSelected()); + } + + + +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/provider/ProviderConstants.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/provider/ProviderConstants.java index 236841320ed..6f5f65fd50d 100644 --- a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/provider/ProviderConstants.java +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/provider/ProviderConstants.java @@ -186,6 +186,11 @@ public class ProviderConstants { */ public static final String OPERATION_REINDEX_RESPONSE_JOB_ID = "jobId"; + /** + * Operation name for the $member-match operation + */ + public static final String OPERATION_MEMBER_MATCH = "$member-match"; + @Deprecated public static final String MARK_ALL_RESOURCES_FOR_REINDEXING = "$mark-all-resources-for-reindexing"; /** diff --git a/pom.xml b/pom.xml index 56285d07bad..a2a296dff28 100644 --- a/pom.xml +++ b/pom.xml @@ -2519,6 +2519,7 @@ Adding Google analytics in target/site for <body> + ]]> @@ -2843,6 +2844,7 @@ validate generate-sources + ${maven.multiModuleProjectDirectory}/src/checkstyle/checkstyle_config_nofixmes.xml UTF-8 true