open-dpc-jp 0.1.0__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.
- open_dpc/__init__.py +3 -0
- open_dpc/discovery.py +78 -0
- open_dpc/disease_master.py +188 -0
- open_dpc/ef.py +294 -0
- open_dpc/ff1_all_payloads.py +478 -0
- open_dpc/master_archive.py +30 -0
- open_dpc/medication_periods.py +168 -0
- open_dpc/parsing.py +267 -0
- open_dpc/references/__init__.py +92 -0
- open_dpc/references/disease.py +47 -0
- open_dpc/schemas/__init__.py +128 -0
- open_dpc/schemas/compiled/dpc-2014-20140501.json +993 -0
- open_dpc/schemas/compiled/dpc-2015-20150402.json +993 -0
- open_dpc/schemas/compiled/dpc-2016-20160401.json +1256 -0
- open_dpc/schemas/compiled/dpc-2017-20170401.json +1256 -0
- open_dpc/schemas/compiled/dpc-2018-20180406.json +1447 -0
- open_dpc/schemas/compiled/dpc-2019-20190401.json +1443 -0
- open_dpc/schemas/compiled/dpc-2020-20200330.json +1453 -0
- open_dpc/schemas/compiled/dpc-2021-20210401.json +1453 -0
- open_dpc/schemas/compiled/dpc-2022-20220401.json +1527 -0
- open_dpc/schemas/compiled/dpc-2023-20230401.json +1527 -0
- open_dpc/schemas/compiled/dpc-2024-20240401.json +1530 -0
- open_dpc/schemas/compiled/dpc-2024-20240601.json +1732 -0
- open_dpc/schemas/compiled/dpc-2025-20250530.json +1743 -0
- open_dpc/schemas/compiled/dpc-2026-20260401.json +1745 -0
- open_dpc/schemas/compiled/dpc-2026-20260601.json +1648 -0
- open_dpc/schemas/compiled/dpc-2026-20260717.json +1648 -0
- open_dpc/schemas/compiled/legacy-mixed-v1.json +700 -0
- open_dpc/schemas/compiled/registry.json +140 -0
- open_dpc/schemas/compiled/source_ledger.json +293 -0
- open_dpc/semantics/__init__.py +3 -0
- open_dpc/semantics/classification.py +45 -0
- open_dpc/semantics/comorbidity/__init__.py +183 -0
- open_dpc/semantics/comorbidity/definitions/mirai-id-cci-v1.json +437 -0
- open_dpc/semantics/comorbidity/definitions/quan-2005.json +406 -0
- open_dpc/semantics/comorbidity/definitions/registry.json +14 -0
- open_dpc/semantics/diagnosis.py +91 -0
- open_dpc/semantics/drugs.py +6 -0
- open_dpc/semantics/procedures.py +6 -0
- open_dpc/variables.py +10 -0
- open_dpc_jp/__init__.py +18 -0
- open_dpc_jp-0.1.0.dist-info/METADATA +54 -0
- open_dpc_jp-0.1.0.dist-info/RECORD +45 -0
- open_dpc_jp-0.1.0.dist-info/WHEEL +4 -0
- open_dpc_jp-0.1.0.dist-info/licenses/LICENSE +21 -0
open_dpc/__init__.py
ADDED
open_dpc/discovery.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""DPC file discovery for case-scoped lazy loading."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
DPC_KINDS = ["ff1", "efn", "efg"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def classify_dpc_file(path: Path) -> str | None:
|
|
12
|
+
"""Classify DPC file kind by lower-cased filename prefix."""
|
|
13
|
+
name = path.name.lower()
|
|
14
|
+
for kind in DPC_KINDS:
|
|
15
|
+
if name.startswith(f"{kind}_"):
|
|
16
|
+
return kind
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _month_tokens(case_context: dict[str, Any]) -> list[str]:
|
|
21
|
+
tokens: list[str] = []
|
|
22
|
+
for key in ["event_date", "encounter_start_date", "encounter_end_date"]:
|
|
23
|
+
raw = str(case_context.get(key) or "")
|
|
24
|
+
if len(raw) >= 6 and raw[:6].isdigit():
|
|
25
|
+
tokens.append(raw[:6])
|
|
26
|
+
tokens.append(raw[2:6])
|
|
27
|
+
return tokens
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _identifier_tokens(case_context: dict[str, Any]) -> list[str]:
|
|
31
|
+
tokens: list[str] = []
|
|
32
|
+
for key in ["research_id", "data_identifier"]:
|
|
33
|
+
value = str(case_context.get(key) or "").strip().lower()
|
|
34
|
+
if value:
|
|
35
|
+
tokens.append(value)
|
|
36
|
+
return list(dict.fromkeys(tokens))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def discover_dpc_files_for_case(dpc_dir: Path, case_context: dict[str, Any]) -> dict[str, Any]:
|
|
40
|
+
"""Find DPC candidate files for a case without reading file contents."""
|
|
41
|
+
result: dict[str, list[str]] = {kind: [] for kind in DPC_KINDS}
|
|
42
|
+
warnings: list[dict[str, Any]] = []
|
|
43
|
+
if not dpc_dir.exists():
|
|
44
|
+
return {
|
|
45
|
+
"dpc_files": result,
|
|
46
|
+
"has_dpc": False,
|
|
47
|
+
"warnings": [{"status": "dpc_dir_not_found", "path": str(dpc_dir)}],
|
|
48
|
+
}
|
|
49
|
+
if not dpc_dir.is_dir():
|
|
50
|
+
return {
|
|
51
|
+
"dpc_files": result,
|
|
52
|
+
"has_dpc": False,
|
|
53
|
+
"warnings": [{"status": "dpc_path_not_directory", "path": str(dpc_dir)}],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
identifier_tokens = _identifier_tokens(case_context)
|
|
57
|
+
month_tokens = [token.lower() for token in _month_tokens(case_context)]
|
|
58
|
+
if not identifier_tokens:
|
|
59
|
+
warnings.append({"status": "no_case_identifier_tokens"})
|
|
60
|
+
return {"dpc_files": result, "has_dpc": False, "warnings": warnings}
|
|
61
|
+
|
|
62
|
+
for path in sorted(dpc_dir.rglob("*")):
|
|
63
|
+
if not path.is_file():
|
|
64
|
+
continue
|
|
65
|
+
kind = classify_dpc_file(path)
|
|
66
|
+
if kind is None:
|
|
67
|
+
continue
|
|
68
|
+
haystack = str(path).lower()
|
|
69
|
+
if any(token in haystack for token in identifier_tokens):
|
|
70
|
+
result[kind].append(str(path))
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
"dpc_files": result,
|
|
74
|
+
"has_dpc": any(result.values()),
|
|
75
|
+
"match_tokens": identifier_tokens,
|
|
76
|
+
"month_tokens": month_tokens,
|
|
77
|
+
"warnings": warnings,
|
|
78
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Load the SSK disease-name master and resolve receipt codes to ICD-10."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
MASTER_FILENAME_PATTERN = re.compile(r"^b_(\d{8})\.txt$", re.IGNORECASE)
|
|
13
|
+
EXPECTED_COLUMN_COUNT = 46
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def normalize_icd10(value: Any) -> str:
|
|
17
|
+
if value is None:
|
|
18
|
+
return ""
|
|
19
|
+
return str(value).strip().upper().replace(".", "").replace("-", "")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class DiseaseMasterEntry:
|
|
24
|
+
disease_code: str
|
|
25
|
+
disease_name: str
|
|
26
|
+
icd10_primary: str
|
|
27
|
+
icd10_secondary: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class DiagnosisCodeResolution:
|
|
32
|
+
icd10: str
|
|
33
|
+
source: str
|
|
34
|
+
disease_code: str
|
|
35
|
+
dpc_icd10: str
|
|
36
|
+
master_icd10: str
|
|
37
|
+
master_icd10_secondary: str
|
|
38
|
+
master_disease_name: str
|
|
39
|
+
|
|
40
|
+
def event_metadata(self) -> dict[str, str]:
|
|
41
|
+
return {
|
|
42
|
+
"icd10_source": self.source,
|
|
43
|
+
"dpc_icd10": self.dpc_icd10,
|
|
44
|
+
"master_icd10": self.master_icd10,
|
|
45
|
+
"master_icd10_secondary": self.master_icd10_secondary,
|
|
46
|
+
"master_disease_name": self.master_disease_name,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class DiseaseMaster:
|
|
52
|
+
entries: dict[str, DiseaseMasterEntry] = field(default_factory=dict)
|
|
53
|
+
status: str = "missing"
|
|
54
|
+
file_name: str | None = None
|
|
55
|
+
version: str | None = None
|
|
56
|
+
warnings: list[dict[str, Any]] = field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
def resolve(self, disease_code: Any, dpc_icd10: Any = None) -> DiagnosisCodeResolution:
|
|
59
|
+
code = str(disease_code or "").strip()
|
|
60
|
+
source_icd10 = normalize_icd10(dpc_icd10)
|
|
61
|
+
receipt_fallback = normalize_icd10(code)
|
|
62
|
+
# EFg currently carries the receipt code in its icd10 slot. Treat that as
|
|
63
|
+
# a fallback code rather than claiming it was supplied as a real ICD-10.
|
|
64
|
+
if source_icd10 == receipt_fallback and code.isdigit() and len(code) == 7:
|
|
65
|
+
source_icd10 = ""
|
|
66
|
+
entry = self.entries.get(code)
|
|
67
|
+
master_icd10 = entry.icd10_primary if entry else ""
|
|
68
|
+
if master_icd10:
|
|
69
|
+
resolved = master_icd10
|
|
70
|
+
source = "master"
|
|
71
|
+
elif source_icd10:
|
|
72
|
+
resolved = source_icd10
|
|
73
|
+
source = "dpc"
|
|
74
|
+
else:
|
|
75
|
+
resolved = receipt_fallback or "-"
|
|
76
|
+
source = "receipt_code"
|
|
77
|
+
return DiagnosisCodeResolution(
|
|
78
|
+
icd10=resolved,
|
|
79
|
+
source=source,
|
|
80
|
+
disease_code=code,
|
|
81
|
+
dpc_icd10=source_icd10,
|
|
82
|
+
master_icd10=master_icd10,
|
|
83
|
+
master_icd10_secondary=entry.icd10_secondary if entry else "",
|
|
84
|
+
master_disease_name=entry.disease_name if entry else "",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def summary(self) -> dict[str, Any]:
|
|
88
|
+
return {
|
|
89
|
+
"status": self.status,
|
|
90
|
+
"file_name": self.file_name,
|
|
91
|
+
"version": self.version,
|
|
92
|
+
"record_count": len(self.entries),
|
|
93
|
+
"warnings": self.warnings,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _latest_master_path(reference_dir: Path) -> tuple[Path | None, str | None]:
|
|
98
|
+
candidates: list[tuple[str, Path]] = []
|
|
99
|
+
if reference_dir.is_dir():
|
|
100
|
+
for path in reference_dir.iterdir():
|
|
101
|
+
match = MASTER_FILENAME_PATTERN.match(path.name)
|
|
102
|
+
if path.is_file() and match:
|
|
103
|
+
candidates.append((match.group(1), path))
|
|
104
|
+
if not candidates:
|
|
105
|
+
return None, None
|
|
106
|
+
version, path = max(candidates, key=lambda item: item[0])
|
|
107
|
+
return path, version
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@lru_cache(maxsize=8)
|
|
111
|
+
def _read_master_cached(
|
|
112
|
+
path_string: str, size: int, mtime_ns: int
|
|
113
|
+
) -> tuple[dict[str, DiseaseMasterEntry], tuple[dict[str, Any], ...]]:
|
|
114
|
+
_ = size, mtime_ns
|
|
115
|
+
with Path(path_string).open("r", encoding="cp932", newline="") as handle:
|
|
116
|
+
return _parse_master_rows(csv.reader(handle))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _parse_master_rows(rows):
|
|
120
|
+
entries: dict[str, DiseaseMasterEntry] = {}
|
|
121
|
+
invalid_rows = 0
|
|
122
|
+
invalid_samples: list[int] = []
|
|
123
|
+
for line_no, row in enumerate(rows, start=1):
|
|
124
|
+
if (
|
|
125
|
+
len(row) != EXPECTED_COLUMN_COUNT
|
|
126
|
+
or row[1].upper() != "B"
|
|
127
|
+
or len(row[2]) != 7
|
|
128
|
+
or not row[2].isdigit()
|
|
129
|
+
):
|
|
130
|
+
invalid_rows += 1
|
|
131
|
+
if len(invalid_samples) < 5:
|
|
132
|
+
invalid_samples.append(line_no)
|
|
133
|
+
continue
|
|
134
|
+
code = row[2]
|
|
135
|
+
entries[code] = DiseaseMasterEntry(
|
|
136
|
+
disease_code=code,
|
|
137
|
+
disease_name=row[5].strip(),
|
|
138
|
+
icd10_primary=normalize_icd10(row[15]),
|
|
139
|
+
icd10_secondary=normalize_icd10(row[16]),
|
|
140
|
+
)
|
|
141
|
+
warnings: list[dict[str, Any]] = []
|
|
142
|
+
if invalid_rows:
|
|
143
|
+
warnings.append(
|
|
144
|
+
{
|
|
145
|
+
"status": "invalid_disease_master_rows",
|
|
146
|
+
"count": invalid_rows,
|
|
147
|
+
"sample_lines": invalid_samples,
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
return entries, tuple(warnings)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def load_disease_master(reference_dir: Path | None, *, file_name: str | None = None) -> DiseaseMaster:
|
|
154
|
+
"""Return the newest usable disease master without making it a blocking dependency."""
|
|
155
|
+
if reference_dir is None or not reference_dir.is_dir():
|
|
156
|
+
return DiseaseMaster(
|
|
157
|
+
warnings=[{"status": "disease_master_reference_dir_missing"}]
|
|
158
|
+
)
|
|
159
|
+
if file_name is not None:
|
|
160
|
+
match = MASTER_FILENAME_PATTERN.fullmatch(file_name)
|
|
161
|
+
if not match:
|
|
162
|
+
return DiseaseMaster(status="error", warnings=[{"status": "invalid_master_file_name"}])
|
|
163
|
+
path, version = reference_dir / file_name, match.group(1)
|
|
164
|
+
else:
|
|
165
|
+
path, version = _latest_master_path(reference_dir)
|
|
166
|
+
if path is None:
|
|
167
|
+
return DiseaseMaster(
|
|
168
|
+
warnings=[{"status": "disease_master_file_missing"}]
|
|
169
|
+
)
|
|
170
|
+
try:
|
|
171
|
+
stat = path.stat()
|
|
172
|
+
entries, warnings = _read_master_cached(
|
|
173
|
+
str(path.resolve()), stat.st_size, stat.st_mtime_ns
|
|
174
|
+
)
|
|
175
|
+
except (OSError, UnicodeError, csv.Error) as exc:
|
|
176
|
+
return DiseaseMaster(
|
|
177
|
+
status="error",
|
|
178
|
+
file_name=path.name,
|
|
179
|
+
version=version,
|
|
180
|
+
warnings=[{"status": "disease_master_read_failed", "error": str(exc)}],
|
|
181
|
+
)
|
|
182
|
+
return DiseaseMaster(
|
|
183
|
+
entries=entries,
|
|
184
|
+
status="warning" if warnings else "ok",
|
|
185
|
+
file_name=path.name,
|
|
186
|
+
version=version,
|
|
187
|
+
warnings=list(warnings),
|
|
188
|
+
)
|
open_dpc/ef.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Reader for DPC EF integrated files (EFn/EFg)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
from calendar import monthrange
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
import math
|
|
9
|
+
import unicodedata
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from open_dpc.medication_periods import derive_medication_period
|
|
14
|
+
|
|
15
|
+
from open_dpc.schemas import legacy_schema as _legacy_schema
|
|
16
|
+
_legacy_ef = _legacy_schema()['ef']
|
|
17
|
+
EF_COLUMNS = _legacy_ef['columns']
|
|
18
|
+
|
|
19
|
+
EF_FIXED_WIDTHS = _legacy_ef['widths']
|
|
20
|
+
|
|
21
|
+
DATA_CATEGORY_LABELS = _legacy_ef['category_labels']
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
HEADER_MARKER_COLUMN = "データ識別番号"
|
|
25
|
+
|
|
26
|
+
def _normalize_column_name(name: str) -> str:
|
|
27
|
+
return unicodedata.normalize("NFKC", name).strip()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _normalize_columns(row: list[str]) -> list[str]:
|
|
31
|
+
return [_normalize_column_name(value) for value in row]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_header_row(row: list[str]) -> bool:
|
|
35
|
+
if len(row) < 2:
|
|
36
|
+
return False
|
|
37
|
+
return _normalize_column_name(row[1]) == HEADER_MARKER_COLUMN
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _resolve_columns(rows: list[list[str]]) -> tuple[list[str], list[list[str]]]:
|
|
41
|
+
if rows and _is_header_row(rows[0]):
|
|
42
|
+
return _normalize_columns(rows[0]), rows[1:]
|
|
43
|
+
return EF_COLUMNS, rows
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _field_value(record: dict[str, str], *names: str) -> str:
|
|
47
|
+
for name in names:
|
|
48
|
+
value = record.get(name)
|
|
49
|
+
if value:
|
|
50
|
+
return value
|
|
51
|
+
return record.get(names[0], "") if names else ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _to_number(raw: Any) -> float | None:
|
|
55
|
+
value = str(raw or "").strip().replace(",", "")
|
|
56
|
+
if not value:
|
|
57
|
+
return None
|
|
58
|
+
try:
|
|
59
|
+
numeric = float(value)
|
|
60
|
+
except ValueError:
|
|
61
|
+
return None
|
|
62
|
+
return numeric if math.isfinite(numeric) else None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def normalize_ef_date(raw: Any) -> dict[str, str]:
|
|
66
|
+
"""Normalize EF date strings for timeline coordinates while preserving precision."""
|
|
67
|
+
raw_date = str(raw or "").strip()
|
|
68
|
+
digits = "".join(ch for ch in raw_date if ch.isdigit())
|
|
69
|
+
if len(digits) < 8:
|
|
70
|
+
return {"date": digits, "raw_date": raw_date, "date_precision": "unknown"}
|
|
71
|
+
digits = digits[:8]
|
|
72
|
+
year = digits[:4]
|
|
73
|
+
month = digits[4:6]
|
|
74
|
+
day = digits[6:8]
|
|
75
|
+
if month == "00" and day == "00":
|
|
76
|
+
return {"date": f"{year}0101", "raw_date": raw_date, "date_precision": "year"}
|
|
77
|
+
if day == "00":
|
|
78
|
+
return {"date": f"{year}{month}01", "raw_date": raw_date, "date_precision": "month"}
|
|
79
|
+
return {"date": digits, "raw_date": raw_date, "date_precision": "day"}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def ef_period_end_date(normalized_date: str, date_precision: str) -> str:
|
|
83
|
+
"""Return an EF precision-span end without raising on malformed calendar data."""
|
|
84
|
+
normalized_date = str(normalized_date or "")
|
|
85
|
+
date_precision = str(date_precision or "")
|
|
86
|
+
if not (len(normalized_date) == 8 and normalized_date.isdigit()):
|
|
87
|
+
return normalized_date
|
|
88
|
+
try:
|
|
89
|
+
year = int(normalized_date[:4])
|
|
90
|
+
month = int(normalized_date[4:6])
|
|
91
|
+
day = int(normalized_date[6:8])
|
|
92
|
+
if date_precision == "year":
|
|
93
|
+
datetime(year, 1, 1)
|
|
94
|
+
return f"{year:04d}1231"
|
|
95
|
+
if date_precision == "month":
|
|
96
|
+
datetime(year, month, 1)
|
|
97
|
+
return f"{year:04d}{month:02d}{monthrange(year, month)[1]:02d}"
|
|
98
|
+
datetime(year, month, day)
|
|
99
|
+
except ValueError:
|
|
100
|
+
return normalized_date
|
|
101
|
+
return normalized_date
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _truthy_detail_no(value: str) -> bool:
|
|
105
|
+
return value.isdigit() and value != "000"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def is_ef_parent_row(record: dict[str, Any]) -> bool:
|
|
109
|
+
"""Return whether an EF record is an E-derived parent row, not a detail row."""
|
|
110
|
+
detail_no = str(_field_value(record, "行為明細番号", "detail_no") or "").strip()
|
|
111
|
+
ef17 = str(_field_value(record, "行為明細区分情報") or "").strip().upper()
|
|
112
|
+
return detail_no == "000" or ef17 == "NULL"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def is_comment_row(record: dict[str, Any]) -> bool:
|
|
116
|
+
"""Return whether an EF record is a receipt comment row."""
|
|
117
|
+
code = str(_field_value(record, "レセプト電算処理システム用コード", "standardCode") or "")
|
|
118
|
+
return code.startswith("8")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def is_efg_diagnosis_row(record: dict[str, Any]) -> bool:
|
|
122
|
+
"""Return whether an EFg record is an SY diagnosis row."""
|
|
123
|
+
return str(_field_value(record, "データ区分", "data_kubun") or "").upper() == "SY"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def split_ef17(raw: dict[str, Any] | str, source_kind: str) -> dict[str, Any]:
|
|
127
|
+
"""Split EF-17 flags according to EFn or EFg semantics."""
|
|
128
|
+
if isinstance(raw, dict):
|
|
129
|
+
value = str(_field_value(raw, "行為明細区分情報") or "").strip()
|
|
130
|
+
else:
|
|
131
|
+
value = str(raw or "").strip()
|
|
132
|
+
result: dict[str, Any] = {"ef17_raw": value, "ef17_valid": len(value) >= 7 and value.isdigit()}
|
|
133
|
+
if not result["ef17_valid"]:
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
source_kind = source_kind.lower()
|
|
137
|
+
if source_kind == "efg":
|
|
138
|
+
result.update(
|
|
139
|
+
{
|
|
140
|
+
"outside_prescription_flag": value[0],
|
|
141
|
+
"generic_prescription_flag": value[1],
|
|
142
|
+
"sex_code": value[2],
|
|
143
|
+
"outcome_code": value[3],
|
|
144
|
+
"main_disease_flag": value[4],
|
|
145
|
+
"inclusive_management_flag": value[5],
|
|
146
|
+
"refill_prescription_flag": value[6],
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
else:
|
|
150
|
+
result.update(
|
|
151
|
+
{
|
|
152
|
+
"discharge_prescription_flag": value[0],
|
|
153
|
+
"inclusive_item_flag": value[1],
|
|
154
|
+
"brought_in_drug_flag": value[2],
|
|
155
|
+
"brought_in_prescriber_flag": value[3],
|
|
156
|
+
"dpc_applicability_flag": value[4],
|
|
157
|
+
"basic_lab_xray_inclusive_flag": value[5],
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
return result
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def normalize_ef_record(record: dict[str, Any], source_kind: str) -> dict[str, Any]:
|
|
164
|
+
"""Attach source-kind-specific aliases while preserving the original EF columns."""
|
|
165
|
+
source_kind = source_kind.lower()
|
|
166
|
+
data_kubun = str(_field_value(record, "データ区分") or "")
|
|
167
|
+
detail_no = str(_field_value(record, "行為明細番号") or "")
|
|
168
|
+
usage_raw = _field_value(record, "使用量")
|
|
169
|
+
action_count_raw = _field_value(record, "行為回数")
|
|
170
|
+
event_date_info = normalize_ef_date(
|
|
171
|
+
_field_value(record, "実施年月日", "実施年月日・診療開始日")
|
|
172
|
+
)
|
|
173
|
+
normalized: dict[str, Any] = {
|
|
174
|
+
**record,
|
|
175
|
+
"sourceKind": source_kind,
|
|
176
|
+
"facility_code": _field_value(record, "施設コード"),
|
|
177
|
+
"patient_id": _field_value(record, "データ識別番号"),
|
|
178
|
+
"data_kubun": data_kubun,
|
|
179
|
+
"categoryLabel": DATA_CATEGORY_LABELS.get(data_kubun, data_kubun),
|
|
180
|
+
"order_no": _field_value(record, "順序番号"),
|
|
181
|
+
"detail_no": detail_no,
|
|
182
|
+
"hospital_item_code": _field_value(record, "病院点数マスタコード"),
|
|
183
|
+
"receipt_code": _field_value(
|
|
184
|
+
record, "レセプト電算処理システム用コード", "レセプト電算コード"
|
|
185
|
+
),
|
|
186
|
+
"interpretation_code": _field_value(record, "解釈番号"),
|
|
187
|
+
"item_name": _field_value(record, "診療明細名称"),
|
|
188
|
+
"usage_value_raw": usage_raw,
|
|
189
|
+
"usage_value_num": _to_number(usage_raw),
|
|
190
|
+
"unit_code": _field_value(record, "基準単位"),
|
|
191
|
+
"amount_or_points": _field_value(record, "明細点数・金額"),
|
|
192
|
+
"yen_point_kubun": _field_value(record, "円・点区分"),
|
|
193
|
+
"fee_for_service_points": _field_value(record, "出来高実績点数"),
|
|
194
|
+
"action_count": action_count_raw,
|
|
195
|
+
"action_count_num": _to_number(action_count_raw),
|
|
196
|
+
"receipt_type_code": _field_value(record, "レセプト種別コード"),
|
|
197
|
+
"receipt_department_code": _field_value(record, "レセプト科区分"),
|
|
198
|
+
"department_code": _field_value(record, "診療科区分"),
|
|
199
|
+
"eventDate": event_date_info["date"],
|
|
200
|
+
"raw_date": event_date_info["raw_date"],
|
|
201
|
+
"date_precision": event_date_info["date_precision"],
|
|
202
|
+
"standardCode": _field_value(
|
|
203
|
+
record, "レセプト電算処理システム用コード", "レセプト電算コード"
|
|
204
|
+
),
|
|
205
|
+
"masterCode": _field_value(record, "病院点数マスタコード"),
|
|
206
|
+
"interpretationCode": _field_value(record, "解釈番号"),
|
|
207
|
+
"name": _field_value(record, "診療明細名称"),
|
|
208
|
+
"quantity": usage_raw,
|
|
209
|
+
"unitCode": _field_value(record, "基準単位"),
|
|
210
|
+
}
|
|
211
|
+
normalized.update(split_ef17(record, source_kind))
|
|
212
|
+
normalized["is_parent_row"] = is_ef_parent_row(normalized)
|
|
213
|
+
normalized["is_comment_row"] = is_comment_row(normalized)
|
|
214
|
+
normalized["is_diagnosis_row"] = source_kind == "efg" and is_efg_diagnosis_row(normalized)
|
|
215
|
+
if source_kind == "efg":
|
|
216
|
+
normalized.update(
|
|
217
|
+
{
|
|
218
|
+
"birth_date": _field_value(record, "退院年月日"),
|
|
219
|
+
"visit_date": _field_value(record, "入院年月日"),
|
|
220
|
+
"visit_month": _field_value(record, "入院年月日")[:6],
|
|
221
|
+
"service_date": event_date_info["date"],
|
|
222
|
+
"raw_service_date": event_date_info["raw_date"],
|
|
223
|
+
"service_date_precision": event_date_info["date_precision"],
|
|
224
|
+
}
|
|
225
|
+
)
|
|
226
|
+
else:
|
|
227
|
+
normalized.update(
|
|
228
|
+
{
|
|
229
|
+
"admission_date": _field_value(record, "入院年月日"),
|
|
230
|
+
"discharge_date": _field_value(record, "退院年月日"),
|
|
231
|
+
"service_date": event_date_info["date"],
|
|
232
|
+
"raw_service_date": event_date_info["raw_date"],
|
|
233
|
+
"service_date_precision": event_date_info["date_precision"],
|
|
234
|
+
}
|
|
235
|
+
)
|
|
236
|
+
normalized.update(derive_medication_period(normalized))
|
|
237
|
+
return normalized
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _split_fixed_width(line: str) -> list[str]:
|
|
241
|
+
values: list[str] = []
|
|
242
|
+
offset = 0
|
|
243
|
+
for width in EF_FIXED_WIDTHS:
|
|
244
|
+
values.append(line[offset : offset + width].strip())
|
|
245
|
+
offset += width
|
|
246
|
+
return values
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _iter_rows(path: Path, *, encoding: str, delimiter: str | None) -> list[list[str]]:
|
|
250
|
+
with path.open("r", encoding=encoding, newline="") as f:
|
|
251
|
+
if delimiter:
|
|
252
|
+
return list(csv.reader(f, delimiter=delimiter))
|
|
253
|
+
rows: list[list[str]] = []
|
|
254
|
+
for raw in f:
|
|
255
|
+
line = raw.rstrip("\r\n")
|
|
256
|
+
if "\t" in line:
|
|
257
|
+
rows.append(next(csv.reader([line], delimiter="\t")))
|
|
258
|
+
elif "," in line:
|
|
259
|
+
rows.append(next(csv.reader([line])))
|
|
260
|
+
else:
|
|
261
|
+
rows.append(_split_fixed_width(line))
|
|
262
|
+
return rows
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _to_record(row: list[str], source_kind: str, columns: list[str]) -> dict[str, Any]:
|
|
266
|
+
padded = row + [""] * max(0, len(columns) - len(row))
|
|
267
|
+
record = {column: padded[index].strip() for index, column in enumerate(columns)}
|
|
268
|
+
return normalize_ef_record(record, source_kind)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def read_ef_file(
|
|
272
|
+
path: Path,
|
|
273
|
+
*,
|
|
274
|
+
source_kind: str | None = None,
|
|
275
|
+
encoding: str = "cp932",
|
|
276
|
+
delimiter: str | None = None,
|
|
277
|
+
limit: int | None = None,
|
|
278
|
+
) -> dict[str, Any]:
|
|
279
|
+
"""Read EFn/EFg source records without study classification or GUI projections."""
|
|
280
|
+
inferred_kind = source_kind or path.name[:3].lower()
|
|
281
|
+
rows = _iter_rows(path, encoding=encoding, delimiter=delimiter)
|
|
282
|
+
columns, data_rows = _resolve_columns(rows)
|
|
283
|
+
records = [_to_record(row, inferred_kind, columns) for row in data_rows]
|
|
284
|
+
if limit is not None:
|
|
285
|
+
records = records[:limit]
|
|
286
|
+
return {
|
|
287
|
+
"sourcePath": str(path),
|
|
288
|
+
"sourceKind": inferred_kind,
|
|
289
|
+
"columns": columns,
|
|
290
|
+
"recordCount": len(records),
|
|
291
|
+
"records": records,
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|