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