diff --git a/src/main/java/io/github/carlos_emr/carlos/commn/model/Prevention.java b/src/main/java/io/github/carlos_emr/carlos/commn/model/Prevention.java index 1e08253ad2f..dde65be821c 100644 --- a/src/main/java/io/github/carlos_emr/carlos/commn/model/Prevention.java +++ b/src/main/java/io/github/carlos_emr/carlos/commn/model/Prevention.java @@ -235,6 +235,27 @@ public String getDeletedRawValue() { return String.valueOf(deleted); } + /** + * Returns the raw integer value of the {@code refused} field. + *

+ * Standard semantics: {@code 0}=active/completed, {@code 1}=refused, + * {@code 2}=ineligible, {@code 3}=completed externally. + *

+ * For the "Smoking" prevention type, this field is repurposed to encode + * smoking history status: {@code 0}=Yes (current smoker), {@code 1}=No (non-smoker), + * {@code 2}=Previous (ex-smoker). + * + * @return int raw refused status value (0, 1, 2, or 3), or {@code -1} if the stored + * value is outside the expected range + * @since 2026-03-14 + */ + public int getRefusedRawValue() { + if (refused >= '0' && refused <= '3') { + return refused - '0'; + } + return -1; + } + public List getPreventionExts() { return this.preventionExts; } diff --git a/src/main/java/io/github/carlos_emr/carlos/prevention/Prevention.java b/src/main/java/io/github/carlos_emr/carlos/prevention/Prevention.java index ae2920f848a..e646b3c4bc3 100644 --- a/src/main/java/io/github/carlos_emr/carlos/prevention/Prevention.java +++ b/src/main/java/io/github/carlos_emr/carlos/prevention/Prevention.java @@ -279,7 +279,7 @@ public boolean isPreventionNever(String preventionType) { } public boolean isNotPreventionNever(String preventionType) { - return !isNotPreventionNever(preventionType); + return !isPreventionNever(preventionType); } @@ -302,6 +302,65 @@ public boolean isNotInelligible(String preventionType) { return !isInelligible(preventionType); } + /** + * Returns the raw refused status value for the most recent prevention record of the given type. + *

+ * Standard semantics: {@code 0}=active/completed, {@code 1}=refused, + * {@code 2}=ineligible, {@code 3}=completed externally. + *

+ * For the "Smoking" prevention type, the refused field is repurposed to encode + * smoking history status: + *

+ *

+ * Important note on {@code isPreventionNever("Smoking")}: The {@code never} + * column in the preventions table is separate from the {@code refused} column and is + * always {@code 0} for all three smoking states. Therefore {@code isPreventionNever} + * cannot be used to detect non-smokers; use {@code isCurrentlySmoking} instead. + * + * @param preventionType String the prevention type key (e.g. "Smoking", "LDCT") + * @return int the raw refused status value, or {@code -1} if no record exists + * @since 2026-03-14 + */ + public int getRefusedStatus(String preventionType) { + int status = -1; + Vector vec = (Vector) preventionTypes.get(preventionType); + if (vec != null && !vec.isEmpty()) { + PreventionItem p = (PreventionItem) vec.get(vec.size() - 1); + status = p.getRefusedStatus(); + } + return status; + } + + /** + * Returns {@code true} if the most recent prevention record for the given type indicates + * a "currently active" or "yes" response (refused status = 0). + *

+ * For the "Smoking" prevention type specifically, this returns {@code true} when the + * patient is a current smoker. The Smoking assessment stores its answer in the + * {@code refused} field: {@code 0}=Yes (current smoker), {@code 1}=No (non-smoker), + * {@code 2}=Previous (ex-smoker). + *

+ * Use this method instead of {@code !isPreventionNever("Smoking")} for LDCT eligibility + * checks. The {@code never} column is always {@code 0} for all smoking states and cannot + * distinguish current smokers from non-smokers. + * + * @param preventionType String the prevention type key; must be {@code "Smoking"} — + * returns {@code false} for any other type to prevent semantic misuse + * @return boolean {@code true} if preventionType is "Smoking" and the most recent record + * has refused status = 0 (current smoker) + * @since 2026-03-14 + */ + public boolean isCurrentlySmoking(String preventionType) { + if (!"Smoking".equals(preventionType)) { + return false; + } + return getRefusedStatus(preventionType) == 0; + } + public int getHowManyMonthsSinceLast(String preventionType) { int retval = -1; diff --git a/src/main/java/io/github/carlos_emr/carlos/prevention/PreventionItem.java b/src/main/java/io/github/carlos_emr/carlos/prevention/PreventionItem.java index 5b0dacdb81f..7e06f798e22 100644 --- a/src/main/java/io/github/carlos_emr/carlos/prevention/PreventionItem.java +++ b/src/main/java/io/github/carlos_emr/carlos/prevention/PreventionItem.java @@ -46,6 +46,11 @@ public class PreventionItem { Date nextDate = null; String never = null; boolean refused; + // Raw refused status value (0=active, 1=refused, 2=ineligible, 3=completedExternally). + // For the "Smoking" type: 0=Yes (current), 1=No (non-smoker), 2=Previous (ex-smoker). + // Defaults to -1 (unknown/uninitialized) to avoid misclassifying items built via + // constructors that do not hydrate this field from a Prevention record. + private int refusedStatus = -1; private boolean inelligible = false; private boolean remoteEntry = false; @@ -78,6 +83,7 @@ public PreventionItem(Prevention pp) { this.never = ConversionUtils.toBoolString(pp.isNever()); this.nextDate = pp.getNextDate(); this.refused = pp.isRefused(); + this.refusedStatus = pp.getRefusedRawValue(); this.inelligible = pp.isIneligible(); } @@ -89,6 +95,23 @@ public boolean getNeverVal() { return ret; } + /** + * Returns the raw refused status value from the underlying prevention record. + *

+ * Standard semantics: {@code 0}=active/completed, {@code 1}=refused, + * {@code 2}=ineligible, {@code 3}=completed externally. + *

+ * For the "Smoking" prevention type, this field is repurposed to encode + * smoking history: {@code 0}=Yes (current smoker), {@code 1}=No (non-smoker), + * {@code 2}=Previous (ex-smoker). + * + * @return int raw refused status value + * @since 2026-03-14 + */ + public int getRefusedStatus() { + return refusedStatus; + } + /** * Getter for property datePreformed. * diff --git a/src/main/resources/oscar/oscarPrevention/prevention.drl b/src/main/resources/oscar/oscarPrevention/prevention.drl index cb3ac54de4a..e3e51f6c2c5 100644 --- a/src/main/resources/oscar/oscarPrevention/prevention.drl +++ b/src/main/resources/oscar/oscarPrevention/prevention.drl @@ -51,6 +51,12 @@ // Status: isPreventionNever(String), isNextDateSet(String), isPassedNextDate(String), // isInelligible(String), isLastPreventionWithinRange(String, String, String), // isTodayinDateRange(String, String) +// Smoking: isCurrentlySmoking(String), getRefusedStatus(String) +// NOTE: isPreventionNever("Smoking") is INCORRECT for smoking checks — the +// "never" column is always 0 for all smoking states (Yes/No/Previous). +// The smoking answer is stored in the "refused" column: +// 0=Yes (current smoker), 1=No (non-smoker), 2=Previous (ex-smoker). +// Use isCurrentlySmoking("Smoking") to check for active smoking status. // Sex: isMale(), isFemale() // // Consequence methods: @@ -1144,13 +1150,20 @@ end // LDCT: Low-Dose CT Lung Cancer Screening (2 rules) // Canadian guideline (CTFPHC 2016): Annual LDCT for adults aged 55-74 who are // high-risk smokers (30+ pack-years or equivalent significant smoking history) -// Rule 1: WARNING if no LDCT on record for patients aged 55-74 with positive smoking history +// Rule 1: WARNING if no LDCT on record for patients aged 55-74 who are current smokers // Rule 2: WARNING if LDCT count is 1 or 2 and it is > 12 months old (annual x3 years) // LDCT 2 stops firing after 3rd annual screen (count >= 3). -// Precondition (heavy smoker detection): Smoking assessment must be on record AND -// not marked as "never" (non-smoker). This uses the Prevention module's "Smoking" type. -// A Smoking record exists (getNumberOfPreventionType("Smoking") > 0) AND -// the record is NOT marked as never-smoked (!isPreventionNever("Smoking")). +// +// Smoking status check (LDCT 1): +// The Smoking prevention type uses the "refused" column (NOT the "never" column) to +// store the patient's smoking answer. The "never" column is always 0 for all smoking +// states, so isPreventionNever("Smoking") cannot be used here. +// Smoking refused column encoding: +// 0 = Yes (currently smoking) <-- LDCT rule should fire +// 1 = No (not smoking) <-- LDCT rule should NOT fire +// 2 = Previous (ex-smoker) <-- LDCT rule should NOT fire +// isCurrentlySmoking("Smoking") returns true only when refused = 0. +// // Note: Full heavy-smoker detection (pack-years, cigarettes/day) requires measurement // values (NOSK > 23, POSK > 0, SKST = yes, SMK = yes, SmkS > 23) which are stored in // the measurements table and are not accessible from the Prevention DRL fact object. @@ -1162,7 +1175,7 @@ rule "LDCT 1" eval( prev.getAgeInYears() >= 55 ) eval( prev.getAgeInYears() <= 74 ) eval( prev.getNumberOfPreventionType("Smoking") > 0 ) - eval( !prev.isPreventionNever("Smoking") ) + eval( prev.isCurrentlySmoking("Smoking") ) eval( prev.getHowManyMonthsSinceLast("LDCT") == -1 ) eval( !prev.isPreventionNever("LDCT") ) eval( !prev.isInelligible("LDCT") )