Skip to content

Commit a577a7d

Browse files
BFD-4593 Add EOB Outcome Search (#3174)
Co-authored-by: patrickgmayday <patrick.gulrajani@maydaystrategies.tech>
1 parent 28b0cef commit a577a7d

23 files changed

Lines changed: 682 additions & 106 deletions

apps/bfd-server-ng/src/main/java/gov/cms/bfd/server/ng/claim/ClaimRepository.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ public class ClaimRepository {
8787
public List<ClaimBase> findByIds(
8888
@MeterTag(key = "hasServiceUpdated", expression = "hasServiceUpdated()")
8989
@MeterTag(key = "hasLastUpdated", expression = "hasLasUpdated()")
90+
@MeterTag(key = "hasOutcomes", expression = "hasOutcomes()")
9091
@MeterTag(key = "hasSources", expression = "hasSources()")
9192
ClaimIdSearchCriteria criteria) {
9293
if (criteria.claimUniqueIds() == null || criteria.claimUniqueIds().isEmpty()) {
@@ -96,6 +97,7 @@ public List<ClaimBase> findByIds(
9697
List.of(
9798
new BillablePeriodFilterParam(criteria.serviceDate()),
9899
new LastUpdatedFilterParam(criteria.lastUpdated()),
100+
new OutcomeFilterParam(criteria.outcomes()),
99101
new SourceFilterParam(criteria.sources()));
100102

101103
var professionalSharedSystemsClaims =
@@ -169,6 +171,7 @@ public List<ClaimBase> findByBeneXrefSk(
169171
@MeterTag(key = "hasLastUpdated", expression = "hasLasUpdated()")
170172
@MeterTag(key = "hasTags", expression = "hasTags()")
171173
@MeterTag(key = "hasClaimTypeCodes", expression = "hasClaimTypeCodes()")
174+
@MeterTag(key = "hasOutcomes", expression = "hasOutcomes()")
172175
@MeterTag(key = "hasSources", expression = "hasSources()")
173176
ClaimSearchCriteria criteria) {
174177

@@ -178,6 +181,7 @@ public List<ClaimBase> findByBeneXrefSk(
178181
new LastUpdatedFilterParam(criteria.lastUpdated()),
179182
new ClaimTypeCodeFilterParam(criteria.claimTypeCodes()),
180183
new TagCriteriaFilterParam(criteria.tagCriteria()),
184+
new OutcomeFilterParam(criteria.outcomes()),
181185
new SourceFilterParam(criteria.sources()));
182186

183187
var futures =
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package gov.cms.bfd.server.ng.claim.filter;
2+
3+
import gov.cms.bfd.server.ng.DbFilter;
4+
import gov.cms.bfd.server.ng.DbFilterBuilder;
5+
import gov.cms.bfd.server.ng.DbFilterParam;
6+
import gov.cms.bfd.server.ng.claim.model.ClaimPaidStatusCode;
7+
import gov.cms.bfd.server.ng.claim.model.SystemType;
8+
import java.util.ArrayList;
9+
import java.util.List;
10+
import java.util.Optional;
11+
import org.hl7.fhir.r4.model.ExplanationOfBenefit;
12+
import org.jetbrains.annotations.NotNull;
13+
14+
/**
15+
* Filters claims by outcome.
16+
*
17+
* @param outcomes EOB claim outcomes
18+
*/
19+
public record OutcomeFilterParam(List<List<ExplanationOfBenefit.RemittanceOutcome>> outcomes)
20+
implements DbFilterBuilder {
21+
private static final List<Optional<ClaimPaidStatusCode>> COMPLETE_SHARED_SYSTEMS_STATUS_CODES =
22+
ClaimPaidStatusCode.findByOutcome(ExplanationOfBenefit.RemittanceOutcome.COMPLETE).stream()
23+
.map(Optional::of)
24+
.toList();
25+
26+
@NotNull
27+
@Override
28+
public DbFilter getFilters(@NotNull String tableAlias, @NotNull SystemType systemType) {
29+
if (outcomes.isEmpty()) {
30+
return DbFilter.empty();
31+
}
32+
33+
var stringBuilder = new StringBuilder();
34+
var params = new ArrayList<DbFilterParam>();
35+
36+
for (var i = 0; i < outcomes.size(); i++) {
37+
var orList = outcomes.get(i);
38+
if (orList.isEmpty()) {
39+
continue;
40+
}
41+
42+
var filter = getOutcomeFilter(tableAlias, systemType, orList, i);
43+
stringBuilder.append(filter.filterClause());
44+
params.addAll(filter.params());
45+
}
46+
47+
return new DbFilter(stringBuilder.toString(), params);
48+
}
49+
50+
private DbFilter getOutcomeFilter(
51+
String tableAlias,
52+
SystemType systemType,
53+
List<ExplanationOfBenefit.RemittanceOutcome> orList,
54+
int index) {
55+
56+
switch (systemType) {
57+
case NCH, DDPS -> {
58+
if (orList.contains(ExplanationOfBenefit.RemittanceOutcome.COMPLETE)) {
59+
return DbFilter.empty();
60+
}
61+
return noMatchesFilter();
62+
}
63+
case SS -> {
64+
var clauses = new ArrayList<String>();
65+
var params = new ArrayList<DbFilterParam>();
66+
67+
if (orList.contains(ExplanationOfBenefit.RemittanceOutcome.COMPLETE)) {
68+
var paramName = String.format("completeOutcomeStatuses_%d", index);
69+
clauses.add(String.format("%s.claimPaidStatusCode IN :%s", tableAlias, paramName));
70+
params.add(new DbFilterParam(paramName, COMPLETE_SHARED_SYSTEMS_STATUS_CODES));
71+
}
72+
73+
if (orList.contains(ExplanationOfBenefit.RemittanceOutcome.PARTIAL)) {
74+
var paramName = String.format("completeOutcomeStatusesForPartial_%d", index);
75+
clauses.add(
76+
String.format(
77+
"(%s.claimPaidStatusCode IS NULL OR %s.claimPaidStatusCode NOT IN :%s)",
78+
tableAlias, tableAlias, paramName));
79+
params.add(new DbFilterParam(paramName, COMPLETE_SHARED_SYSTEMS_STATUS_CODES));
80+
}
81+
82+
if (clauses.isEmpty()) {
83+
return noMatchesFilter();
84+
}
85+
86+
return new DbFilter(String.format(" AND (%s)", String.join(" OR ", clauses)), params);
87+
}
88+
default -> {
89+
return noMatchesFilter();
90+
}
91+
}
92+
}
93+
94+
private static DbFilter noMatchesFilter() {
95+
return new DbFilter(" AND FALSE", List.of());
96+
}
97+
98+
@Override
99+
public boolean matchesSystemType(@NotNull SystemType systemType) {
100+
if (outcomes.isEmpty()) {
101+
return true;
102+
}
103+
104+
var requestedOutcomes = outcomes.stream().flatMap(List::stream).toList();
105+
106+
return switch (systemType) {
107+
case NCH, DDPS -> requestedOutcomes.contains(ExplanationOfBenefit.RemittanceOutcome.COMPLETE);
108+
case SS ->
109+
requestedOutcomes.contains(ExplanationOfBenefit.RemittanceOutcome.COMPLETE)
110+
|| requestedOutcomes.contains(ExplanationOfBenefit.RemittanceOutcome.PARTIAL);
111+
default -> false;
112+
};
113+
}
114+
}

apps/bfd-server-ng/src/main/java/gov/cms/bfd/server/ng/claim/model/ClaimBase.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,4 +147,29 @@ protected ExplanationOfBenefit sortedEob(ExplanationOfBenefit eob) {
147147
* @return the ClaimRelatedCondition
148148
*/
149149
public abstract Optional<ClaimRelatedCondition> getClaimRelatedCondition();
150+
151+
/**
152+
* Returns the claim paid status code if applicable to this claim source type. Defaults to empty
153+
* for base claims (like NCH/DDPS) that do not track this field.
154+
*
155+
* @return an optional containing the claim paid status code
156+
*/
157+
public Optional<ClaimPaidStatusCode> getClaimPaidStatusCode() {
158+
return Optional.empty();
159+
}
160+
161+
/**
162+
* Shared Systems claims use CLM_PD_STUS_CD to determine outcome, no longer using audit-trail
163+
* logic. Standard base claims with no status code will ignore this.
164+
*
165+
* @param eob the EOB being built
166+
*/
167+
public void applyOutcomeOverride(ExplanationOfBenefit eob) {
168+
// Only Shared Systems claims derive outcome from CLM_PD_STUS_CD. Missing or unmapped paid
169+
// status codes are resolved as PARTIAL to match the outcome search filter behavior.
170+
if (this instanceof ClaimInstitutionalSharedSystems
171+
|| this instanceof ClaimProfessionalSharedSystems) {
172+
ClaimPaidStatusCode.resolveOutcome(getClaimPaidStatusCode()).ifPresent(eob::setOutcome);
173+
}
174+
}
150175
}

apps/bfd-server-ng/src/main/java/gov/cms/bfd/server/ng/claim/model/ClaimInstitutionalBase.java

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,6 @@ public abstract class ClaimInstitutionalBase extends ClaimBase {
7676
*/
7777
abstract void addSubclassCareTeam(ExplanationOfBenefit eob, SequenceGenerator sequenceGenerator);
7878

79-
/**
80-
* Optionally overrides the EOB outcome. NCH has no override
81-
*
82-
* @param eob the EOB being built
83-
*/
84-
protected void applyOutcomeOverride(ExplanationOfBenefit eob) {}
85-
8679
@Override
8780
public ExplanationOfBenefit toFhir(ClaimFilterOptions options, ClaimState claimState) {
8881
var eob = super.toFhir(options, claimState);

apps/bfd-server-ng/src/main/java/gov/cms/bfd/server/ng/claim/model/ClaimInstitutionalSharedSystems.java

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
package gov.cms.bfd.server.ng.claim.model;
22

3+
import static gov.cms.bfd.server.ng.claim.model.ClaimDiagnosisType.*;
4+
5+
import gov.cms.bfd.server.ng.converter.ClaimPaidStatusCodeConverter;
36
import gov.cms.bfd.server.ng.util.SequenceGenerator;
47
import jakarta.persistence.AttributeOverride;
58
import jakarta.persistence.Column;
9+
import jakarta.persistence.Convert;
610
import jakarta.persistence.Embedded;
711
import jakarta.persistence.Entity;
812
import jakarta.persistence.FetchType;
@@ -51,6 +55,10 @@ public class ClaimInstitutionalSharedSystems extends ClaimInstitutionalBase {
5155
@JoinColumn(name = "clm_uniq_id")
5256
private SortedSet<ClaimItemInstitutionalSharedSystems> claimItems;
5357

58+
@Column(name = "clm_pd_stus_cd")
59+
@Convert(converter = ClaimPaidStatusCodeConverter.class)
60+
private Optional<ClaimPaidStatusCode> claimPaidStatusCode;
61+
5462
@Override
5563
Optional<ClaimRecordType> getClaimRecordTypeOptional() {
5664
return Optional.of(claimRecordType);
@@ -85,18 +93,6 @@ public static SystemType getSystemType() {
8593
return claimRecordType.toFhir(supportingInfoFactory).stream().toList();
8694
}
8795

88-
@Override
89-
protected void applyOutcomeOverride(ExplanationOfBenefit eob) {
90-
var auditTrailStatusCode =
91-
claimAuditTrailStatusCode.flatMap(
92-
status ->
93-
ClaimAuditTrailStatusCode.tryFromCode(
94-
getMetaSourceSk(), status, ClaimAuditTrailLocationCode.NA));
95-
auditTrailStatusCode.ifPresentOrElse(
96-
status -> eob.setOutcome(status.getOutcome(getFinalAction())),
97-
() -> eob.setOutcome(ExplanationOfBenefit.RemittanceOutcome.PARTIAL));
98-
}
99-
10096
/** NCH has no additional care-team members beyond the referring provider added by the base. */
10197
@Override
10298
protected void addSubclassCareTeam(
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package gov.cms.bfd.server.ng.claim.model;
2+
3+
import java.util.Arrays;
4+
import java.util.List;
5+
import java.util.Optional;
6+
import lombok.AllArgsConstructor;
7+
import lombok.Getter;
8+
import org.hl7.fhir.r4.model.ExplanationOfBenefit;
9+
10+
/** The paid status code of the claim. */
11+
@Getter
12+
@AllArgsConstructor
13+
public enum ClaimPaidStatusCode {
14+
15+
/** P. */
16+
P("P", ExplanationOfBenefit.RemittanceOutcome.COMPLETE),
17+
18+
/** 1. */
19+
NUM_1("1", ExplanationOfBenefit.RemittanceOutcome.COMPLETE),
20+
21+
/** R. */
22+
R("R", ExplanationOfBenefit.RemittanceOutcome.COMPLETE),
23+
24+
/** 2. */
25+
NUM_2("2", ExplanationOfBenefit.RemittanceOutcome.COMPLETE),
26+
27+
/** D. */
28+
D("D", ExplanationOfBenefit.RemittanceOutcome.COMPLETE),
29+
30+
/** Y. */
31+
Y("Y", ExplanationOfBenefit.RemittanceOutcome.COMPLETE),
32+
33+
/** Empty value (normalized from "~"). */
34+
EMPTY("", ExplanationOfBenefit.RemittanceOutcome.PARTIAL),
35+
36+
/** I. */
37+
I("I", ExplanationOfBenefit.RemittanceOutcome.PARTIAL),
38+
39+
/** S. */
40+
S("S", ExplanationOfBenefit.RemittanceOutcome.PARTIAL),
41+
42+
/** T. */
43+
T("T", ExplanationOfBenefit.RemittanceOutcome.PARTIAL);
44+
45+
private final String code;
46+
private final ExplanationOfBenefit.RemittanceOutcome outcome;
47+
48+
/**
49+
* Convert from a database code.
50+
*
51+
* @param code database code
52+
* @return claim paid status code
53+
*/
54+
public static Optional<ClaimPaidStatusCode> tryFromCode(String code) {
55+
if (code == null) {
56+
return Optional.empty();
57+
}
58+
59+
return Arrays.stream(values()).filter(v -> v.code.equals(code.trim())).findFirst();
60+
}
61+
62+
/**
63+
* Gets all claim paid status codes that map to the provided remittance outcome.
64+
*
65+
* @param outcome the remittance outcome
66+
* @return claim paid status codes for the outcome
67+
*/
68+
public static List<ClaimPaidStatusCode> findByOutcome(
69+
ExplanationOfBenefit.RemittanceOutcome outcome) {
70+
return Arrays.stream(values())
71+
.filter(statusCode -> outcome.equals(statusCode.getOutcome()))
72+
.toList();
73+
}
74+
75+
/**
76+
* Safely resolves the remittance outcome from a status code, defaulting to PARTIAL if the status
77+
* code or its mapped outcome is empty.
78+
*
79+
* @param statusCode the status code to evaluate
80+
* @return the resolved FHIR RemittanceOutcome
81+
*/
82+
public static Optional<ExplanationOfBenefit.RemittanceOutcome> resolveOutcome(
83+
Optional<ClaimPaidStatusCode> statusCode) {
84+
return statusCode
85+
.map(ClaimPaidStatusCode::getOutcome)
86+
.or(() -> Optional.of(ExplanationOfBenefit.RemittanceOutcome.PARTIAL));
87+
}
88+
}

apps/bfd-server-ng/src/main/java/gov/cms/bfd/server/ng/claim/model/ClaimProfessionalBase.java

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,16 +51,6 @@ public abstract class ClaimProfessionalBase extends ClaimBase {
5151

5252
abstract Optional<ClaimRecordType> getClaimRecordTypeOptional();
5353

54-
/**
55-
* Optionally overrides the EOB outcome for subclass-specific logic (e.g. SS PAC-stage-2 audit
56-
* trail status).
57-
*
58-
* @param eob the EOB being built
59-
*/
60-
void applyOutcomeOverride(ExplanationOfBenefit eob) {
61-
// default: no override SS overrides this
62-
}
63-
6454
/** {@inheritDoc} */
6555
@Override
6656
public ExplanationOfBenefit toFhir(ClaimFilterOptions options, ClaimState claimState) {

apps/bfd-server-ng/src/main/java/gov/cms/bfd/server/ng/claim/model/ClaimProfessionalSharedSystems.java

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
import static gov.cms.bfd.server.ng.claim.model.ClaimSubtype.PDE;
44

5+
import gov.cms.bfd.server.ng.converter.ClaimPaidStatusCodeConverter;
56
import gov.cms.bfd.server.ng.util.SequenceGenerator;
67
import jakarta.persistence.Column;
8+
import jakarta.persistence.Convert;
79
import jakarta.persistence.Embedded;
810
import jakarta.persistence.Entity;
911
import jakarta.persistence.FetchType;
@@ -60,6 +62,10 @@ public class ClaimProfessionalSharedSystems extends ClaimProfessionalBase {
6062
@JoinColumn(name = "clm_uniq_id")
6163
private SortedSet<ClaimItemProfessionalSharedSystems> claimItems;
6264

65+
@Column(name = "clm_pd_stus_cd")
66+
@Convert(converter = ClaimPaidStatusCodeConverter.class)
67+
private Optional<ClaimPaidStatusCode> claimPaidStatusCode;
68+
6369
/**
6470
* SS-specific supporting info: blood pints, primary payor code, contractor number, submission
6571
* date, provider assignment switch, and clinical trial number.
@@ -121,22 +127,6 @@ protected void addSubclassCareTeam(
121127
.ifPresent(eob::addCareTeam);
122128
}
123129

124-
/**
125-
* For PAC claims, the outcome is driven by the audit-trail status code, audit-status code, and
126-
* the meta source sk rather than the default claim-type outcome.
127-
*/
128-
@Override
129-
protected void applyOutcomeOverride(ExplanationOfBenefit eob) {
130-
var auditTrailStatusCode =
131-
claimAuditTrailStatusCode.flatMap(
132-
status ->
133-
ClaimAuditTrailStatusCode.tryFromCode(
134-
getMetaSourceSk(), status, claimAuditTrailLocationCode));
135-
auditTrailStatusCode.ifPresentOrElse(
136-
status -> eob.setOutcome(status.getOutcome(getFinalAction())),
137-
() -> eob.setOutcome(ExplanationOfBenefit.RemittanceOutcome.PARTIAL));
138-
}
139-
140130
@Override
141131
public SortedSet<ClaimItemBase> getItems() {
142132
return new TreeSet<ClaimItemBase>(getClaimItems());

0 commit comments

Comments
 (0)