dak.c2s/backend/app/services/import_service.py
CCS Admin c302a91411 feat: auto-update fall_id when KVNR is entered on a case
When a KVNR is entered on a case that has a random-suffix or legacy
Nachname-based fall_id, the fall_id is automatically rebuilt using
the new KVNR.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 17:09:50 +00:00

216 lines
6.1 KiB
Python

"""Import service for DAK Zweitmeinungs-Portal.
Handles:
- fall_id generation: YYYY-KW02d-fallgruppe-KVNR (or random suffix)
- Duplicate detection: by fall_id or (nachname, fallgruppe, datum, vorname, geburtsdatum)
- Preview/confirm flow: preview_import() checks for duplicates, confirm_import() inserts
- Import logging: writes ImportLog entry on each confirmed import
"""
import logging
import random
import re
import string
from sqlalchemy.orm import Session
from app.models.audit import ImportLog
from app.models.case import Case
from app.schemas.import_schemas import ImportPreview, ImportResult, ImportRow
from app.services.csv_parser import ParsedCase
logger = logging.getLogger(__name__)
def generate_random_suffix(length: int = 6) -> str:
"""Generate a random alphanumeric suffix (uppercase + digits)."""
charset = string.ascii_uppercase + string.digits
return "".join(random.choices(charset, k=length))
def generate_fall_id(parsed: ParsedCase) -> str:
"""Generate unique fall_id: YYYY-KW02d-fallgruppe-KVNR.
Uses KVNR as identifier. Falls back to 6-char random suffix if
KVNR is missing or empty.
Examples:
- 2026-06-onko-A123456789
- 2026-12-kardio-X7K9M2 (random fallback)
"""
suffix = parsed.kvnr if parsed.kvnr else generate_random_suffix()
return f"{parsed.jahr}-{parsed.kw:02d}-{parsed.fallgruppe}-{suffix}"
# KVNR format: letter followed by 9 digits (e.g. A123456789)
KVNR_PATTERN = re.compile(r'^[A-Z]\d{9}$')
def has_random_suffix(fall_id: str | None) -> bool:
"""Check if a fall_id ends with a non-KVNR suffix.
Returns True for random suffixes (6-char alphanumeric) and legacy
Nachname-based suffixes. Returns False for KVNR suffixes.
"""
if not fall_id:
return False
parts = fall_id.rsplit("-", 1)
if len(parts) < 2:
return False
suffix = parts[1]
# If it matches KVNR pattern, it's NOT a random suffix
if KVNR_PATTERN.match(suffix):
return False
return True
def check_duplicate(db: Session, parsed: ParsedCase) -> bool:
"""Check if a case already exists in the database.
Match criteria (OR):
1. Exact fall_id match
2. Personal data match: (nachname, fallgruppe, datum) plus optional
(vorname, geburtsdatum) when available
"""
fall_id = generate_fall_id(parsed)
# Check by fall_id
existing = db.query(Case).filter(Case.fall_id == fall_id).first()
if existing:
return True
# Check by personal data combination
query = db.query(Case).filter(
Case.nachname == parsed.nachname,
Case.fallgruppe == parsed.fallgruppe,
Case.datum == parsed.datum,
)
if parsed.vorname:
query = query.filter(Case.vorname == parsed.vorname)
if parsed.geburtsdatum:
query = query.filter(Case.geburtsdatum == parsed.geburtsdatum)
return query.first() is not None
def preview_import(
db: Session,
parsed_cases: list[ParsedCase],
filename: str,
) -> ImportPreview:
"""Check parsed cases against DB for duplicates, return preview.
This is the first step of the two-phase import flow. It does NOT
modify the database -- only reads to detect duplicates.
"""
rows: list[ImportRow] = []
duplicates = 0
for i, pc in enumerate(parsed_cases, start=1):
is_dup = check_duplicate(db, pc)
if is_dup:
duplicates += 1
rows.append(
ImportRow(
row_number=i,
nachname=pc.nachname,
vorname=pc.vorname,
geburtsdatum=pc.geburtsdatum,
kvnr=pc.kvnr,
fallgruppe=pc.fallgruppe,
datum=pc.datum,
is_duplicate=is_dup,
fall_id=generate_fall_id(pc),
)
)
logger.info(
"Import preview for '%s': %d total, %d new, %d duplicates",
filename,
len(parsed_cases),
len(parsed_cases) - duplicates,
duplicates,
)
return ImportPreview(
filename=filename,
total_rows=len(parsed_cases),
new_cases=len(parsed_cases) - duplicates,
duplicates=duplicates,
rows=rows,
)
def confirm_import(
db: Session,
parsed_cases: list[ParsedCase],
filename: str,
user_id: int | None = None,
) -> ImportResult:
"""Insert non-duplicate cases into the database.
This is the second step of the two-phase import flow. It:
1. Re-checks each case for duplicates (in case of concurrent imports)
2. Creates Case rows for new cases
3. Logs the import in ImportLog
4. Commits the transaction
"""
imported = 0
skipped = 0
errors: list[str] = []
for pc in parsed_cases:
try:
if check_duplicate(db, pc):
skipped += 1
continue
case = Case(
fall_id=generate_fall_id(pc),
crm_ticket_id=pc.crm_ticket_id,
jahr=pc.jahr,
kw=pc.kw,
datum=pc.datum,
nachname=pc.nachname,
vorname=pc.vorname,
geburtsdatum=pc.geburtsdatum,
kvnr=pc.kvnr,
fallgruppe=pc.fallgruppe,
import_source=filename,
)
db.add(case)
imported += 1
except Exception as e:
errors.append(f"{pc.nachname}: {e}")
logger.warning("Import error for case %s: %s", pc.nachname, e)
# Flush to detect any DB-level constraint violations before logging
db.flush()
# Log the import
log = ImportLog(
filename=filename,
import_type="csv_crm",
cases_imported=imported,
cases_skipped=skipped,
cases_updated=0,
errors="; ".join(errors) if errors else None,
imported_by=user_id,
)
db.add(log)
db.commit()
logger.info(
"Import confirmed for '%s': %d imported, %d skipped, %d errors",
filename,
imported,
skipped,
len(errors),
)
return ImportResult(
imported=imported,
skipped=skipped,
updated=0,
errors=errors,
)