hccinfhir 0.2.2__py3-none-any.whl → 0.2.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hccinfhir/datamodels.py +121 -6
- hccinfhir/extractor_834.py +552 -359
- hccinfhir/sample_files/sample_834_02.txt +1 -0
- hccinfhir/sample_files/sample_834_03.txt +1 -0
- hccinfhir/sample_files/sample_834_04.txt +1 -0
- hccinfhir/sample_files/sample_834_05.txt +1 -0
- hccinfhir/sample_files/sample_834_06.txt +1 -0
- {hccinfhir-0.2.2.dist-info → hccinfhir-0.2.3.dist-info}/METADATA +5 -5
- {hccinfhir-0.2.2.dist-info → hccinfhir-0.2.3.dist-info}/RECORD +11 -6
- {hccinfhir-0.2.2.dist-info → hccinfhir-0.2.3.dist-info}/WHEEL +0 -0
- {hccinfhir-0.2.2.dist-info → hccinfhir-0.2.3.dist-info}/licenses/LICENSE +0 -0
hccinfhir/extractor_834.py
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
|
|
1
|
+
"""
|
|
2
|
+
X12 834 Benefit Enrollment Parser for California DHCS Medi-Cal
|
|
3
|
+
|
|
4
|
+
Extracts enrollment and demographic data from 834 transactions with focus on:
|
|
5
|
+
- Risk adjustment fields (dual eligibility, OREC/CREC, SNP, LTI)
|
|
6
|
+
- CA DHCS FAME-specific fields
|
|
7
|
+
- HCP (Health Care Plan) coverage history
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import List, Optional, Dict, Tuple
|
|
2
11
|
from pydantic import BaseModel
|
|
3
12
|
from datetime import datetime, date
|
|
4
|
-
from hccinfhir.datamodels import Demographics, EnrollmentData
|
|
13
|
+
from hccinfhir.datamodels import Demographics, EnrollmentData, HCPCoveragePeriod
|
|
5
14
|
from hccinfhir.constants import (
|
|
6
15
|
VALID_DUAL_CODES,
|
|
7
16
|
FULL_BENEFIT_DUAL_CODES,
|
|
8
17
|
PARTIAL_BENEFIT_DUAL_CODES,
|
|
9
|
-
VALID_OREC_VALUES,
|
|
10
18
|
VALID_CREC_VALUES,
|
|
11
19
|
X12_SEX_CODE_MAPPING,
|
|
12
20
|
NON_DUAL_CODE,
|
|
@@ -14,33 +22,88 @@ from hccinfhir.constants import (
|
|
|
14
22
|
map_aid_code_to_dual_status,
|
|
15
23
|
)
|
|
16
24
|
|
|
17
|
-
|
|
18
|
-
|
|
25
|
+
# Constants
|
|
26
|
+
TRANSACTION_TYPES = {"005010X220A1": "834"}
|
|
27
|
+
|
|
28
|
+
LANGUAGE_CODES = {
|
|
29
|
+
'SPA': 'Spanish', 'ENG': 'English', 'CHI': 'Chinese', 'VIE': 'Vietnamese',
|
|
30
|
+
'KOR': 'Korean', 'TAG': 'Tagalog', 'ARM': 'Armenian', 'FAR': 'Farsi',
|
|
31
|
+
'ARA': 'Arabic', 'RUS': 'Russian', 'JPN': 'Japanese', 'HIN': 'Hindi',
|
|
32
|
+
'CAM': 'Cambodian', 'HMO': 'Hmong', 'LAO': 'Lao', 'THA': 'Thai',
|
|
19
33
|
}
|
|
20
34
|
|
|
35
|
+
MEDICARE_KEYWORDS = {'MEDICARE', 'MA', 'PART A', 'PART B', 'PART C', 'PART D', 'MEDICARE ADVANTAGE', 'MA-PD'}
|
|
36
|
+
MEDICAID_KEYWORDS = {'MEDICAID', 'MEDI-CAL', 'MEDI CAL', 'MEDIC-AID', 'LTC'}
|
|
37
|
+
SNP_KEYWORDS = {'SNP', 'SPECIAL NEEDS', 'D-SNP', 'DSNP', 'DUAL ELIGIBLE SNP'}
|
|
38
|
+
LTI_KEYWORDS = {'LTC', 'LONG TERM CARE', 'LONG-TERM CARE', 'NURSING HOME', 'SKILLED NURSING', 'SNF', 'INSTITUTIONALIZED'}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class HCPContext(BaseModel):
|
|
42
|
+
"""Single HD loop (HCP coverage period)"""
|
|
43
|
+
start_date: Optional[str] = None
|
|
44
|
+
end_date: Optional[str] = None
|
|
45
|
+
hcp_code: Optional[str] = None
|
|
46
|
+
hcp_status: Optional[str] = None
|
|
47
|
+
aid_codes: Optional[str] = None
|
|
48
|
+
|
|
49
|
+
|
|
21
50
|
class MemberContext(BaseModel):
|
|
22
51
|
"""Tracks member-level data across segments within 834 transaction"""
|
|
23
52
|
# Identifiers
|
|
24
53
|
member_id: Optional[str] = None
|
|
25
|
-
mbi: Optional[str] = None
|
|
54
|
+
mbi: Optional[str] = None
|
|
26
55
|
medicaid_id: Optional[str] = None
|
|
56
|
+
hic: Optional[str] = None
|
|
57
|
+
|
|
58
|
+
# Name
|
|
59
|
+
first_name: Optional[str] = None
|
|
60
|
+
last_name: Optional[str] = None
|
|
61
|
+
middle_name: Optional[str] = None
|
|
27
62
|
|
|
28
63
|
# Demographics
|
|
29
64
|
dob: Optional[str] = None
|
|
30
65
|
sex: Optional[str] = None
|
|
66
|
+
race: Optional[str] = None
|
|
67
|
+
language: Optional[str] = None
|
|
68
|
+
death_date: Optional[str] = None
|
|
69
|
+
|
|
70
|
+
# Address
|
|
71
|
+
address_1: Optional[str] = None
|
|
72
|
+
address_2: Optional[str] = None
|
|
73
|
+
city: Optional[str] = None
|
|
74
|
+
state: Optional[str] = None
|
|
75
|
+
zip: Optional[str] = None
|
|
76
|
+
phone: Optional[str] = None
|
|
31
77
|
|
|
32
78
|
# Coverage Status
|
|
33
|
-
maintenance_type: Optional[str] = None
|
|
79
|
+
maintenance_type: Optional[str] = None
|
|
80
|
+
maintenance_reason_code: Optional[str] = None
|
|
81
|
+
benefit_status_code: Optional[str] = None
|
|
34
82
|
coverage_start_date: Optional[str] = None
|
|
35
83
|
coverage_end_date: Optional[str] = None
|
|
36
84
|
|
|
37
85
|
# Medicare/Medicaid Status
|
|
38
86
|
has_medicare: bool = False
|
|
39
87
|
has_medicaid: bool = False
|
|
40
|
-
medicare_status_code: Optional[str] = None
|
|
88
|
+
medicare_status_code: Optional[str] = None
|
|
41
89
|
medi_cal_aid_code: Optional[str] = None
|
|
42
90
|
dual_elgbl_cd: Optional[str] = None
|
|
43
91
|
|
|
92
|
+
# CA DHCS / FAME Specific
|
|
93
|
+
fame_county_id: Optional[str] = None
|
|
94
|
+
case_number: Optional[str] = None
|
|
95
|
+
fame_card_issue_date: Optional[str] = None
|
|
96
|
+
fame_redetermination_date: Optional[str] = None
|
|
97
|
+
fame_death_date: Optional[str] = None
|
|
98
|
+
primary_aid_code: Optional[str] = None
|
|
99
|
+
carrier_code: Optional[str] = None
|
|
100
|
+
fed_contract_number: Optional[str] = None
|
|
101
|
+
client_reporting_cat: Optional[str] = None
|
|
102
|
+
res_addr_flag: Optional[str] = None
|
|
103
|
+
reas_add_ind: Optional[str] = None
|
|
104
|
+
res_zip_deliv_code: Optional[str] = None
|
|
105
|
+
cin_check_digit: Optional[str] = None
|
|
106
|
+
|
|
44
107
|
# Risk Adjustment Fields
|
|
45
108
|
orec: Optional[str] = None
|
|
46
109
|
crec: Optional[str] = None
|
|
@@ -48,93 +111,74 @@ class MemberContext(BaseModel):
|
|
|
48
111
|
low_income: bool = False
|
|
49
112
|
lti: bool = False
|
|
50
113
|
|
|
51
|
-
#
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
age=enrollment.age or 0,
|
|
56
|
-
sex=enrollment.sex or 'M',
|
|
57
|
-
dual_elgbl_cd=enrollment.dual_elgbl_cd,
|
|
58
|
-
orec=enrollment.orec or '',
|
|
59
|
-
crec=enrollment.crec or '',
|
|
60
|
-
new_enrollee=enrollment.new_enrollee,
|
|
61
|
-
snp=enrollment.snp,
|
|
62
|
-
low_income=enrollment.low_income,
|
|
63
|
-
lti=enrollment.lti
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
def is_losing_medicaid(enrollment: EnrollmentData, within_days: int = 90) -> bool:
|
|
67
|
-
"""Check if member will lose Medicaid within specified days
|
|
68
|
-
|
|
69
|
-
Args:
|
|
70
|
-
enrollment: EnrollmentData object
|
|
71
|
-
within_days: Number of days to look ahead (default 90)
|
|
72
|
-
|
|
73
|
-
Returns:
|
|
74
|
-
True if Medicaid coverage ends within specified days
|
|
75
|
-
"""
|
|
76
|
-
if not enrollment.coverage_end_date or not enrollment.has_medicaid:
|
|
77
|
-
return False
|
|
114
|
+
# HCP Info
|
|
115
|
+
hcp_code: Optional[str] = None
|
|
116
|
+
hcp_status: Optional[str] = None
|
|
117
|
+
amount: Optional[str] = None
|
|
78
118
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
days_until_end = (end_date - today).days
|
|
119
|
+
# HCP History
|
|
120
|
+
hcp_history: List[HCPContext] = []
|
|
121
|
+
current_hcp: Optional[HCPContext] = None
|
|
83
122
|
|
|
84
|
-
return 0 <= days_until_end <= within_days
|
|
85
|
-
except (ValueError, AttributeError):
|
|
86
|
-
return False
|
|
87
123
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
124
|
+
# ============================================================================
|
|
125
|
+
# Utility Functions
|
|
126
|
+
# ============================================================================
|
|
91
127
|
|
|
92
|
-
def
|
|
93
|
-
"""
|
|
128
|
+
def get_segment_value(segment: List[str], index: int, default: Optional[str] = None) -> Optional[str]:
|
|
129
|
+
"""Safely get value from segment at given index"""
|
|
130
|
+
if len(segment) > index and segment[index]:
|
|
131
|
+
return segment[index]
|
|
132
|
+
return default
|
|
94
133
|
|
|
95
|
-
Args:
|
|
96
|
-
enrollment: EnrollmentData object
|
|
97
|
-
|
|
98
|
-
Returns:
|
|
99
|
-
Dictionary with Medicaid status, dual eligibility, and loss indicators
|
|
100
|
-
"""
|
|
101
|
-
return {
|
|
102
|
-
'member_id': enrollment.member_id,
|
|
103
|
-
'has_medicaid': enrollment.has_medicaid,
|
|
104
|
-
'has_medicare': enrollment.has_medicare,
|
|
105
|
-
'dual_status': enrollment.dual_elgbl_cd,
|
|
106
|
-
'is_full_benefit_dual': enrollment.is_full_benefit_dual,
|
|
107
|
-
'is_partial_benefit_dual': enrollment.is_partial_benefit_dual,
|
|
108
|
-
'coverage_end_date': enrollment.coverage_end_date,
|
|
109
|
-
'is_termination': is_medicaid_terminated(enrollment),
|
|
110
|
-
'losing_medicaid_30d': is_losing_medicaid(enrollment, 30),
|
|
111
|
-
'losing_medicaid_60d': is_losing_medicaid(enrollment, 60),
|
|
112
|
-
'losing_medicaid_90d': is_losing_medicaid(enrollment, 90)
|
|
113
|
-
}
|
|
114
134
|
|
|
115
135
|
def parse_date(date_str: str) -> Optional[str]:
|
|
116
|
-
"""Convert 8-digit date string to ISO format YYYY-MM-DD"""
|
|
117
|
-
if not
|
|
136
|
+
"""Convert 8-digit date string (YYYYMMDD) to ISO format (YYYY-MM-DD)"""
|
|
137
|
+
if not date_str or len(date_str) < 8:
|
|
118
138
|
return None
|
|
119
139
|
try:
|
|
120
140
|
year, month, day = int(date_str[:4]), int(date_str[4:6]), int(date_str[6:8])
|
|
121
|
-
if
|
|
122
|
-
return
|
|
123
|
-
|
|
141
|
+
if 1900 <= year <= 2100 and 1 <= month <= 12 and 1 <= day <= 31:
|
|
142
|
+
return f"{year:04d}-{month:02d}-{day:02d}"
|
|
143
|
+
except (ValueError, IndexError):
|
|
144
|
+
pass
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def parse_yymmdd(date_str: str) -> Optional[str]:
|
|
149
|
+
"""Convert 6-digit date (YYMMDD) to ISO format"""
|
|
150
|
+
if not date_str or len(date_str) < 6:
|
|
151
|
+
return None
|
|
152
|
+
try:
|
|
153
|
+
yy = int(date_str[:2])
|
|
154
|
+
year = 2000 + yy if yy < 50 else 1900 + yy
|
|
155
|
+
return f"{year}-{date_str[2:4]}-{date_str[4:6]}"
|
|
124
156
|
except (ValueError, IndexError):
|
|
125
157
|
return None
|
|
126
158
|
|
|
159
|
+
|
|
160
|
+
def strip_leading_zeros(value: str) -> str:
|
|
161
|
+
"""Strip leading zeros but keep at least one character"""
|
|
162
|
+
return value.lstrip('0') or value
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def get_composite_part(value: str, index: int) -> Optional[str]:
|
|
166
|
+
"""Get part from semicolon-delimited composite value (0-indexed)"""
|
|
167
|
+
if not value:
|
|
168
|
+
return None
|
|
169
|
+
parts = value.split(';')
|
|
170
|
+
if len(parts) > index and parts[index]:
|
|
171
|
+
return parts[index]
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
127
175
|
def calculate_age(dob: str, reference_date: Optional[str] = None) -> Optional[int]:
|
|
128
176
|
"""Calculate age from DOB in YYYY-MM-DD format"""
|
|
129
177
|
if not dob:
|
|
130
178
|
return None
|
|
131
179
|
try:
|
|
132
180
|
birth_date = datetime.strptime(dob, "%Y-%m-%d").date()
|
|
133
|
-
if reference_date
|
|
134
|
-
ref_date = datetime.strptime(reference_date, "%Y-%m-%d").date()
|
|
135
|
-
else:
|
|
136
|
-
ref_date = date.today()
|
|
137
|
-
|
|
181
|
+
ref_date = datetime.strptime(reference_date, "%Y-%m-%d").date() if reference_date else date.today()
|
|
138
182
|
age = ref_date.year - birth_date.year
|
|
139
183
|
if (ref_date.month, ref_date.day) < (birth_date.month, birth_date.day):
|
|
140
184
|
age -= 1
|
|
@@ -142,121 +186,325 @@ def calculate_age(dob: str, reference_date: Optional[str] = None) -> Optional[in
|
|
|
142
186
|
except (ValueError, AttributeError):
|
|
143
187
|
return None
|
|
144
188
|
|
|
145
|
-
def get_segment_value(segment: List[str], index: int, default: Optional[str] = None) -> Optional[str]:
|
|
146
|
-
"""Safely get value from segment at given index"""
|
|
147
|
-
try:
|
|
148
|
-
if len(segment) > index and segment[index]:
|
|
149
|
-
return segment[index]
|
|
150
|
-
except (IndexError, TypeError):
|
|
151
|
-
pass
|
|
152
|
-
return default
|
|
153
|
-
|
|
154
|
-
def parse_composite_ref_value(value: str) -> str:
|
|
155
|
-
"""Parse X12 composite element format: 'qualifier;id;...'
|
|
156
|
-
|
|
157
|
-
X12 uses semicolons to separate sub-elements within a composite data element.
|
|
158
|
-
Example: REF*23*9;20061234; where 9 is the ID type qualifier
|
|
159
189
|
|
|
160
|
-
|
|
161
|
-
|
|
190
|
+
def is_new_enrollee(coverage_start_date: Optional[str], reference_date: Optional[str] = None) -> bool:
|
|
191
|
+
"""Determine if member is new enrollee (<= 3 months since coverage start)"""
|
|
192
|
+
if not coverage_start_date:
|
|
193
|
+
return False
|
|
194
|
+
try:
|
|
195
|
+
start_date = datetime.strptime(coverage_start_date, "%Y-%m-%d").date()
|
|
196
|
+
ref_date = datetime.strptime(reference_date, "%Y-%m-%d").date() if reference_date else date.today()
|
|
197
|
+
months_diff = (ref_date.year - start_date.year) * 12 + (ref_date.month - start_date.month)
|
|
198
|
+
return months_diff <= 3
|
|
199
|
+
except (ValueError, AttributeError):
|
|
200
|
+
return False
|
|
162
201
|
|
|
163
|
-
Returns:
|
|
164
|
-
The last non-empty sub-element (the actual ID)
|
|
165
|
-
"""
|
|
166
|
-
if not value:
|
|
167
|
-
return value
|
|
168
202
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
203
|
+
def contains_any_keyword(text: str, keywords: set) -> bool:
|
|
204
|
+
"""Check if text contains any of the keywords"""
|
|
205
|
+
text_upper = text.upper()
|
|
206
|
+
return any(kw in text_upper for kw in keywords)
|
|
173
207
|
|
|
174
|
-
return value
|
|
175
208
|
|
|
209
|
+
# ============================================================================
|
|
210
|
+
# Dual Eligibility Logic
|
|
211
|
+
# ============================================================================
|
|
176
212
|
|
|
177
213
|
def determine_dual_status(member: MemberContext) -> str:
|
|
178
|
-
"""
|
|
179
|
-
|
|
180
|
-
Priority
|
|
181
|
-
1. Explicit dual_elgbl_cd from REF segment
|
|
182
|
-
2. California Medi-Cal aid code mapping
|
|
183
|
-
3. Medicare status code (QMB, SLMB, etc.)
|
|
184
|
-
4. Presence of both Medicare and Medicaid coverage
|
|
185
|
-
5. Default to non-dual ('00')
|
|
214
|
+
"""Derive dual eligibility code from available data
|
|
215
|
+
|
|
216
|
+
Priority: explicit code > aid code mapping > medicare status > both coverages > default
|
|
186
217
|
"""
|
|
187
|
-
|
|
188
|
-
if member.dual_elgbl_cd and member.dual_elgbl_cd in VALID_DUAL_CODES:
|
|
218
|
+
if member.dual_elgbl_cd in VALID_DUAL_CODES:
|
|
189
219
|
return member.dual_elgbl_cd
|
|
190
220
|
|
|
191
|
-
# Priority 2: California aid code mapping
|
|
192
221
|
if member.medi_cal_aid_code:
|
|
193
222
|
dual_code = map_aid_code_to_dual_status(member.medi_cal_aid_code)
|
|
194
223
|
if dual_code != NON_DUAL_CODE:
|
|
195
224
|
return dual_code
|
|
196
225
|
|
|
197
|
-
# Priority 3: Medicare status code
|
|
198
226
|
if member.medicare_status_code:
|
|
199
227
|
dual_code = map_medicare_status_to_dual_code(member.medicare_status_code)
|
|
200
228
|
if dual_code != NON_DUAL_CODE:
|
|
201
229
|
return dual_code
|
|
202
230
|
|
|
203
|
-
# Priority 4: Both Medicare and Medicaid coverage present
|
|
204
231
|
if member.has_medicare and (member.has_medicaid or member.medicaid_id):
|
|
205
|
-
|
|
206
|
-
return '08'
|
|
232
|
+
return '08' # Other Full Dual
|
|
207
233
|
|
|
208
|
-
# Default: Non-dual
|
|
209
234
|
return NON_DUAL_CODE
|
|
210
235
|
|
|
236
|
+
|
|
211
237
|
def classify_dual_benefit_level(dual_code: str) -> Tuple[bool, bool]:
|
|
212
|
-
"""
|
|
238
|
+
"""Return (is_full_benefit_dual, is_partial_benefit_dual)"""
|
|
239
|
+
return dual_code in FULL_BENEFIT_DUAL_CODES, dual_code in PARTIAL_BENEFIT_DUAL_CODES
|
|
213
240
|
|
|
214
|
-
Full Benefit Dual codes: 02, 04, 08
|
|
215
|
-
- Uses CFA_ (Community, Full Benefit Dual, Aged) prefix
|
|
216
|
-
- Uses CFD_ (Community, Full Benefit Dual, Disabled) prefix
|
|
217
241
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
"""
|
|
222
|
-
is_fbd = dual_code in FULL_BENEFIT_DUAL_CODES
|
|
223
|
-
is_pbd = dual_code in PARTIAL_BENEFIT_DUAL_CODES
|
|
242
|
+
# ============================================================================
|
|
243
|
+
# REF Segment Parsers (CA DHCS specific composites)
|
|
244
|
+
# ============================================================================
|
|
224
245
|
|
|
225
|
-
|
|
246
|
+
def parse_ref_23(value: str, member: MemberContext) -> None:
|
|
247
|
+
"""REF*23: Cin_Check_Digit;Fame_Card_Issue_Date;;..."""
|
|
248
|
+
member.cin_check_digit = get_composite_part(value, 0)
|
|
249
|
+
card_date = get_composite_part(value, 1)
|
|
250
|
+
if card_date and len(card_date) >= 8:
|
|
251
|
+
member.fame_card_issue_date = card_date[:8]
|
|
226
252
|
|
|
227
|
-
def is_new_enrollee(coverage_start_date: Optional[str], reference_date: Optional[str] = None) -> bool:
|
|
228
|
-
"""Determine if member is new enrollee (<= 3 months since coverage start)"""
|
|
229
|
-
if not coverage_start_date:
|
|
230
|
-
return False
|
|
231
253
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
254
|
+
def parse_ref_3h(value: str, member: MemberContext) -> None:
|
|
255
|
+
"""REF*3H: County;AID_Code;Case_Number;;..."""
|
|
256
|
+
member.fame_county_id = get_composite_part(value, 0)
|
|
257
|
+
aid_code = get_composite_part(value, 1)
|
|
258
|
+
if aid_code and not member.medi_cal_aid_code:
|
|
259
|
+
member.medi_cal_aid_code = aid_code
|
|
260
|
+
member.case_number = get_composite_part(value, 2)
|
|
238
261
|
|
|
239
|
-
# Calculate months difference
|
|
240
|
-
months_diff = (ref_date.year - start_date.year) * 12 + (ref_date.month - start_date.month)
|
|
241
262
|
|
|
242
|
-
|
|
263
|
+
def parse_ref_6o(value: str, member: MemberContext) -> None:
|
|
264
|
+
"""REF*6O: ?;RES-ADDR-FLAG;REAS-ADD-IND;?;RES-ZIP-DELIV-CODE;?"""
|
|
265
|
+
member.res_addr_flag = get_composite_part(value, 1)
|
|
266
|
+
member.reas_add_ind = get_composite_part(value, 2)
|
|
267
|
+
zip_code = get_composite_part(value, 4)
|
|
268
|
+
if zip_code:
|
|
269
|
+
member.res_zip_deliv_code = strip_leading_zeros(zip_code)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def parse_ref_dx(value: str, member: MemberContext) -> None:
|
|
273
|
+
"""REF*DX: Fed_Contract_Number;Carrier_Code;Policy_Start;..."""
|
|
274
|
+
member.fed_contract_number = get_composite_part(value, 0)
|
|
275
|
+
carrier = get_composite_part(value, 1)
|
|
276
|
+
if carrier:
|
|
277
|
+
member.carrier_code = strip_leading_zeros(carrier)
|
|
278
|
+
policy_start = get_composite_part(value, 2)
|
|
279
|
+
if policy_start and len(policy_start) >= 8:
|
|
280
|
+
member.coverage_start_date = policy_start[:8]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def parse_ref_17(value: str, member: MemberContext) -> None:
|
|
284
|
+
"""REF*17: YYYYMM;;... (FAME redetermination date)"""
|
|
285
|
+
yyyymm = get_composite_part(value, 0)
|
|
286
|
+
if yyyymm and len(yyyymm) >= 6:
|
|
287
|
+
member.fame_redetermination_date = f"{yyyymm[:4]}-{yyyymm[4:6]}-01"
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# ============================================================================
|
|
291
|
+
# Public API Functions
|
|
292
|
+
# ============================================================================
|
|
293
|
+
|
|
294
|
+
def enrollment_to_demographics(enrollment: EnrollmentData) -> Demographics:
|
|
295
|
+
"""Convert EnrollmentData to Demographics model for risk calculation"""
|
|
296
|
+
return Demographics(
|
|
297
|
+
age=enrollment.age or 0,
|
|
298
|
+
sex=enrollment.sex or 'M',
|
|
299
|
+
dual_elgbl_cd=enrollment.dual_elgbl_cd,
|
|
300
|
+
orec=enrollment.orec or '',
|
|
301
|
+
crec=enrollment.crec or '',
|
|
302
|
+
new_enrollee=enrollment.new_enrollee,
|
|
303
|
+
snp=enrollment.snp,
|
|
304
|
+
low_income=enrollment.low_income,
|
|
305
|
+
lti=enrollment.lti
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def is_losing_medicaid(enrollment: EnrollmentData, within_days: int = 90) -> bool:
|
|
310
|
+
"""Check if member will lose Medicaid within specified days"""
|
|
311
|
+
if not enrollment.coverage_end_date or not enrollment.has_medicaid:
|
|
312
|
+
return False
|
|
313
|
+
try:
|
|
314
|
+
end_date = datetime.strptime(enrollment.coverage_end_date, "%Y-%m-%d").date()
|
|
315
|
+
days_until_end = (end_date - date.today()).days
|
|
316
|
+
return 0 <= days_until_end <= within_days
|
|
243
317
|
except (ValueError, AttributeError):
|
|
244
318
|
return False
|
|
245
319
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
320
|
+
|
|
321
|
+
def is_medicaid_terminated(enrollment: EnrollmentData) -> bool:
|
|
322
|
+
"""Check if Medicaid coverage is being terminated (maintenance type 024)"""
|
|
323
|
+
return enrollment.maintenance_type == '024'
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def medicaid_status_summary(enrollment: EnrollmentData) -> Dict:
|
|
327
|
+
"""Get summary of Medicaid coverage status for monitoring"""
|
|
328
|
+
return {
|
|
329
|
+
'member_id': enrollment.member_id,
|
|
330
|
+
'has_medicaid': enrollment.has_medicaid,
|
|
331
|
+
'has_medicare': enrollment.has_medicare,
|
|
332
|
+
'dual_status': enrollment.dual_elgbl_cd,
|
|
333
|
+
'is_full_benefit_dual': enrollment.is_full_benefit_dual,
|
|
334
|
+
'is_partial_benefit_dual': enrollment.is_partial_benefit_dual,
|
|
335
|
+
'coverage_end_date': enrollment.coverage_end_date,
|
|
336
|
+
'is_termination': is_medicaid_terminated(enrollment),
|
|
337
|
+
'losing_medicaid_30d': is_losing_medicaid(enrollment, 30),
|
|
338
|
+
'losing_medicaid_60d': is_losing_medicaid(enrollment, 60),
|
|
339
|
+
'losing_medicaid_90d': is_losing_medicaid(enrollment, 90)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ============================================================================
|
|
344
|
+
# Main Parsing Logic
|
|
345
|
+
# ============================================================================
|
|
346
|
+
|
|
347
|
+
def _process_ref_segment(qualifier: str, value: str, member: MemberContext, in_hd_loop: bool) -> None:
|
|
348
|
+
"""Process REF segment based on qualifier"""
|
|
349
|
+
# HD-loop specific
|
|
350
|
+
if in_hd_loop and member.current_hcp and qualifier == 'CE':
|
|
351
|
+
member.current_hcp.aid_codes = value
|
|
352
|
+
# Also set primary_aid_code from position 0 if not already set
|
|
353
|
+
if not member.primary_aid_code:
|
|
354
|
+
member.primary_aid_code = get_composite_part(value, 0)
|
|
355
|
+
return
|
|
356
|
+
|
|
357
|
+
# Member identifiers
|
|
358
|
+
if qualifier == '0F' and not member.member_id:
|
|
359
|
+
member.member_id = value
|
|
360
|
+
elif qualifier == 'ZZ' and not member.member_id:
|
|
361
|
+
member.member_id = value
|
|
362
|
+
elif qualifier == '6P':
|
|
363
|
+
member.mbi = value
|
|
364
|
+
member.has_medicare = True
|
|
365
|
+
elif qualifier == 'F6':
|
|
366
|
+
member.hic = value
|
|
367
|
+
if not member.mbi:
|
|
368
|
+
member.mbi = value
|
|
369
|
+
member.has_medicare = True
|
|
370
|
+
elif qualifier == '1D':
|
|
371
|
+
parts = [p for p in value.split(';') if p]
|
|
372
|
+
member.medicaid_id = parts[-1] if parts else value
|
|
373
|
+
member.has_medicaid = True
|
|
374
|
+
elif qualifier == '23':
|
|
375
|
+
parse_ref_23(value, member)
|
|
376
|
+
member.has_medicaid = True
|
|
377
|
+
# California Medi-Cal
|
|
378
|
+
elif qualifier == 'ABB':
|
|
379
|
+
member.medicare_status_code = value
|
|
380
|
+
elif qualifier == 'AB':
|
|
381
|
+
member.medi_cal_aid_code = value
|
|
382
|
+
# CA DHCS custom
|
|
383
|
+
elif qualifier == '3H':
|
|
384
|
+
parse_ref_3h(value, member)
|
|
385
|
+
elif qualifier == '6O':
|
|
386
|
+
parse_ref_6o(value, member)
|
|
387
|
+
elif qualifier == 'RB':
|
|
388
|
+
member.primary_aid_code = value
|
|
389
|
+
elif qualifier == 'CE' and not member.primary_aid_code:
|
|
390
|
+
# Fallback: get primary_aid_code from REF*CE position 0 if not set by REF*RB
|
|
391
|
+
member.primary_aid_code = get_composite_part(value, 0)
|
|
392
|
+
elif qualifier == 'ZX' and not member.fame_county_id:
|
|
393
|
+
member.fame_county_id = value
|
|
394
|
+
elif qualifier == '17':
|
|
395
|
+
if in_hd_loop:
|
|
396
|
+
member.client_reporting_cat = value
|
|
397
|
+
else:
|
|
398
|
+
parse_ref_17(value, member)
|
|
399
|
+
elif qualifier == 'DX':
|
|
400
|
+
parse_ref_dx(value, member)
|
|
401
|
+
# Dual eligibility
|
|
402
|
+
elif qualifier == 'F5' and value in VALID_DUAL_CODES:
|
|
403
|
+
member.dual_elgbl_cd = value
|
|
404
|
+
elif qualifier == 'DY' and value in VALID_CREC_VALUES:
|
|
405
|
+
member.crec = value
|
|
406
|
+
elif qualifier == 'EJ':
|
|
407
|
+
member.low_income = value.upper() in ('Y', 'YES', '1', 'TRUE')
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _process_hd_segment(segment: List[str], member: MemberContext) -> None:
|
|
411
|
+
"""Process HD (Health Coverage) segment"""
|
|
412
|
+
# Save previous HCP context
|
|
413
|
+
if member.current_hcp:
|
|
414
|
+
member.hcp_history.append(member.current_hcp)
|
|
415
|
+
|
|
416
|
+
member.current_hcp = HCPContext()
|
|
417
|
+
|
|
418
|
+
# Parse HCP code and status from HD04
|
|
419
|
+
plan_desc = get_segment_value(segment, 4, '')
|
|
420
|
+
if plan_desc and ';' in plan_desc:
|
|
421
|
+
parts = plan_desc.split(';')
|
|
422
|
+
if parts[0]:
|
|
423
|
+
hcp_code = strip_leading_zeros(parts[0])
|
|
424
|
+
member.current_hcp.hcp_code = hcp_code
|
|
425
|
+
if not member.hcp_code:
|
|
426
|
+
member.hcp_code = hcp_code
|
|
427
|
+
if len(parts) > 1 and parts[1]:
|
|
428
|
+
hcp_status = strip_leading_zeros(parts[1])
|
|
429
|
+
member.current_hcp.hcp_status = hcp_status
|
|
430
|
+
if not member.hcp_status:
|
|
431
|
+
member.hcp_status = hcp_status
|
|
432
|
+
|
|
433
|
+
# Detect coverage types from combined fields
|
|
434
|
+
insurance_line = get_segment_value(segment, 3, '')
|
|
435
|
+
insurance_type = get_segment_value(segment, 6, '')
|
|
436
|
+
combined = f"{insurance_line} {plan_desc} {insurance_type}"
|
|
437
|
+
|
|
438
|
+
if contains_any_keyword(combined, MEDICARE_KEYWORDS):
|
|
439
|
+
member.has_medicare = True
|
|
440
|
+
if contains_any_keyword(combined, MEDICAID_KEYWORDS):
|
|
441
|
+
member.has_medicaid = True
|
|
442
|
+
if contains_any_keyword(combined, SNP_KEYWORDS):
|
|
443
|
+
member.snp = True
|
|
444
|
+
if any(kw in combined.upper() for kw in ('D-SNP', 'DSNP', 'DUAL')):
|
|
445
|
+
member.has_medicare = True
|
|
446
|
+
member.has_medicaid = True
|
|
447
|
+
if contains_any_keyword(combined, LTI_KEYWORDS):
|
|
448
|
+
member.lti = True
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _finalize_member(member: MemberContext, source: str, report_date: str) -> EnrollmentData:
|
|
452
|
+
"""Convert MemberContext to EnrollmentData"""
|
|
453
|
+
if member.current_hcp:
|
|
454
|
+
member.hcp_history.append(member.current_hcp)
|
|
455
|
+
member.current_hcp = None
|
|
456
|
+
|
|
457
|
+
age = calculate_age(member.dob)
|
|
458
|
+
dual_code = determine_dual_status(member)
|
|
459
|
+
is_fbd, is_pbd = classify_dual_benefit_level(dual_code)
|
|
460
|
+
new_enrollee = is_new_enrollee(member.coverage_start_date)
|
|
461
|
+
|
|
462
|
+
hcp_history = [
|
|
463
|
+
HCPCoveragePeriod(
|
|
464
|
+
start_date=hcp.start_date, end_date=hcp.end_date,
|
|
465
|
+
hcp_code=hcp.hcp_code, hcp_status=hcp.hcp_status, aid_codes=hcp.aid_codes
|
|
466
|
+
)
|
|
467
|
+
for hcp in member.hcp_history
|
|
468
|
+
]
|
|
469
|
+
|
|
470
|
+
return EnrollmentData(
|
|
471
|
+
source=source, report_date=report_date,
|
|
472
|
+
member_id=member.member_id, mbi=member.mbi, medicaid_id=member.medicaid_id,
|
|
473
|
+
hic=member.hic, cin_check_digit=member.cin_check_digit,
|
|
474
|
+
first_name=member.first_name, last_name=member.last_name, middle_name=member.middle_name,
|
|
475
|
+
dob=member.dob, age=age, sex=member.sex, race=member.race,
|
|
476
|
+
language=member.language, death_date=member.death_date,
|
|
477
|
+
address_1=member.address_1, address_2=member.address_2,
|
|
478
|
+
city=member.city, state=member.state, zip=member.zip, phone=member.phone,
|
|
479
|
+
maintenance_type=member.maintenance_type,
|
|
480
|
+
maintenance_reason_code=member.maintenance_reason_code,
|
|
481
|
+
benefit_status_code=member.benefit_status_code,
|
|
482
|
+
coverage_start_date=member.coverage_start_date,
|
|
483
|
+
coverage_end_date=member.coverage_end_date,
|
|
484
|
+
has_medicare=member.has_medicare, has_medicaid=member.has_medicaid,
|
|
485
|
+
dual_elgbl_cd=dual_code, is_full_benefit_dual=is_fbd, is_partial_benefit_dual=is_pbd,
|
|
486
|
+
medicare_status_code=member.medicare_status_code,
|
|
487
|
+
medi_cal_aid_code=member.medi_cal_aid_code,
|
|
488
|
+
fame_county_id=member.fame_county_id, case_number=member.case_number,
|
|
489
|
+
fame_card_issue_date=member.fame_card_issue_date,
|
|
490
|
+
fame_redetermination_date=member.fame_redetermination_date,
|
|
491
|
+
fame_death_date=member.fame_death_date, primary_aid_code=member.primary_aid_code,
|
|
492
|
+
carrier_code=member.carrier_code, fed_contract_number=member.fed_contract_number,
|
|
493
|
+
client_reporting_cat=member.client_reporting_cat,
|
|
494
|
+
res_addr_flag=member.res_addr_flag, reas_add_ind=member.reas_add_ind,
|
|
495
|
+
res_zip_deliv_code=member.res_zip_deliv_code,
|
|
496
|
+
orec=member.orec, crec=member.crec, snp=member.snp,
|
|
497
|
+
low_income=member.low_income, lti=member.lti, new_enrollee=new_enrollee,
|
|
498
|
+
hcp_code=member.hcp_code, hcp_status=member.hcp_status, amount=member.amount,
|
|
499
|
+
hcp_history=hcp_history
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def parse_834_enrollment(segments: List[List[str]], source: str = None, report_date: str = None) -> List[EnrollmentData]:
|
|
504
|
+
"""Extract enrollment data from 834 transaction segments"""
|
|
258
505
|
enrollments = []
|
|
259
506
|
member = MemberContext()
|
|
507
|
+
in_hd_loop = False
|
|
260
508
|
|
|
261
509
|
for segment in segments:
|
|
262
510
|
if len(segment) < 2:
|
|
@@ -264,217 +512,140 @@ def parse_834_enrollment(segments: List[List[str]]) -> List[EnrollmentData]:
|
|
|
264
512
|
|
|
265
513
|
seg_id = segment[0]
|
|
266
514
|
|
|
267
|
-
#
|
|
268
|
-
if seg_id == '
|
|
269
|
-
|
|
515
|
+
# BGN - Source identifier
|
|
516
|
+
if seg_id == 'BGN' and len(segment) >= 3:
|
|
517
|
+
bgn_ref = get_segment_value(segment, 2)
|
|
518
|
+
if bgn_ref and '-' in bgn_ref:
|
|
519
|
+
parts = bgn_ref.split('-')
|
|
520
|
+
if len(parts) >= 2:
|
|
521
|
+
source = f"{parts[0]}-{parts[1]}"
|
|
522
|
+
if 'south la' in bgn_ref.lower():
|
|
523
|
+
source = f"SLA-{source}"
|
|
524
|
+
|
|
525
|
+
# N1*IN - Plan name (for SLA prefix)
|
|
526
|
+
elif seg_id == 'N1' and get_segment_value(segment, 1) == 'IN':
|
|
527
|
+
plan_name = get_segment_value(segment, 2, '')
|
|
528
|
+
if 'South LA' in plan_name and source and not source.startswith('SLA-'):
|
|
529
|
+
source = f"SLA-{source}"
|
|
530
|
+
|
|
531
|
+
# INS - Start of member loop
|
|
532
|
+
elif seg_id == 'INS' and len(segment) >= 3:
|
|
270
533
|
if member.member_id or member.has_medicare or member.has_medicaid:
|
|
271
|
-
enrollments.append(
|
|
272
|
-
|
|
273
|
-
# Start new member
|
|
534
|
+
enrollments.append(_finalize_member(member, source, report_date))
|
|
274
535
|
member = MemberContext()
|
|
275
|
-
|
|
276
|
-
# INS03 - Maintenance Type Code
|
|
536
|
+
in_hd_loop = False
|
|
277
537
|
member.maintenance_type = get_segment_value(segment, 3)
|
|
278
|
-
|
|
538
|
+
member.maintenance_reason_code = get_segment_value(segment, 4)
|
|
539
|
+
member.benefit_status_code = get_segment_value(segment, 5)
|
|
279
540
|
|
|
280
|
-
#
|
|
541
|
+
# REF - Reference identifiers
|
|
281
542
|
elif seg_id == 'REF' and len(segment) >= 3:
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if not member.member_id:
|
|
294
|
-
member.member_id =
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
member.crec = value
|
|
330
|
-
elif qualifier == 'EJ': # Low Income Subsidy indicator
|
|
331
|
-
member.low_income = (value.upper() in ['Y', 'YES', '1', 'TRUE'])
|
|
332
|
-
|
|
333
|
-
# ===== NM1 - Member Name =====
|
|
334
|
-
elif seg_id == 'NM1' and len(segment) >= 4:
|
|
335
|
-
qualifier = segment[1]
|
|
336
|
-
|
|
337
|
-
if qualifier == 'IL': # Insured or Subscriber
|
|
338
|
-
# NM109 = Identification Code (Member ID)
|
|
339
|
-
if len(segment) > 9:
|
|
340
|
-
id_value = get_segment_value(segment, 9)
|
|
341
|
-
if id_value and not member.member_id:
|
|
342
|
-
member.member_id = id_value
|
|
343
|
-
|
|
344
|
-
# ===== DMG - Demographics ***CRITICAL SEGMENT*** =====
|
|
543
|
+
value = get_segment_value(segment, 2)
|
|
544
|
+
if value:
|
|
545
|
+
_process_ref_segment(segment[1], value, member, in_hd_loop)
|
|
546
|
+
|
|
547
|
+
# NM1*IL - Member name
|
|
548
|
+
elif seg_id == 'NM1' and get_segment_value(segment, 1) == 'IL':
|
|
549
|
+
member.last_name = get_segment_value(segment, 3)
|
|
550
|
+
member.first_name = get_segment_value(segment, 4)
|
|
551
|
+
member.middle_name = get_segment_value(segment, 5)
|
|
552
|
+
if len(segment) > 9:
|
|
553
|
+
id_val = get_segment_value(segment, 9)
|
|
554
|
+
if id_val and not member.member_id:
|
|
555
|
+
member.member_id = id_val
|
|
556
|
+
|
|
557
|
+
# PER - Contact (phone)
|
|
558
|
+
elif seg_id == 'PER':
|
|
559
|
+
for i, val in enumerate(segment):
|
|
560
|
+
if val == 'TE' and i + 1 < len(segment):
|
|
561
|
+
member.phone = segment[i + 1]
|
|
562
|
+
break
|
|
563
|
+
|
|
564
|
+
# N3 - Address
|
|
565
|
+
elif seg_id == 'N3':
|
|
566
|
+
member.address_1 = get_segment_value(segment, 1)
|
|
567
|
+
member.address_2 = get_segment_value(segment, 2)
|
|
568
|
+
|
|
569
|
+
# N4 - City/State/Zip
|
|
570
|
+
elif seg_id == 'N4' and len(segment) >= 4:
|
|
571
|
+
city = get_segment_value(segment, 1)
|
|
572
|
+
state = get_segment_value(segment, 2)
|
|
573
|
+
# Strip state suffix from city if embedded
|
|
574
|
+
if city and state and city.upper().endswith(' ' + state.upper()):
|
|
575
|
+
city = city[:-len(state)-1].strip()
|
|
576
|
+
member.city = city.lower() if city else None
|
|
577
|
+
member.state = state.lower() if state else None
|
|
578
|
+
member.zip = get_segment_value(segment, 3)
|
|
579
|
+
# County code
|
|
580
|
+
if len(segment) > 6 and segment[5] == 'CY' and not member.fame_county_id:
|
|
581
|
+
member.fame_county_id = get_segment_value(segment, 6)
|
|
582
|
+
|
|
583
|
+
# LUI - Language
|
|
584
|
+
elif seg_id == 'LUI' and len(segment) >= 3:
|
|
585
|
+
lang_code = get_segment_value(segment, 2)
|
|
586
|
+
if lang_code:
|
|
587
|
+
member.language = LANGUAGE_CODES.get(lang_code.upper(), lang_code)
|
|
588
|
+
|
|
589
|
+
# DMG - Demographics
|
|
345
590
|
elif seg_id == 'DMG' and len(segment) >= 3:
|
|
346
|
-
# DMG02 = Date of Birth
|
|
347
591
|
dob_str = get_segment_value(segment, 2)
|
|
348
592
|
if dob_str:
|
|
349
593
|
member.dob = parse_date(dob_str)
|
|
350
|
-
|
|
351
|
-
# DMG03 = Gender Code
|
|
352
594
|
sex = get_segment_value(segment, 3)
|
|
353
595
|
if sex in X12_SEX_CODE_MAPPING:
|
|
354
596
|
member.sex = X12_SEX_CODE_MAPPING[sex]
|
|
597
|
+
member.race = get_segment_value(segment, 5)
|
|
598
|
+
death_str = get_segment_value(segment, 6)
|
|
599
|
+
if death_str and len(death_str) >= 8:
|
|
600
|
+
member.death_date = parse_date(death_str[:8])
|
|
355
601
|
|
|
356
|
-
#
|
|
602
|
+
# DTP - Dates
|
|
357
603
|
elif seg_id == 'DTP' and len(segment) >= 4:
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
604
|
+
qualifier = segment[1]
|
|
605
|
+
fmt = segment[2]
|
|
606
|
+
date_val = get_segment_value(segment, 3)
|
|
607
|
+
if date_val and fmt.endswith('D8'):
|
|
608
|
+
parsed = parse_date(date_val[:8] if len(date_val) >= 8 else date_val)
|
|
609
|
+
if parsed:
|
|
610
|
+
if in_hd_loop and member.current_hcp:
|
|
611
|
+
if qualifier == '348':
|
|
612
|
+
member.current_hcp.start_date = parsed
|
|
613
|
+
# Also set member-level coverage_start_date
|
|
614
|
+
if not member.coverage_start_date:
|
|
615
|
+
member.coverage_start_date = parsed
|
|
616
|
+
elif qualifier == '349':
|
|
617
|
+
member.current_hcp.end_date = parsed
|
|
618
|
+
# Also set member-level coverage_end_date
|
|
619
|
+
if not member.coverage_end_date:
|
|
620
|
+
member.coverage_end_date = parsed
|
|
621
|
+
else:
|
|
622
|
+
if qualifier == '348' and not member.coverage_start_date:
|
|
623
|
+
member.coverage_start_date = parsed
|
|
624
|
+
elif qualifier == '349' and not member.coverage_end_date:
|
|
625
|
+
member.coverage_end_date = parsed
|
|
626
|
+
elif qualifier == '338':
|
|
627
|
+
if not member.coverage_start_date:
|
|
628
|
+
member.coverage_start_date = parsed
|
|
629
|
+
member.has_medicare = True
|
|
630
|
+
elif qualifier == '435':
|
|
631
|
+
member.death_date = parsed
|
|
632
|
+
member.fame_death_date = parsed
|
|
633
|
+
|
|
634
|
+
# HD - Health coverage
|
|
635
|
+
elif seg_id == 'HD' and len(segment) >= 4:
|
|
636
|
+
_process_hd_segment(segment, member)
|
|
637
|
+
in_hd_loop = True
|
|
369
638
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
elif date_qualifier == '349': # Benefit End Date
|
|
374
|
-
member.coverage_end_date = parsed_date
|
|
375
|
-
elif date_qualifier == '338': # Medicare Part A/B Effective Date
|
|
376
|
-
if not member.coverage_start_date:
|
|
377
|
-
member.coverage_start_date = parsed_date
|
|
378
|
-
member.has_medicare = True
|
|
639
|
+
# AMT - Amount
|
|
640
|
+
elif seg_id == 'AMT' and len(segment) >= 3:
|
|
641
|
+
member.amount = get_segment_value(segment, 2)
|
|
379
642
|
|
|
380
|
-
|
|
381
|
-
elif seg_id == 'HD' and len(segment) >= 4:
|
|
382
|
-
# HD03 = Insurance Line Code
|
|
383
|
-
insurance_line = get_segment_value(segment, 3, '').upper()
|
|
384
|
-
|
|
385
|
-
# HD04 = Plan Coverage Description
|
|
386
|
-
plan_desc = get_segment_value(segment, 4, '').upper()
|
|
387
|
-
|
|
388
|
-
# HD06 = Insurance Type Code
|
|
389
|
-
insurance_type = get_segment_value(segment, 6, '').upper()
|
|
390
|
-
|
|
391
|
-
# Combine all fields for pattern matching
|
|
392
|
-
combined = f"{insurance_line} {plan_desc} {insurance_type}"
|
|
393
|
-
|
|
394
|
-
# Detect Medicare coverage
|
|
395
|
-
if any(keyword in combined for keyword in [
|
|
396
|
-
'MEDICARE', 'MA', 'PART A', 'PART B', 'PART C', 'PART D',
|
|
397
|
-
'MEDICARE ADVANTAGE', 'MA-PD'
|
|
398
|
-
]):
|
|
399
|
-
member.has_medicare = True
|
|
400
|
-
|
|
401
|
-
# Detect Medicaid/Medi-Cal coverage
|
|
402
|
-
if any(keyword in combined for keyword in [
|
|
403
|
-
'MEDICAID', 'MEDI-CAL', 'MEDI CAL', 'MEDIC-AID'
|
|
404
|
-
]):
|
|
405
|
-
member.has_medicaid = True
|
|
406
|
-
|
|
407
|
-
# Detect SNP (Special Needs Plan)
|
|
408
|
-
if any(keyword in combined for keyword in [
|
|
409
|
-
'SNP', 'SPECIAL NEEDS', 'D-SNP', 'DSNP', 'DUAL ELIGIBLE SNP'
|
|
410
|
-
]):
|
|
411
|
-
member.snp = True
|
|
412
|
-
# If it's a D-SNP, they are definitely dual eligible
|
|
413
|
-
if 'D-SNP' in combined or 'DSNP' in combined or 'DUAL' in combined:
|
|
414
|
-
member.has_medicare = True
|
|
415
|
-
member.has_medicaid = True
|
|
416
|
-
|
|
417
|
-
# Detect LTI (Long Term Institutionalized)
|
|
418
|
-
if any(keyword in combined for keyword in [
|
|
419
|
-
'LTC', 'LONG TERM CARE', 'LONG-TERM CARE', 'NURSING HOME',
|
|
420
|
-
'SKILLED NURSING', 'SNF', 'INSTITUTIONALIZED'
|
|
421
|
-
]):
|
|
422
|
-
member.lti = True
|
|
423
|
-
|
|
424
|
-
# Don't forget last member
|
|
643
|
+
# Finalize last member
|
|
425
644
|
if member.member_id or member.has_medicare or member.has_medicaid:
|
|
426
|
-
enrollments.append(
|
|
645
|
+
enrollments.append(_finalize_member(member, source, report_date))
|
|
427
646
|
|
|
428
647
|
return enrollments
|
|
429
648
|
|
|
430
|
-
def create_enrollment_data(member: MemberContext) -> EnrollmentData:
|
|
431
|
-
"""Convert MemberContext to EnrollmentData with risk adjustment fields"""
|
|
432
|
-
|
|
433
|
-
# Calculate age
|
|
434
|
-
age = calculate_age(member.dob) if member.dob else None
|
|
435
|
-
|
|
436
|
-
# Determine dual eligibility status
|
|
437
|
-
dual_code = determine_dual_status(member)
|
|
438
|
-
|
|
439
|
-
# Classify FBD vs PBD
|
|
440
|
-
is_fbd, is_pbd = classify_dual_benefit_level(dual_code)
|
|
441
|
-
|
|
442
|
-
# Determine new enrollee status
|
|
443
|
-
new_enrollee = is_new_enrollee(member.coverage_start_date)
|
|
444
|
-
|
|
445
|
-
return EnrollmentData(
|
|
446
|
-
# Identifiers
|
|
447
|
-
member_id=member.member_id,
|
|
448
|
-
mbi=member.mbi,
|
|
449
|
-
medicaid_id=member.medicaid_id,
|
|
450
|
-
|
|
451
|
-
# Demographics
|
|
452
|
-
dob=member.dob,
|
|
453
|
-
age=age,
|
|
454
|
-
sex=member.sex,
|
|
455
|
-
|
|
456
|
-
# Coverage tracking
|
|
457
|
-
maintenance_type=member.maintenance_type,
|
|
458
|
-
coverage_start_date=member.coverage_start_date,
|
|
459
|
-
coverage_end_date=member.coverage_end_date,
|
|
460
|
-
|
|
461
|
-
# Dual Eligibility
|
|
462
|
-
has_medicare=member.has_medicare,
|
|
463
|
-
has_medicaid=member.has_medicaid,
|
|
464
|
-
dual_elgbl_cd=dual_code,
|
|
465
|
-
is_full_benefit_dual=is_fbd,
|
|
466
|
-
is_partial_benefit_dual=is_pbd,
|
|
467
|
-
medicare_status_code=member.medicare_status_code,
|
|
468
|
-
medi_cal_aid_code=member.medi_cal_aid_code,
|
|
469
|
-
|
|
470
|
-
# Risk Adjustment
|
|
471
|
-
orec=member.orec,
|
|
472
|
-
crec=member.crec,
|
|
473
|
-
snp=member.snp,
|
|
474
|
-
low_income=member.low_income,
|
|
475
|
-
lti=member.lti,
|
|
476
|
-
new_enrollee=new_enrollee
|
|
477
|
-
)
|
|
478
649
|
|
|
479
650
|
def extract_enrollment_834(content: str) -> List[EnrollmentData]:
|
|
480
651
|
"""Main entry point for 834 parsing
|
|
@@ -483,29 +654,51 @@ def extract_enrollment_834(content: str) -> List[EnrollmentData]:
|
|
|
483
654
|
content: Raw X12 834 transaction file content
|
|
484
655
|
|
|
485
656
|
Returns:
|
|
486
|
-
List of EnrollmentData objects
|
|
657
|
+
List of EnrollmentData objects
|
|
487
658
|
|
|
488
659
|
Raises:
|
|
489
|
-
ValueError: If content is empty or invalid
|
|
660
|
+
ValueError: If content is empty or invalid format
|
|
490
661
|
"""
|
|
491
662
|
if not content:
|
|
492
663
|
raise ValueError("Input X12 834 data cannot be empty")
|
|
493
664
|
|
|
494
|
-
|
|
495
|
-
segments = [seg.strip().split('*')
|
|
496
|
-
for seg in content.split('~') if seg.strip()]
|
|
497
|
-
|
|
665
|
+
segments = [seg.strip().split('*') for seg in content.split('~') if seg.strip()]
|
|
498
666
|
if not segments:
|
|
499
667
|
raise ValueError("No valid segments found in 834 data")
|
|
500
668
|
|
|
501
|
-
# Validate
|
|
502
|
-
|
|
669
|
+
# Validate 834 structure - must have ISA or ST*834 segment
|
|
670
|
+
segment_ids = {seg[0] for seg in segments if seg}
|
|
671
|
+
has_isa = 'ISA' in segment_ids
|
|
672
|
+
has_st_834 = any(
|
|
673
|
+
seg[0] == 'ST' and len(seg) > 1 and seg[1] == '834'
|
|
674
|
+
for seg in segments
|
|
675
|
+
)
|
|
676
|
+
if not has_isa and not has_st_834:
|
|
677
|
+
raise ValueError("Invalid or unsupported 834 format")
|
|
678
|
+
|
|
679
|
+
# Extract header info
|
|
680
|
+
source = None
|
|
681
|
+
report_date = None
|
|
682
|
+
|
|
503
683
|
for segment in segments:
|
|
504
|
-
|
|
505
|
-
transaction_type = TRANSACTION_TYPES.get(segment[8])
|
|
506
|
-
break
|
|
684
|
+
seg_id = segment[0]
|
|
507
685
|
|
|
508
|
-
|
|
509
|
-
|
|
686
|
+
if seg_id == 'ISA' and len(segment) > 9:
|
|
687
|
+
source = get_segment_value(segment, 6)
|
|
688
|
+
if source:
|
|
689
|
+
source = source.strip()
|
|
690
|
+
isa_date = get_segment_value(segment, 9)
|
|
691
|
+
if isa_date:
|
|
692
|
+
report_date = parse_yymmdd(isa_date)
|
|
693
|
+
|
|
694
|
+
elif seg_id == 'GS' and len(segment) > 4:
|
|
695
|
+
if not source:
|
|
696
|
+
source = get_segment_value(segment, 2)
|
|
697
|
+
gs_date = get_segment_value(segment, 4)
|
|
698
|
+
if gs_date and len(gs_date) >= 8:
|
|
699
|
+
report_date = f"{gs_date[:4]}-{gs_date[4:6]}-{gs_date[6:8]}"
|
|
700
|
+
if len(segment) > 8 and segment[8] not in TRANSACTION_TYPES:
|
|
701
|
+
raise ValueError("Invalid or unsupported 834 format")
|
|
702
|
+
break
|
|
510
703
|
|
|
511
|
-
return parse_834_enrollment(segments)
|
|
704
|
+
return parse_834_enrollment(segments, source, report_date)
|