From ba1cff6c54439d0a65f340d3dcd3d9fe8b811139 Mon Sep 17 00:00:00 2001 From: Harold Asiimwe Date: Wed, 7 Jan 2026 17:34:08 +0300 Subject: [PATCH 1/9] #13771 - Add Epipulse export functionality for Measles disease --- .../EpipulseDiseaseExportEntryDto.java | 285 +++++++++ .../epipulse/EpipulseDiseaseExportResult.java | 47 ++ .../epipulse/EpipulseLaboratoryMapper.java | 290 +++++++++ .../api/epipulse/EpipulseSubjectCode.java | 3 +- .../referencevalue/EpipulseDiseaseRef.java | 3 +- sormas-api/src/main/resources/enum.properties | 7 +- .../EpipulseDiseaseExportFacadeEjb.java | 253 ++++++++ .../EpipulseDiseaseExportService.java | 603 ++++++++++++++++++ .../epipulse/EpipulseExportTimerEjb.java | 3 + 9 files changed, 1491 insertions(+), 3 deletions(-) create mode 100644 sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseLaboratoryMapper.java diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportEntryDto.java b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportEntryDto.java index 026696a1d67..8fcbee93860 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportEntryDto.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportEntryDto.java @@ -82,6 +82,27 @@ public class EpipulseDiseaseExportEntryDto { private List immunizations; private List vaccinations; + // MEAS-specific laboratory fields (can be mapped from existing SORMAS data) + private Date dateOfSpecimen; + private Date dateOfLaboratoryResult; + private List typeOfSpecimenCollected; // SampleMaterial mapped to EpiPulse codes (repeatable) + private String resultOfVirusDetection; // PathogenTestResultType mapped to POS/NEG/EQUI/NOTEST + private String genotype; // PathogenTest typingId/genoTypeResult + private List typeOfSpecimenSerology; // SampleMaterial for serology tests (repeatable) + private String resultIgG; // IgG test result + private String resultIgM; // IgM test result + + // Phase 3: Clinical and epidemiology fields (mapped from existing SORMAS data) + private Date dateOfInvestigation; // CaseDataDto.investigatedDate + private Boolean clusterRelated; // EpiDataDto.clusterRelated + private String clusterIdentification; // EpiDataDto.clusterTypeText + private List clusterSetting; // EpiDataDto.clusterType mapped to EpiPulse codes (repeatable) + private String importedStatus; // EpiDataDto.caseImportedStatus mapped to EpiPulse codes + private List complicationDiagnosis; // SymptomsDto complications (repeatable) + private Boolean clinicalCriteriaStatus; // Derived from CaseDataDto.clinicalConfirmation + private List placeOfInfection; // EpiDataDto.exposures locations (repeatable) + private String causeOfDeath; // PersonDto.causeOfDeathDetails + public String getReportingCountry() { return reportingCountry; } @@ -326,6 +347,70 @@ public void setVaccinations(List vaccinations) { this.vaccinations = vaccinations; } + public Date getDateOfSpecimen() { + return dateOfSpecimen; + } + + public void setDateOfSpecimen(Date dateOfSpecimen) { + this.dateOfSpecimen = dateOfSpecimen; + } + + public Date getDateOfLaboratoryResult() { + return dateOfLaboratoryResult; + } + + public void setDateOfLaboratoryResult(Date dateOfLaboratoryResult) { + this.dateOfLaboratoryResult = dateOfLaboratoryResult; + } + + public List getTypeOfSpecimenCollected() { + return typeOfSpecimenCollected; + } + + public void setTypeOfSpecimenCollected(List typeOfSpecimenCollected) { + this.typeOfSpecimenCollected = typeOfSpecimenCollected; + } + + public String getResultOfVirusDetection() { + return resultOfVirusDetection; + } + + public void setResultOfVirusDetection(String resultOfVirusDetection) { + this.resultOfVirusDetection = resultOfVirusDetection; + } + + public String getGenotype() { + return genotype; + } + + public void setGenotype(String genotype) { + this.genotype = genotype; + } + + public List getTypeOfSpecimenSerology() { + return typeOfSpecimenSerology; + } + + public void setTypeOfSpecimenSerology(List typeOfSpecimenSerology) { + this.typeOfSpecimenSerology = typeOfSpecimenSerology; + } + + public String getResultIgG() { + return resultIgG; + } + + public void setResultIgG(String resultIgG) { + this.resultIgG = resultIgG; + } + + public String getResultIgM() { + return resultIgM; + } + + public void setResultIgM(String resultIgM) { + this.resultIgM = resultIgM; + } + public String getDiseaseForCsv() { return EpipulseDiseaseRef.getBySubjectCode(subjectCode).name(); } @@ -365,6 +450,7 @@ public String getAgeForCsv() { public String getAgeMonthForCsv() { switch (subjectCode) { case PERT: + case MEAS: if (ageYears != null && ageYears < 2) { return ageMonths == null ? null : ageMonths.toString(); } @@ -384,6 +470,7 @@ public String getGenderForCsv() { public String getPlaceOfResidenceForCsv() { switch (subjectCode) { case PERT: + case MEAS: if (addressCommunityNutsCode != null && !addressCommunityNutsCode.isEmpty()) { return addressCommunityNutsCode; } else if (addressDistrictNutsCode != null && !addressDistrictNutsCode.isEmpty()) { @@ -400,6 +487,7 @@ public String getPlaceOfResidenceForCsv() { public String getPlaceOfNotificationForCsv() { switch (subjectCode) { case PERT: + case MEAS: if (responsibleCommunityNutsCode != null && !responsibleCommunityNutsCode.isEmpty()) { return responsibleCommunityNutsCode; } else if (responsibleDistrictNutsCode != null && !responsibleDistrictNutsCode.isEmpty()) { @@ -529,6 +617,203 @@ public String getGestationalAgeAtVaccinationForCsv() { return null; } + // MEAS-specific laboratory field CSV getters + public String getDateOfSpecimenForCsv() { + return formatDateForCsv(dateOfSpecimen); + } + + public String getDateOfLaboratoryResultForCsv() { + return formatDateForCsv(dateOfLaboratoryResult); + } + + public List getTypeOfSpecimenCollectedForCsv(int maxSpecimenVirDetect) { + // Repeatable field - return list padded to max length + List specimens = new ArrayList<>(); + for (int i = 0; i < maxSpecimenVirDetect; i++) { + if (typeOfSpecimenCollected != null && i < typeOfSpecimenCollected.size()) { + specimens.add(typeOfSpecimenCollected.get(i)); + } else { + specimens.add(""); + } + } + return specimens; + } + + public String getResultOfVirusDetectionForCsv() { + // Already mapped to EpiPulse codes (POS/NEG/EQUI/NOTEST) + return resultOfVirusDetection; + } + + public String getGenotypeForCsv() { + // Already mapped to EpiPulse genotype codes (MEASV_A, MEASV_B1, etc.) + return genotype; + } + + public List getTypeOfSpecimenSerologyForCsv(int maxSpecimenSero) { + // Repeatable field - return list padded to max length + List specimens = new ArrayList<>(); + for (int i = 0; i < maxSpecimenSero; i++) { + if (typeOfSpecimenSerology != null && i < typeOfSpecimenSerology.size()) { + specimens.add(typeOfSpecimenSerology.get(i)); + } else { + specimens.add(""); + } + } + return specimens; + } + + public String getResultIgGForCsv() { + // Already mapped to EpiPulse codes (POS/NEG/EQUI/NOTEST) + return resultIgG; + } + + public String getResultIgMForCsv() { + // Already mapped to EpiPulse codes (POS/NEG/EQUI/NOTEST) + return resultIgM; + } + + // Phase 3: Getters and setters for clinical and epidemiology fields + public Date getDateOfInvestigation() { + return dateOfInvestigation; + } + + public void setDateOfInvestigation(Date dateOfInvestigation) { + this.dateOfInvestigation = dateOfInvestigation; + } + + public Boolean getClusterRelated() { + return clusterRelated; + } + + public void setClusterRelated(Boolean clusterRelated) { + this.clusterRelated = clusterRelated; + } + + public String getClusterIdentification() { + return clusterIdentification; + } + + public void setClusterIdentification(String clusterIdentification) { + this.clusterIdentification = clusterIdentification; + } + + public List getClusterSetting() { + return clusterSetting; + } + + public void setClusterSetting(List clusterSetting) { + this.clusterSetting = clusterSetting; + } + + public String getImportedStatus() { + return importedStatus; + } + + public void setImportedStatus(String importedStatus) { + this.importedStatus = importedStatus; + } + + public List getComplicationDiagnosis() { + return complicationDiagnosis; + } + + public void setComplicationDiagnosis(List complicationDiagnosis) { + this.complicationDiagnosis = complicationDiagnosis; + } + + public Boolean getClinicalCriteriaStatus() { + return clinicalCriteriaStatus; + } + + public void setClinicalCriteriaStatus(Boolean clinicalCriteriaStatus) { + this.clinicalCriteriaStatus = clinicalCriteriaStatus; + } + + public List getPlaceOfInfection() { + return placeOfInfection; + } + + public void setPlaceOfInfection(List placeOfInfection) { + this.placeOfInfection = placeOfInfection; + } + + public String getCauseOfDeath() { + return causeOfDeath; + } + + public void setCauseOfDeath(String causeOfDeath) { + this.causeOfDeath = causeOfDeath; + } + + // Phase 3: CSV getter methods + public String getDateOfInvestigationForCsv() { + return formatDateForCsv(dateOfInvestigation); + } + + public String getClusterRelatedForCsv() { + return clusterRelated != null ? String.valueOf(clusterRelated) : ""; + } + + public String getClusterIdentificationForCsv() { + return clusterIdentification != null ? clusterIdentification : ""; + } + + public List getClusterSettingForCsv(int maxClusterSettings) { + // Repeatable field - return list padded to max length + List settings = new ArrayList<>(); + for (int i = 0; i < maxClusterSettings; i++) { + if (clusterSetting != null && i < clusterSetting.size()) { + settings.add(clusterSetting.get(i)); + } else { + settings.add(""); + } + } + return settings; + } + + public String getImportedStatusForCsv() { + return importedStatus != null ? importedStatus : ""; + } + + public List getComplicationDiagnosisForCsv(int maxComplicationDiagnosis) { + // Repeatable field - return list padded to max length + List complications = new ArrayList<>(); + for (int i = 0; i < maxComplicationDiagnosis; i++) { + if (complicationDiagnosis != null && i < complicationDiagnosis.size()) { + complications.add(complicationDiagnosis.get(i)); + } else { + // Use "NONE" for first empty slot if no complications, otherwise empty + if (i == 0 && (complicationDiagnosis == null || complicationDiagnosis.isEmpty())) { + complications.add("NONE"); + } else { + complications.add(""); + } + } + } + return complications; + } + + public String getClinicalCriteriaStatusForCsv() { + return clinicalCriteriaStatus != null ? String.valueOf(clinicalCriteriaStatus) : ""; + } + + public List getPlaceOfInfectionForCsv(int maxPlaceOfInfection) { + // Repeatable field - return list padded to max length + List places = new ArrayList<>(); + for (int i = 0; i < maxPlaceOfInfection; i++) { + if (placeOfInfection != null && i < placeOfInfection.size()) { + places.add(placeOfInfection.get(i)); + } else { + places.add(""); + } + } + return places; + } + + public String getCauseOfDeathForCsv() { + return causeOfDeath != null ? causeOfDeath : ""; + } + public void calculateAge() { if (symptomOnsetDate == null || yearOfBirth == null || monthOfBirth == null || dayOfBirth == null) { return; diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportResult.java b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportResult.java index d543c307caa..d1118a428d9 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportResult.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportResult.java @@ -23,6 +23,13 @@ public class EpipulseDiseaseExportResult { private int maxImmunizations; private List exportEntryList; + // MEAS repeatable field max counts + private int maxComplicationDiagnosis; + private int maxClusterSettings; + private int maxPlaceOfInfection; + private int maxSpecimenVirDetect; + private int maxSpecimenSero; + public int getMaxPathogenTests() { return maxPathogenTests; } @@ -46,4 +53,44 @@ public List getExportEntryList() { public void setExportEntryList(List exportEntryList) { this.exportEntryList = exportEntryList; } + + public int getMaxComplicationDiagnosis() { + return maxComplicationDiagnosis; + } + + public void setMaxComplicationDiagnosis(int maxComplicationDiagnosis) { + this.maxComplicationDiagnosis = maxComplicationDiagnosis; + } + + public int getMaxClusterSettings() { + return maxClusterSettings; + } + + public void setMaxClusterSettings(int maxClusterSettings) { + this.maxClusterSettings = maxClusterSettings; + } + + public int getMaxPlaceOfInfection() { + return maxPlaceOfInfection; + } + + public void setMaxPlaceOfInfection(int maxPlaceOfInfection) { + this.maxPlaceOfInfection = maxPlaceOfInfection; + } + + public int getMaxSpecimenVirDetect() { + return maxSpecimenVirDetect; + } + + public void setMaxSpecimenVirDetect(int maxSpecimenVirDetect) { + this.maxSpecimenVirDetect = maxSpecimenVirDetect; + } + + public int getMaxSpecimenSero() { + return maxSpecimenSero; + } + + public void setMaxSpecimenSero(int maxSpecimenSero) { + this.maxSpecimenSero = maxSpecimenSero; + } } diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseLaboratoryMapper.java b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseLaboratoryMapper.java new file mode 100644 index 00000000000..c6acc1b3799 --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseLaboratoryMapper.java @@ -0,0 +1,290 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2024 Helmholtz-Zentrum für Infektionsforschung GmbH (HZI) + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.api.epipulse; + +import java.util.ArrayList; +import java.util.List; + +import de.symeda.sormas.api.epidata.CaseImportedStatus; +import de.symeda.sormas.api.epidata.ClusterType; +import de.symeda.sormas.api.sample.PathogenTestResultType; +import de.symeda.sormas.api.sample.SampleMaterial; +import de.symeda.sormas.api.symptoms.SymptomState; +import de.symeda.sormas.api.utils.YesNoUnknown; + +/** + * Utility class for mapping SORMAS laboratory and epidemiology data to EpiPulse codes for MEAS export. + * Based on metadata analysis from 20250929_EpiPulse_CasesMetadata_mapped.xlsx + */ +public class EpipulseLaboratoryMapper { + + /** + * Maps SORMAS SampleMaterial enum to EpiPulse specimen type codes. + *

+ * EpiPulse Reference Values: + * - DRYBLOSP = Dry blood spot + * - EDTA = EDTA whole blood + * - NASALSWAB = Nasal swab + * - OTH = Other + * - SALOR = Saliva/oral fluid + * - SER = Serum + * - URINE = Urine + * + * @param sampleMaterial + * SORMAS sample material enum + * @return EpiPulse specimen type code, or null if not mappable + */ + public static String mapSampleMaterialToEpipulseCode(SampleMaterial sampleMaterial) { + if (sampleMaterial == null) { + return null; + } + + switch (sampleMaterial) { + case BLOOD: + case SERA: + return "SER"; // Serum + case URINE: + return "URINE"; + case NASAL_SWAB: + return "NASALSWAB"; + case THROAT_SWAB: + case RECTAL_SWAB: + case OTHER: + return "OTH"; + case SALIVA: + return "SALOR"; // Saliva/oral fluid + case EDTA_WHOLE_BLOOD: + return "EDTA"; // EDTA whole blood + default: + return "OTH"; + } + } + + /** + * Maps SORMAS PathogenTestResultType enum to EpiPulse test result codes. + *

+ * EpiPulse Reference Values: + * - EQUI = Equivocal + * - NEG = Negative + * - NOTEST = Not tested + * - POS = Positive + * + * @param testResult + * SORMAS test result enum + * @return EpiPulse result code (POS/NEG/EQUI/NOTEST) + */ + public static String mapTestResultToEpipulseCode(PathogenTestResultType testResult) { + if (testResult == null) { + return "NOTEST"; + } + + switch (testResult) { + case POSITIVE: + return "POS"; + case NEGATIVE: + return "NEG"; + case INDETERMINATE: + return "EQUI"; + case PENDING: + case NOT_DONE: + return "NOTEST"; + default: + return "NOTEST"; + } + } + + /** + * Validates and normalizes genotype string to match EpiPulse reference values. + *

+ * EpiPulse accepts 49 measles virus genotypes: MEASV_A, MEASV_B1, MEASV_B2, MEASV_B3, + * MEASV_C1, MEASV_C2, MEASV_D1-D11, MEASV_E, MEASV_F, MEASV_G1-G3, MEASV_H1-H2, etc. + * + * @param genotypeText + * SORMAS genotype string (from typingId or genoTypeResult) + * @return Normalized EpiPulse genotype code, or null if not a valid measles genotype + */ + public static String normalizeGenotypeForEpipulse(String genotypeText) { + if (genotypeText == null || genotypeText.trim().isEmpty()) { + return null; + } + + String normalized = genotypeText.trim().toUpperCase(); + + // If already in MEASV_ format, return as-is + if (normalized.startsWith("MEASV_")) { + return normalized; + } + + // Try to parse formats like "A", "B1", "D10", etc. and add MEASV_ prefix + if (normalized.matches("^[A-Z]\\d*$")) { + return "MEASV_" + normalized; + } + + // Try to parse formats like "Genotype A", "MeV-A", etc. + if (normalized.contains("A")) { + return "MEASV_A"; + } + if (normalized.matches(".*B[12]?.*")) { + if (normalized.contains("B1")) { + return "MEASV_B1"; + } else if (normalized.contains("B2")) { + return "MEASV_B2"; + } else if (normalized.contains("B3")) { + return "MEASV_B3"; + } + return "MEASV_B1"; // Default to B1 + } + + // If we can't parse it, return null (field will be empty in CSV) + return null; + } + + /** + * Maps SORMAS ClusterType enum to EpiPulse cluster setting codes. + *

+ * EpiPulse Reference Values: + * - CHILDCARE = Childcare setting + * - FAM = Family + * - MIL = Military + * - NOS = Nosocomial (healthcare) + * - OTH = Other + * - SCH = School + * - SPORT = Sports team + * - UNI = University + * + * @param clusterType + * SORMAS cluster type enum + * @return EpiPulse cluster setting code + */ + public static String mapClusterTypeToEpipulseCode(ClusterType clusterType) { + if (clusterType == null) { + return null; + } + + switch (clusterType) { + case KINDERGARTEN_OR_CHILDCARE: + return "CHILDCARE"; + case FAMILY: + return "FAM"; + case MILITARY: + return "MIL"; + case NOSOCOMIAL: + return "NOS"; + case SCHOOL: + return "SCH"; + case SPORTS_TEAM: + return "SPORT"; + case UNIVERSITY: + return "UNI"; + case OTHER: + return "OTH"; + default: + return null; + } + } + + /** + * Maps SORMAS CaseImportedStatus enum to EpiPulse imported status codes. + *

+ * EpiPulse Reference Values: + * - AUTOCH = Autochthonous (not imported case) + * - IMP = Imported case + * - IMPR = Import-related case + * - UNK = Unknown importation status + * + * @param importedStatus + * SORMAS case imported status enum + * @return EpiPulse imported status code + */ + public static String mapCaseImportedStatusToEpipulseCode(CaseImportedStatus importedStatus) { + if (importedStatus == null) { + return null; + } + + switch (importedStatus) { + case IMPORTED_CASE: + return "IMP"; + case IMPORT_RELATED_CASE: + return "IMPR"; + case UNKNOWN_IMPORTATION_STATUS: + return "UNK"; + case NOT_IMPORTED_CASE: + return "AUTOCH"; + default: + return null; + } + } + + /** + * Maps SORMAS Symptoms complication fields to EpiPulse complication diagnosis codes. + *

+ * EpiPulse Reference Values: + * - ACENCE = Acute encephalitis + * - DIARR = Diarrhea + * - NONE = No complications + * - OME = Otitis media + * - OTH = Other + * - PNEU = Pneumonia + * + * @param acuteEncephalitis + * Acute encephalitis symptom state + * @param diarrhea + * Diarrhea symptom state + * @param otitisMedia + * Otitis media symptom state + * @param otherComplications + * Other complications symptom state + * @return List of EpiPulse complication codes (empty list returns "NONE" in CSV) + */ + public static List mapSymptomsToComplicationCodes( + SymptomState acuteEncephalitis, + SymptomState diarrhea, + SymptomState otitisMedia, + SymptomState otherComplications) { + + List complications = new ArrayList<>(); + + if (acuteEncephalitis == SymptomState.YES) { + complications.add("ACENCE"); + } + if (diarrhea == SymptomState.YES) { + complications.add("DIARR"); + } + if (otitisMedia == SymptomState.YES) { + complications.add("OME"); + } + if (otherComplications == SymptomState.YES) { + complications.add("OTH"); + } + + // Note: PNEU (pneumonia) not currently available in SORMAS Symptoms for MEAS + // If no complications found, empty list will result in "NONE" in CSV + + return complications; + } + + /** + * Derives clinical criteria status from clinical confirmation field. + * Clinical criteria are considered met if case has clinical confirmation = YES. + * + * @param clinicalConfirmation + * SORMAS clinical confirmation field + * @return true if clinically confirmed, false otherwise + */ + public static Boolean deriveClinicalCriteriaStatus(YesNoUnknown clinicalConfirmation) { + return clinicalConfirmation == YesNoUnknown.YES; + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseSubjectCode.java b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseSubjectCode.java index 6b8e83f526b..6f3af3bcedc 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseSubjectCode.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseSubjectCode.java @@ -23,7 +23,8 @@ public enum EpipulseSubjectCode { - PERT(true, Disease.PERTUSSIS, false); + PERT(true, Disease.PERTUSSIS, false), + MEAS(true, Disease.MEASLES, false); private final boolean diseaseModel; private final Disease disease; diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/referencevalue/EpipulseDiseaseRef.java b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/referencevalue/EpipulseDiseaseRef.java index 177eb7425d2..636d0f67153 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/referencevalue/EpipulseDiseaseRef.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/referencevalue/EpipulseDiseaseRef.java @@ -20,7 +20,8 @@ public enum EpipulseDiseaseRef { - PERT(EpipulseSubjectCode.PERT); + PERT(EpipulseSubjectCode.PERT), + MEAS(EpipulseSubjectCode.MEAS); private final EpipulseSubjectCode[] subjectCodes; diff --git a/sormas-api/src/main/resources/enum.properties b/sormas-api/src/main/resources/enum.properties index 41671c60bd7..069789a8a1c 100644 --- a/sormas-api/src/main/resources/enum.properties +++ b/sormas-api/src/main/resources/enum.properties @@ -2859,4 +2859,9 @@ EpipulseExportStatus.FAILED=Failed EpipulseExportStatus.CANCELLED=Cancelled # EpipulseSubjectCode -EpipulseSubjectCode.PERT = Pertussis \ No newline at end of file +EpipulseSubjectCode.PERT = Pertussis +EpipulseSubjectCode.MEAS = Measles + +# EpipulseDiseaseRef +EpipulseDiseaseRef.PERT = Pertussis +EpipulseDiseaseRef.MEAS = Measles \ No newline at end of file diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportFacadeEjb.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportFacadeEjb.java index 6a988bedf8f..c700548dabd 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportFacadeEjb.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportFacadeEjb.java @@ -225,6 +225,259 @@ public void startPertussisExport(String uuid) { } } + public void startMeaslesExport(String uuid) { + + CSVWriter writer = null; + EpipulseExport epipulseExport = null; + EpipulseExportStatus exportStatus = EpipulseExportStatus.FAILED; + boolean shouldUpdateStatus = false; + + Integer totalRecords = null; + BigDecimal exportFileSizeBytes = null; + String exportFileName = null; + String exportFilePath = null; + + try { + epipulseExport = epipulseExportService.getByUuid(uuid); + + if (epipulseExport == null) { + logger.error("EpipulseExport with uuid " + uuid + " not found"); + return; + } + + if (epipulseExport.getStatus() != EpipulseExportStatus.PENDING) { + logger.error("EpipulseExport with uuid " + uuid + " is not in status PENDING"); + return; + } + + shouldUpdateStatus = true; + + diseaseExportService.updateStatusForBackgroundProcess(uuid, EpipulseExportStatus.IN_PROGRESS, null, null, null); + + EpipulseExportDto exportDto = epipulseExportEjb.toEpipulseExportDto(epipulseExport); + + String serverCountryLocale = configFacadeEjb.getCountryLocale(); + String serverCountryCode = configFacadeEjb.getCountryCode(); + String serverCountryName = configFacadeEjb.getCountryName(); + + String generatedFilesPath = configFacadeEjb.getGeneratedFilesPath(); + exportFileName = diseaseExportService.generateDownloadFileName(exportDto, epipulseExport.getId()); + exportFilePath = generatedFilesPath + "/" + exportFileName; + + EpipulseDiseaseExportResult exportResult = diseaseExportService.exportMeaslesCaseBased(exportDto, serverCountryCode, serverCountryName); + totalRecords = exportResult.getExportEntryList().size(); + + writer = CSVUtils.createCSVWriter( + new OutputStreamWriter(new FileOutputStream(exportFilePath), StandardCharsets.UTF_8), + configFacadeEjb.getCsvSeparator()); + + // MEAS CSV columns - including Phase 2 laboratory fields and Phase 3 clinical/epidemiology fields + List columnNames = new ArrayList<>( + List.of( + "Disease", + "ReportingCountry", + "Status", + "SubjectCode", + "NationalRecordId", + "DataSource", + "DateUsedForStatistics", + "Age", + "AgeMonth", + "Gender", + "CaseClassification", + "DateOfOnset", + "DateOfNotification", + "Hospitalisation", + "Outcome", + "PlaceOfNotification", + "PlaceOfResidence", + "DateOfSpecimen", + "DateOfLaboratoryResult")); + + // Repeatable field: TypeOfSpecimenCollected + if (exportResult.getMaxSpecimenVirDetect() > 0) { + for (int i = 1; i <= exportResult.getMaxSpecimenVirDetect(); i++) { + columnNames.add("TypeOfSpecimenCollected"); + } + } + + columnNames.addAll(List.of("ResultOfVirusDetection", "Genotype")); + + // Repeatable field: TypeOfSpecimenForSerologicalAnalysis + if (exportResult.getMaxSpecimenSero() > 0) { + for (int i = 1; i <= exportResult.getMaxSpecimenSero(); i++) { + columnNames.add("TypeOfSpecimenForSerologicalAnalysis"); + } + } + + columnNames.addAll(List.of("ResultIgG", "ResultIgM", "DateOfInvestigation", "ClusterRelated", "ClusterIdentification")); + + // Repeatable field: ClusterSetting + if (exportResult.getMaxClusterSettings() > 0) { + for (int i = 1; i <= exportResult.getMaxClusterSettings(); i++) { + columnNames.add("ClusterSetting"); + } + } + + columnNames.add("ImportedStatus"); + + // Repeatable field: ComplicationDiagnosis + if (exportResult.getMaxComplicationDiagnosis() > 0) { + for (int i = 1; i <= exportResult.getMaxComplicationDiagnosis(); i++) { + columnNames.add("ComplicationDiagnosis"); + } + } + + columnNames.add("ClinicalCriteriaStatus"); + + // Repeatable field: PlaceOfInfection + if (exportResult.getMaxPlaceOfInfection() > 0) { + for (int i = 1; i <= exportResult.getMaxPlaceOfInfection(); i++) { + columnNames.add("PlaceOfInfection"); + } + } + + columnNames.add("CauseOfDeath"); + + if (exportResult.getMaxImmunizations() > 0) { + columnNames.add("DateOfLastVaccination"); + } + + columnNames.add("VaccinationStatus"); + + //write the headers + writer.writeNext(columnNames.toArray(new String[columnNames.size()])); + + //write entries + String[] exportLine = new String[columnNames.size()]; + int index; + for (EpipulseDiseaseExportEntryDto dto : exportResult.getExportEntryList()) { + index = -1; + + exportLine[++index] = dto.getDiseaseForCsv(); + exportLine[++index] = dto.getReportingCountryForCsv(); + exportLine[++index] = dto.getStatusForCsv(); + exportLine[++index] = dto.getSubjectCodeForCsv(); + exportLine[++index] = dto.getNationalRecordIdForCsv(); + exportLine[++index] = dto.getDataSourceForCsv(); + exportLine[++index] = dto.getDateUsedForStatisticsCsv(); + exportLine[++index] = dto.getAgeForCsv(); + exportLine[++index] = dto.getAgeMonthForCsv(); + exportLine[++index] = dto.getGenderForCsv(); + exportLine[++index] = dto.getCaseClassificationForCsv(); + exportLine[++index] = dto.getDateOfOnsetForCsv(); + exportLine[++index] = dto.getDateOfNotificationForCsv(); + exportLine[++index] = dto.getHospitalizationForCsv(); + exportLine[++index] = dto.getOutcomeForCsv(); + exportLine[++index] = dto.getPlaceOfNotificationForCsv(); + exportLine[++index] = dto.getPlaceOfResidenceForCsv(); + + // Phase 2: Laboratory fields + exportLine[++index] = dto.getDateOfSpecimenForCsv(); + exportLine[++index] = dto.getDateOfLaboratoryResultForCsv(); + + // Repeatable: TypeOfSpecimenCollected + if (exportResult.getMaxSpecimenVirDetect() > 0) { + List specimenCollected = dto.getTypeOfSpecimenCollectedForCsv(exportResult.getMaxSpecimenVirDetect()); + for (String specimen : specimenCollected) { + exportLine[++index] = specimen; + } + } + + exportLine[++index] = dto.getResultOfVirusDetectionForCsv(); + exportLine[++index] = dto.getGenotypeForCsv(); + + // Repeatable: TypeOfSpecimenForSerologicalAnalysis + if (exportResult.getMaxSpecimenSero() > 0) { + List specimenSerology = dto.getTypeOfSpecimenSerologyForCsv(exportResult.getMaxSpecimenSero()); + for (String specimen : specimenSerology) { + exportLine[++index] = specimen; + } + } + + exportLine[++index] = dto.getResultIgGForCsv(); + exportLine[++index] = dto.getResultIgMForCsv(); + + // Phase 3: Clinical and epidemiology fields + exportLine[++index] = dto.getDateOfInvestigationForCsv(); + exportLine[++index] = dto.getClusterRelatedForCsv(); + exportLine[++index] = dto.getClusterIdentificationForCsv(); + + // Repeatable: ClusterSetting + if (exportResult.getMaxClusterSettings() > 0) { + List clusterSettings = dto.getClusterSettingForCsv(exportResult.getMaxClusterSettings()); + for (String setting : clusterSettings) { + exportLine[++index] = setting; + } + } + + exportLine[++index] = dto.getImportedStatusForCsv(); + + // Repeatable: ComplicationDiagnosis + if (exportResult.getMaxComplicationDiagnosis() > 0) { + List complications = dto.getComplicationDiagnosisForCsv(exportResult.getMaxComplicationDiagnosis()); + for (String complication : complications) { + exportLine[++index] = complication; + } + } + + exportLine[++index] = dto.getClinicalCriteriaStatusForCsv(); + + // Repeatable: PlaceOfInfection + if (exportResult.getMaxPlaceOfInfection() > 0) { + List placesOfInfection = dto.getPlaceOfInfectionForCsv(exportResult.getMaxPlaceOfInfection()); + for (String place : placesOfInfection) { + exportLine[++index] = place; + } + } + + exportLine[++index] = dto.getCauseOfDeathForCsv(); + + if (exportResult.getMaxImmunizations() > 0) { + exportLine[++index] = dto.getDateOfLastVaccinationForCsv(); + } + + exportLine[++index] = dto.getVaccinationStatusForCsv(); + + writer.writeNext(exportLine); + } + + exportStatus = EpipulseExportStatus.COMPLETED; + } catch (Exception e) { + exportStatus = EpipulseExportStatus.FAILED; + + logger.error("Error during export with uuid " + uuid + ": " + e.getMessage(), e); + } finally { + if (writer != null) { + try { + writer.close(); + } catch (Exception e) { + logger.error("CRITICAL: Failed to close CSVWriter for uuid " + uuid + ": " + e.getMessage(), e); + } + } + + // Calculate file size after writer is closed + if (exportFilePath != null && exportStatus == EpipulseExportStatus.COMPLETED) { + try { + long fileSizeInBytes = Files.size(Paths.get(exportFilePath)); + exportFileSizeBytes = new BigDecimal(fileSizeInBytes); + logger.info("Export file size for uuid {}: {} bytes", uuid, fileSizeInBytes); + } catch (Exception e) { + logger.error("CRITICAL: Failed to calculate file size for uuid {}: {}", uuid, e.getMessage(), e); + } + } + + if (shouldUpdateStatus && epipulseExport != null) { + try { + diseaseExportService + .updateStatusForBackgroundProcess(epipulseExport.getUuid(), exportStatus, totalRecords, exportFileName, exportFileSizeBytes); + } catch (Exception e) { + logger.error("CRITICAL: Failed to update export status for uuid " + uuid + ": " + e.getMessage(), e); + } + } + } + } + @LocalBean @Stateless public static class EpipulseDiseaseExportFacadeEjbLocal extends EpipulseDiseaseExportFacadeEjb { diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportService.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportService.java index e08ab82a46d..c8c9b71c2ab 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportService.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportService.java @@ -36,16 +36,22 @@ import de.symeda.sormas.api.caze.CaseClassification; import de.symeda.sormas.api.caze.CaseOutcome; +import de.symeda.sormas.api.epidata.CaseImportedStatus; +import de.symeda.sormas.api.epidata.ClusterType; import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportEntryDto; import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportResult; import de.symeda.sormas.api.epipulse.EpipulseExportDto; import de.symeda.sormas.api.epipulse.EpipulseExportStatus; +import de.symeda.sormas.api.epipulse.EpipulseLaboratoryMapper; import de.symeda.sormas.api.epipulse.EpipulseSubjectCode; import de.symeda.sormas.api.epipulse.referencevalue.EpipulsePathogenTestTypeRef; import de.symeda.sormas.api.hospitalization.HospitalizationReasonType; import de.symeda.sormas.api.immunization.MeansOfImmunization; import de.symeda.sormas.api.person.Sex; +import de.symeda.sormas.api.sample.PathogenTestResultType; import de.symeda.sormas.api.sample.PathogenTestType; +import de.symeda.sormas.api.symptoms.SymptomState; +import de.symeda.sormas.api.sample.SampleMaterial; import de.symeda.sormas.api.utils.DateHelper; import de.symeda.sormas.api.utils.YesNoUnknown; import de.symeda.sormas.backend.util.ModelConstants; @@ -387,6 +393,591 @@ public EpipulseDiseaseExportResult exportPertussisCaseBased(EpipulseExportDto ex return exportResult; } + public EpipulseDiseaseExportResult exportMeaslesCaseBased(EpipulseExportDto exportDto, String serverCountryLocale, String serverCountryName) + throws SQLException, IllegalStateException, IllegalArgumentException { + + EpipulseDiseaseExportResult exportResult = new EpipulseDiseaseExportResult(); + + try { + //lookup reporting country + //@formatter:off + String reportingCountryQuery = + "select code as reporting_country " + + "from epipulse_location_configuration " + + "where type='Country' and country_iso2_code = :countryIso2Code"; + //@formatter:on + + @SuppressWarnings("unchecked") + String reportingCountry = (String) em.createNativeQuery(reportingCountryQuery) + .setParameter("countryIso2Code", serverCountryLocale) + .getResultStream() + .filter(java.util.Objects::nonNull) + .findFirst() + .orElse(null); + + if (StringUtils.isBlank(reportingCountry)) { + throw new IllegalArgumentException("Invalid server country code: " + serverCountryLocale); + } + + //lookup server country nuts code + //@formatter:off + String serverCountryQuery = + "select nutscode " + + "from country " + + "where lower(defaultname) = :countryName"; + //@formatter:on + + @SuppressWarnings("unchecked") + String serverCountryNutsCode = (String) em.createNativeQuery(serverCountryQuery) + .setParameter("countryName", serverCountryName.toLowerCase()) + .getResultStream() + .filter(java.util.Objects::nonNull) + .findFirst() + .orElse(null); + + //get subject code + //@formatter:off + String subjectCodeQuery = + "select subjectcode " + + "from epipulse_subjectcode_configuration " + + "where disease=:disease and aggregatedreporting='No'"; + //@formatter:on + + @SuppressWarnings("unchecked") + String subjectCode = (String) em.createNativeQuery(subjectCodeQuery) + .setParameter("disease", exportDto.getSubjectCode().name()) + .getResultStream() + .filter(java.util.Objects::nonNull) + .findFirst() + .orElse(null); + + if (StringUtils.isBlank(subjectCode)) { + throw new IllegalStateException("Subject code is empty"); + } + + //@formatter:off + String diseaseExportQuery = + "WITH variables AS (SELECT :disease AS disease," + + " :subjectCode AS subject_code," + + " :countryLocale AS country_locale," + + " CAST(:startDate AS date) AS start_date," + + " CAST(:endDate AS date) AS end_date)," + + " config_data AS (SELECT v.subject_code," + + " (SELECT epl.code" + + " FROM epipulse_location_configuration epl" + + " WHERE epl.type = 'Country'" + + " AND epl.country_iso2_code = v.country_locale) as reporting_country," + + " (SELECT epd.datasource" + + " FROM epipulse_datasource_configuration epd" + + " WHERE epd.country_iso2_code = v.country_locale" + + " AND epd.subjectcode = v.subject_code) as datasource" + + " FROM variables v)," + + " filtered_cases AS (SELECT c.id," + + " c.uuid," + + " c.deleted," + + " c.reportdate," + + " c.caseclassification," + + " c.outcome," + + " c.person_id," + + " c.symptoms_id," + + " c.hospitalization_id," + + " c.responsibleregion_id," + + " c.responsibledistrict_id," + + " c.responsiblecommunity_id," + + " c.epidata_id," + + " c.investigateddate," + + " c.clinicalconfirmation" + + " FROM cases c" + + " CROSS JOIN variables v" + + " WHERE c.disease = v.disease" + + " AND c.reportdate >= v.start_date" + + " AND c.reportdate < (v.end_date + interval '1 day'))," + + " case_all_prev_hsp_from_latest AS (SELECT prev_hsp.hospitalization_id," + + " STRING_AGG(CONCAT_WS('|'," + + " COALESCE(prev_hsp.admittedtohealthfacility, '')," + + " COALESCE(prev_hsp.hospitalizationreason, '')," + + " COALESCE(" + + " TO_CHAR(prev_hsp.admissiondate, 'YYYY-MM-DD')," + + " '')," + + " COALESCE(" + + " TO_CHAR(prev_hsp.dischargedate, 'YYYY-MM-DD')," + + " '')" + + " ), '#'" + + " ORDER BY prev_hsp.admissiondate DESC) as all_prev_hsp_from_latest" + + " FROM previoushospitalization as prev_hsp" + + " WHERE hospitalization_id IN (SELECT hospitalization_id" + + " FROM filtered_cases" + + " WHERE hospitalization_id IS NOT NULL)" + + " GROUP BY prev_hsp.hospitalization_id)," + + " case_all_samples_from_latest AS (SELECT samples.associatedcase_id," + + " ARRAY_AGG(samples.id ORDER BY samples.sampledatetime DESC) as all_sample_ids_from_latest" + + " FROM samples" + + " WHERE samples.associatedcase_id IN (SELECT id FROM filtered_cases)" + + " GROUP BY samples.associatedcase_id)," + + " sample_all_pathogen_tests_from_latest AS (SELECT pathogentest.sample_id," + + " STRING_AGG(CONCAT_WS('|'," + + " pathogentest.testtype," + + " pathogentest.testresult" + + " ), '#'" + + " ORDER BY pathogentest.testdatetime DESC) AS all_pathogen_tests_from_latest" + + " FROM pathogentest" + + " INNER JOIN case_all_samples_from_latest" + + " ON pathogentest.sample_id = ANY" + + " (case_all_samples_from_latest.all_sample_ids_from_latest)" + + " GROUP BY pathogentest.sample_id)," + + "case_all_immunizations AS (SELECT i.person_id," + + " STRING_AGG(CONCAT_WS('|'," + + " COALESCE(to_char(i.startdate, 'YYYY-MM-DD'), '')," + + " COALESCE(to_char(i.enddate, 'YYYY-MM-DD'), '')," + + " COALESCE(i.meansofimmunization, '')," + + " COALESCE(CAST(i.numberofdoses as text), '')), '#'" + + " ORDER BY i.startdate DESC) as all_immunizations_from_latest" + + " FROM immunization i" + + " CROSS JOIN variables v" + + " where i.person_id IN (SELECT person_id FROM filtered_cases)" + + " and i.disease = v.disease" + + " and i.meansofimmunization IN (:meansOfImmVaccination, :meansOfImmVaccinationRecovery)" + + " GROUP BY i.person_id)," + + "case_all_vaccinations AS (SELECT i.person_id," + + " STRING_AGG(CONCAT_WS('|'," + + " COALESCE(to_char(v.vaccinationdate, 'YYYY-MM-DD'), '')," + + " COALESCE(v.vaccinedose, '')), '#'" + + " ORDER BY v.vaccinationdate DESC) as all_vaccinations_from_latest" + + " FROM immunization i" + + " INNER JOIN vaccination v ON i.id = v.immunization_id" + + " CROSS JOIN variables" + + " WHERE i.person_id IN (SELECT person_id FROM filtered_cases)" + + " and i.disease = variables.disease" + + " and i.meansofimmunization IN (:meansOfImmVaccination, :meansOfImmVaccinationRecovery)" + + " GROUP BY i.person_id), " + + "sample_data AS (SELECT c.id as case_id," + + " MIN(s.sampledatetime) as first_specimen_date," + + " STRING_AGG(DISTINCT CAST(s2.samplematerial AS text), ',' ORDER BY CAST(s2.samplematerial AS text)) as specimen_types_virus," + + " STRING_AGG(DISTINCT CAST(s3.samplematerial AS text), ',' ORDER BY CAST(s3.samplematerial AS text)) as specimen_types_serology " + + " FROM filtered_cases c " + + " LEFT JOIN samples s ON s.associatedcase_id = c.id AND s.deleted = false " + + " LEFT JOIN samples s2 ON s2.associatedcase_id = c.id AND s2.deleted = false AND s2.samplematerial IS NOT NULL " + + " LEFT JOIN (SELECT DISTINCT s_sero.id, s_sero.associatedcase_id, s_sero.samplematerial " + + " FROM samples s_sero " + + " JOIN pathogentest pt_sero ON pt_sero.sample_id = s_sero.id " + + " WHERE s_sero.deleted = false " + + " AND pt_sero.testtype IN ('IGG_SERUM_ANTIBODY', 'IGM_SERUM_ANTIBODY', 'SEROLOGY')) s3 " + + " ON s3.associatedcase_id = c.id " + + " GROUP BY c.id), " + + "virus_detection_data AS (SELECT c.id as case_id," + + " MIN(pt.testdatetime) as lab_result_date," + + " (SELECT pt2.testresult " + + " FROM samples s2 " + + " JOIN pathogentest pt2 ON pt2.sample_id = s2.id " + + " WHERE s2.associatedcase_id = c.id " + + " AND s2.deleted = false " + + " AND pt2.testtype IN ('PCR_RT_PCR', 'CULTURE', 'ISOLATION', 'DIRECT_FLUORESCENT_ANTIBODY', 'INDIRECT_FLUORESCENT_ANTIBODY') " + + " AND pt2.testresultverified = true " + + " ORDER BY pt2.testdatetime ASC " + + " LIMIT 1) as virus_detection_result," + + " (SELECT COALESCE(pt3.typingid, pt3.genotyperesult) " + + " FROM samples s3 " + + " JOIN pathogentest pt3 ON pt3.sample_id = s3.id " + + " WHERE s3.associatedcase_id = c.id " + + " AND s3.deleted = false " + + " AND (pt3.typingid IS NOT NULL OR pt3.genotyperesult IS NOT NULL) " + + " ORDER BY pt3.testdatetime ASC " + + " LIMIT 1) as genotype_raw " + + " FROM filtered_cases c " + + " LEFT JOIN samples s ON s.associatedcase_id = c.id AND s.deleted = false " + + " LEFT JOIN pathogentest pt ON pt.sample_id = s.id " + + " AND pt.testtype IN ('PCR_RT_PCR', 'CULTURE', 'ISOLATION', 'DIRECT_FLUORESCENT_ANTIBODY', 'INDIRECT_FLUORESCENT_ANTIBODY') " + + " AND pt.testresultverified = true " + + " GROUP BY c.id), " + + "igg_serology_data AS (SELECT c.id as case_id," + + " (SELECT CASE " + + " WHEN pt_igg.fourfoldincreaseantibodytiter = 'YES' THEN 'POSITIVE' " + + " ELSE pt_igg.testresult " + + " END " + + " FROM samples s_igg " + + " JOIN pathogentest pt_igg ON pt_igg.sample_id = s_igg.id " + + " WHERE s_igg.associatedcase_id = c.id " + + " AND s_igg.deleted = false " + + " AND pt_igg.testtype = 'IGG_SERUM_ANTIBODY' " + + " ORDER BY pt_igg.testdatetime ASC " + + " LIMIT 1) as igg_result " + + " FROM filtered_cases c), " + + "igm_serology_data AS (SELECT c.id as case_id," + + " (SELECT pt_igm.testresult " + + " FROM samples s_igm " + + " JOIN pathogentest pt_igm ON pt_igm.sample_id = s_igm.id " + + " WHERE s_igm.associatedcase_id = c.id " + + " AND s_igm.deleted = false " + + " AND pt_igm.testtype = 'IGM_SERUM_ANTIBODY' " + + " ORDER BY pt_igm.testdatetime ASC " + + " LIMIT 1) as igm_result " + + " FROM filtered_cases c), " + + "epidata_cluster AS (SELECT c.id as case_id," + + " epi.clusterrelated," + + " epi.clustertypetext," + + " epi.clustertype," + + " epi.caseimportedstatus " + + " FROM filtered_cases c " + + " LEFT JOIN epidata epi ON c.epidata_id = epi.id), " + + "exposure_locations AS (SELECT e.epidata_id," + + " STRING_AGG(" + + " CASE " + + " WHEN co.defaultname IS NOT NULL THEN co.defaultname " + + " WHEN l.city IS NOT NULL THEN l.city " + + " ELSE l.details " + + " END, " + + " '; ' " + + " ORDER BY e.startdate DESC" + + " ) as infection_locations " + + " FROM exposures e " + + " JOIN location l ON e.location_id = l.id " + + " LEFT JOIN country co ON l.country_id = co.id " + + " WHERE e.epidata_id IN (SELECT epidata_id FROM filtered_cases WHERE epidata_id IS NOT NULL) " + + " GROUP BY e.epidata_id), " + + "complications_data AS (SELECT c.id as case_id," + + " s.acuteencephalitis," + + " s.diarrhea," + + " s.otitismedia," + + " s.othercomplications " + + " FROM filtered_cases c " + + " LEFT JOIN symptoms s ON c.symptoms_id = s.id) " + + "SELECT cd.reporting_country," + + " c.deleted," + + " cd.subject_code," + + " c.uuid as case_uuid," + + " cd.datasource," + + " cast(c.reportdate as date) as case_reportdate," + + " person.birthdate_yyyy," + + " person.birthdate_mm," + + " person.birthdate_dd," + + " cast(symptom.onsetdate as date) as symptom_onsetdate," + + " person.sex," + + " person_address_community.nutscode as address_community_nutscode," + + " person_address_district.nutscode as address_district_nutscode," + + " person_address_region.nutscode as address_region_nutscode," + + " person_address_country.nutscode as address_country_nutscode," + + " responsible_community.nutscode as responsible_community_nutscode," + + " responsible_district.nutscode as responsible_district_nutscode," + + " responsible_region.nutscode as responsible_region_nutscode," + + " c.caseclassification," + + " hospitalization.admittedtohealthfacility," + + " hospitalization.hospitalizationreason," + + " cast(hospitalization.admissiondate as date) as admissiondate," + + " cast(hospitalization.dischargedate as date) as dischargedate," + + " c.outcome as case_outcome," + + " case_all_prev_hsp_from_latest.all_prev_hsp_from_latest," + + " sample_all_pathogen_tests_from_latest.all_pathogen_tests_from_latest," + + " case_all_immunizations.all_immunizations_from_latest," + + " case_all_vaccinations.all_vaccinations_from_latest," + + " sd.first_specimen_date," + + " vd.lab_result_date," + + " sd.specimen_types_virus," + + " vd.virus_detection_result," + + " vd.genotype_raw," + + " sd.specimen_types_serology," + + " igg.igg_result," + + " igm.igm_result," + + " cast(c.investigateddate as date) as investigated_date," + + " ec.clusterrelated," + + " ec.clustertypetext," + + " ec.clustertype," + + " ec.caseimportedstatus," + + " comp.acuteencephalitis," + + " comp.diarrhea," + + " comp.otitismedia," + + " comp.othercomplications," + + " c.clinicalconfirmation," + + " el.infection_locations," + + " person.causeofdeathdetails " + + "FROM filtered_cases c" + + " CROSS JOIN config_data cd" + + " LEFT JOIN region responsible_region ON c.responsibleregion_id = responsible_region.id" + + " LEFT JOIN district responsible_district ON c.responsibledistrict_id = responsible_district.id" + + " LEFT JOIN community responsible_community ON c.responsiblecommunity_id = responsible_community.id" + + " LEFT JOIN person ON c.person_id = person.id" + + " LEFT JOIN location person_address ON person.address_id = person_address.id" + + " LEFT JOIN country person_address_country ON person_address.country_id = person_address_country.id" + + " LEFT JOIN region person_address_region ON person_address.region_id = person_address_region.id" + + " LEFT JOIN district person_address_district ON person_address.district_id = person_address_district.id" + + " LEFT JOIN community person_address_community ON person_address.community_id = person_address_community.id" + + " LEFT JOIN symptoms symptom ON c.symptoms_id = symptom.id" + + " LEFT JOIN hospitalization ON c.hospitalization_id = hospitalization.id" + + " LEFT JOIN case_all_prev_hsp_from_latest ON (" + + " hospitalization.id = case_all_prev_hsp_from_latest.hospitalization_id" + + " )" + + " LEFT JOIN case_all_samples_from_latest ON (" + + " c.id = case_all_samples_from_latest.associatedcase_id" + + " )" + + " LEFT JOIN sample_all_pathogen_tests_from_latest ON (" + + " sample_all_pathogen_tests_from_latest.sample_id = ANY (case_all_samples_from_latest.all_sample_ids_from_latest)" + + " )" + + " LEFT JOIN case_all_immunizations ON (" + + " case_all_immunizations.person_id = c.person_id" + + " )" + + " LEFT JOIN case_all_vaccinations ON (" + + " case_all_vaccinations.person_id = c.person_id" + + " )" + + " LEFT JOIN sample_data sd ON sd.case_id = c.id" + + " LEFT JOIN virus_detection_data vd ON vd.case_id = c.id" + + " LEFT JOIN igg_serology_data igg ON igg.case_id = c.id" + + " LEFT JOIN igm_serology_data igm ON igm.case_id = c.id" + + " LEFT JOIN epidata_cluster ec ON ec.case_id = c.id" + + " LEFT JOIN exposure_locations el ON el.epidata_id = c.epidata_id" + + " LEFT JOIN complications_data comp ON comp.case_id = c.id " + + "ORDER BY c.reportdate"; + //@formatter:on + Query query = em.createNativeQuery(diseaseExportQuery); + query.setParameter("disease", exportDto.getSubjectCode().getDisease().name()); + query.setParameter("subjectCode", subjectCode); + query.setParameter("countryLocale", serverCountryLocale); + query.setParameter("startDate", DateHelper.convertDateToDbFormat(exportDto.getStartDate())); + query.setParameter("endDate", DateHelper.convertDateToDbFormat(exportDto.getEndDate())); + query.setParameter("meansOfImmVaccination", MeansOfImmunization.VACCINATION.name()); + query.setParameter("meansOfImmVaccinationRecovery", MeansOfImmunization.VACCINATION_RECOVERY.name()); + + @SuppressWarnings("unchecked") + List resultList = query.getResultList(); + + List exportEntryList = new ArrayList<>(); + EpipulseDiseaseExportEntryDto dto = null; + int maxPathogenTests = 0; + int maxImmunizations = 0; + int pathogenTestCount = 0; + int immunizationCount = 0; + + List subjectCodePathogenTestTypes = + EpipulsePathogenTestTypeRef.getPathogenTestTypesByDisease(exportDto.getSubjectCode()); + + int index; + for (Object[] row : resultList) { + index = -1; + + dto = new EpipulseDiseaseExportEntryDto(); + dto.setReportingCountry((String) row[++index]); + dto.setDeleted((Boolean) row[++index]); + + String subjectCodeFromDb = (String) row[++index]; + if (!StringUtils.isBlank(subjectCodeFromDb)) { + dto.setSubjectCode(EpipulseSubjectCode.valueOf(subjectCodeFromDb)); + } + + dto.setNationalRecordId((String) row[++index]); + + dto.setDataSource((String) row[++index]); + dto.setReportDate((Date) row[++index]); + dto.setYearOfBirth((Integer) row[++index]); + dto.setMonthOfBirth((Integer) row[++index]); + dto.setDayOfBirth((Integer) row[++index]); + dto.setSymptomOnsetDate((Date) row[++index]); + + String sex = (String) row[++index]; + if (!StringUtils.isBlank(sex)) { + dto.setSex(Sex.valueOf(sex)); + } + + dto.setAddressCommunityNutsCode((String) row[++index]); + dto.setAddressDistrictNutsCode((String) row[++index]); + dto.setAddressRegionNutsCode((String) row[++index]); + dto.setAddressCountryNutsCode((String) row[++index]); + + dto.setResponsibleCommunityNutsCode((String) row[++index]); + dto.setResponsibleDistrictNutsCode((String) row[++index]); + dto.setResponsibleRegionNutsCode((String) row[++index]); + + dto.setServerCountryNutsCode(serverCountryNutsCode); + + String caseClassification = (String) row[++index]; + if (!StringUtils.isBlank(caseClassification)) { + dto.setCaseClassification(CaseClassification.valueOf(caseClassification)); + } + + String admittedToHealthFacility = (String) row[++index]; + if (!StringUtils.isBlank(admittedToHealthFacility)) { + dto.setAdmittedToHealthFacility(YesNoUnknown.valueOf(admittedToHealthFacility)); + } + + String hospitalizationReason = (String) row[++index]; + if (!StringUtils.isBlank(hospitalizationReason)) { + dto.setHospitalizationReason(HospitalizationReasonType.valueOf(hospitalizationReason)); + } + + dto.setAdmissionDate((Date) row[++index]); + dto.setDischargeDate((Date) row[++index]); + + String caseOutcome = (String) row[++index]; + if (!StringUtils.isBlank(caseOutcome)) { + dto.setCaseOutcome(CaseOutcome.valueOf(caseOutcome)); + } + + dto.setPreviousHospitalizations(dto.parsePreviousHospitalizationChecks((String) row[++index])); + dto.setPathogenTests(dto.parsePathogenTestChecks((String) row[++index], subjectCodePathogenTestTypes)); + dto.setImmunizations(dto.parseImmunizationChecks((String) row[++index])); + dto.setVaccinations(dto.parseVaccinations((String) row[++index])); + + // Phase 2: Laboratory data population for MEAS export + dto.setDateOfSpecimen((Date) row[++index]); + dto.setDateOfLaboratoryResult((Date) row[++index]); + + String specimenTypesVirusRaw = (String) row[++index]; + if (!StringUtils.isBlank(specimenTypesVirusRaw)) { + List specimenTypesVirus = new ArrayList<>(); + for (String specimenType : specimenTypesVirusRaw.split(",")) { + specimenTypesVirus.add( + EpipulseLaboratoryMapper.mapSampleMaterialToEpipulseCode(SampleMaterial.valueOf(specimenType.trim()))); + } + dto.setTypeOfSpecimenCollected(specimenTypesVirus); + } + + String virusDetectionResultRaw = (String) row[++index]; + if (!StringUtils.isBlank(virusDetectionResultRaw)) { + dto.setResultOfVirusDetection( + EpipulseLaboratoryMapper.mapTestResultToEpipulseCode(PathogenTestResultType.valueOf(virusDetectionResultRaw))); + } + + String genotypeRaw = (String) row[++index]; + if (!StringUtils.isBlank(genotypeRaw)) { + dto.setGenotype(EpipulseLaboratoryMapper.normalizeGenotypeForEpipulse(genotypeRaw)); + } + + String specimenTypesSerologyRaw = (String) row[++index]; + if (!StringUtils.isBlank(specimenTypesSerologyRaw)) { + List specimenTypesSerology = new ArrayList<>(); + for (String specimenType : specimenTypesSerologyRaw.split(",")) { + specimenTypesSerology.add( + EpipulseLaboratoryMapper.mapSampleMaterialToEpipulseCode(SampleMaterial.valueOf(specimenType.trim()))); + } + dto.setTypeOfSpecimenSerology(specimenTypesSerology); + } + + String iggResultRaw = (String) row[++index]; + if (!StringUtils.isBlank(iggResultRaw)) { + dto.setResultIgG(EpipulseLaboratoryMapper.mapTestResultToEpipulseCode(PathogenTestResultType.valueOf(iggResultRaw))); + } + + String igmResultRaw = (String) row[++index]; + if (!StringUtils.isBlank(igmResultRaw)) { + dto.setResultIgM(EpipulseLaboratoryMapper.mapTestResultToEpipulseCode(PathogenTestResultType.valueOf(igmResultRaw))); + } + + // Phase 3: Clinical and epidemiology data population for MEAS export + dto.setDateOfInvestigation((Date) row[++index]); + + Boolean clusterRelated = (Boolean) row[++index]; + dto.setClusterRelated(clusterRelated); + + dto.setClusterIdentification((String) row[++index]); + + String clusterTypeRaw = (String) row[++index]; + if (!StringUtils.isBlank(clusterTypeRaw)) { + // ClusterSetting is repeatable, but typically only one value per case + List clusterSettings = new ArrayList<>(); + clusterSettings.add( + EpipulseLaboratoryMapper.mapClusterTypeToEpipulseCode(ClusterType.valueOf(clusterTypeRaw))); + dto.setClusterSetting(clusterSettings); + } + + String caseImportedStatusRaw = (String) row[++index]; + if (!StringUtils.isBlank(caseImportedStatusRaw)) { + dto.setImportedStatus( + EpipulseLaboratoryMapper.mapCaseImportedStatusToEpipulseCode(CaseImportedStatus.valueOf(caseImportedStatusRaw))); + } + + // Complications mapping + String acuteEncephalitisRaw = (String) row[++index]; + String diarrheaRaw = (String) row[++index]; + String otitisMediaRaw = (String) row[++index]; + String otherComplicationsRaw = (String) row[++index]; + + SymptomState acuteEncephalitis = parseSymptomState(acuteEncephalitisRaw); + SymptomState diarrhea = parseSymptomState(diarrheaRaw); + SymptomState otitisMedia = parseSymptomState(otitisMediaRaw); + SymptomState otherComplications = parseSymptomState(otherComplicationsRaw); + + dto.setComplicationDiagnosis( + EpipulseLaboratoryMapper.mapSymptomsToComplicationCodes( + acuteEncephalitis, + diarrhea, + otitisMedia, + otherComplications)); + + // Clinical criteria status + String clinicalConfirmationRaw = (String) row[++index]; + if (!StringUtils.isBlank(clinicalConfirmationRaw)) { + dto.setClinicalCriteriaStatus( + EpipulseLaboratoryMapper.deriveClinicalCriteriaStatus(YesNoUnknown.valueOf(clinicalConfirmationRaw))); + } + + // Place of infection (exposure locations - semicolon-separated from SQL) + String placeOfInfectionRaw = (String) row[++index]; + if (!StringUtils.isBlank(placeOfInfectionRaw)) { + List placesOfInfection = new ArrayList<>(); + for (String place : placeOfInfectionRaw.split(";")) { + if (!place.trim().isEmpty()) { + placesOfInfection.add(place.trim()); + } + } + dto.setPlaceOfInfection(placesOfInfection); + } + + // Cause of death + dto.setCauseOfDeath((String) row[++index]); + + dto.calculateAge(); + + pathogenTestCount = dto.getPathogenTests().size(); + if (pathogenTestCount > maxPathogenTests) { + maxPathogenTests = pathogenTestCount; + } + + immunizationCount = dto.getImmunizations().size(); + if (immunizationCount > maxImmunizations) { + maxImmunizations = immunizationCount; + } + + exportEntryList.add(dto); + } + + // Track max counts for MEAS repeatable fields + int maxComplicationDiagnosis = 0; + int maxClusterSettings = 0; + int maxPlaceOfInfection = 0; + int maxSpecimenVirDetect = 0; + int maxSpecimenSero = 0; + + for (EpipulseDiseaseExportEntryDto entry : exportEntryList) { + if (entry.getComplicationDiagnosis() != null && entry.getComplicationDiagnosis().size() > maxComplicationDiagnosis) { + maxComplicationDiagnosis = entry.getComplicationDiagnosis().size(); + } + if (entry.getClusterSetting() != null && entry.getClusterSetting().size() > maxClusterSettings) { + maxClusterSettings = entry.getClusterSetting().size(); + } + if (entry.getPlaceOfInfection() != null && entry.getPlaceOfInfection().size() > maxPlaceOfInfection) { + maxPlaceOfInfection = entry.getPlaceOfInfection().size(); + } + if (entry.getTypeOfSpecimenCollected() != null && entry.getTypeOfSpecimenCollected().size() > maxSpecimenVirDetect) { + maxSpecimenVirDetect = entry.getTypeOfSpecimenCollected().size(); + } + if (entry.getTypeOfSpecimenSerology() != null && entry.getTypeOfSpecimenSerology().size() > maxSpecimenSero) { + maxSpecimenSero = entry.getTypeOfSpecimenSerology().size(); + } + } + + exportResult.setMaxPathogenTests(maxPathogenTests); + exportResult.setMaxImmunizations(maxImmunizations); + exportResult.setMaxComplicationDiagnosis(maxComplicationDiagnosis); + exportResult.setMaxClusterSettings(maxClusterSettings); + exportResult.setMaxPlaceOfInfection(maxPlaceOfInfection); + exportResult.setMaxSpecimenVirDetect(maxSpecimenVirDetect); + exportResult.setMaxSpecimenSero(maxSpecimenSero); + exportResult.setExportEntryList(exportEntryList); + } catch (Exception e) { + logger.error("Error while exporting case based " + exportDto.getSubjectCode() + ":" + e.getMessage()); + throw e; + } + + return exportResult; + } + @TransactionAttribute(TransactionAttributeType.REQUIRES_NEW) public void updateStatusForBackgroundProcess( String exportUuid, @@ -464,4 +1055,16 @@ public String generateDownloadFileName(EpipulseExportDto exportDto, Long exportI + "_" + StringUtils.replace(DateHelper.convertDateToDbFormat(exportDto.getEndDate()), "-", "") + "_" + exportId + "_" + (System.currentTimeMillis()) + ".csv"; } + + private SymptomState parseSymptomState(String value) { + if (StringUtils.isBlank(value)) { + return null; + } + try { + return SymptomState.valueOf(value); + } catch (IllegalArgumentException e) { + logger.warn("Invalid SymptomState value '{}', treating as null", value); + return null; + } + } } diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseExportTimerEjb.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseExportTimerEjb.java index d5342f16e1f..54cf245b560 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseExportTimerEjb.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseExportTimerEjb.java @@ -67,6 +67,9 @@ public void exportDiseaseTimeout(Timer timer) { case PERT: diseaseExportFacadeEjb.startPertussisExport(uuid); break; + case MEAS: + diseaseExportFacadeEjb.startMeaslesExport(uuid); + break; default: logger.warn("No export for subject code: {}", subjectCodeStr); break; From 16d75d6080d6a20e92e2df3ad661334d2477f68d Mon Sep 17 00:00:00 2001 From: Harold Asiimwe Date: Fri, 9 Jan 2026 13:02:50 +0300 Subject: [PATCH 2/9] #13771 - Add Epipulse export functionality for Measles disease --- .../epipulse/EpipulseCommonDtoMapper.java | 190 ++++ .../EpipulseConfigurationLookupService.java | 160 +++ .../EpipulseCsvExportOrchestrator.java | 197 ++++ .../EpipulseDiseaseExportFacadeEjb.java | 448 +-------- .../EpipulseDiseaseExportService.java | 945 +----------------- .../epipulse/EpipulseSqlCteBuilder.java | 306 ++++++ ...AbstractEpipulseDiseaseExportStrategy.java | 220 ++++ .../epipulse/strategy/CsvExportStrategy.java | 46 + .../strategy/MeaslesCsvExportStrategy.java | 212 ++++ .../strategy/MeaslesExportStrategy.java | 394 ++++++++ .../strategy/PertussisCsvExportStrategy.java | 114 +++ .../strategy/PertussisExportStrategy.java | 65 ++ .../util/EpipulseConfigurationContext.java | 60 ++ 13 files changed, 1983 insertions(+), 1374 deletions(-) create mode 100644 sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCommonDtoMapper.java create mode 100644 sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseConfigurationLookupService.java create mode 100644 sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCsvExportOrchestrator.java create mode 100644 sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseSqlCteBuilder.java create mode 100644 sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/AbstractEpipulseDiseaseExportStrategy.java create mode 100644 sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/CsvExportStrategy.java create mode 100644 sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesCsvExportStrategy.java create mode 100644 sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesExportStrategy.java create mode 100644 sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/PertussisCsvExportStrategy.java create mode 100644 sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/PertussisExportStrategy.java create mode 100644 sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/util/EpipulseConfigurationContext.java diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCommonDtoMapper.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCommonDtoMapper.java new file mode 100644 index 00000000000..c8c78ffd190 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCommonDtoMapper.java @@ -0,0 +1,190 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2024 Helmholtz-Zentrum für Infektionsforschung GmbH (HZI) + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.backend.epipulse; + +import java.util.Date; +import java.util.List; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.symeda.sormas.api.caze.CaseClassification; +import de.symeda.sormas.api.caze.CaseOutcome; +import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportEntryDto; +import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportResult; +import de.symeda.sormas.api.epipulse.EpipulseSubjectCode; +import de.symeda.sormas.api.hospitalization.HospitalizationReasonType; +import de.symeda.sormas.api.person.Sex; +import de.symeda.sormas.api.sample.PathogenTestType; +import de.symeda.sormas.api.utils.YesNoUnknown; + +/** + * Static utility class for mapping common DTO fields that are identical across all disease exports. + * This mapper handles the first fields (indices 0-27) that are shared between Pertussis, Measles, + * and other disease exports. + */ +public class EpipulseCommonDtoMapper { + + private static final Logger logger = LoggerFactory.getLogger(EpipulseCommonDtoMapper.class); + + private EpipulseCommonDtoMapper() { + // Utility class - prevent instantiation + } + + /** + * Maps common fields that are identical for all disease exports. + * This method handles the standard case data, demographics, location, hospitalization, + * and outcome information that is common to all diseases. + * + * @param dto + * the DTO to populate + * @param row + * the database result row + * @param serverCountryNutsCode + * the NUTS code for the server country + * @param subjectCodePathogenTestTypes + * the pathogen test types for the disease + * @return the next index position for disease-specific fields + */ + public static int mapCommonFields( + EpipulseDiseaseExportEntryDto dto, + Object[] row, + String serverCountryNutsCode, + List subjectCodePathogenTestTypes) { + + int index = -1; + + // Index 0: Reporting Country + dto.setReportingCountry((String) row[++index]); + + // Index 1: Deleted flag + dto.setDeleted((Boolean) row[++index]); + + // Index 2: Subject Code + String subjectCodeFromDb = (String) row[++index]; + if (!StringUtils.isBlank(subjectCodeFromDb)) { + dto.setSubjectCode(EpipulseSubjectCode.valueOf(subjectCodeFromDb)); + } + + // Index 3: National Record ID (case UUID) + dto.setNationalRecordId((String) row[++index]); + + // Index 4: Data Source + dto.setDataSource((String) row[++index]); + + // Index 5: Report Date + dto.setReportDate((Date) row[++index]); + + // Index 6-8: Birth Date Components + dto.setYearOfBirth((Integer) row[++index]); + dto.setMonthOfBirth((Integer) row[++index]); + dto.setDayOfBirth((Integer) row[++index]); + + // Index 9: Symptom Onset Date + dto.setSymptomOnsetDate((Date) row[++index]); + + // Index 10: Sex + String sex = (String) row[++index]; + if (!StringUtils.isBlank(sex)) { + dto.setSex(Sex.valueOf(sex)); + } + + // Index 11-14: Address NUTS Codes + dto.setAddressCommunityNutsCode((String) row[++index]); + dto.setAddressDistrictNutsCode((String) row[++index]); + dto.setAddressRegionNutsCode((String) row[++index]); + dto.setAddressCountryNutsCode((String) row[++index]); + + // Index 15-17: Responsible Area NUTS Codes + dto.setResponsibleCommunityNutsCode((String) row[++index]); + dto.setResponsibleDistrictNutsCode((String) row[++index]); + dto.setResponsibleRegionNutsCode((String) row[++index]); + + // Server Country NUTS Code (not from row, passed as parameter) + dto.setServerCountryNutsCode(serverCountryNutsCode); + + // Index 18: Case Classification + String caseClassification = (String) row[++index]; + if (!StringUtils.isBlank(caseClassification)) { + dto.setCaseClassification(CaseClassification.valueOf(caseClassification)); + } + + // Index 19: Admitted to Health Facility + String admittedToHealthFacility = (String) row[++index]; + if (!StringUtils.isBlank(admittedToHealthFacility)) { + dto.setAdmittedToHealthFacility(YesNoUnknown.valueOf(admittedToHealthFacility)); + } + + // Index 20: Hospitalization Reason + String hospitalizationReason = (String) row[++index]; + if (!StringUtils.isBlank(hospitalizationReason)) { + dto.setHospitalizationReason(HospitalizationReasonType.valueOf(hospitalizationReason)); + } + + // Index 21-22: Admission and Discharge Dates + dto.setAdmissionDate((Date) row[++index]); + dto.setDischargeDate((Date) row[++index]); + + // Index 23: Case Outcome + String caseOutcome = (String) row[++index]; + if (!StringUtils.isBlank(caseOutcome)) { + dto.setCaseOutcome(CaseOutcome.valueOf(caseOutcome)); + } + + // Index 24-27: Aggregated Collections (previous hospitalizations, pathogen tests, immunizations, vaccinations) + dto.setPreviousHospitalizations(dto.parsePreviousHospitalizationChecks((String) row[++index])); + dto.setPathogenTests(dto.parsePathogenTestChecks((String) row[++index], subjectCodePathogenTestTypes)); + dto.setImmunizations(dto.parseImmunizationChecks((String) row[++index])); + dto.setVaccinations(dto.parseVaccinations((String) row[++index])); + + // Return the last index used (27 for the vaccinations field) + // The aggregated collections (previous hospitalizations, pathogen tests, immunizations, vaccinations) + // are also common fields + return index; + } + + /** + * Calculates common max counts (pathogenTests and immunizations) that are tracked + * across all disease exports. Disease-specific max counts are handled by the + * individual strategy implementations. + * + * @param entries + * the list of export entries + * @param result + * the result object to populate with max counts + */ + public static void calculateCommonMaxCounts(List entries, EpipulseDiseaseExportResult result) { + + int maxPathogenTests = 0; + int maxImmunizations = 0; + + for (EpipulseDiseaseExportEntryDto entry : entries) { + int pathogenTestCount = entry.getPathogenTests().size(); + if (pathogenTestCount > maxPathogenTests) { + maxPathogenTests = pathogenTestCount; + } + + int immunizationCount = entry.getImmunizations().size(); + if (immunizationCount > maxImmunizations) { + maxImmunizations = immunizationCount; + } + } + + result.setMaxPathogenTests(maxPathogenTests); + result.setMaxImmunizations(maxImmunizations); + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseConfigurationLookupService.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseConfigurationLookupService.java new file mode 100644 index 00000000000..df0df8936cd --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseConfigurationLookupService.java @@ -0,0 +1,160 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2024 Helmholtz-Zentrum für Infektionsforschung GmbH (HZI) + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.backend.epipulse; + +import javax.ejb.LocalBean; +import javax.ejb.Stateless; +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.symeda.sormas.api.epipulse.EpipulseExportDto; +import de.symeda.sormas.api.epipulse.EpipulseSubjectCode; +import de.symeda.sormas.backend.epipulse.util.EpipulseConfigurationContext; +import de.symeda.sormas.backend.util.ModelConstants; + +/** + * Service for looking up Epipulse configuration values from the database. + * This service extracts the common configuration lookup logic that was duplicated + * in both exportPertussisCaseBased and exportMeaslesCaseBased methods. + */ +@Stateless +@LocalBean +public class EpipulseConfigurationLookupService { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + @PersistenceContext(unitName = ModelConstants.PERSISTENCE_UNIT_NAME) + private EntityManager em; + + /** + * Looks up all required configuration values for an Epipulse export. + * + * @param exportDto + * the export request DTO containing the subject code + * @param serverCountryLocale + * the server country ISO2 code (e.g., "LU") + * @param serverCountryName + * the server country name (e.g., "Luxembourg") + * @return a context object containing all looked-up configuration values + * @throws IllegalArgumentException + * if the server country code is invalid + * @throws IllegalStateException + * if the subject code lookup fails + */ + public EpipulseConfigurationContext lookupConfiguration(EpipulseExportDto exportDto, String serverCountryLocale, String serverCountryName) + throws IllegalArgumentException, IllegalStateException { + + String reportingCountry = lookupReportingCountry(serverCountryLocale); + String serverCountryNutsCode = lookupServerCountryNutsCode(serverCountryName); + String subjectCode = lookupSubjectCode(exportDto.getSubjectCode()); + + return new EpipulseConfigurationContext(reportingCountry, serverCountryNutsCode, subjectCode); + } + + /** + * Looks up the Epipulse reporting country code for the given ISO2 country code. + * + * @param countryIso2Code + * the ISO2 country code (e.g., "LU") + * @return the Epipulse reporting country code + * @throws IllegalArgumentException + * if the country code is invalid + */ + private String lookupReportingCountry(String countryIso2Code) throws IllegalArgumentException { + //@formatter:off + String reportingCountryQuery = + "select code as reporting_country " + + "from epipulse_location_configuration " + + "where type='Country' and country_iso2_code = :countryIso2Code"; + //@formatter:on + + @SuppressWarnings("unchecked") + String reportingCountry = (String) em.createNativeQuery(reportingCountryQuery) + .setParameter("countryIso2Code", countryIso2Code) + .getResultStream() + .filter(java.util.Objects::nonNull) + .findFirst() + .orElse(null); + + if (StringUtils.isBlank(reportingCountry)) { + throw new IllegalArgumentException("Invalid server country code: " + countryIso2Code); + } + + return reportingCountry; + } + + /** + * Looks up the NUTS code for the given country name. + * + * @param countryName + * the country name (e.g., "Luxembourg") + * @return the NUTS code for the country, or null if not found + */ + private String lookupServerCountryNutsCode(String countryName) { + //@formatter:off + String serverCountryQuery = + "select nutscode " + + "from country " + + "where lower(defaultname) = :countryName"; + //@formatter:on + + @SuppressWarnings("unchecked") + String serverCountryNutsCode = (String) em.createNativeQuery(serverCountryQuery) + .setParameter("countryName", countryName.toLowerCase()) + .getResultStream() + .filter(java.util.Objects::nonNull) + .findFirst() + .orElse(null); + + return serverCountryNutsCode; + } + + /** + * Looks up the Epipulse subject code for the given disease. + * + * @param subjectCodeEnum + * the subject code enum value (e.g., PERT, MEAS) + * @return the Epipulse subject code string + * @throws IllegalStateException + * if the subject code lookup fails + */ + private String lookupSubjectCode(EpipulseSubjectCode subjectCodeEnum) throws IllegalStateException { + //@formatter:off + String subjectCodeQuery = + "select subjectcode " + + "from epipulse_subjectcode_configuration " + + "where disease=:disease and aggregatedreporting='No'"; + //@formatter:on + + @SuppressWarnings("unchecked") + String subjectCode = (String) em.createNativeQuery(subjectCodeQuery) + .setParameter("disease", subjectCodeEnum.name()) + .getResultStream() + .filter(java.util.Objects::nonNull) + .findFirst() + .orElse(null); + + if (StringUtils.isBlank(subjectCode)) { + throw new IllegalStateException("Subject code is empty"); + } + + return subjectCode; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCsvExportOrchestrator.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCsvExportOrchestrator.java new file mode 100644 index 00000000000..42b03b96687 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCsvExportOrchestrator.java @@ -0,0 +1,197 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2024 Helmholtz-Zentrum für Infektionsforschung GmbH (HZI) + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.backend.epipulse; + +import java.io.FileOutputStream; +import java.io.OutputStreamWriter; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.sql.SQLException; +import java.util.List; + +import javax.ejb.EJB; +import javax.ejb.LocalBean; +import javax.ejb.Stateless; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.opencsv.CSVWriter; + +import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportEntryDto; +import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportResult; +import de.symeda.sormas.api.epipulse.EpipulseExportDto; +import de.symeda.sormas.api.epipulse.EpipulseExportStatus; +import de.symeda.sormas.api.utils.CSVUtils; +import de.symeda.sormas.backend.common.ConfigFacadeEjb; +import de.symeda.sormas.backend.epipulse.strategy.CsvExportStrategy; + +/** + * Orchestrator service that handles the common export flow for all disease-specific CSV exports. + * It is responsible for extracting setup, validation, error handling, and finalization logic. + */ +@Stateless +@LocalBean +public class EpipulseCsvExportOrchestrator { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + @EJB + private EpipulseExportFacadeEjb.EpipulseExportFacadeEjbLocal epipulseExportEjb; + + @EJB + private EpipulseExportService epipulseExportService; + + @EJB + private EpipulseDiseaseExportService diseaseExportService; + + @EJB + private ConfigFacadeEjb.ConfigFacadeEjbLocal configFacadeEjb; + + /** + * Orchestrates the complete export flow for a disease-specific CSV export. + * + * @param uuid + * the UUID of the export + * @param exportFunction + * the function that performs the disease-specific data export + * @param csvStrategy + * the strategy that defines CSV columns and row writing + */ + public void orchestrateExport(String uuid, ExportFunction exportFunction, CsvExportStrategy csvStrategy) { + + CSVWriter writer = null; + EpipulseExport epipulseExport = null; + EpipulseExportStatus exportStatus = EpipulseExportStatus.FAILED; + boolean shouldUpdateStatus = false; + + Integer totalRecords = null; + BigDecimal exportFileSizeBytes = null; + String exportFileName = null; + String exportFilePath = null; + + try { + // Validation + epipulseExport = epipulseExportService.getByUuid(uuid); + + if (epipulseExport == null) { + logger.error("EpipulseExport with uuid " + uuid + " not found"); + return; + } + + if (epipulseExport.getStatus() != EpipulseExportStatus.PENDING) { + logger.error("EpipulseExport with uuid " + uuid + " is not in status PENDING"); + return; + } + + shouldUpdateStatus = true; + + // Update status to IN_PROGRESS + diseaseExportService.updateStatusForBackgroundProcess(uuid, EpipulseExportStatus.IN_PROGRESS, null, null, null); + + // Load configuration + EpipulseExportDto exportDto = epipulseExportEjb.toEpipulseExportDto(epipulseExport); + String serverCountryCode = configFacadeEjb.getCountryCode(); + String serverCountryName = configFacadeEjb.getCountryName(); + + // Setup file path + String generatedFilesPath = configFacadeEjb.getGeneratedFilesPath(); + exportFileName = diseaseExportService.generateDownloadFileName(exportDto, epipulseExport.getId()); + exportFilePath = generatedFilesPath + "/" + exportFileName; + + // Execute disease-specific export + EpipulseDiseaseExportResult exportResult = exportFunction.execute(exportDto, serverCountryCode, serverCountryName); + totalRecords = exportResult.getExportEntryList().size(); + + // Setup CSV writer + writer = CSVUtils.createCSVWriter( + new OutputStreamWriter(new FileOutputStream(exportFilePath), StandardCharsets.UTF_8), + configFacadeEjb.getCsvSeparator()); + + // Build column names using strategy + List columnNames = csvStrategy.buildColumnNames(exportResult); + + // Write headers + writer.writeNext(columnNames.toArray(new String[columnNames.size()])); + + // Write entries using strategy + String[] exportLine = new String[columnNames.size()]; + for (EpipulseDiseaseExportEntryDto dto : exportResult.getExportEntryList()) { + csvStrategy.writeEntryRow(dto, exportLine, exportResult); + writer.writeNext(exportLine); + } + + exportStatus = EpipulseExportStatus.COMPLETED; + } catch (Exception e) { + exportStatus = EpipulseExportStatus.FAILED; + logger.error("Error during export with uuid " + uuid + ": " + e.getMessage(), e); + } finally { + // Close writer + if (writer != null) { + try { + writer.close(); + } catch (Exception e) { + logger.error("CRITICAL: Failed to close CSVWriter for uuid " + uuid + ": " + e.getMessage(), e); + } + } + + // Calculate file size after writer is closed + if (exportFilePath != null && exportStatus == EpipulseExportStatus.COMPLETED) { + try { + long fileSizeInBytes = Files.size(Paths.get(exportFilePath)); + exportFileSizeBytes = new BigDecimal(fileSizeInBytes); + logger.info("Export file size for uuid {}: {} bytes", uuid, fileSizeInBytes); + } catch (Exception e) { + logger.error("CRITICAL: Failed to calculate file size for uuid {}: {}", uuid, e.getMessage(), e); + } + } + + // Update final status + if (shouldUpdateStatus && epipulseExport != null) { + try { + diseaseExportService + .updateStatusForBackgroundProcess(epipulseExport.getUuid(), exportStatus, totalRecords, exportFileName, exportFileSizeBytes); + } catch (Exception e) { + logger.error("CRITICAL: Failed to update export status for uuid " + uuid + ": " + e.getMessage(), e); + } + } + } + } + + /** + * Functional interface for disease-specific export operations. + */ + @FunctionalInterface + public interface ExportFunction { + + /** + * Executes the disease-specific data export. + * + * @param dto + * the export DTO + * @param countryCode + * the country code + * @param countryName + * the country name + * @return the export result containing entries and max counts + * @throws SQLException + * if database error occurs + */ + EpipulseDiseaseExportResult execute(EpipulseExportDto dto, String countryCode, String countryName) throws SQLException; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportFacadeEjb.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportFacadeEjb.java index c700548dabd..1ef257bae55 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportFacadeEjb.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportFacadeEjb.java @@ -15,467 +15,35 @@ package de.symeda.sormas.backend.epipulse; -import java.io.FileOutputStream; -import java.io.OutputStreamWriter; -import java.math.BigDecimal; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.List; - import javax.ejb.EJB; import javax.ejb.LocalBean; import javax.ejb.Stateless; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.opencsv.CSVWriter; - -import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportEntryDto; import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportFacade; -import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportResult; -import de.symeda.sormas.api.epipulse.EpipulseExportDto; -import de.symeda.sormas.api.epipulse.EpipulseExportStatus; -import de.symeda.sormas.api.utils.CSVUtils; -import de.symeda.sormas.backend.common.ConfigFacadeEjb; +import de.symeda.sormas.backend.epipulse.strategy.MeaslesCsvExportStrategy; +import de.symeda.sormas.backend.epipulse.strategy.PertussisCsvExportStrategy; @Stateless(name = "EpipulseDiseaseExportFacade") public class EpipulseDiseaseExportFacadeEjb implements EpipulseDiseaseExportFacade { - private final Logger logger = LoggerFactory.getLogger(getClass()); - @EJB - private EpipulseDiseaseExportService diseaseExportService; + private EpipulseCsvExportOrchestrator orchestrator; @EJB - private EpipulseExportFacadeEjb.EpipulseExportFacadeEjbLocal epipulseExportEjb; + private PertussisCsvExportStrategy pertussisStrategy; @EJB - private EpipulseExportService epipulseExportService; + private MeaslesCsvExportStrategy measlesStrategy; @EJB - private ConfigFacadeEjb.ConfigFacadeEjbLocal configFacadeEjb; + private EpipulseDiseaseExportService diseaseExportService; public void startPertussisExport(String uuid) { - - CSVWriter writer = null; - EpipulseExport epipulseExport = null; - EpipulseExportStatus exportStatus = EpipulseExportStatus.FAILED; - boolean shouldUpdateStatus = false; - - Integer totalRecords = null; - BigDecimal exportFileSizeBytes = null; - String exportFileName = null; - String exportFilePath = null; - - try { - epipulseExport = epipulseExportService.getByUuid(uuid); - - if (epipulseExport == null) { - logger.error("EpipulseExport with uuid " + uuid + " not found"); - return; - } - - if (epipulseExport.getStatus() != EpipulseExportStatus.PENDING) { - logger.error("EpipulseExport with uuid " + uuid + " is not in status PENDING"); - return; - } - - shouldUpdateStatus = true; - - diseaseExportService.updateStatusForBackgroundProcess(uuid, EpipulseExportStatus.IN_PROGRESS, null, null, null); - - EpipulseExportDto exportDto = epipulseExportEjb.toEpipulseExportDto(epipulseExport); - - String serverCountryLocale = configFacadeEjb.getCountryLocale(); - String serverCountryCode = configFacadeEjb.getCountryCode(); - String serverCountryName = configFacadeEjb.getCountryName(); - - String generatedFilesPath = configFacadeEjb.getGeneratedFilesPath(); - exportFileName = diseaseExportService.generateDownloadFileName(exportDto, epipulseExport.getId()); - exportFilePath = generatedFilesPath + "/" + exportFileName; - - EpipulseDiseaseExportResult exportResult = diseaseExportService.exportPertussisCaseBased(exportDto, serverCountryCode, serverCountryName); - totalRecords = exportResult.getExportEntryList().size(); - - //logger.info("Total records found for export: " + exportResult.getExportEntryList().size() + ""); - - writer = CSVUtils.createCSVWriter( - new OutputStreamWriter(new FileOutputStream(exportFilePath), StandardCharsets.UTF_8), - configFacadeEjb.getCsvSeparator()); - - List columnNames = new ArrayList<>( - List.of( - "Disease", - "ReportingCountry", - "Status", - "SubjectCode", - "NationalRecordId", - "DataSource", - "DateUsedForStatistics", - "Age", - "AgeMonth", - "Gender", - "PlaceOfResidence", - "PlaceOfNotification", - "CaseClassification", - "DateOfOnset", - "DateOfNotification", - "Hospitalisation", - "Outcome")); - - if (exportResult.getMaxPathogenTests() > 0) { - for (int i = 0; i < exportResult.getMaxPathogenTests(); i++) { - columnNames.add("PathogenDetectionMethod"); - } - } - - if (exportResult.getMaxImmunizations() > 0) { - columnNames.add("DateOfLastVaccination"); - } - - columnNames.add("VaccinationStatus"); - - //columnNames.add("VaccinationStatusMaternal"); - //columnNames.add("GestationalAgeAtVaccination"); - - //write the headers - writer.writeNext(columnNames.toArray(new String[columnNames.size()])); - - //write entries - String[] exportLine = new String[columnNames.size()]; - List pathogenDetectionMethods = new ArrayList<>(); - int index; - for (EpipulseDiseaseExportEntryDto dto : exportResult.getExportEntryList()) { - index = -1; - - exportLine[++index] = dto.getDiseaseForCsv(); - exportLine[++index] = dto.getReportingCountryForCsv(); - - exportLine[++index] = dto.getStatusForCsv(); - exportLine[++index] = dto.getSubjectCodeForCsv(); - exportLine[++index] = dto.getNationalRecordIdForCsv(); - exportLine[++index] = dto.getDataSourceForCsv(); - exportLine[++index] = dto.getDateUsedForStatisticsCsv(); - exportLine[++index] = dto.getAgeForCsv(); - exportLine[++index] = dto.getAgeMonthForCsv(); - exportLine[++index] = dto.getGenderForCsv(); - exportLine[++index] = dto.getPlaceOfResidenceForCsv(); - exportLine[++index] = dto.getPlaceOfNotificationForCsv(); - exportLine[++index] = dto.getCaseClassificationForCsv(); - exportLine[++index] = dto.getDateOfOnsetForCsv(); - exportLine[++index] = dto.getDateOfNotificationForCsv(); - exportLine[++index] = dto.getHospitalizationForCsv(); - exportLine[++index] = dto.getOutcomeForCsv(); - - if (exportResult.getMaxPathogenTests() > 0) { - pathogenDetectionMethods = dto.getPathogenDetectionMethodsForCsv(exportResult.getMaxPathogenTests()); - for (String pathogenDetectionMethod : pathogenDetectionMethods) { - exportLine[++index] = pathogenDetectionMethod; - } - } - - if (exportResult.getMaxImmunizations() > 0) { - exportLine[++index] = dto.getDateOfLastVaccinationForCsv(); - } - - exportLine[++index] = dto.getVaccinationStatusForCsv(); - - //exportLine[++index] = dto.getVaccinationStatusMaternalForCsv(); - //exportLine[++index] = dto.getGestationalAgeAtVaccinationForCsv(); - - writer.writeNext(exportLine); - } - - exportStatus = EpipulseExportStatus.COMPLETED; - } catch (Exception e) { - exportStatus = EpipulseExportStatus.FAILED; - - logger.error("Error during export with uuid " + uuid + ": " + e.getMessage(), e); - } finally { - if (writer != null) { - try { - writer.close(); - } catch (Exception e) { - logger.error("CRITICAL: Failed to close CSVWriter for uuid " + uuid + ": " + e.getMessage(), e); - } - } - - // Calculate file size after writer is closed - if (exportFilePath != null && exportStatus == EpipulseExportStatus.COMPLETED) { - try { - long fileSizeInBytes = Files.size(Paths.get(exportFilePath)); - exportFileSizeBytes = new BigDecimal(fileSizeInBytes); - logger.info("Export file size for uuid {}: {} bytes", uuid, fileSizeInBytes); - } catch (Exception e) { - logger.error("CRITICAL: Failed to calculate file size for uuid {}: {}", uuid, e.getMessage(), e); - } - } - - if (shouldUpdateStatus && epipulseExport != null) { - try { - diseaseExportService - .updateStatusForBackgroundProcess(epipulseExport.getUuid(), exportStatus, totalRecords, exportFileName, exportFileSizeBytes); - } catch (Exception e) { - logger.error("CRITICAL: Failed to update export status for uuid " + uuid + ": " + e.getMessage(), e); - } - } - } + orchestrator.orchestrateExport(uuid, diseaseExportService::exportPertussisCaseBased, pertussisStrategy); } public void startMeaslesExport(String uuid) { - - CSVWriter writer = null; - EpipulseExport epipulseExport = null; - EpipulseExportStatus exportStatus = EpipulseExportStatus.FAILED; - boolean shouldUpdateStatus = false; - - Integer totalRecords = null; - BigDecimal exportFileSizeBytes = null; - String exportFileName = null; - String exportFilePath = null; - - try { - epipulseExport = epipulseExportService.getByUuid(uuid); - - if (epipulseExport == null) { - logger.error("EpipulseExport with uuid " + uuid + " not found"); - return; - } - - if (epipulseExport.getStatus() != EpipulseExportStatus.PENDING) { - logger.error("EpipulseExport with uuid " + uuid + " is not in status PENDING"); - return; - } - - shouldUpdateStatus = true; - - diseaseExportService.updateStatusForBackgroundProcess(uuid, EpipulseExportStatus.IN_PROGRESS, null, null, null); - - EpipulseExportDto exportDto = epipulseExportEjb.toEpipulseExportDto(epipulseExport); - - String serverCountryLocale = configFacadeEjb.getCountryLocale(); - String serverCountryCode = configFacadeEjb.getCountryCode(); - String serverCountryName = configFacadeEjb.getCountryName(); - - String generatedFilesPath = configFacadeEjb.getGeneratedFilesPath(); - exportFileName = diseaseExportService.generateDownloadFileName(exportDto, epipulseExport.getId()); - exportFilePath = generatedFilesPath + "/" + exportFileName; - - EpipulseDiseaseExportResult exportResult = diseaseExportService.exportMeaslesCaseBased(exportDto, serverCountryCode, serverCountryName); - totalRecords = exportResult.getExportEntryList().size(); - - writer = CSVUtils.createCSVWriter( - new OutputStreamWriter(new FileOutputStream(exportFilePath), StandardCharsets.UTF_8), - configFacadeEjb.getCsvSeparator()); - - // MEAS CSV columns - including Phase 2 laboratory fields and Phase 3 clinical/epidemiology fields - List columnNames = new ArrayList<>( - List.of( - "Disease", - "ReportingCountry", - "Status", - "SubjectCode", - "NationalRecordId", - "DataSource", - "DateUsedForStatistics", - "Age", - "AgeMonth", - "Gender", - "CaseClassification", - "DateOfOnset", - "DateOfNotification", - "Hospitalisation", - "Outcome", - "PlaceOfNotification", - "PlaceOfResidence", - "DateOfSpecimen", - "DateOfLaboratoryResult")); - - // Repeatable field: TypeOfSpecimenCollected - if (exportResult.getMaxSpecimenVirDetect() > 0) { - for (int i = 1; i <= exportResult.getMaxSpecimenVirDetect(); i++) { - columnNames.add("TypeOfSpecimenCollected"); - } - } - - columnNames.addAll(List.of("ResultOfVirusDetection", "Genotype")); - - // Repeatable field: TypeOfSpecimenForSerologicalAnalysis - if (exportResult.getMaxSpecimenSero() > 0) { - for (int i = 1; i <= exportResult.getMaxSpecimenSero(); i++) { - columnNames.add("TypeOfSpecimenForSerologicalAnalysis"); - } - } - - columnNames.addAll(List.of("ResultIgG", "ResultIgM", "DateOfInvestigation", "ClusterRelated", "ClusterIdentification")); - - // Repeatable field: ClusterSetting - if (exportResult.getMaxClusterSettings() > 0) { - for (int i = 1; i <= exportResult.getMaxClusterSettings(); i++) { - columnNames.add("ClusterSetting"); - } - } - - columnNames.add("ImportedStatus"); - - // Repeatable field: ComplicationDiagnosis - if (exportResult.getMaxComplicationDiagnosis() > 0) { - for (int i = 1; i <= exportResult.getMaxComplicationDiagnosis(); i++) { - columnNames.add("ComplicationDiagnosis"); - } - } - - columnNames.add("ClinicalCriteriaStatus"); - - // Repeatable field: PlaceOfInfection - if (exportResult.getMaxPlaceOfInfection() > 0) { - for (int i = 1; i <= exportResult.getMaxPlaceOfInfection(); i++) { - columnNames.add("PlaceOfInfection"); - } - } - - columnNames.add("CauseOfDeath"); - - if (exportResult.getMaxImmunizations() > 0) { - columnNames.add("DateOfLastVaccination"); - } - - columnNames.add("VaccinationStatus"); - - //write the headers - writer.writeNext(columnNames.toArray(new String[columnNames.size()])); - - //write entries - String[] exportLine = new String[columnNames.size()]; - int index; - for (EpipulseDiseaseExportEntryDto dto : exportResult.getExportEntryList()) { - index = -1; - - exportLine[++index] = dto.getDiseaseForCsv(); - exportLine[++index] = dto.getReportingCountryForCsv(); - exportLine[++index] = dto.getStatusForCsv(); - exportLine[++index] = dto.getSubjectCodeForCsv(); - exportLine[++index] = dto.getNationalRecordIdForCsv(); - exportLine[++index] = dto.getDataSourceForCsv(); - exportLine[++index] = dto.getDateUsedForStatisticsCsv(); - exportLine[++index] = dto.getAgeForCsv(); - exportLine[++index] = dto.getAgeMonthForCsv(); - exportLine[++index] = dto.getGenderForCsv(); - exportLine[++index] = dto.getCaseClassificationForCsv(); - exportLine[++index] = dto.getDateOfOnsetForCsv(); - exportLine[++index] = dto.getDateOfNotificationForCsv(); - exportLine[++index] = dto.getHospitalizationForCsv(); - exportLine[++index] = dto.getOutcomeForCsv(); - exportLine[++index] = dto.getPlaceOfNotificationForCsv(); - exportLine[++index] = dto.getPlaceOfResidenceForCsv(); - - // Phase 2: Laboratory fields - exportLine[++index] = dto.getDateOfSpecimenForCsv(); - exportLine[++index] = dto.getDateOfLaboratoryResultForCsv(); - - // Repeatable: TypeOfSpecimenCollected - if (exportResult.getMaxSpecimenVirDetect() > 0) { - List specimenCollected = dto.getTypeOfSpecimenCollectedForCsv(exportResult.getMaxSpecimenVirDetect()); - for (String specimen : specimenCollected) { - exportLine[++index] = specimen; - } - } - - exportLine[++index] = dto.getResultOfVirusDetectionForCsv(); - exportLine[++index] = dto.getGenotypeForCsv(); - - // Repeatable: TypeOfSpecimenForSerologicalAnalysis - if (exportResult.getMaxSpecimenSero() > 0) { - List specimenSerology = dto.getTypeOfSpecimenSerologyForCsv(exportResult.getMaxSpecimenSero()); - for (String specimen : specimenSerology) { - exportLine[++index] = specimen; - } - } - - exportLine[++index] = dto.getResultIgGForCsv(); - exportLine[++index] = dto.getResultIgMForCsv(); - - // Phase 3: Clinical and epidemiology fields - exportLine[++index] = dto.getDateOfInvestigationForCsv(); - exportLine[++index] = dto.getClusterRelatedForCsv(); - exportLine[++index] = dto.getClusterIdentificationForCsv(); - - // Repeatable: ClusterSetting - if (exportResult.getMaxClusterSettings() > 0) { - List clusterSettings = dto.getClusterSettingForCsv(exportResult.getMaxClusterSettings()); - for (String setting : clusterSettings) { - exportLine[++index] = setting; - } - } - - exportLine[++index] = dto.getImportedStatusForCsv(); - - // Repeatable: ComplicationDiagnosis - if (exportResult.getMaxComplicationDiagnosis() > 0) { - List complications = dto.getComplicationDiagnosisForCsv(exportResult.getMaxComplicationDiagnosis()); - for (String complication : complications) { - exportLine[++index] = complication; - } - } - - exportLine[++index] = dto.getClinicalCriteriaStatusForCsv(); - - // Repeatable: PlaceOfInfection - if (exportResult.getMaxPlaceOfInfection() > 0) { - List placesOfInfection = dto.getPlaceOfInfectionForCsv(exportResult.getMaxPlaceOfInfection()); - for (String place : placesOfInfection) { - exportLine[++index] = place; - } - } - - exportLine[++index] = dto.getCauseOfDeathForCsv(); - - if (exportResult.getMaxImmunizations() > 0) { - exportLine[++index] = dto.getDateOfLastVaccinationForCsv(); - } - - exportLine[++index] = dto.getVaccinationStatusForCsv(); - - writer.writeNext(exportLine); - } - - exportStatus = EpipulseExportStatus.COMPLETED; - } catch (Exception e) { - exportStatus = EpipulseExportStatus.FAILED; - - logger.error("Error during export with uuid " + uuid + ": " + e.getMessage(), e); - } finally { - if (writer != null) { - try { - writer.close(); - } catch (Exception e) { - logger.error("CRITICAL: Failed to close CSVWriter for uuid " + uuid + ": " + e.getMessage(), e); - } - } - - // Calculate file size after writer is closed - if (exportFilePath != null && exportStatus == EpipulseExportStatus.COMPLETED) { - try { - long fileSizeInBytes = Files.size(Paths.get(exportFilePath)); - exportFileSizeBytes = new BigDecimal(fileSizeInBytes); - logger.info("Export file size for uuid {}: {} bytes", uuid, fileSizeInBytes); - } catch (Exception e) { - logger.error("CRITICAL: Failed to calculate file size for uuid {}: {}", uuid, e.getMessage(), e); - } - } - - if (shouldUpdateStatus && epipulseExport != null) { - try { - diseaseExportService - .updateStatusForBackgroundProcess(epipulseExport.getUuid(), exportStatus, totalRecords, exportFileName, exportFileSizeBytes); - } catch (Exception e) { - logger.error("CRITICAL: Failed to update export status for uuid " + uuid + ": " + e.getMessage(), e); - } - } - } + orchestrator.orchestrateExport(uuid, diseaseExportService::exportMeaslesCaseBased, measlesStrategy); } @LocalBean diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportService.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportService.java index c8c9b71c2ab..26fa424f9dc 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportService.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportService.java @@ -18,10 +18,8 @@ import java.math.BigDecimal; import java.sql.SQLException; import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; +import javax.ejb.EJB; import javax.ejb.LocalBean; import javax.ejb.Stateless; import javax.ejb.TransactionAttribute; @@ -34,26 +32,12 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import de.symeda.sormas.api.caze.CaseClassification; -import de.symeda.sormas.api.caze.CaseOutcome; -import de.symeda.sormas.api.epidata.CaseImportedStatus; -import de.symeda.sormas.api.epidata.ClusterType; -import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportEntryDto; import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportResult; import de.symeda.sormas.api.epipulse.EpipulseExportDto; import de.symeda.sormas.api.epipulse.EpipulseExportStatus; -import de.symeda.sormas.api.epipulse.EpipulseLaboratoryMapper; -import de.symeda.sormas.api.epipulse.EpipulseSubjectCode; -import de.symeda.sormas.api.epipulse.referencevalue.EpipulsePathogenTestTypeRef; -import de.symeda.sormas.api.hospitalization.HospitalizationReasonType; -import de.symeda.sormas.api.immunization.MeansOfImmunization; -import de.symeda.sormas.api.person.Sex; -import de.symeda.sormas.api.sample.PathogenTestResultType; -import de.symeda.sormas.api.sample.PathogenTestType; -import de.symeda.sormas.api.symptoms.SymptomState; -import de.symeda.sormas.api.sample.SampleMaterial; import de.symeda.sormas.api.utils.DateHelper; -import de.symeda.sormas.api.utils.YesNoUnknown; +import de.symeda.sormas.backend.epipulse.strategy.MeaslesExportStrategy; +import de.symeda.sormas.backend.epipulse.strategy.PertussisExportStrategy; import de.symeda.sormas.backend.util.ModelConstants; @Stateless @@ -67,915 +51,20 @@ public class EpipulseDiseaseExportService { @PersistenceContext(unitName = ModelConstants.PERSISTENCE_UNIT_NAME) private EntityManager em; - public EpipulseDiseaseExportResult exportPertussisCaseBased(EpipulseExportDto exportDto, String serverCountryLocale, String serverCountryName) - throws SQLException, IllegalStateException, IllegalArgumentException { - - EpipulseDiseaseExportResult exportResult = new EpipulseDiseaseExportResult(); - - try { - //lookup reporting country - //@formatter:off - String reportingCountryQuery = - "select code as reporting_country " + - "from epipulse_location_configuration " + - "where type='Country' and country_iso2_code = :countryIso2Code"; - //@formatter:on - - @SuppressWarnings("unchecked") - String reportingCountry = (String) em.createNativeQuery(reportingCountryQuery) - .setParameter("countryIso2Code", serverCountryLocale) - .getResultStream() - .filter(java.util.Objects::nonNull) - .findFirst() - .orElse(null); - - if (StringUtils.isBlank(reportingCountry)) { - throw new IllegalArgumentException("Invalid server country code: " + serverCountryLocale); - } - - //lookup server country nuts code - //@formatter:off - String serverCountryQuery = - "select nutscode " + - "from country " + - "where lower(defaultname) = :countryName"; - //@formatter:on - - @SuppressWarnings("unchecked") - String serverCountryNutsCode = (String) em.createNativeQuery(serverCountryQuery) - .setParameter("countryName", serverCountryName.toLowerCase()) - .getResultStream() - .filter(java.util.Objects::nonNull) - .findFirst() - .orElse(null); - - //get subject code - //@formatter:off - String subjectCodeQuery = - "select subjectcode " + - "from epipulse_subjectcode_configuration " + - "where disease=:disease and aggregatedreporting='No'"; - //@formatter:on - - @SuppressWarnings("unchecked") - String subjectCode = (String) em.createNativeQuery(subjectCodeQuery) - .setParameter("disease", exportDto.getSubjectCode().name()) - .getResultStream() - .filter(java.util.Objects::nonNull) - .findFirst() - .orElse(null); + @EJB + private PertussisExportStrategy pertussisExportStrategy; - if (StringUtils.isBlank(subjectCode)) { - throw new IllegalStateException("Subject code is empty"); - } - - //@formatter:off - String diseaseExportQuery = - "WITH variables AS (SELECT :disease AS disease," + - " :subjectCode AS subject_code," + - " :countryLocale AS country_locale," + - " CAST(:startDate AS date) AS start_date," + - " CAST(:endDate AS date) AS end_date)," + - " config_data AS (SELECT v.subject_code," + - " (SELECT epl.code" + - " FROM epipulse_location_configuration epl" + - " WHERE epl.type = 'Country'" + - " AND epl.country_iso2_code = v.country_locale) as reporting_country," + - " (SELECT epd.datasource" + - " FROM epipulse_datasource_configuration epd" + - " WHERE epd.country_iso2_code = v.country_locale" + - " AND epd.subjectcode = v.subject_code) as datasource" + - " FROM variables v)," + - " filtered_cases AS (SELECT c.id," + - " c.uuid," + - " c.deleted," + - " c.reportdate," + - " c.caseclassification," + - " c.outcome," + - " c.person_id," + - " c.symptoms_id," + - " c.hospitalization_id," + - " c.responsibleregion_id," + - " c.responsibledistrict_id," + - " c.responsiblecommunity_id" + - " FROM cases c" + - " CROSS JOIN variables v" + - " WHERE c.disease = v.disease" + - " AND c.reportdate >= v.start_date" + - " AND c.reportdate < (v.end_date + interval '1 day'))," + - " case_all_prev_hsp_from_latest AS (SELECT prev_hsp.hospitalization_id," + - " STRING_AGG(CONCAT_WS('|'," + - " COALESCE(prev_hsp.admittedtohealthfacility, '')," + - " COALESCE(prev_hsp.hospitalizationreason, '')," + - " COALESCE(" + - " TO_CHAR(prev_hsp.admissiondate, 'YYYY-MM-DD')," + - " '')," + - " COALESCE(" + - " TO_CHAR(prev_hsp.dischargedate, 'YYYY-MM-DD')," + - " '')" + - " ), '#'" + - " ORDER BY prev_hsp.admissiondate DESC) as all_prev_hsp_from_latest" + - " FROM previoushospitalization as prev_hsp" + - " WHERE hospitalization_id IN (SELECT hospitalization_id" + - " FROM filtered_cases" + - " WHERE hospitalization_id IS NOT NULL)" + - " GROUP BY prev_hsp.hospitalization_id)," + - " case_all_samples_from_latest AS (SELECT samples.associatedcase_id," + - " ARRAY_AGG(samples.id ORDER BY samples.sampledatetime DESC) as all_sample_ids_from_latest" + - " FROM samples" + - " WHERE samples.associatedcase_id IN (SELECT id FROM filtered_cases)" + - " GROUP BY samples.associatedcase_id)," + - " sample_all_pathogen_tests_from_latest AS (SELECT pathogentest.sample_id," + - " STRING_AGG(CONCAT_WS('|'," + - " pathogentest.testtype," + - " pathogentest.testresult" + - " ), '#'" + - " ORDER BY pathogentest.testdatetime DESC) AS all_pathogen_tests_from_latest" + - " FROM pathogentest" + - " INNER JOIN case_all_samples_from_latest" + - " ON pathogentest.sample_id = ANY" + - " (case_all_samples_from_latest.all_sample_ids_from_latest)" + - " GROUP BY pathogentest.sample_id)," + - "case_all_immunizations AS (SELECT i.person_id," + - " STRING_AGG(CONCAT_WS('|'," + - " COALESCE(to_char(i.startdate, 'YYYY-MM-DD'), '')," + - " COALESCE(to_char(i.enddate, 'YYYY-MM-DD'), '')," + - " COALESCE(i.meansofimmunization, '')," + - " COALESCE(CAST(i.numberofdoses as text), '')), '#'" + - " ORDER BY i.startdate DESC) as all_immunizations_from_latest" + - " FROM immunization i" + - " CROSS JOIN variables v" + - " where i.person_id IN (SELECT person_id FROM filtered_cases)" + - " and i.disease = v.disease" + - " and i.meansofimmunization IN (:meansOfImmVaccination, :meansOfImmVaccinationRecovery)" + - " GROUP BY i.person_id)," + - "case_all_vaccinations AS (SELECT i.person_id," + - " STRING_AGG(CONCAT_WS('|'," + - " COALESCE(to_char(v.vaccinationdate, 'YYYY-MM-DD'), '')," + - " COALESCE(v.vaccinedose, '')), '#'" + - " ORDER BY v.vaccinationdate DESC) as all_vaccinations_from_latest" + - " FROM immunization i" + - " INNER JOIN vaccination v ON i.id = v.immunization_id" + - " CROSS JOIN variables" + - " WHERE i.person_id IN (SELECT person_id FROM filtered_cases)" + - " and i.disease = variables.disease" + - " and i.meansofimmunization IN (:meansOfImmVaccination, :meansOfImmVaccinationRecovery)" + - " GROUP BY i.person_id) " + - "SELECT cd.reporting_country," + - " c.deleted," + - " cd.subject_code," + - " c.uuid as case_uuid," + - " cd.datasource," + - " cast(c.reportdate as date) as case_reportdate," + - " person.birthdate_yyyy," + - " person.birthdate_mm," + - " person.birthdate_dd," + - " cast(symptom.onsetdate as date) as symptom_onsetdate," + - " person.sex," + - " person_address_community.nutscode as address_community_nutscode," + - " person_address_district.nutscode as address_district_nutscode," + - " person_address_region.nutscode as address_region_nutscode," + - " person_address_country.nutscode as address_country_nutscode," + - " responsible_community.nutscode as responsible_community_nutscode," + - " responsible_district.nutscode as responsible_district_nutscode," + - " responsible_region.nutscode as responsible_region_nutscode," + - " c.caseclassification," + - " hospitalization.admittedtohealthfacility," + - " hospitalization.hospitalizationreason," + - " cast(hospitalization.admissiondate as date) as admissiondate," + - " cast(hospitalization.dischargedate as date) as dischargedate," + - " c.outcome as case_outcome," + - " case_all_prev_hsp_from_latest.all_prev_hsp_from_latest," + - " sample_all_pathogen_tests_from_latest.all_pathogen_tests_from_latest," + - " case_all_immunizations.all_immunizations_from_latest," + - " case_all_vaccinations.all_vaccinations_from_latest " + - "FROM filtered_cases c" + - " CROSS JOIN config_data cd" + - " LEFT JOIN region responsible_region ON c.responsibleregion_id = responsible_region.id" + - " LEFT JOIN district responsible_district ON c.responsibledistrict_id = responsible_district.id" + - " LEFT JOIN community responsible_community ON c.responsiblecommunity_id = responsible_community.id" + - " LEFT JOIN person ON c.person_id = person.id" + - " LEFT JOIN location person_address ON person.address_id = person_address.id" + - " LEFT JOIN country person_address_country ON person_address.country_id = person_address_country.id" + - " LEFT JOIN region person_address_region ON person_address.region_id = person_address_region.id" + - " LEFT JOIN district person_address_district ON person_address.district_id = person_address_district.id" + - " LEFT JOIN community person_address_community ON person_address.community_id = person_address_community.id" + - " LEFT JOIN symptoms symptom ON c.symptoms_id = symptom.id" + - " LEFT JOIN hospitalization ON c.hospitalization_id = hospitalization.id" + - " LEFT JOIN case_all_prev_hsp_from_latest ON (" + - " hospitalization.id = case_all_prev_hsp_from_latest.hospitalization_id" + - " )" + - " LEFT JOIN case_all_samples_from_latest ON (" + - " c.id = case_all_samples_from_latest.associatedcase_id" + - " )" + - " LEFT JOIN sample_all_pathogen_tests_from_latest ON (" + - " sample_all_pathogen_tests_from_latest.sample_id = ANY (case_all_samples_from_latest.all_sample_ids_from_latest)" + - " )" + - " LEFT JOIN case_all_immunizations ON (" + - " case_all_immunizations.person_id = c.person_id" + - " )" + - " LEFT JOIN case_all_vaccinations ON (" + - " case_all_vaccinations.person_id = c.person_id" + - " ) " + - "ORDER BY c.reportdate"; - //@formatter:on - Query query = em.createNativeQuery(diseaseExportQuery); - query.setParameter("disease", exportDto.getSubjectCode().getDisease().name()); - query.setParameter("subjectCode", subjectCode); - query.setParameter("countryLocale", serverCountryLocale); - query.setParameter("startDate", DateHelper.convertDateToDbFormat(exportDto.getStartDate())); - query.setParameter("endDate", DateHelper.convertDateToDbFormat(exportDto.getEndDate())); - query.setParameter("meansOfImmVaccination", MeansOfImmunization.VACCINATION.name()); - query.setParameter("meansOfImmVaccinationRecovery", MeansOfImmunization.VACCINATION_RECOVERY.name()); - - @SuppressWarnings("unchecked") - List resultList = query.getResultList(); - - List exportEntryList = new ArrayList<>(); - EpipulseDiseaseExportEntryDto dto = null; - int maxPathogenTests = 0; - int maxImmunizations = 0; - int pathogenTestCount = 0; - int immunizationCount = 0; - - List subjectCodePathogenTestTypes = - EpipulsePathogenTestTypeRef.getPathogenTestTypesByDisease(exportDto.getSubjectCode()); - - int index; - for (Object[] row : resultList) { - index = -1; - - dto = new EpipulseDiseaseExportEntryDto(); - dto.setReportingCountry((String) row[++index]); - dto.setDeleted((Boolean) row[++index]); - - String subjectCodeFromDb = (String) row[++index]; - if (!StringUtils.isBlank(subjectCodeFromDb)) { - dto.setSubjectCode(EpipulseSubjectCode.valueOf(subjectCodeFromDb)); - } - - dto.setNationalRecordId((String) row[++index]); - - dto.setDataSource((String) row[++index]); - dto.setReportDate((Date) row[++index]); - dto.setYearOfBirth((Integer) row[++index]); - dto.setMonthOfBirth((Integer) row[++index]); - dto.setDayOfBirth((Integer) row[++index]); - dto.setSymptomOnsetDate((Date) row[++index]); - - String sex = (String) row[++index]; - if (!StringUtils.isBlank(sex)) { - dto.setSex(Sex.valueOf(sex)); - } - - dto.setAddressCommunityNutsCode((String) row[++index]); - dto.setAddressDistrictNutsCode((String) row[++index]); - dto.setAddressRegionNutsCode((String) row[++index]); - dto.setAddressCountryNutsCode((String) row[++index]); - - dto.setResponsibleCommunityNutsCode((String) row[++index]); - dto.setResponsibleDistrictNutsCode((String) row[++index]); - dto.setResponsibleRegionNutsCode((String) row[++index]); - - dto.setServerCountryNutsCode(serverCountryNutsCode); - - String caseClassification = (String) row[++index]; - if (!StringUtils.isBlank(caseClassification)) { - dto.setCaseClassification(CaseClassification.valueOf(caseClassification)); - } + @EJB + private MeaslesExportStrategy measlesExportStrategy; - String admittedToHealthFacility = (String) row[++index]; - if (!StringUtils.isBlank(admittedToHealthFacility)) { - dto.setAdmittedToHealthFacility(YesNoUnknown.valueOf(admittedToHealthFacility)); - } - - String hospitalizationReason = (String) row[++index]; - if (!StringUtils.isBlank(hospitalizationReason)) { - dto.setHospitalizationReason(HospitalizationReasonType.valueOf(hospitalizationReason)); - } - - dto.setAdmissionDate((Date) row[++index]); - dto.setDischargeDate((Date) row[++index]); - - String caseOutcome = (String) row[++index]; - if (!StringUtils.isBlank(caseOutcome)) { - dto.setCaseOutcome(CaseOutcome.valueOf(caseOutcome)); - } - - dto.setPreviousHospitalizations(dto.parsePreviousHospitalizationChecks((String) row[++index])); - dto.setPathogenTests(dto.parsePathogenTestChecks((String) row[++index], subjectCodePathogenTestTypes)); - dto.setImmunizations(dto.parseImmunizationChecks((String) row[++index])); - dto.setVaccinations(dto.parseVaccinations((String) row[++index])); - - dto.calculateAge(); - - pathogenTestCount = dto.getPathogenTests().size(); - if (pathogenTestCount > maxPathogenTests) { - maxPathogenTests = pathogenTestCount; - } - - immunizationCount = dto.getImmunizations().size(); - if (immunizationCount > maxImmunizations) { - maxImmunizations = immunizationCount; - } - - exportEntryList.add(dto); - } - - exportResult.setMaxPathogenTests(maxPathogenTests); - exportResult.setMaxImmunizations(maxImmunizations); - exportResult.setExportEntryList(exportEntryList); - } catch (Exception e) { - logger.error("Error while exporting case based " + exportDto.getSubjectCode() + ":" + e.getMessage()); - throw e; - } - - return exportResult; + public EpipulseDiseaseExportResult exportPertussisCaseBased(EpipulseExportDto exportDto, String serverCountryLocale, String serverCountryName) + throws SQLException, IllegalStateException, IllegalArgumentException { + return pertussisExportStrategy.export(exportDto, serverCountryLocale, serverCountryName); } public EpipulseDiseaseExportResult exportMeaslesCaseBased(EpipulseExportDto exportDto, String serverCountryLocale, String serverCountryName) throws SQLException, IllegalStateException, IllegalArgumentException { - - EpipulseDiseaseExportResult exportResult = new EpipulseDiseaseExportResult(); - - try { - //lookup reporting country - //@formatter:off - String reportingCountryQuery = - "select code as reporting_country " + - "from epipulse_location_configuration " + - "where type='Country' and country_iso2_code = :countryIso2Code"; - //@formatter:on - - @SuppressWarnings("unchecked") - String reportingCountry = (String) em.createNativeQuery(reportingCountryQuery) - .setParameter("countryIso2Code", serverCountryLocale) - .getResultStream() - .filter(java.util.Objects::nonNull) - .findFirst() - .orElse(null); - - if (StringUtils.isBlank(reportingCountry)) { - throw new IllegalArgumentException("Invalid server country code: " + serverCountryLocale); - } - - //lookup server country nuts code - //@formatter:off - String serverCountryQuery = - "select nutscode " + - "from country " + - "where lower(defaultname) = :countryName"; - //@formatter:on - - @SuppressWarnings("unchecked") - String serverCountryNutsCode = (String) em.createNativeQuery(serverCountryQuery) - .setParameter("countryName", serverCountryName.toLowerCase()) - .getResultStream() - .filter(java.util.Objects::nonNull) - .findFirst() - .orElse(null); - - //get subject code - //@formatter:off - String subjectCodeQuery = - "select subjectcode " + - "from epipulse_subjectcode_configuration " + - "where disease=:disease and aggregatedreporting='No'"; - //@formatter:on - - @SuppressWarnings("unchecked") - String subjectCode = (String) em.createNativeQuery(subjectCodeQuery) - .setParameter("disease", exportDto.getSubjectCode().name()) - .getResultStream() - .filter(java.util.Objects::nonNull) - .findFirst() - .orElse(null); - - if (StringUtils.isBlank(subjectCode)) { - throw new IllegalStateException("Subject code is empty"); - } - - //@formatter:off - String diseaseExportQuery = - "WITH variables AS (SELECT :disease AS disease," + - " :subjectCode AS subject_code," + - " :countryLocale AS country_locale," + - " CAST(:startDate AS date) AS start_date," + - " CAST(:endDate AS date) AS end_date)," + - " config_data AS (SELECT v.subject_code," + - " (SELECT epl.code" + - " FROM epipulse_location_configuration epl" + - " WHERE epl.type = 'Country'" + - " AND epl.country_iso2_code = v.country_locale) as reporting_country," + - " (SELECT epd.datasource" + - " FROM epipulse_datasource_configuration epd" + - " WHERE epd.country_iso2_code = v.country_locale" + - " AND epd.subjectcode = v.subject_code) as datasource" + - " FROM variables v)," + - " filtered_cases AS (SELECT c.id," + - " c.uuid," + - " c.deleted," + - " c.reportdate," + - " c.caseclassification," + - " c.outcome," + - " c.person_id," + - " c.symptoms_id," + - " c.hospitalization_id," + - " c.responsibleregion_id," + - " c.responsibledistrict_id," + - " c.responsiblecommunity_id," + - " c.epidata_id," + - " c.investigateddate," + - " c.clinicalconfirmation" + - " FROM cases c" + - " CROSS JOIN variables v" + - " WHERE c.disease = v.disease" + - " AND c.reportdate >= v.start_date" + - " AND c.reportdate < (v.end_date + interval '1 day'))," + - " case_all_prev_hsp_from_latest AS (SELECT prev_hsp.hospitalization_id," + - " STRING_AGG(CONCAT_WS('|'," + - " COALESCE(prev_hsp.admittedtohealthfacility, '')," + - " COALESCE(prev_hsp.hospitalizationreason, '')," + - " COALESCE(" + - " TO_CHAR(prev_hsp.admissiondate, 'YYYY-MM-DD')," + - " '')," + - " COALESCE(" + - " TO_CHAR(prev_hsp.dischargedate, 'YYYY-MM-DD')," + - " '')" + - " ), '#'" + - " ORDER BY prev_hsp.admissiondate DESC) as all_prev_hsp_from_latest" + - " FROM previoushospitalization as prev_hsp" + - " WHERE hospitalization_id IN (SELECT hospitalization_id" + - " FROM filtered_cases" + - " WHERE hospitalization_id IS NOT NULL)" + - " GROUP BY prev_hsp.hospitalization_id)," + - " case_all_samples_from_latest AS (SELECT samples.associatedcase_id," + - " ARRAY_AGG(samples.id ORDER BY samples.sampledatetime DESC) as all_sample_ids_from_latest" + - " FROM samples" + - " WHERE samples.associatedcase_id IN (SELECT id FROM filtered_cases)" + - " GROUP BY samples.associatedcase_id)," + - " sample_all_pathogen_tests_from_latest AS (SELECT pathogentest.sample_id," + - " STRING_AGG(CONCAT_WS('|'," + - " pathogentest.testtype," + - " pathogentest.testresult" + - " ), '#'" + - " ORDER BY pathogentest.testdatetime DESC) AS all_pathogen_tests_from_latest" + - " FROM pathogentest" + - " INNER JOIN case_all_samples_from_latest" + - " ON pathogentest.sample_id = ANY" + - " (case_all_samples_from_latest.all_sample_ids_from_latest)" + - " GROUP BY pathogentest.sample_id)," + - "case_all_immunizations AS (SELECT i.person_id," + - " STRING_AGG(CONCAT_WS('|'," + - " COALESCE(to_char(i.startdate, 'YYYY-MM-DD'), '')," + - " COALESCE(to_char(i.enddate, 'YYYY-MM-DD'), '')," + - " COALESCE(i.meansofimmunization, '')," + - " COALESCE(CAST(i.numberofdoses as text), '')), '#'" + - " ORDER BY i.startdate DESC) as all_immunizations_from_latest" + - " FROM immunization i" + - " CROSS JOIN variables v" + - " where i.person_id IN (SELECT person_id FROM filtered_cases)" + - " and i.disease = v.disease" + - " and i.meansofimmunization IN (:meansOfImmVaccination, :meansOfImmVaccinationRecovery)" + - " GROUP BY i.person_id)," + - "case_all_vaccinations AS (SELECT i.person_id," + - " STRING_AGG(CONCAT_WS('|'," + - " COALESCE(to_char(v.vaccinationdate, 'YYYY-MM-DD'), '')," + - " COALESCE(v.vaccinedose, '')), '#'" + - " ORDER BY v.vaccinationdate DESC) as all_vaccinations_from_latest" + - " FROM immunization i" + - " INNER JOIN vaccination v ON i.id = v.immunization_id" + - " CROSS JOIN variables" + - " WHERE i.person_id IN (SELECT person_id FROM filtered_cases)" + - " and i.disease = variables.disease" + - " and i.meansofimmunization IN (:meansOfImmVaccination, :meansOfImmVaccinationRecovery)" + - " GROUP BY i.person_id), " + - "sample_data AS (SELECT c.id as case_id," + - " MIN(s.sampledatetime) as first_specimen_date," + - " STRING_AGG(DISTINCT CAST(s2.samplematerial AS text), ',' ORDER BY CAST(s2.samplematerial AS text)) as specimen_types_virus," + - " STRING_AGG(DISTINCT CAST(s3.samplematerial AS text), ',' ORDER BY CAST(s3.samplematerial AS text)) as specimen_types_serology " + - " FROM filtered_cases c " + - " LEFT JOIN samples s ON s.associatedcase_id = c.id AND s.deleted = false " + - " LEFT JOIN samples s2 ON s2.associatedcase_id = c.id AND s2.deleted = false AND s2.samplematerial IS NOT NULL " + - " LEFT JOIN (SELECT DISTINCT s_sero.id, s_sero.associatedcase_id, s_sero.samplematerial " + - " FROM samples s_sero " + - " JOIN pathogentest pt_sero ON pt_sero.sample_id = s_sero.id " + - " WHERE s_sero.deleted = false " + - " AND pt_sero.testtype IN ('IGG_SERUM_ANTIBODY', 'IGM_SERUM_ANTIBODY', 'SEROLOGY')) s3 " + - " ON s3.associatedcase_id = c.id " + - " GROUP BY c.id), " + - "virus_detection_data AS (SELECT c.id as case_id," + - " MIN(pt.testdatetime) as lab_result_date," + - " (SELECT pt2.testresult " + - " FROM samples s2 " + - " JOIN pathogentest pt2 ON pt2.sample_id = s2.id " + - " WHERE s2.associatedcase_id = c.id " + - " AND s2.deleted = false " + - " AND pt2.testtype IN ('PCR_RT_PCR', 'CULTURE', 'ISOLATION', 'DIRECT_FLUORESCENT_ANTIBODY', 'INDIRECT_FLUORESCENT_ANTIBODY') " + - " AND pt2.testresultverified = true " + - " ORDER BY pt2.testdatetime ASC " + - " LIMIT 1) as virus_detection_result," + - " (SELECT COALESCE(pt3.typingid, pt3.genotyperesult) " + - " FROM samples s3 " + - " JOIN pathogentest pt3 ON pt3.sample_id = s3.id " + - " WHERE s3.associatedcase_id = c.id " + - " AND s3.deleted = false " + - " AND (pt3.typingid IS NOT NULL OR pt3.genotyperesult IS NOT NULL) " + - " ORDER BY pt3.testdatetime ASC " + - " LIMIT 1) as genotype_raw " + - " FROM filtered_cases c " + - " LEFT JOIN samples s ON s.associatedcase_id = c.id AND s.deleted = false " + - " LEFT JOIN pathogentest pt ON pt.sample_id = s.id " + - " AND pt.testtype IN ('PCR_RT_PCR', 'CULTURE', 'ISOLATION', 'DIRECT_FLUORESCENT_ANTIBODY', 'INDIRECT_FLUORESCENT_ANTIBODY') " + - " AND pt.testresultverified = true " + - " GROUP BY c.id), " + - "igg_serology_data AS (SELECT c.id as case_id," + - " (SELECT CASE " + - " WHEN pt_igg.fourfoldincreaseantibodytiter = 'YES' THEN 'POSITIVE' " + - " ELSE pt_igg.testresult " + - " END " + - " FROM samples s_igg " + - " JOIN pathogentest pt_igg ON pt_igg.sample_id = s_igg.id " + - " WHERE s_igg.associatedcase_id = c.id " + - " AND s_igg.deleted = false " + - " AND pt_igg.testtype = 'IGG_SERUM_ANTIBODY' " + - " ORDER BY pt_igg.testdatetime ASC " + - " LIMIT 1) as igg_result " + - " FROM filtered_cases c), " + - "igm_serology_data AS (SELECT c.id as case_id," + - " (SELECT pt_igm.testresult " + - " FROM samples s_igm " + - " JOIN pathogentest pt_igm ON pt_igm.sample_id = s_igm.id " + - " WHERE s_igm.associatedcase_id = c.id " + - " AND s_igm.deleted = false " + - " AND pt_igm.testtype = 'IGM_SERUM_ANTIBODY' " + - " ORDER BY pt_igm.testdatetime ASC " + - " LIMIT 1) as igm_result " + - " FROM filtered_cases c), " + - "epidata_cluster AS (SELECT c.id as case_id," + - " epi.clusterrelated," + - " epi.clustertypetext," + - " epi.clustertype," + - " epi.caseimportedstatus " + - " FROM filtered_cases c " + - " LEFT JOIN epidata epi ON c.epidata_id = epi.id), " + - "exposure_locations AS (SELECT e.epidata_id," + - " STRING_AGG(" + - " CASE " + - " WHEN co.defaultname IS NOT NULL THEN co.defaultname " + - " WHEN l.city IS NOT NULL THEN l.city " + - " ELSE l.details " + - " END, " + - " '; ' " + - " ORDER BY e.startdate DESC" + - " ) as infection_locations " + - " FROM exposures e " + - " JOIN location l ON e.location_id = l.id " + - " LEFT JOIN country co ON l.country_id = co.id " + - " WHERE e.epidata_id IN (SELECT epidata_id FROM filtered_cases WHERE epidata_id IS NOT NULL) " + - " GROUP BY e.epidata_id), " + - "complications_data AS (SELECT c.id as case_id," + - " s.acuteencephalitis," + - " s.diarrhea," + - " s.otitismedia," + - " s.othercomplications " + - " FROM filtered_cases c " + - " LEFT JOIN symptoms s ON c.symptoms_id = s.id) " + - "SELECT cd.reporting_country," + - " c.deleted," + - " cd.subject_code," + - " c.uuid as case_uuid," + - " cd.datasource," + - " cast(c.reportdate as date) as case_reportdate," + - " person.birthdate_yyyy," + - " person.birthdate_mm," + - " person.birthdate_dd," + - " cast(symptom.onsetdate as date) as symptom_onsetdate," + - " person.sex," + - " person_address_community.nutscode as address_community_nutscode," + - " person_address_district.nutscode as address_district_nutscode," + - " person_address_region.nutscode as address_region_nutscode," + - " person_address_country.nutscode as address_country_nutscode," + - " responsible_community.nutscode as responsible_community_nutscode," + - " responsible_district.nutscode as responsible_district_nutscode," + - " responsible_region.nutscode as responsible_region_nutscode," + - " c.caseclassification," + - " hospitalization.admittedtohealthfacility," + - " hospitalization.hospitalizationreason," + - " cast(hospitalization.admissiondate as date) as admissiondate," + - " cast(hospitalization.dischargedate as date) as dischargedate," + - " c.outcome as case_outcome," + - " case_all_prev_hsp_from_latest.all_prev_hsp_from_latest," + - " sample_all_pathogen_tests_from_latest.all_pathogen_tests_from_latest," + - " case_all_immunizations.all_immunizations_from_latest," + - " case_all_vaccinations.all_vaccinations_from_latest," + - " sd.first_specimen_date," + - " vd.lab_result_date," + - " sd.specimen_types_virus," + - " vd.virus_detection_result," + - " vd.genotype_raw," + - " sd.specimen_types_serology," + - " igg.igg_result," + - " igm.igm_result," + - " cast(c.investigateddate as date) as investigated_date," + - " ec.clusterrelated," + - " ec.clustertypetext," + - " ec.clustertype," + - " ec.caseimportedstatus," + - " comp.acuteencephalitis," + - " comp.diarrhea," + - " comp.otitismedia," + - " comp.othercomplications," + - " c.clinicalconfirmation," + - " el.infection_locations," + - " person.causeofdeathdetails " + - "FROM filtered_cases c" + - " CROSS JOIN config_data cd" + - " LEFT JOIN region responsible_region ON c.responsibleregion_id = responsible_region.id" + - " LEFT JOIN district responsible_district ON c.responsibledistrict_id = responsible_district.id" + - " LEFT JOIN community responsible_community ON c.responsiblecommunity_id = responsible_community.id" + - " LEFT JOIN person ON c.person_id = person.id" + - " LEFT JOIN location person_address ON person.address_id = person_address.id" + - " LEFT JOIN country person_address_country ON person_address.country_id = person_address_country.id" + - " LEFT JOIN region person_address_region ON person_address.region_id = person_address_region.id" + - " LEFT JOIN district person_address_district ON person_address.district_id = person_address_district.id" + - " LEFT JOIN community person_address_community ON person_address.community_id = person_address_community.id" + - " LEFT JOIN symptoms symptom ON c.symptoms_id = symptom.id" + - " LEFT JOIN hospitalization ON c.hospitalization_id = hospitalization.id" + - " LEFT JOIN case_all_prev_hsp_from_latest ON (" + - " hospitalization.id = case_all_prev_hsp_from_latest.hospitalization_id" + - " )" + - " LEFT JOIN case_all_samples_from_latest ON (" + - " c.id = case_all_samples_from_latest.associatedcase_id" + - " )" + - " LEFT JOIN sample_all_pathogen_tests_from_latest ON (" + - " sample_all_pathogen_tests_from_latest.sample_id = ANY (case_all_samples_from_latest.all_sample_ids_from_latest)" + - " )" + - " LEFT JOIN case_all_immunizations ON (" + - " case_all_immunizations.person_id = c.person_id" + - " )" + - " LEFT JOIN case_all_vaccinations ON (" + - " case_all_vaccinations.person_id = c.person_id" + - " )" + - " LEFT JOIN sample_data sd ON sd.case_id = c.id" + - " LEFT JOIN virus_detection_data vd ON vd.case_id = c.id" + - " LEFT JOIN igg_serology_data igg ON igg.case_id = c.id" + - " LEFT JOIN igm_serology_data igm ON igm.case_id = c.id" + - " LEFT JOIN epidata_cluster ec ON ec.case_id = c.id" + - " LEFT JOIN exposure_locations el ON el.epidata_id = c.epidata_id" + - " LEFT JOIN complications_data comp ON comp.case_id = c.id " + - "ORDER BY c.reportdate"; - //@formatter:on - Query query = em.createNativeQuery(diseaseExportQuery); - query.setParameter("disease", exportDto.getSubjectCode().getDisease().name()); - query.setParameter("subjectCode", subjectCode); - query.setParameter("countryLocale", serverCountryLocale); - query.setParameter("startDate", DateHelper.convertDateToDbFormat(exportDto.getStartDate())); - query.setParameter("endDate", DateHelper.convertDateToDbFormat(exportDto.getEndDate())); - query.setParameter("meansOfImmVaccination", MeansOfImmunization.VACCINATION.name()); - query.setParameter("meansOfImmVaccinationRecovery", MeansOfImmunization.VACCINATION_RECOVERY.name()); - - @SuppressWarnings("unchecked") - List resultList = query.getResultList(); - - List exportEntryList = new ArrayList<>(); - EpipulseDiseaseExportEntryDto dto = null; - int maxPathogenTests = 0; - int maxImmunizations = 0; - int pathogenTestCount = 0; - int immunizationCount = 0; - - List subjectCodePathogenTestTypes = - EpipulsePathogenTestTypeRef.getPathogenTestTypesByDisease(exportDto.getSubjectCode()); - - int index; - for (Object[] row : resultList) { - index = -1; - - dto = new EpipulseDiseaseExportEntryDto(); - dto.setReportingCountry((String) row[++index]); - dto.setDeleted((Boolean) row[++index]); - - String subjectCodeFromDb = (String) row[++index]; - if (!StringUtils.isBlank(subjectCodeFromDb)) { - dto.setSubjectCode(EpipulseSubjectCode.valueOf(subjectCodeFromDb)); - } - - dto.setNationalRecordId((String) row[++index]); - - dto.setDataSource((String) row[++index]); - dto.setReportDate((Date) row[++index]); - dto.setYearOfBirth((Integer) row[++index]); - dto.setMonthOfBirth((Integer) row[++index]); - dto.setDayOfBirth((Integer) row[++index]); - dto.setSymptomOnsetDate((Date) row[++index]); - - String sex = (String) row[++index]; - if (!StringUtils.isBlank(sex)) { - dto.setSex(Sex.valueOf(sex)); - } - - dto.setAddressCommunityNutsCode((String) row[++index]); - dto.setAddressDistrictNutsCode((String) row[++index]); - dto.setAddressRegionNutsCode((String) row[++index]); - dto.setAddressCountryNutsCode((String) row[++index]); - - dto.setResponsibleCommunityNutsCode((String) row[++index]); - dto.setResponsibleDistrictNutsCode((String) row[++index]); - dto.setResponsibleRegionNutsCode((String) row[++index]); - - dto.setServerCountryNutsCode(serverCountryNutsCode); - - String caseClassification = (String) row[++index]; - if (!StringUtils.isBlank(caseClassification)) { - dto.setCaseClassification(CaseClassification.valueOf(caseClassification)); - } - - String admittedToHealthFacility = (String) row[++index]; - if (!StringUtils.isBlank(admittedToHealthFacility)) { - dto.setAdmittedToHealthFacility(YesNoUnknown.valueOf(admittedToHealthFacility)); - } - - String hospitalizationReason = (String) row[++index]; - if (!StringUtils.isBlank(hospitalizationReason)) { - dto.setHospitalizationReason(HospitalizationReasonType.valueOf(hospitalizationReason)); - } - - dto.setAdmissionDate((Date) row[++index]); - dto.setDischargeDate((Date) row[++index]); - - String caseOutcome = (String) row[++index]; - if (!StringUtils.isBlank(caseOutcome)) { - dto.setCaseOutcome(CaseOutcome.valueOf(caseOutcome)); - } - - dto.setPreviousHospitalizations(dto.parsePreviousHospitalizationChecks((String) row[++index])); - dto.setPathogenTests(dto.parsePathogenTestChecks((String) row[++index], subjectCodePathogenTestTypes)); - dto.setImmunizations(dto.parseImmunizationChecks((String) row[++index])); - dto.setVaccinations(dto.parseVaccinations((String) row[++index])); - - // Phase 2: Laboratory data population for MEAS export - dto.setDateOfSpecimen((Date) row[++index]); - dto.setDateOfLaboratoryResult((Date) row[++index]); - - String specimenTypesVirusRaw = (String) row[++index]; - if (!StringUtils.isBlank(specimenTypesVirusRaw)) { - List specimenTypesVirus = new ArrayList<>(); - for (String specimenType : specimenTypesVirusRaw.split(",")) { - specimenTypesVirus.add( - EpipulseLaboratoryMapper.mapSampleMaterialToEpipulseCode(SampleMaterial.valueOf(specimenType.trim()))); - } - dto.setTypeOfSpecimenCollected(specimenTypesVirus); - } - - String virusDetectionResultRaw = (String) row[++index]; - if (!StringUtils.isBlank(virusDetectionResultRaw)) { - dto.setResultOfVirusDetection( - EpipulseLaboratoryMapper.mapTestResultToEpipulseCode(PathogenTestResultType.valueOf(virusDetectionResultRaw))); - } - - String genotypeRaw = (String) row[++index]; - if (!StringUtils.isBlank(genotypeRaw)) { - dto.setGenotype(EpipulseLaboratoryMapper.normalizeGenotypeForEpipulse(genotypeRaw)); - } - - String specimenTypesSerologyRaw = (String) row[++index]; - if (!StringUtils.isBlank(specimenTypesSerologyRaw)) { - List specimenTypesSerology = new ArrayList<>(); - for (String specimenType : specimenTypesSerologyRaw.split(",")) { - specimenTypesSerology.add( - EpipulseLaboratoryMapper.mapSampleMaterialToEpipulseCode(SampleMaterial.valueOf(specimenType.trim()))); - } - dto.setTypeOfSpecimenSerology(specimenTypesSerology); - } - - String iggResultRaw = (String) row[++index]; - if (!StringUtils.isBlank(iggResultRaw)) { - dto.setResultIgG(EpipulseLaboratoryMapper.mapTestResultToEpipulseCode(PathogenTestResultType.valueOf(iggResultRaw))); - } - - String igmResultRaw = (String) row[++index]; - if (!StringUtils.isBlank(igmResultRaw)) { - dto.setResultIgM(EpipulseLaboratoryMapper.mapTestResultToEpipulseCode(PathogenTestResultType.valueOf(igmResultRaw))); - } - - // Phase 3: Clinical and epidemiology data population for MEAS export - dto.setDateOfInvestigation((Date) row[++index]); - - Boolean clusterRelated = (Boolean) row[++index]; - dto.setClusterRelated(clusterRelated); - - dto.setClusterIdentification((String) row[++index]); - - String clusterTypeRaw = (String) row[++index]; - if (!StringUtils.isBlank(clusterTypeRaw)) { - // ClusterSetting is repeatable, but typically only one value per case - List clusterSettings = new ArrayList<>(); - clusterSettings.add( - EpipulseLaboratoryMapper.mapClusterTypeToEpipulseCode(ClusterType.valueOf(clusterTypeRaw))); - dto.setClusterSetting(clusterSettings); - } - - String caseImportedStatusRaw = (String) row[++index]; - if (!StringUtils.isBlank(caseImportedStatusRaw)) { - dto.setImportedStatus( - EpipulseLaboratoryMapper.mapCaseImportedStatusToEpipulseCode(CaseImportedStatus.valueOf(caseImportedStatusRaw))); - } - - // Complications mapping - String acuteEncephalitisRaw = (String) row[++index]; - String diarrheaRaw = (String) row[++index]; - String otitisMediaRaw = (String) row[++index]; - String otherComplicationsRaw = (String) row[++index]; - - SymptomState acuteEncephalitis = parseSymptomState(acuteEncephalitisRaw); - SymptomState diarrhea = parseSymptomState(diarrheaRaw); - SymptomState otitisMedia = parseSymptomState(otitisMediaRaw); - SymptomState otherComplications = parseSymptomState(otherComplicationsRaw); - - dto.setComplicationDiagnosis( - EpipulseLaboratoryMapper.mapSymptomsToComplicationCodes( - acuteEncephalitis, - diarrhea, - otitisMedia, - otherComplications)); - - // Clinical criteria status - String clinicalConfirmationRaw = (String) row[++index]; - if (!StringUtils.isBlank(clinicalConfirmationRaw)) { - dto.setClinicalCriteriaStatus( - EpipulseLaboratoryMapper.deriveClinicalCriteriaStatus(YesNoUnknown.valueOf(clinicalConfirmationRaw))); - } - - // Place of infection (exposure locations - semicolon-separated from SQL) - String placeOfInfectionRaw = (String) row[++index]; - if (!StringUtils.isBlank(placeOfInfectionRaw)) { - List placesOfInfection = new ArrayList<>(); - for (String place : placeOfInfectionRaw.split(";")) { - if (!place.trim().isEmpty()) { - placesOfInfection.add(place.trim()); - } - } - dto.setPlaceOfInfection(placesOfInfection); - } - - // Cause of death - dto.setCauseOfDeath((String) row[++index]); - - dto.calculateAge(); - - pathogenTestCount = dto.getPathogenTests().size(); - if (pathogenTestCount > maxPathogenTests) { - maxPathogenTests = pathogenTestCount; - } - - immunizationCount = dto.getImmunizations().size(); - if (immunizationCount > maxImmunizations) { - maxImmunizations = immunizationCount; - } - - exportEntryList.add(dto); - } - - // Track max counts for MEAS repeatable fields - int maxComplicationDiagnosis = 0; - int maxClusterSettings = 0; - int maxPlaceOfInfection = 0; - int maxSpecimenVirDetect = 0; - int maxSpecimenSero = 0; - - for (EpipulseDiseaseExportEntryDto entry : exportEntryList) { - if (entry.getComplicationDiagnosis() != null && entry.getComplicationDiagnosis().size() > maxComplicationDiagnosis) { - maxComplicationDiagnosis = entry.getComplicationDiagnosis().size(); - } - if (entry.getClusterSetting() != null && entry.getClusterSetting().size() > maxClusterSettings) { - maxClusterSettings = entry.getClusterSetting().size(); - } - if (entry.getPlaceOfInfection() != null && entry.getPlaceOfInfection().size() > maxPlaceOfInfection) { - maxPlaceOfInfection = entry.getPlaceOfInfection().size(); - } - if (entry.getTypeOfSpecimenCollected() != null && entry.getTypeOfSpecimenCollected().size() > maxSpecimenVirDetect) { - maxSpecimenVirDetect = entry.getTypeOfSpecimenCollected().size(); - } - if (entry.getTypeOfSpecimenSerology() != null && entry.getTypeOfSpecimenSerology().size() > maxSpecimenSero) { - maxSpecimenSero = entry.getTypeOfSpecimenSerology().size(); - } - } - - exportResult.setMaxPathogenTests(maxPathogenTests); - exportResult.setMaxImmunizations(maxImmunizations); - exportResult.setMaxComplicationDiagnosis(maxComplicationDiagnosis); - exportResult.setMaxClusterSettings(maxClusterSettings); - exportResult.setMaxPlaceOfInfection(maxPlaceOfInfection); - exportResult.setMaxSpecimenVirDetect(maxSpecimenVirDetect); - exportResult.setMaxSpecimenSero(maxSpecimenSero); - exportResult.setExportEntryList(exportEntryList); - } catch (Exception e) { - logger.error("Error while exporting case based " + exportDto.getSubjectCode() + ":" + e.getMessage()); - throw e; - } - - return exportResult; + return measlesExportStrategy.export(exportDto, serverCountryLocale, serverCountryName); } @TransactionAttribute(TransactionAttributeType.REQUIRES_NEW) @@ -1055,16 +144,4 @@ public String generateDownloadFileName(EpipulseExportDto exportDto, Long exportI + "_" + StringUtils.replace(DateHelper.convertDateToDbFormat(exportDto.getEndDate()), "-", "") + "_" + exportId + "_" + (System.currentTimeMillis()) + ".csv"; } - - private SymptomState parseSymptomState(String value) { - if (StringUtils.isBlank(value)) { - return null; - } - try { - return SymptomState.valueOf(value); - } catch (IllegalArgumentException e) { - logger.warn("Invalid SymptomState value '{}', treating as null", value); - return null; - } - } } diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseSqlCteBuilder.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseSqlCteBuilder.java new file mode 100644 index 00000000000..c426b278c96 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseSqlCteBuilder.java @@ -0,0 +1,306 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2024 Helmholtz-Zentrum für Infektionsforschung GmbH (HZI) + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.backend.epipulse; + +import javax.ejb.LocalBean; +import javax.ejb.Stateless; + +/** + * Service for building SQL Common Table Expressions (CTEs) for Epipulse disease exports. + * This service extracts the common SQL query fragments that are shared between different + * disease export strategies (Pertussis, Measles, etc.). + */ +@Stateless +@LocalBean +public class EpipulseSqlCteBuilder { + + /** + * Builds the variables CTE that defines the export parameters. + * This CTE is used by all other CTEs to access the disease, subject code, dates, etc. + * + * @return the variables CTE SQL fragment + */ + public String buildVariablesCte() { + //@formatter:off + return "WITH variables AS (SELECT :disease AS disease," + + " :subjectCode AS subject_code," + + " :countryLocale AS country_locale," + + " CAST(:startDate AS date) AS start_date," + + " CAST(:endDate AS date) AS end_date),"; + //@formatter:on + } + + /** + * Builds the config_data CTE that looks up reporting country and data source. + * + * @return the config_data CTE SQL fragment + */ + public String buildConfigDataCte() { + //@formatter:off + return " config_data AS (SELECT v.subject_code," + + " (SELECT epl.code" + + " FROM epipulse_location_configuration epl" + + " WHERE epl.type = 'Country'" + + " AND epl.country_iso2_code = v.country_locale) as reporting_country," + + " (SELECT epd.datasource" + + " FROM epipulse_datasource_configuration epd" + + " WHERE epd.country_iso2_code = v.country_locale" + + " AND epd.subjectcode = v.subject_code) as datasource" + + " FROM variables v),"; + //@formatter:on + } + + /** + * Builds the filtered_cases CTE that selects all cases matching the export criteria. + * + * @param includeMeaslesFields + * if true, includes additional fields required for Measles export + * (epidata_id, investigateddate, clinicalconfirmation) + * @return the filtered_cases CTE SQL fragment + */ + public String buildFilteredCasesCte(boolean includeMeaslesFields) { + StringBuilder cte = new StringBuilder(); + //@formatter:off + cte.append(" filtered_cases AS (SELECT c.id,") + .append(" c.uuid,") + .append(" c.deleted,") + .append(" c.reportdate,") + .append(" c.caseclassification,") + .append(" c.outcome,") + .append(" c.person_id,") + .append(" c.symptoms_id,") + .append(" c.hospitalization_id,") + .append(" c.responsibleregion_id,") + .append(" c.responsibledistrict_id,") + .append(" c.responsiblecommunity_id"); + + if (includeMeaslesFields) { + cte.append(",") + .append(" c.epidata_id,") + .append(" c.investigateddate,") + .append(" c.clinicalconfirmation"); + } + + cte.append(" FROM cases c") + .append(" CROSS JOIN variables v") + .append(" WHERE c.disease = v.disease") + .append(" AND c.reportdate >= v.start_date") + .append(" AND c.reportdate < (v.end_date + interval '1 day')),"); + //@formatter:on + + return cte.toString(); + } + + /** + * Builds the case_all_prev_hsp_from_latest CTE that aggregates previous hospitalizations. + * + * @return the previous hospitalizations CTE SQL fragment + */ + public String buildPreviousHospitalizationsCte() { + //@formatter:off + return " case_all_prev_hsp_from_latest AS (SELECT prev_hsp.hospitalization_id," + + " STRING_AGG(CONCAT_WS('|'," + + " COALESCE(prev_hsp.admittedtohealthfacility, '')," + + " COALESCE(prev_hsp.hospitalizationreason, '')," + + " COALESCE(" + + " TO_CHAR(prev_hsp.admissiondate, 'YYYY-MM-DD')," + + " '')," + + " COALESCE(" + + " TO_CHAR(prev_hsp.dischargedate, 'YYYY-MM-DD')," + + " '')" + + " ), '#'" + + " ORDER BY prev_hsp.admissiondate DESC) as all_prev_hsp_from_latest" + + " FROM previoushospitalization as prev_hsp" + + " WHERE hospitalization_id IN (SELECT hospitalization_id" + + " FROM filtered_cases" + + " WHERE hospitalization_id IS NOT NULL)" + + " GROUP BY prev_hsp.hospitalization_id),"; + //@formatter:on + } + + /** + * Builds the case_all_samples_from_latest CTE that aggregates all samples for filtered cases. + * + * @return the samples CTE SQL fragment + */ + public String buildSamplesCte() { + //@formatter:off + return " case_all_samples_from_latest AS (SELECT samples.associatedcase_id," + + " ARRAY_AGG(samples.id ORDER BY samples.sampledatetime DESC) as all_sample_ids_from_latest" + + " FROM samples" + + " WHERE samples.associatedcase_id IN (SELECT id FROM filtered_cases)" + + " GROUP BY samples.associatedcase_id),"; + //@formatter:on + } + + /** + * Builds the sample_all_pathogen_tests_from_latest CTE that aggregates pathogen test results. + * Groups by associatedcase_id to prevent duplicate rows when a case has multiple samples. + * + * @return the pathogen tests CTE SQL fragment + */ + public String buildPathogenTestsCte() { + //@formatter:off + return " sample_all_pathogen_tests_from_latest AS (SELECT case_all_samples_from_latest.associatedcase_id," + + " STRING_AGG(CONCAT_WS('|'," + + " pathogentest.testtype," + + " pathogentest.testresult" + + " ), '#'" + + " ORDER BY pathogentest.testdatetime DESC) AS all_pathogen_tests_from_latest" + + " FROM pathogentest" + + " INNER JOIN case_all_samples_from_latest" + + " ON pathogentest.sample_id = ANY" + + " (case_all_samples_from_latest.all_sample_ids_from_latest)" + + " GROUP BY case_all_samples_from_latest.associatedcase_id),"; + //@formatter:on + } + + /** + * Builds the case_all_immunizations CTE that aggregates immunization records. + * + * @return the immunizations CTE SQL fragment + */ + public String buildImmunizationsCte() { + //@formatter:off + return "case_all_immunizations AS (SELECT i.person_id," + + " STRING_AGG(CONCAT_WS('|'," + + " COALESCE(to_char(i.startdate, 'YYYY-MM-DD'), '')," + + " COALESCE(to_char(i.enddate, 'YYYY-MM-DD'), '')," + + " COALESCE(i.meansofimmunization, '')," + + " COALESCE(CAST(i.numberofdoses as text), '')), '#'" + + " ORDER BY i.startdate DESC) as all_immunizations_from_latest" + + " FROM immunization i" + + " CROSS JOIN variables v" + + " where i.person_id IN (SELECT person_id FROM filtered_cases)" + + " and i.disease = v.disease" + + " and i.meansofimmunization IN (:meansOfImmVaccination, :meansOfImmVaccinationRecovery)" + + " GROUP BY i.person_id),"; + //@formatter:on + } + + /** + * Builds the case_all_vaccinations CTE that aggregates vaccination records. + * + * @return the vaccinations CTE SQL fragment + */ + public String buildVaccinationsCte() { + //@formatter:off + return "case_all_vaccinations AS (SELECT i.person_id," + + " STRING_AGG(CONCAT_WS('|'," + + " COALESCE(to_char(v.vaccinationdate, 'YYYY-MM-DD'), '')," + + " COALESCE(v.vaccinedose, '')), '#'" + + " ORDER BY v.vaccinationdate DESC) as all_vaccinations_from_latest" + + " FROM immunization i" + + " INNER JOIN vaccination v ON i.id = v.immunization_id" + + " CROSS JOIN variables" + + " WHERE i.person_id IN (SELECT person_id FROM filtered_cases)" + + " and i.disease = variables.disease" + + " and i.meansofimmunization IN (:meansOfImmVaccination, :meansOfImmVaccinationRecovery)" + + " GROUP BY i.person_id) "; + //@formatter:on + } + + /** + * Builds just the common SELECT field list (without the SELECT keyword or FROM clause). + * This allows strategies to append disease-specific fields before the FROM clause. + * + * @return the common SELECT fields as a comma-separated list + */ + public String buildCommonSelectFields() { + //@formatter:off + return "cd.reporting_country," + + " c.deleted," + + " cd.subject_code," + + " c.uuid as case_uuid," + + " cd.datasource," + + " cast(c.reportdate as date) as case_reportdate," + + " person.birthdate_yyyy," + + " person.birthdate_mm," + + " person.birthdate_dd," + + " cast(symptom.onsetdate as date) as symptom_onsetdate," + + " person.sex," + + " person_address_community.nutscode as address_community_nutscode," + + " person_address_district.nutscode as address_district_nutscode," + + " person_address_region.nutscode as address_region_nutscode," + + " person_address_country.nutscode as address_country_nutscode," + + " responsible_community.nutscode as responsible_community_nutscode," + + " responsible_district.nutscode as responsible_district_nutscode," + + " responsible_region.nutscode as responsible_region_nutscode," + + " c.caseclassification," + + " hospitalization.admittedtohealthfacility," + + " hospitalization.hospitalizationreason," + + " cast(hospitalization.admissiondate as date) as admissiondate," + + " cast(hospitalization.dischargedate as date) as dischargedate," + + " c.outcome as case_outcome," + + " case_all_prev_hsp_from_latest.all_prev_hsp_from_latest," + + " sample_all_pathogen_tests_from_latest.all_pathogen_tests_from_latest," + + " case_all_immunizations.all_immunizations_from_latest," + + " case_all_vaccinations.all_vaccinations_from_latest"; + //@formatter:on + } + + /** + * Builds the common FROM and JOIN clauses. + * This includes all joins that are common to all disease exports. + * + * @return the FROM and JOIN clauses SQL fragment + */ + public String buildCommonFromAndJoins() { + StringBuilder joins = new StringBuilder(); + //@formatter:off + joins.append(" FROM filtered_cases c") + .append(" CROSS JOIN config_data cd") + .append(" LEFT JOIN region responsible_region ON c.responsibleregion_id = responsible_region.id") + .append(" LEFT JOIN district responsible_district ON c.responsibledistrict_id = responsible_district.id") + .append(" LEFT JOIN community responsible_community ON c.responsiblecommunity_id = responsible_community.id") + .append(" LEFT JOIN person ON c.person_id = person.id") + .append(" LEFT JOIN location person_address ON person.address_id = person_address.id") + .append(" LEFT JOIN country person_address_country ON person_address.country_id = person_address_country.id") + .append(" LEFT JOIN region person_address_region ON person_address.region_id = person_address_region.id") + .append(" LEFT JOIN district person_address_district ON person_address.district_id = person_address_district.id") + .append(" LEFT JOIN community person_address_community ON person_address.community_id = person_address_community.id") + .append(" LEFT JOIN symptoms symptom ON c.symptoms_id = symptom.id") + .append(" LEFT JOIN hospitalization ON c.hospitalization_id = hospitalization.id") + .append(" LEFT JOIN case_all_prev_hsp_from_latest ON (") + .append(" hospitalization.id = case_all_prev_hsp_from_latest.hospitalization_id") + .append(" )") + .append(" LEFT JOIN case_all_samples_from_latest ON (") + .append(" c.id = case_all_samples_from_latest.associatedcase_id") + .append(" )") + .append(" LEFT JOIN sample_all_pathogen_tests_from_latest ON (") + .append(" c.id = sample_all_pathogen_tests_from_latest.associatedcase_id") + .append(" )") + .append(" LEFT JOIN case_all_immunizations ON (") + .append(" case_all_immunizations.person_id = c.person_id") + .append(" )") + .append(" LEFT JOIN case_all_vaccinations ON (") + .append(" case_all_vaccinations.person_id = c.person_id") + .append(" )"); + //@formatter:on + + return joins.toString(); + } + + /** + * Builds the main SELECT clause with all common fields and joins. + * This is a convenience method for diseases that don't need additional fields. + * + * @return the complete SELECT statement with FROM, JOINs, and ORDER BY + */ + public String buildMainSelectClause() { + return "SELECT " + buildCommonSelectFields() + buildCommonFromAndJoins() + " ORDER BY c.reportdate"; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/AbstractEpipulseDiseaseExportStrategy.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/AbstractEpipulseDiseaseExportStrategy.java new file mode 100644 index 00000000000..02a46f85046 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/AbstractEpipulseDiseaseExportStrategy.java @@ -0,0 +1,220 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2024 Helmholtz-Zentrum für Infektionsforschung GmbH (HZI) + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.backend.epipulse.strategy; + +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +import javax.ejb.EJB; +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import javax.persistence.Query; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportEntryDto; +import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportResult; +import de.symeda.sormas.api.epipulse.EpipulseExportDto; +import de.symeda.sormas.api.epipulse.referencevalue.EpipulsePathogenTestTypeRef; +import de.symeda.sormas.api.immunization.MeansOfImmunization; +import de.symeda.sormas.api.sample.PathogenTestType; +import de.symeda.sormas.api.utils.DateHelper; +import de.symeda.sormas.backend.epipulse.EpipulseCommonDtoMapper; +import de.symeda.sormas.backend.epipulse.EpipulseConfigurationLookupService; +import de.symeda.sormas.backend.epipulse.EpipulseSqlCteBuilder; +import de.symeda.sormas.backend.epipulse.util.EpipulseConfigurationContext; +import de.symeda.sormas.backend.util.ModelConstants; + +/** + * Abstract base class implementing the Template Method pattern for Epipulse disease exports. + * This class defines the overall export algorithm while allowing subclasses to customize + * disease-specific behavior through abstract methods. + *

+ * The template method {@link #export(EpipulseExportDto, String, String)} orchestrates: + * 1. Configuration lookup (common) + * 2. SQL query building (disease-specific hook) + * 3. Query execution (common) + * 4. DTO mapping (combination of common + disease-specific) + * 5. Result building with max counts (common + disease-specific) + */ +public abstract class AbstractEpipulseDiseaseExportStrategy { + + protected final Logger logger = LoggerFactory.getLogger(getClass()); + + @PersistenceContext(unitName = ModelConstants.PERSISTENCE_UNIT_NAME) + protected EntityManager em; + + @EJB + protected EpipulseConfigurationLookupService configLookupService; + + @EJB + protected EpipulseSqlCteBuilder sqlCteBuilder; + + /** + * Template method that orchestrates the entire export process. + * This method defines the algorithm structure and delegates disease-specific + * variations to abstract methods implemented by subclasses. + *

+ * Note: This method should not be overridden by subclasses (template method pattern). + * The 'final' modifier was removed to allow EJB proxy generation. + * + * @param exportDto + * the export request containing subject code, date range, etc. + * @param serverCountryLocale + * the server country ISO2 code (e.g., "LU") + * @param serverCountryName + * the server country name (e.g., "Luxembourg") + * @return the export result containing all case entries and max counts + * @throws SQLException + * if database query execution fails + * @throws IllegalStateException + * if configuration lookup fails + * @throws IllegalArgumentException + * if parameters are invalid + */ + public EpipulseDiseaseExportResult export(EpipulseExportDto exportDto, String serverCountryLocale, String serverCountryName) + throws SQLException, IllegalStateException, IllegalArgumentException { + + EpipulseDiseaseExportResult exportResult = new EpipulseDiseaseExportResult(); + + try { + // Step 1: Lookup configuration (common) + EpipulseConfigurationContext config = configLookupService.lookupConfiguration(exportDto, serverCountryLocale, serverCountryName); + + // Step 2: Build disease-specific query (template method hook) + String queryString = buildDiseaseExportQuery(); + + // Step 3: Execute query with parameters (common) + Query query = em.createNativeQuery(queryString); + setQueryParameters(query, exportDto, config, serverCountryLocale); + @SuppressWarnings("unchecked") + List resultList = query.getResultList(); + + // Step 4: Map results to DTOs (combination of common + disease-specific) + List exportEntryList = mapResultsToEntryDtos(resultList, exportDto, config.getServerCountryNutsCode()); + + // Step 5: Calculate max counts and build result + EpipulseCommonDtoMapper.calculateCommonMaxCounts(exportEntryList, exportResult); + calculateDiseaseSpecificMaxCounts(exportEntryList, exportResult); + + exportResult.setExportEntryList(exportEntryList); + + } catch (Exception e) { + logger.error("Error while exporting case based " + exportDto.getSubjectCode() + ":" + e.getMessage()); + throw e; + } + + return exportResult; + } + + /** + * Builds the complete SQL query for the disease export. + * Subclasses must implement this to construct the full query including: + * - Common CTEs (via sqlCteBuilder) + * - Disease-specific CTEs + * - Main SELECT clause + * + * @return the complete SQL query string + */ + protected abstract String buildDiseaseExportQuery(); + + /** + * Maps disease-specific fields from the database row to the DTO. + * This method is called after common fields have been mapped. + * + * @param dto + * the DTO to populate + * @param row + * the database result row + * @param startIndex + * the index where disease-specific fields start (typically 27) + */ + protected abstract void mapDiseaseSpecificFields(EpipulseDiseaseExportEntryDto dto, Object[] row, int startIndex); + + /** + * Calculates disease-specific max counts for repeatable fields. + * Common max counts (pathogenTests, immunizations) are handled by EpipulseCommonDtoMapper. + * + * @param entries + * the list of export entries + * @param result + * the result object to populate with max counts + */ + protected abstract void calculateDiseaseSpecificMaxCounts(List entries, EpipulseDiseaseExportResult result); + + /** + * Sets common query parameters on the prepared query. + * Parameter binding is identical for all diseases. + * This method is private to prevent overriding by subclasses. + * + * @param query + * the query to set parameters on + * @param exportDto + * the export request DTO + * @param config + * the configuration context + */ + private void setQueryParameters(Query query, EpipulseExportDto exportDto, EpipulseConfigurationContext config, String serverCountryLocale) { + query.setParameter("disease", exportDto.getSubjectCode().getDisease().name()); + query.setParameter("subjectCode", config.getSubjectCode()); + query.setParameter("countryLocale", serverCountryLocale); // Use ISO2 country code (e.g., "LU"), not full subject code + query.setParameter("startDate", DateHelper.convertDateToDbFormat(exportDto.getStartDate())); + query.setParameter("endDate", DateHelper.convertDateToDbFormat(exportDto.getEndDate())); + query.setParameter("meansOfImmVaccination", MeansOfImmunization.VACCINATION.name()); + query.setParameter("meansOfImmVaccinationRecovery", MeansOfImmunization.VACCINATION_RECOVERY.name()); + } + + /** + * Maps database result rows to export entry DTOs. + * This method combines common field mapping with disease-specific field mapping. + * This method is private to prevent overriding by subclasses. + * + * @param resultList + * the list of database result rows + * @param exportDto + * the export request DTO + * @param serverCountryNutsCode + * the server country NUTS code + * @return the list of mapped export entry DTOs + */ + private List mapResultsToEntryDtos( + List resultList, + EpipulseExportDto exportDto, + String serverCountryNutsCode) { + + List exportEntryList = new ArrayList<>(); + List subjectCodePathogenTestTypes = EpipulsePathogenTestTypeRef.getPathogenTestTypesByDisease(exportDto.getSubjectCode()); + + for (Object[] row : resultList) { + EpipulseDiseaseExportEntryDto dto = new EpipulseDiseaseExportEntryDto(); + + // Map common fields (indices 0-27, returns last index used) + int nextIndex = EpipulseCommonDtoMapper.mapCommonFields(dto, row, serverCountryNutsCode, subjectCodePathogenTestTypes); + + // Map disease-specific fields starting at nextIndex + mapDiseaseSpecificFields(dto, row, nextIndex); + + // Calculate age (common to all diseases) + dto.calculateAge(); + + exportEntryList.add(dto); + } + + return exportEntryList; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/CsvExportStrategy.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/CsvExportStrategy.java new file mode 100644 index 00000000000..3404532a5fd --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/CsvExportStrategy.java @@ -0,0 +1,46 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2024 Helmholtz-Zentrum für Infektionsforschung GmbH (HZI) + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.backend.epipulse.strategy; + +import java.util.List; + +import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportEntryDto; +import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportResult; + +/** + * Strategy interface for disease-specific CSV export generation. + * Implementations define column names and row writing logic for each disease. + */ +public interface CsvExportStrategy { + + /** + * Builds the CSV column names list for this disease. + * + * @param exportResult the export result containing max counts for repeatable fields + * @return ordered list of column names + */ + List buildColumnNames(EpipulseDiseaseExportResult exportResult); + + /** + * Writes one export entry to the exportLine array. + * + * @param dto the entry to write + * @param exportLine the output array (pre-sized to column count) + * @param exportResult the export result containing max counts + * @return the final index written (for validation) + */ + int writeEntryRow(EpipulseDiseaseExportEntryDto dto, String[] exportLine, EpipulseDiseaseExportResult exportResult); +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesCsvExportStrategy.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesCsvExportStrategy.java new file mode 100644 index 00000000000..0e40e9674cc --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesCsvExportStrategy.java @@ -0,0 +1,212 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2024 Helmholtz-Zentrum für Infektionsforschung GmbH (HZI) + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.backend.epipulse.strategy; + +import java.util.ArrayList; +import java.util.List; + +import javax.ejb.LocalBean; +import javax.ejb.Stateless; + +import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportEntryDto; +import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportResult; + +/** + * CSV export strategy for Measles disease. + * Handles 41+ columns with 5 repeatable field types: + * - TypeOfSpecimenCollected (virus detection) + * - TypeOfSpecimenForSerologicalAnalysis (serology) + * - ClusterSetting + * - ComplicationDiagnosis + * - PlaceOfInfection + */ +@Stateless +@LocalBean +public class MeaslesCsvExportStrategy implements CsvExportStrategy { + + @Override + public List buildColumnNames(EpipulseDiseaseExportResult exportResult) { + List columnNames = new ArrayList<>( + List.of( + "Disease", + "ReportingCountry", + "Status", + "SubjectCode", + "NationalRecordId", + "DataSource", + "DateUsedForStatistics", + "Age", + "AgeMonth", + "Gender", + "CaseClassification", + "DateOfOnset", + "DateOfNotification", + "Hospitalisation", + "Outcome", + "PlaceOfNotification", + "PlaceOfResidence", + "DateOfSpecimen", + "DateOfLaboratoryResult")); + + // Repeatable field: TypeOfSpecimenCollected (virus detection) + if (exportResult.getMaxSpecimenVirDetect() > 0) { + for (int i = 1; i <= exportResult.getMaxSpecimenVirDetect(); i++) { + columnNames.add("TypeOfSpecimenCollected"); + } + } + + columnNames.addAll(List.of("ResultOfVirusDetection", "Genotype")); + + // Repeatable field: TypeOfSpecimenForSerologicalAnalysis + if (exportResult.getMaxSpecimenSero() > 0) { + for (int i = 1; i <= exportResult.getMaxSpecimenSero(); i++) { + columnNames.add("TypeOfSpecimenForSerologicalAnalysis"); + } + } + + columnNames.addAll(List.of("ResultIgG", "ResultIgM", "DateOfInvestigation", "ClusterRelated", "ClusterIdentification")); + + // Repeatable field: ClusterSetting + if (exportResult.getMaxClusterSettings() > 0) { + for (int i = 1; i <= exportResult.getMaxClusterSettings(); i++) { + columnNames.add("ClusterSetting"); + } + } + + columnNames.add("ImportedStatus"); + + // Repeatable field: ComplicationDiagnosis + if (exportResult.getMaxComplicationDiagnosis() > 0) { + for (int i = 1; i <= exportResult.getMaxComplicationDiagnosis(); i++) { + columnNames.add("ComplicationDiagnosis"); + } + } + + columnNames.add("ClinicalCriteriaStatus"); + + // Repeatable field: PlaceOfInfection + if (exportResult.getMaxPlaceOfInfection() > 0) { + for (int i = 1; i <= exportResult.getMaxPlaceOfInfection(); i++) { + columnNames.add("PlaceOfInfection"); + } + } + + columnNames.add("CauseOfDeath"); + + // Add vaccination columns + if (exportResult.getMaxImmunizations() > 0) { + columnNames.add("DateOfLastVaccination"); + } + + columnNames.add("VaccinationStatus"); + + return columnNames; + } + + @Override + public int writeEntryRow(EpipulseDiseaseExportEntryDto dto, String[] exportLine, EpipulseDiseaseExportResult exportResult) { + int index = -1; + + // Write fixed columns + exportLine[++index] = dto.getDiseaseForCsv(); + exportLine[++index] = dto.getReportingCountryForCsv(); + exportLine[++index] = dto.getStatusForCsv(); + exportLine[++index] = dto.getSubjectCodeForCsv(); + exportLine[++index] = dto.getNationalRecordIdForCsv(); + exportLine[++index] = dto.getDataSourceForCsv(); + exportLine[++index] = dto.getDateUsedForStatisticsCsv(); + exportLine[++index] = dto.getAgeForCsv(); + exportLine[++index] = dto.getAgeMonthForCsv(); + exportLine[++index] = dto.getGenderForCsv(); + exportLine[++index] = dto.getCaseClassificationForCsv(); + exportLine[++index] = dto.getDateOfOnsetForCsv(); + exportLine[++index] = dto.getDateOfNotificationForCsv(); + exportLine[++index] = dto.getHospitalizationForCsv(); + exportLine[++index] = dto.getOutcomeForCsv(); + exportLine[++index] = dto.getPlaceOfNotificationForCsv(); + exportLine[++index] = dto.getPlaceOfResidenceForCsv(); + + // Laboratory fields + exportLine[++index] = dto.getDateOfSpecimenForCsv(); + exportLine[++index] = dto.getDateOfLaboratoryResultForCsv(); + + // Repeatable: TypeOfSpecimenCollected (virus detection) + if (exportResult.getMaxSpecimenVirDetect() > 0) { + List specimenCollected = dto.getTypeOfSpecimenCollectedForCsv(exportResult.getMaxSpecimenVirDetect()); + for (String specimen : specimenCollected) { + exportLine[++index] = specimen; + } + } + + exportLine[++index] = dto.getResultOfVirusDetectionForCsv(); + exportLine[++index] = dto.getGenotypeForCsv(); + + // Repeatable: TypeOfSpecimenForSerologicalAnalysis + if (exportResult.getMaxSpecimenSero() > 0) { + List specimenSerology = dto.getTypeOfSpecimenSerologyForCsv(exportResult.getMaxSpecimenSero()); + for (String specimen : specimenSerology) { + exportLine[++index] = specimen; + } + } + + exportLine[++index] = dto.getResultIgGForCsv(); + exportLine[++index] = dto.getResultIgMForCsv(); + + // Clinical and epidemiology fields + exportLine[++index] = dto.getDateOfInvestigationForCsv(); + exportLine[++index] = dto.getClusterRelatedForCsv(); + exportLine[++index] = dto.getClusterIdentificationForCsv(); + + // Repeatable: ClusterSetting + if (exportResult.getMaxClusterSettings() > 0) { + List clusterSettings = dto.getClusterSettingForCsv(exportResult.getMaxClusterSettings()); + for (String setting : clusterSettings) { + exportLine[++index] = setting; + } + } + + exportLine[++index] = dto.getImportedStatusForCsv(); + + // Repeatable: ComplicationDiagnosis + if (exportResult.getMaxComplicationDiagnosis() > 0) { + List complications = dto.getComplicationDiagnosisForCsv(exportResult.getMaxComplicationDiagnosis()); + for (String complication : complications) { + exportLine[++index] = complication; + } + } + + exportLine[++index] = dto.getClinicalCriteriaStatusForCsv(); + + // Repeatable: PlaceOfInfection + if (exportResult.getMaxPlaceOfInfection() > 0) { + List placesOfInfection = dto.getPlaceOfInfectionForCsv(exportResult.getMaxPlaceOfInfection()); + for (String place : placesOfInfection) { + exportLine[++index] = place; + } + } + + exportLine[++index] = dto.getCauseOfDeathForCsv(); + + // Vaccination columns + if (exportResult.getMaxImmunizations() > 0) { + exportLine[++index] = dto.getDateOfLastVaccinationForCsv(); + } + + exportLine[++index] = dto.getVaccinationStatusForCsv(); + + return index; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesExportStrategy.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesExportStrategy.java new file mode 100644 index 00000000000..12b9bc727ee --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesExportStrategy.java @@ -0,0 +1,394 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2024 Helmholtz-Zentrum für Infektionsforschung GmbH (HZI) + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.backend.epipulse.strategy; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import javax.ejb.LocalBean; +import javax.ejb.Stateless; + +import org.apache.commons.lang3.StringUtils; + +import de.symeda.sormas.api.epidata.CaseImportedStatus; +import de.symeda.sormas.api.epidata.ClusterType; +import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportEntryDto; +import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportResult; +import de.symeda.sormas.api.epipulse.EpipulseLaboratoryMapper; +import de.symeda.sormas.api.sample.PathogenTestResultType; +import de.symeda.sormas.api.sample.SampleMaterial; +import de.symeda.sormas.api.symptoms.SymptomState; +import de.symeda.sormas.api.utils.YesNoUnknown; + +/** + * Export strategy for Measles (MEAS) disease exports. + * Measles extends the common fields with laboratory data and clinical/epidemiology data. + * This includes 7 additional CTEs, 20 additional DTO fields, and 5 additional max count trackers. + */ +@Stateless +@LocalBean +public class MeaslesExportStrategy extends AbstractEpipulseDiseaseExportStrategy { + + @Override + protected String buildDiseaseExportQuery() { + StringBuilder query = new StringBuilder(); + + // Common CTEs + query.append(sqlCteBuilder.buildVariablesCte()); + query.append(sqlCteBuilder.buildConfigDataCte()); + query.append(sqlCteBuilder.buildFilteredCasesCte(true)); // Include Measles-specific fields (epidata_id, investigateddate, clinicalconfirmation) + query.append(sqlCteBuilder.buildPreviousHospitalizationsCte()); + query.append(sqlCteBuilder.buildSamplesCte()); + query.append(sqlCteBuilder.buildPathogenTestsCte()); + query.append(sqlCteBuilder.buildImmunizationsCte()); + query.append(sqlCteBuilder.buildVaccinationsCte()); + + // Measles-specific CTEs + query.append(buildSampleDataCte()); + query.append(buildVirusDetectionDataCte()); + query.append(buildIggSerologyDataCte()); + query.append(buildIgmSerologyDataCte()); + query.append(buildEpidataClusterCte()); + query.append(buildExposureLocationsCte()); + query.append(buildComplicationsDataCte()); + + // Main SELECT clause with Measles-specific fields + query.append(buildMeaslesSelectClause()); + + return query.toString(); + } + + @Override + protected void mapDiseaseSpecificFields(EpipulseDiseaseExportEntryDto dto, Object[] row, int startIndex) { + int index = startIndex; + + // Laboratory data (indices 28-35) + dto.setDateOfSpecimen((Date) row[++index]); + dto.setDateOfLaboratoryResult((Date) row[++index]); + + String specimenTypesVirusRaw = (String) row[++index]; + if (!StringUtils.isBlank(specimenTypesVirusRaw)) { + List specimenTypesVirus = new ArrayList<>(); + for (String specimenType : specimenTypesVirusRaw.split(",")) { + specimenTypesVirus.add(EpipulseLaboratoryMapper.mapSampleMaterialToEpipulseCode(SampleMaterial.valueOf(specimenType.trim()))); + } + dto.setTypeOfSpecimenCollected(specimenTypesVirus); + } + + String virusDetectionResultRaw = (String) row[++index]; + if (!StringUtils.isBlank(virusDetectionResultRaw)) { + dto.setResultOfVirusDetection( + EpipulseLaboratoryMapper.mapTestResultToEpipulseCode(PathogenTestResultType.valueOf(virusDetectionResultRaw))); + } + + String genotypeRaw = (String) row[++index]; + if (!StringUtils.isBlank(genotypeRaw)) { + dto.setGenotype(EpipulseLaboratoryMapper.normalizeGenotypeForEpipulse(genotypeRaw)); + } + + String specimenTypesSerologyRaw = (String) row[++index]; + if (!StringUtils.isBlank(specimenTypesSerologyRaw)) { + List specimenTypesSerology = new ArrayList<>(); + for (String specimenType : specimenTypesSerologyRaw.split(",")) { + specimenTypesSerology.add(EpipulseLaboratoryMapper.mapSampleMaterialToEpipulseCode(SampleMaterial.valueOf(specimenType.trim()))); + } + dto.setTypeOfSpecimenSerology(specimenTypesSerology); + } + + String iggResultRaw = (String) row[++index]; + if (!StringUtils.isBlank(iggResultRaw)) { + dto.setResultIgG(EpipulseLaboratoryMapper.mapTestResultToEpipulseCode(PathogenTestResultType.valueOf(iggResultRaw))); + } + + String igmResultRaw = (String) row[++index]; + if (!StringUtils.isBlank(igmResultRaw)) { + dto.setResultIgM(EpipulseLaboratoryMapper.mapTestResultToEpipulseCode(PathogenTestResultType.valueOf(igmResultRaw))); + } + + // Clinical and epidemiology data (indices 36-47) + dto.setDateOfInvestigation((Date) row[++index]); + + Boolean clusterRelated = (Boolean) row[++index]; + dto.setClusterRelated(clusterRelated); + + dto.setClusterIdentification((String) row[++index]); + + String clusterTypeRaw = (String) row[++index]; + if (!StringUtils.isBlank(clusterTypeRaw)) { + List clusterSettings = new ArrayList<>(); + clusterSettings.add(EpipulseLaboratoryMapper.mapClusterTypeToEpipulseCode(ClusterType.valueOf(clusterTypeRaw))); + dto.setClusterSetting(clusterSettings); + } + + String caseImportedStatusRaw = (String) row[++index]; + if (!StringUtils.isBlank(caseImportedStatusRaw)) { + dto.setImportedStatus(EpipulseLaboratoryMapper.mapCaseImportedStatusToEpipulseCode(CaseImportedStatus.valueOf(caseImportedStatusRaw))); + } + + // Complications mapping (4 fields) + String acuteEncephalitisRaw = (String) row[++index]; + String diarrheaRaw = (String) row[++index]; + String otitisMediaRaw = (String) row[++index]; + String otherComplicationsRaw = (String) row[++index]; + + SymptomState acuteEncephalitis = parseSymptomState(acuteEncephalitisRaw); + SymptomState diarrhea = parseSymptomState(diarrheaRaw); + SymptomState otitisMedia = parseSymptomState(otitisMediaRaw); + SymptomState otherComplications = parseSymptomState(otherComplicationsRaw); + + dto.setComplicationDiagnosis( + EpipulseLaboratoryMapper.mapSymptomsToComplicationCodes(acuteEncephalitis, diarrhea, otitisMedia, otherComplications)); + + // Clinical criteria status + String clinicalConfirmationRaw = (String) row[++index]; + if (!StringUtils.isBlank(clinicalConfirmationRaw)) { + dto.setClinicalCriteriaStatus(EpipulseLaboratoryMapper.deriveClinicalCriteriaStatus(YesNoUnknown.valueOf(clinicalConfirmationRaw))); + } + + // Place of infection (exposure locations) + String placeOfInfectionRaw = (String) row[++index]; + if (!StringUtils.isBlank(placeOfInfectionRaw)) { + List placesOfInfection = new ArrayList<>(); + for (String place : placeOfInfectionRaw.split(";")) { + if (!place.trim().isEmpty()) { + placesOfInfection.add(place.trim()); + } + } + dto.setPlaceOfInfection(placesOfInfection); + } + + // Cause of death + dto.setCauseOfDeath((String) row[++index]); + } + + @Override + protected void calculateDiseaseSpecificMaxCounts(List entries, EpipulseDiseaseExportResult result) { + int maxComplicationDiagnosis = 0; + int maxClusterSettings = 0; + int maxPlaceOfInfection = 0; + int maxSpecimenVirDetect = 0; + int maxSpecimenSero = 0; + + for (EpipulseDiseaseExportEntryDto entry : entries) { + if (entry.getComplicationDiagnosis() != null && entry.getComplicationDiagnosis().size() > maxComplicationDiagnosis) { + maxComplicationDiagnosis = entry.getComplicationDiagnosis().size(); + } + if (entry.getClusterSetting() != null && entry.getClusterSetting().size() > maxClusterSettings) { + maxClusterSettings = entry.getClusterSetting().size(); + } + if (entry.getPlaceOfInfection() != null && entry.getPlaceOfInfection().size() > maxPlaceOfInfection) { + maxPlaceOfInfection = entry.getPlaceOfInfection().size(); + } + if (entry.getTypeOfSpecimenCollected() != null && entry.getTypeOfSpecimenCollected().size() > maxSpecimenVirDetect) { + maxSpecimenVirDetect = entry.getTypeOfSpecimenCollected().size(); + } + if (entry.getTypeOfSpecimenSerology() != null && entry.getTypeOfSpecimenSerology().size() > maxSpecimenSero) { + maxSpecimenSero = entry.getTypeOfSpecimenSerology().size(); + } + } + + result.setMaxComplicationDiagnosis(maxComplicationDiagnosis); + result.setMaxClusterSettings(maxClusterSettings); + result.setMaxPlaceOfInfection(maxPlaceOfInfection); + result.setMaxSpecimenVirDetect(maxSpecimenVirDetect); + result.setMaxSpecimenSero(maxSpecimenSero); + } + + // Helper methods for Measles-specific CTEs + + private String buildSampleDataCte() { + //@formatter:off + return ", sample_data AS (SELECT c.id as case_id," + + " MIN(s.sampledatetime) as first_specimen_date," + + " STRING_AGG(DISTINCT CAST(s2.samplematerial AS text), ',' ORDER BY CAST(s2.samplematerial AS text)) as specimen_types_virus," + + " STRING_AGG(DISTINCT CAST(s3.samplematerial AS text), ',' ORDER BY CAST(s3.samplematerial AS text)) as specimen_types_serology " + + " FROM filtered_cases c " + + " LEFT JOIN samples s ON s.associatedcase_id = c.id AND s.deleted = false " + + " LEFT JOIN samples s2 ON s2.associatedcase_id = c.id AND s2.deleted = false AND s2.samplematerial IS NOT NULL " + + " LEFT JOIN (SELECT DISTINCT s_sero.id, s_sero.associatedcase_id, s_sero.samplematerial " + + " FROM samples s_sero " + + " JOIN pathogentest pt_sero ON pt_sero.sample_id = s_sero.id " + + " WHERE s_sero.deleted = false " + + " AND pt_sero.testtype IN ('IGG_SERUM_ANTIBODY', 'IGM_SERUM_ANTIBODY', 'SEROLOGY')) s3 " + + " ON s3.associatedcase_id = c.id " + + " GROUP BY c.id), "; + //@formatter:on + } + + private String buildVirusDetectionDataCte() { + //@formatter:off + return "virus_detection_data AS (SELECT c.id as case_id," + + " MIN(pt.testdatetime) as lab_result_date," + + " (SELECT pt2.testresult " + + " FROM samples s2 " + + " JOIN pathogentest pt2 ON pt2.sample_id = s2.id " + + " WHERE s2.associatedcase_id = c.id " + + " AND s2.deleted = false " + + " AND pt2.testtype IN ('PCR_RT_PCR', 'CULTURE', 'ISOLATION', 'DIRECT_FLUORESCENT_ANTIBODY', 'INDIRECT_FLUORESCENT_ANTIBODY') " + + " AND pt2.testresultverified = true " + + " ORDER BY pt2.testdatetime ASC " + + " LIMIT 1) as virus_detection_result," + + " (SELECT COALESCE(pt3.typingid, pt3.genotyperesult) " + + " FROM samples s3 " + + " JOIN pathogentest pt3 ON pt3.sample_id = s3.id " + + " WHERE s3.associatedcase_id = c.id " + + " AND s3.deleted = false " + + " AND (pt3.typingid IS NOT NULL OR pt3.genotyperesult IS NOT NULL) " + + " ORDER BY pt3.testdatetime ASC " + + " LIMIT 1) as genotype_raw " + + " FROM filtered_cases c " + + " LEFT JOIN samples s ON s.associatedcase_id = c.id AND s.deleted = false " + + " LEFT JOIN pathogentest pt ON pt.sample_id = s.id " + + " AND pt.testtype IN ('PCR_RT_PCR', 'CULTURE', 'ISOLATION', 'DIRECT_FLUORESCENT_ANTIBODY', 'INDIRECT_FLUORESCENT_ANTIBODY') " + + " AND pt.testresultverified = true " + + " GROUP BY c.id), "; + //@formatter:on + } + + private String buildIggSerologyDataCte() { + //@formatter:off + return "igg_serology_data AS (SELECT c.id as case_id," + + " (SELECT CASE " + + " WHEN pt_igg.fourfoldincreaseantibodytiter = 'YES' THEN 'POSITIVE' " + + " ELSE pt_igg.testresult " + + " END " + + " FROM samples s_igg " + + " JOIN pathogentest pt_igg ON pt_igg.sample_id = s_igg.id " + + " WHERE s_igg.associatedcase_id = c.id " + + " AND s_igg.deleted = false " + + " AND pt_igg.testtype = 'IGG_SERUM_ANTIBODY' " + + " ORDER BY pt_igg.testdatetime ASC " + + " LIMIT 1) as igg_result " + + " FROM filtered_cases c), "; + //@formatter:on + } + + private String buildIgmSerologyDataCte() { + //@formatter:off + return "igm_serology_data AS (SELECT c.id as case_id," + + " (SELECT pt_igm.testresult " + + " FROM samples s_igm " + + " JOIN pathogentest pt_igm ON pt_igm.sample_id = s_igm.id " + + " WHERE s_igm.associatedcase_id = c.id " + + " AND s_igm.deleted = false " + + " AND pt_igm.testtype = 'IGM_SERUM_ANTIBODY' " + + " ORDER BY pt_igm.testdatetime ASC " + + " LIMIT 1) as igm_result " + + " FROM filtered_cases c), "; + //@formatter:on + } + + private String buildEpidataClusterCte() { + //@formatter:off + return "epidata_cluster AS (SELECT c.id as case_id," + + " epi.clusterrelated," + + " epi.clustertypetext," + + " epi.clustertype," + + " epi.caseimportedstatus " + + " FROM filtered_cases c " + + " LEFT JOIN epidata epi ON c.epidata_id = epi.id), "; + //@formatter:on + } + + private String buildExposureLocationsCte() { + //@formatter:off + return "exposure_locations AS (SELECT e.epidata_id," + + " STRING_AGG(" + + " CASE " + + " WHEN co.defaultname IS NOT NULL THEN co.defaultname " + + " WHEN l.city IS NOT NULL THEN l.city " + + " ELSE l.details " + + " END, " + + " '; ' " + + " ORDER BY e.startdate DESC" + + " ) as infection_locations " + + " FROM exposures e " + + " JOIN location l ON e.location_id = l.id " + + " LEFT JOIN country co ON l.country_id = co.id " + + " WHERE e.epidata_id IN (SELECT epidata_id FROM filtered_cases WHERE epidata_id IS NOT NULL) " + + " GROUP BY e.epidata_id), "; + //@formatter:on + } + + private String buildComplicationsDataCte() { + //@formatter:off + return "complications_data AS (SELECT c.id as case_id," + + " s.acuteencephalitis," + + " s.diarrhea," + + " s.otitismedia," + + " s.othercomplications " + + " FROM filtered_cases c " + + " LEFT JOIN symptoms s ON c.symptoms_id = s.id) "; + //@formatter:on + } + + private String buildMeaslesSelectClause() { + StringBuilder select = new StringBuilder(); + //@formatter:off + // Use common SELECT fields and append Measles-specific fields + select.append("SELECT ") + .append(sqlCteBuilder.buildCommonSelectFields()) + .append(",") + .append(" sd.first_specimen_date,") + .append(" vd.lab_result_date,") + .append(" sd.specimen_types_virus,") + .append(" vd.virus_detection_result,") + .append(" vd.genotype_raw,") + .append(" sd.specimen_types_serology,") + .append(" igg.igg_result,") + .append(" igm.igm_result,") + .append(" cast(c.investigateddate as date) as investigated_date,") + .append(" ec.clusterrelated,") + .append(" ec.clustertypetext,") + .append(" ec.clustertype,") + .append(" ec.caseimportedstatus,") + .append(" comp.acuteencephalitis,") + .append(" comp.diarrhea,") + .append(" comp.otitismedia,") + .append(" comp.othercomplications,") + .append(" c.clinicalconfirmation,") + .append(" el.infection_locations,") + .append(" person.causeofdeathdetails "); + + // Use common FROM and JOINs, then append Measles-specific joins + select.append(sqlCteBuilder.buildCommonFromAndJoins()) + .append(" LEFT JOIN sample_data sd ON sd.case_id = c.id") + .append(" LEFT JOIN virus_detection_data vd ON vd.case_id = c.id") + .append(" LEFT JOIN igg_serology_data igg ON igg.case_id = c.id") + .append(" LEFT JOIN igm_serology_data igm ON igm.case_id = c.id") + .append(" LEFT JOIN epidata_cluster ec ON ec.case_id = c.id") + .append(" LEFT JOIN exposure_locations el ON el.epidata_id = c.epidata_id") + .append(" LEFT JOIN complications_data comp ON comp.case_id = c.id "); + + select.append("ORDER BY c.reportdate"); + //@formatter:on + + return select.toString(); + } + + private SymptomState parseSymptomState(String value) { + if (StringUtils.isBlank(value)) { + return null; + } + try { + return SymptomState.valueOf(value); + } catch (IllegalArgumentException e) { + logger.warn("Invalid SymptomState value '{}', treating as null", value); + return null; + } + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/PertussisCsvExportStrategy.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/PertussisCsvExportStrategy.java new file mode 100644 index 00000000000..ec2240fc62f --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/PertussisCsvExportStrategy.java @@ -0,0 +1,114 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2024 Helmholtz-Zentrum für Infektionsforschung GmbH (HZI) + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.backend.epipulse.strategy; + +import java.util.ArrayList; +import java.util.List; + +import javax.ejb.LocalBean; +import javax.ejb.Stateless; + +import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportEntryDto; +import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportResult; + +/** + * CSV export strategy for Pertussis disease. + * Handles 34 columns maximum (17 fixed + dynamic pathogen tests + vaccination). + */ +@Stateless +@LocalBean +public class PertussisCsvExportStrategy implements CsvExportStrategy { + + @Override + public List buildColumnNames(EpipulseDiseaseExportResult exportResult) { + List columnNames = new ArrayList<>( + List.of( + "Disease", + "ReportingCountry", + "Status", + "SubjectCode", + "NationalRecordId", + "DataSource", + "DateUsedForStatistics", + "Age", + "AgeMonth", + "Gender", + "PlaceOfResidence", + "PlaceOfNotification", + "CaseClassification", + "DateOfOnset", + "DateOfNotification", + "Hospitalisation", + "Outcome")); + + // Add repeatable PathogenDetectionMethod columns + if (exportResult.getMaxPathogenTests() > 0) { + for (int i = 0; i < exportResult.getMaxPathogenTests(); i++) { + columnNames.add("PathogenDetectionMethod"); + } + } + + // Add vaccination columns + if (exportResult.getMaxImmunizations() > 0) { + columnNames.add("DateOfLastVaccination"); + } + + columnNames.add("VaccinationStatus"); + + return columnNames; + } + + @Override + public int writeEntryRow(EpipulseDiseaseExportEntryDto dto, String[] exportLine, EpipulseDiseaseExportResult exportResult) { + int index = -1; + + // Write fixed columns + exportLine[++index] = dto.getDiseaseForCsv(); + exportLine[++index] = dto.getReportingCountryForCsv(); + exportLine[++index] = dto.getStatusForCsv(); + exportLine[++index] = dto.getSubjectCodeForCsv(); + exportLine[++index] = dto.getNationalRecordIdForCsv(); + exportLine[++index] = dto.getDataSourceForCsv(); + exportLine[++index] = dto.getDateUsedForStatisticsCsv(); + exportLine[++index] = dto.getAgeForCsv(); + exportLine[++index] = dto.getAgeMonthForCsv(); + exportLine[++index] = dto.getGenderForCsv(); + exportLine[++index] = dto.getPlaceOfResidenceForCsv(); + exportLine[++index] = dto.getPlaceOfNotificationForCsv(); + exportLine[++index] = dto.getCaseClassificationForCsv(); + exportLine[++index] = dto.getDateOfOnsetForCsv(); + exportLine[++index] = dto.getDateOfNotificationForCsv(); + exportLine[++index] = dto.getHospitalizationForCsv(); + exportLine[++index] = dto.getOutcomeForCsv(); + + // Write repeatable pathogen detection methods + if (exportResult.getMaxPathogenTests() > 0) { + List pathogenDetectionMethods = dto.getPathogenDetectionMethodsForCsv(exportResult.getMaxPathogenTests()); + for (String pathogenDetectionMethod : pathogenDetectionMethods) { + exportLine[++index] = pathogenDetectionMethod; + } + } + + // Write vaccination columns + if (exportResult.getMaxImmunizations() > 0) { + exportLine[++index] = dto.getDateOfLastVaccinationForCsv(); + } + + exportLine[++index] = dto.getVaccinationStatusForCsv(); + + return index; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/PertussisExportStrategy.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/PertussisExportStrategy.java new file mode 100644 index 00000000000..6683cd102fa --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/PertussisExportStrategy.java @@ -0,0 +1,65 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2024 Helmholtz-Zentrum für Infektionsforschung GmbH (HZI) + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.backend.epipulse.strategy; + +import java.util.List; + +import javax.ejb.LocalBean; +import javax.ejb.Stateless; + +import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportEntryDto; +import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportResult; + +/** + * Export strategy for Pertussis (PERT) disease exports. + * Pertussis uses only the common fields and CTEs - no disease-specific extensions. + */ +@Stateless +@LocalBean +public class PertussisExportStrategy extends AbstractEpipulseDiseaseExportStrategy { + + @Override + protected String buildDiseaseExportQuery() { + StringBuilder query = new StringBuilder(); + + // Build query using common CTEs only + query.append(sqlCteBuilder.buildVariablesCte()); + query.append(sqlCteBuilder.buildConfigDataCte()); + query.append(sqlCteBuilder.buildFilteredCasesCte(false)); // No Measles-specific fields + query.append(sqlCteBuilder.buildPreviousHospitalizationsCte()); + query.append(sqlCteBuilder.buildSamplesCte()); + query.append(sqlCteBuilder.buildPathogenTestsCte()); + query.append(sqlCteBuilder.buildImmunizationsCte()); + query.append(sqlCteBuilder.buildVaccinationsCte()); + + // Main SELECT clause with common fields only + query.append(sqlCteBuilder.buildMainSelectClause()); + + return query.toString(); + } + + @Override + protected void mapDiseaseSpecificFields(EpipulseDiseaseExportEntryDto dto, Object[] row, int startIndex) { + // Pertussis has no disease-specific fields beyond the common 0-27 + // This method is intentionally empty + } + + @Override + protected void calculateDiseaseSpecificMaxCounts(List entries, EpipulseDiseaseExportResult result) { + // Pertussis has no disease-specific max counts + // Only maxPathogenTests and maxImmunizations are tracked (handled by common mapper) + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/util/EpipulseConfigurationContext.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/util/EpipulseConfigurationContext.java new file mode 100644 index 00000000000..c5031b96cb3 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/util/EpipulseConfigurationContext.java @@ -0,0 +1,60 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2024 Helmholtz-Zentrum für Infektionsforschung GmbH (HZI) + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.backend.epipulse.util; + +import java.io.Serializable; + +/** + * Context object that holds configuration values looked up during Epipulse disease export. + * This DTO encapsulates the three configuration lookups that are common to all disease exports: + * reporting country, server country NUTS code, and subject code. + */ +public class EpipulseConfigurationContext implements Serializable { + + private static final long serialVersionUID = 1L; + + private final String reportingCountry; + private final String serverCountryNutsCode; + private final String subjectCode; + + /** + * Creates a new configuration context. + * + * @param reportingCountry + * the Epipulse reporting country code (e.g., "LU") + * @param serverCountryNutsCode + * the NUTS code for the server country (e.g., "LU") + * @param subjectCode + * the Epipulse subject code (e.g., "PERT", "MEAS") + */ + public EpipulseConfigurationContext(String reportingCountry, String serverCountryNutsCode, String subjectCode) { + this.reportingCountry = reportingCountry; + this.serverCountryNutsCode = serverCountryNutsCode; + this.subjectCode = subjectCode; + } + + public String getReportingCountry() { + return reportingCountry; + } + + public String getServerCountryNutsCode() { + return serverCountryNutsCode; + } + + public String getSubjectCode() { + return subjectCode; + } +} From c89a203f3b5e5ab576c75b771f88a705f6be8437 Mon Sep 17 00:00:00 2001 From: Harold Asiimwe Date: Fri, 9 Jan 2026 13:47:33 +0300 Subject: [PATCH 3/9] #13771 - Add Epipulse export functionality for Measles disease --- .../epipulse/EpipulseDiseaseExportFacade.java | 2 + .../epipulse/EpipulseLaboratoryMapper.java | 42 ++++-- .../epipulse/EpipulseCommonDtoMapper.java | 16 ++- .../EpipulseConfigurationLookupService.java | 4 + .../EpipulseCsvExportOrchestrator.java | 2 +- ...AbstractEpipulseDiseaseExportStrategy.java | 2 +- .../strategy/MeaslesExportStrategy.java | 125 ++++++++++++++---- 7 files changed, 149 insertions(+), 44 deletions(-) diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportFacade.java b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportFacade.java index 1f1e9bfacf2..6ea289db4ce 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportFacade.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportFacade.java @@ -21,4 +21,6 @@ public interface EpipulseDiseaseExportFacade { public void startPertussisExport(String uuid); + + public void startMeaslesExport(String uuid); } diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseLaboratoryMapper.java b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseLaboratoryMapper.java index c6acc1b3799..b7c43176979 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseLaboratoryMapper.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseLaboratoryMapper.java @@ -129,26 +129,42 @@ public static String normalizeGenotypeForEpipulse(String genotypeText) { } // Try to parse formats like "A", "B1", "D10", etc. and add MEASV_ prefix + // This matches a single uppercase letter optionally followed by digits if (normalized.matches("^[A-Z]\\d*$")) { return "MEASV_" + normalized; } - // Try to parse formats like "Genotype A", "MeV-A", etc. - if (normalized.contains("A")) { - return "MEASV_A"; + // Try to extract genotype from common formats with delimiters + // Matches patterns like "MeV-A", "Genotype-B1", "MV/A", "MEASLES-D4" + String extracted = extractGenotypeFromDelimitedFormat(normalized); + if (extracted != null) { + return "MEASV_" + extracted; } - if (normalized.matches(".*B[12]?.*")) { - if (normalized.contains("B1")) { - return "MEASV_B1"; - } else if (normalized.contains("B2")) { - return "MEASV_B2"; - } else if (normalized.contains("B3")) { - return "MEASV_B3"; - } - return "MEASV_B1"; // Default to B1 + + // Return null for ambiguous or unparseable inputs + return null; + } + + /** + * Extracts genotype code from delimited formats like "MeV-A", "Genotype B1", etc. + * Uses strict pattern matching to avoid false positives. + * + * @param normalized + * Uppercase normalized genotype string + * @return Extracted genotype code (e.g., "A", "B1", "D4"), or null if not extractable + */ + private static String extractGenotypeFromDelimitedFormat(String normalized) { + // Match patterns like "PREFIX-A", "PREFIX/B1", "PREFIX_D10", "PREFIX A" + // where PREFIX is some non-numeric text + // This captures the genotype part after common delimiters + String pattern = "(?:MEV|MEASLES?|GENOTYPE|MV)[-_/\\s]+([A-Z]\\d*)"; + java.util.regex.Pattern p = java.util.regex.Pattern.compile(pattern); + java.util.regex.Matcher m = p.matcher(normalized); + + if (m.find()) { + return m.group(1); } - // If we can't parse it, return null (field will be empty in CSV) return null; } diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCommonDtoMapper.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCommonDtoMapper.java index c8c78ffd190..6323a89881e 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCommonDtoMapper.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCommonDtoMapper.java @@ -173,14 +173,18 @@ public static void calculateCommonMaxCounts(List int maxImmunizations = 0; for (EpipulseDiseaseExportEntryDto entry : entries) { - int pathogenTestCount = entry.getPathogenTests().size(); - if (pathogenTestCount > maxPathogenTests) { - maxPathogenTests = pathogenTestCount; + if (entry.getPathogenTests() != null) { + int pathogenTestCount = entry.getPathogenTests().size(); + if (pathogenTestCount > maxPathogenTests) { + maxPathogenTests = pathogenTestCount; + } } - int immunizationCount = entry.getImmunizations().size(); - if (immunizationCount > maxImmunizations) { - maxImmunizations = immunizationCount; + if (entry.getImmunizations() != null) { + int immunizationCount = entry.getImmunizations().size(); + if (immunizationCount > maxImmunizations) { + maxImmunizations = immunizationCount; + } } } diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseConfigurationLookupService.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseConfigurationLookupService.java index df0df8936cd..40533134a48 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseConfigurationLookupService.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseConfigurationLookupService.java @@ -108,6 +108,10 @@ private String lookupReportingCountry(String countryIso2Code) throws IllegalArgu * @return the NUTS code for the country, or null if not found */ private String lookupServerCountryNutsCode(String countryName) { + if (countryName == null) { + return null; + } + //@formatter:off String serverCountryQuery = "select nutscode " + diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCsvExportOrchestrator.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCsvExportOrchestrator.java index 42b03b96687..33ff177f9b0 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCsvExportOrchestrator.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCsvExportOrchestrator.java @@ -130,8 +130,8 @@ public void orchestrateExport(String uuid, ExportFunction exportFunction, CsvExp writer.writeNext(columnNames.toArray(new String[columnNames.size()])); // Write entries using strategy - String[] exportLine = new String[columnNames.size()]; for (EpipulseDiseaseExportEntryDto dto : exportResult.getExportEntryList()) { + String[] exportLine = new String[columnNames.size()]; csvStrategy.writeEntryRow(dto, exportLine, exportResult); writer.writeNext(exportLine); } diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/AbstractEpipulseDiseaseExportStrategy.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/AbstractEpipulseDiseaseExportStrategy.java index 02a46f85046..1fc316cb6a2 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/AbstractEpipulseDiseaseExportStrategy.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/AbstractEpipulseDiseaseExportStrategy.java @@ -115,7 +115,7 @@ public EpipulseDiseaseExportResult export(EpipulseExportDto exportDto, String se exportResult.setExportEntryList(exportEntryList); } catch (Exception e) { - logger.error("Error while exporting case based " + exportDto.getSubjectCode() + ":" + e.getMessage()); + logger.error("Error while exporting case based " + exportDto.getSubjectCode() + ":" + e.getMessage(), e); throw e; } diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesExportStrategy.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesExportStrategy.java index 12b9bc727ee..7bd7f812072 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesExportStrategy.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesExportStrategy.java @@ -81,18 +81,14 @@ protected void mapDiseaseSpecificFields(EpipulseDiseaseExportEntryDto dto, Objec dto.setDateOfLaboratoryResult((Date) row[++index]); String specimenTypesVirusRaw = (String) row[++index]; - if (!StringUtils.isBlank(specimenTypesVirusRaw)) { - List specimenTypesVirus = new ArrayList<>(); - for (String specimenType : specimenTypesVirusRaw.split(",")) { - specimenTypesVirus.add(EpipulseLaboratoryMapper.mapSampleMaterialToEpipulseCode(SampleMaterial.valueOf(specimenType.trim()))); - } - dto.setTypeOfSpecimenCollected(specimenTypesVirus); - } + dto.setTypeOfSpecimenCollected(parseSpecimenTypes(specimenTypesVirusRaw)); String virusDetectionResultRaw = (String) row[++index]; if (!StringUtils.isBlank(virusDetectionResultRaw)) { - dto.setResultOfVirusDetection( - EpipulseLaboratoryMapper.mapTestResultToEpipulseCode(PathogenTestResultType.valueOf(virusDetectionResultRaw))); + PathogenTestResultType virusDetectionResult = parsePathogenTestResultType(virusDetectionResultRaw); + if (virusDetectionResult != null) { + dto.setResultOfVirusDetection(EpipulseLaboratoryMapper.mapTestResultToEpipulseCode(virusDetectionResult)); + } } String genotypeRaw = (String) row[++index]; @@ -101,22 +97,22 @@ protected void mapDiseaseSpecificFields(EpipulseDiseaseExportEntryDto dto, Objec } String specimenTypesSerologyRaw = (String) row[++index]; - if (!StringUtils.isBlank(specimenTypesSerologyRaw)) { - List specimenTypesSerology = new ArrayList<>(); - for (String specimenType : specimenTypesSerologyRaw.split(",")) { - specimenTypesSerology.add(EpipulseLaboratoryMapper.mapSampleMaterialToEpipulseCode(SampleMaterial.valueOf(specimenType.trim()))); - } - dto.setTypeOfSpecimenSerology(specimenTypesSerology); - } + dto.setTypeOfSpecimenSerology(parseSpecimenTypes(specimenTypesSerologyRaw)); String iggResultRaw = (String) row[++index]; if (!StringUtils.isBlank(iggResultRaw)) { - dto.setResultIgG(EpipulseLaboratoryMapper.mapTestResultToEpipulseCode(PathogenTestResultType.valueOf(iggResultRaw))); + PathogenTestResultType iggResult = parsePathogenTestResultType(iggResultRaw); + if (iggResult != null) { + dto.setResultIgG(EpipulseLaboratoryMapper.mapTestResultToEpipulseCode(iggResult)); + } } String igmResultRaw = (String) row[++index]; if (!StringUtils.isBlank(igmResultRaw)) { - dto.setResultIgM(EpipulseLaboratoryMapper.mapTestResultToEpipulseCode(PathogenTestResultType.valueOf(igmResultRaw))); + PathogenTestResultType igmResult = parsePathogenTestResultType(igmResultRaw); + if (igmResult != null) { + dto.setResultIgM(EpipulseLaboratoryMapper.mapTestResultToEpipulseCode(igmResult)); + } } // Clinical and epidemiology data (indices 36-47) @@ -129,14 +125,20 @@ protected void mapDiseaseSpecificFields(EpipulseDiseaseExportEntryDto dto, Objec String clusterTypeRaw = (String) row[++index]; if (!StringUtils.isBlank(clusterTypeRaw)) { - List clusterSettings = new ArrayList<>(); - clusterSettings.add(EpipulseLaboratoryMapper.mapClusterTypeToEpipulseCode(ClusterType.valueOf(clusterTypeRaw))); - dto.setClusterSetting(clusterSettings); + ClusterType clusterType = parseClusterType(clusterTypeRaw); + if (clusterType != null) { + List clusterSettings = new ArrayList<>(); + clusterSettings.add(EpipulseLaboratoryMapper.mapClusterTypeToEpipulseCode(clusterType)); + dto.setClusterSetting(clusterSettings); + } } String caseImportedStatusRaw = (String) row[++index]; if (!StringUtils.isBlank(caseImportedStatusRaw)) { - dto.setImportedStatus(EpipulseLaboratoryMapper.mapCaseImportedStatusToEpipulseCode(CaseImportedStatus.valueOf(caseImportedStatusRaw))); + CaseImportedStatus caseImportedStatus = parseCaseImportedStatus(caseImportedStatusRaw); + if (caseImportedStatus != null) { + dto.setImportedStatus(EpipulseLaboratoryMapper.mapCaseImportedStatusToEpipulseCode(caseImportedStatus)); + } } // Complications mapping (4 fields) @@ -156,7 +158,10 @@ protected void mapDiseaseSpecificFields(EpipulseDiseaseExportEntryDto dto, Objec // Clinical criteria status String clinicalConfirmationRaw = (String) row[++index]; if (!StringUtils.isBlank(clinicalConfirmationRaw)) { - dto.setClinicalCriteriaStatus(EpipulseLaboratoryMapper.deriveClinicalCriteriaStatus(YesNoUnknown.valueOf(clinicalConfirmationRaw))); + YesNoUnknown clinicalConfirmation = parseYesNoUnknown(clinicalConfirmationRaw); + if (clinicalConfirmation != null) { + dto.setClinicalCriteriaStatus(EpipulseLaboratoryMapper.deriveClinicalCriteriaStatus(clinicalConfirmation)); + } } // Place of infection (exposure locations) @@ -380,6 +385,20 @@ private String buildMeaslesSelectClause() { return select.toString(); } + private List parseSpecimenTypes(String specimenTypesRaw) { + if (StringUtils.isBlank(specimenTypesRaw)) { + return null; + } + List specimenTypes = new ArrayList<>(); + for (String specimenType : specimenTypesRaw.split(",")) { + SampleMaterial material = parseSampleMaterial(specimenType.trim()); + if (material != null) { + specimenTypes.add(EpipulseLaboratoryMapper.mapSampleMaterialToEpipulseCode(material)); + } + } + return specimenTypes; + } + private SymptomState parseSymptomState(String value) { if (StringUtils.isBlank(value)) { return null; @@ -391,4 +410,64 @@ private SymptomState parseSymptomState(String value) { return null; } } + + private SampleMaterial parseSampleMaterial(String value) { + if (StringUtils.isBlank(value)) { + return null; + } + try { + return SampleMaterial.valueOf(value); + } catch (IllegalArgumentException e) { + logger.warn("Invalid SampleMaterial value '{}', treating as null", value); + return null; + } + } + + private PathogenTestResultType parsePathogenTestResultType(String value) { + if (StringUtils.isBlank(value)) { + return null; + } + try { + return PathogenTestResultType.valueOf(value); + } catch (IllegalArgumentException e) { + logger.warn("Invalid PathogenTestResultType value '{}', treating as null", value); + return null; + } + } + + private ClusterType parseClusterType(String value) { + if (StringUtils.isBlank(value)) { + return null; + } + try { + return ClusterType.valueOf(value); + } catch (IllegalArgumentException e) { + logger.warn("Invalid ClusterType value '{}', treating as null", value); + return null; + } + } + + private CaseImportedStatus parseCaseImportedStatus(String value) { + if (StringUtils.isBlank(value)) { + return null; + } + try { + return CaseImportedStatus.valueOf(value); + } catch (IllegalArgumentException e) { + logger.warn("Invalid CaseImportedStatus value '{}', treating as null", value); + return null; + } + } + + private YesNoUnknown parseYesNoUnknown(String value) { + if (StringUtils.isBlank(value)) { + return null; + } + try { + return YesNoUnknown.valueOf(value); + } catch (IllegalArgumentException e) { + logger.warn("Invalid YesNoUnknown value '{}', treating as null", value); + return null; + } + } } From ef17b1eda59e2ae33ab9d83254748a717ef8bf80 Mon Sep 17 00:00:00 2001 From: Harold Asiimwe Date: Fri, 9 Jan 2026 14:27:49 +0300 Subject: [PATCH 4/9] #13771 - Add Epipulse export functionality for Measles disease --- .../epipulse/EpipulseLaboratoryMapper.java | 9 +++-- .../EpipulseCsvExportOrchestrator.java | 9 +++-- .../EpipulseDiseaseExportService.java | 33 +++++++++++++++++++ .../strategy/MeaslesExportStrategy.java | 13 ++++++-- 4 files changed, 53 insertions(+), 11 deletions(-) diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseLaboratoryMapper.java b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseLaboratoryMapper.java index b7c43176979..ad49817b7d2 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseLaboratoryMapper.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseLaboratoryMapper.java @@ -69,7 +69,7 @@ public static String mapSampleMaterialToEpipulseCode(SampleMaterial sampleMateri case EDTA_WHOLE_BLOOD: return "EDTA"; // EDTA whole blood default: - return "OTH"; + return null; } } @@ -129,8 +129,8 @@ public static String normalizeGenotypeForEpipulse(String genotypeText) { } // Try to parse formats like "A", "B1", "D10", etc. and add MEASV_ prefix - // This matches a single uppercase letter optionally followed by digits - if (normalized.matches("^[A-Z]\\d*$")) { + // Measles genotypes are limited (e.g., A, B1-3, C1-2, D1-11, E, F, G1-3, H1-2) + if (normalized.matches("^(A|B[1-3]|C[1-2]|D(1[0-1]|[1-9])|E|F|G[1-3]|H[1-2])$")) { return "MEASV_" + normalized; } @@ -301,6 +301,9 @@ public static List mapSymptomsToComplicationCodes( * @return true if clinically confirmed, false otherwise */ public static Boolean deriveClinicalCriteriaStatus(YesNoUnknown clinicalConfirmation) { + if (clinicalConfirmation == null || clinicalConfirmation == YesNoUnknown.UNKNOWN) { + return null; + } return clinicalConfirmation == YesNoUnknown.YES; } } diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCsvExportOrchestrator.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCsvExportOrchestrator.java index 33ff177f9b0..cfe99388d78 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCsvExportOrchestrator.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCsvExportOrchestrator.java @@ -94,16 +94,15 @@ public void orchestrateExport(String uuid, ExportFunction exportFunction, CsvExp return; } - if (epipulseExport.getStatus() != EpipulseExportStatus.PENDING) { - logger.error("EpipulseExport with uuid " + uuid + " is not in status PENDING"); + // Atomic status claim: try to update from PENDING to IN_PROGRESS + boolean claimed = diseaseExportService.tryClaimExportForProcessing(uuid); + if (!claimed) { + logger.info("Export {} not claimed - either already processing or not in PENDING status", uuid); return; } shouldUpdateStatus = true; - // Update status to IN_PROGRESS - diseaseExportService.updateStatusForBackgroundProcess(uuid, EpipulseExportStatus.IN_PROGRESS, null, null, null); - // Load configuration EpipulseExportDto exportDto = epipulseExportEjb.toEpipulseExportDto(epipulseExport); String serverCountryCode = configFacadeEjb.getCountryCode(); diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportService.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportService.java index 26fa424f9dc..4964744023e 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportService.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportService.java @@ -67,6 +67,39 @@ public EpipulseDiseaseExportResult exportMeaslesCaseBased(EpipulseExportDto expo return measlesExportStrategy.export(exportDto, serverCountryLocale, serverCountryName); } + @TransactionAttribute(TransactionAttributeType.REQUIRES_NEW) + public boolean tryClaimExportForProcessing(String exportUuid) { + try { + String sql = "UPDATE epipulse_export SET " + + "status = :newStatus, " + + "status_change_date = now(), " + + "changedate = now() " + + "WHERE uuid = :uuid AND status = :expectedStatus"; + + int updated = em.createNativeQuery(sql) + .setParameter("newStatus", EpipulseExportStatus.IN_PROGRESS.name()) + .setParameter("uuid", exportUuid) + .setParameter("expectedStatus", EpipulseExportStatus.PENDING.name()) + .executeUpdate(); + + em.flush(); + + if (updated > 0) { + logger.info("Successfully claimed export {} for processing", exportUuid); + return true; + } else { + logger.info("Export {} already claimed by another process or not in PENDING status", exportUuid); + return false; + } + + } catch (Exception e) { + logger.error("Failed to claim export {} for processing: {}", exportUuid, e.getMessage(), e); + return false; + } finally { + em.clear(); + } + } + @TransactionAttribute(TransactionAttributeType.REQUIRES_NEW) public void updateStatusForBackgroundProcess( String exportUuid, diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesExportStrategy.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesExportStrategy.java index 7bd7f812072..d502aca744b 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesExportStrategy.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesExportStrategy.java @@ -223,7 +223,13 @@ private String buildSampleDataCte() { " STRING_AGG(DISTINCT CAST(s3.samplematerial AS text), ',' ORDER BY CAST(s3.samplematerial AS text)) as specimen_types_serology " + " FROM filtered_cases c " + " LEFT JOIN samples s ON s.associatedcase_id = c.id AND s.deleted = false " + - " LEFT JOIN samples s2 ON s2.associatedcase_id = c.id AND s2.deleted = false AND s2.samplematerial IS NOT NULL " + + " LEFT JOIN (SELECT DISTINCT s_vir.id, s_vir.associatedcase_id, s_vir.samplematerial " + + " FROM samples s_vir " + + " JOIN pathogentest pt_vir ON pt_vir.sample_id = s_vir.id " + + " WHERE s_vir.deleted = false " + + " AND s_vir.samplematerial IS NOT NULL " + + " AND pt_vir.testtype IN ('PCR_RT_PCR', 'CULTURE', 'ISOLATION', 'DIRECT_FLUORESCENT_ANTIBODY', 'INDIRECT_FLUORESCENT_ANTIBODY')) s2 " + + " ON s2.associatedcase_id = c.id " + " LEFT JOIN (SELECT DISTINCT s_sero.id, s_sero.associatedcase_id, s_sero.samplematerial " + " FROM samples s_sero " + " JOIN pathogentest pt_sero ON pt_sero.sample_id = s_sero.id " + @@ -316,13 +322,14 @@ private String buildExposureLocationsCte() { " CASE " + " WHEN co.defaultname IS NOT NULL THEN co.defaultname " + " WHEN l.city IS NOT NULL THEN l.city " + - " ELSE l.details " + + " WHEN l.details IS NOT NULL THEN l.details " + + " ELSE 'Unknown' " + " END, " + " '; ' " + " ORDER BY e.startdate DESC" + " ) as infection_locations " + " FROM exposures e " + - " JOIN location l ON e.location_id = l.id " + + " LEFT JOIN location l ON e.location_id = l.id " + " LEFT JOIN country co ON l.country_id = co.id " + " WHERE e.epidata_id IN (SELECT epidata_id FROM filtered_cases WHERE epidata_id IS NOT NULL) " + " GROUP BY e.epidata_id), "; From 1b70f5f9f19224d54c39ec6f07f99761fc13f630 Mon Sep 17 00:00:00 2001 From: Harold Asiimwe Date: Fri, 9 Jan 2026 14:57:15 +0300 Subject: [PATCH 5/9] #13771 - Add Epipulse export functionality for Measles disease --- .../epipulse/EpipulseLaboratoryMapper.java | 24 ++++++++++++++--- .../EpipulseCsvExportOrchestrator.java | 26 +++++++++++++++---- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseLaboratoryMapper.java b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseLaboratoryMapper.java index ad49817b7d2..24a6e5350af 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseLaboratoryMapper.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseLaboratoryMapper.java @@ -123,9 +123,13 @@ public static String normalizeGenotypeForEpipulse(String genotypeText) { String normalized = genotypeText.trim().toUpperCase(); - // If already in MEASV_ format, return as-is + // If already in MEASV_ format, validate the suffix and return if valid if (normalized.startsWith("MEASV_")) { - return normalized; + String suffix = normalized.substring(6); // Extract part after "MEASV_" + if (isValidMeaslesGenotype(suffix)) { + return normalized; + } + return null; } // Try to parse formats like "A", "B1", "D10", etc. and add MEASV_ prefix @@ -137,7 +141,7 @@ public static String normalizeGenotypeForEpipulse(String genotypeText) { // Try to extract genotype from common formats with delimiters // Matches patterns like "MeV-A", "Genotype-B1", "MV/A", "MEASLES-D4" String extracted = extractGenotypeFromDelimitedFormat(normalized); - if (extracted != null) { + if (isValidMeaslesGenotype(extracted)) { return "MEASV_" + extracted; } @@ -145,6 +149,20 @@ public static String normalizeGenotypeForEpipulse(String genotypeText) { return null; } + /** + * Validates if a genotype code matches the known measles genotype pattern. + * + * @param genotype + * Genotype code without MEASV_ prefix (e.g., "A", "B1", "D10") + * @return true if the genotype is a valid measles genotype, false otherwise + */ + private static boolean isValidMeaslesGenotype(String genotype) { + if (genotype == null) { + return false; + } + return genotype.matches("^(A|B[1-3]|C[1-2]|D(1[0-1]|[1-9])|E|F|G[1-3]|H[1-2])$"); + } + /** * Extracts genotype code from delimited formats like "MeV-A", "Genotype B1", etc. * Uses strict pattern matching to avoid false positives. diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCsvExportOrchestrator.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCsvExportOrchestrator.java index cfe99388d78..ae47b069ea7 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCsvExportOrchestrator.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCsvExportOrchestrator.java @@ -76,6 +76,8 @@ public class EpipulseCsvExportOrchestrator { public void orchestrateExport(String uuid, ExportFunction exportFunction, CsvExportStrategy csvStrategy) { CSVWriter writer = null; + FileOutputStream fos = null; + OutputStreamWriter osw = null; EpipulseExport epipulseExport = null; EpipulseExportStatus exportStatus = EpipulseExportStatus.FAILED; boolean shouldUpdateStatus = false; @@ -117,10 +119,10 @@ public void orchestrateExport(String uuid, ExportFunction exportFunction, CsvExp EpipulseDiseaseExportResult exportResult = exportFunction.execute(exportDto, serverCountryCode, serverCountryName); totalRecords = exportResult.getExportEntryList().size(); - // Setup CSV writer - writer = CSVUtils.createCSVWriter( - new OutputStreamWriter(new FileOutputStream(exportFilePath), StandardCharsets.UTF_8), - configFacadeEjb.getCsvSeparator()); + // Setup CSV writer with explicit stream management + fos = new FileOutputStream(exportFilePath); + osw = new OutputStreamWriter(fos, StandardCharsets.UTF_8); + writer = CSVUtils.createCSVWriter(osw, configFacadeEjb.getCsvSeparator()); // Build column names using strategy List columnNames = csvStrategy.buildColumnNames(exportResult); @@ -140,7 +142,7 @@ public void orchestrateExport(String uuid, ExportFunction exportFunction, CsvExp exportStatus = EpipulseExportStatus.FAILED; logger.error("Error during export with uuid " + uuid + ": " + e.getMessage(), e); } finally { - // Close writer + // Close resources in reverse order if (writer != null) { try { writer.close(); @@ -148,6 +150,20 @@ public void orchestrateExport(String uuid, ExportFunction exportFunction, CsvExp logger.error("CRITICAL: Failed to close CSVWriter for uuid " + uuid + ": " + e.getMessage(), e); } } + if (osw != null) { + try { + osw.close(); + } catch (Exception e) { + logger.error("CRITICAL: Failed to close OutputStreamWriter for uuid " + uuid + ": " + e.getMessage(), e); + } + } + if (fos != null) { + try { + fos.close(); + } catch (Exception e) { + logger.error("CRITICAL: Failed to close FileOutputStream for uuid " + uuid + ": " + e.getMessage(), e); + } + } // Calculate file size after writer is closed if (exportFilePath != null && exportStatus == EpipulseExportStatus.COMPLETED) { From c97279ae36d0a582f1b633a58f49f84dc24a7275 Mon Sep 17 00:00:00 2001 From: Harold Asiimwe Date: Fri, 9 Jan 2026 15:11:19 +0300 Subject: [PATCH 6/9] #13771 - Add Epipulse export functionality for Measles disease --- .../epipulse/EpipulseLaboratoryMapper.java | 6 +-- .../EpipulseCsvExportOrchestrator.java | 53 +++++-------------- 2 files changed, 17 insertions(+), 42 deletions(-) diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseLaboratoryMapper.java b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseLaboratoryMapper.java index 24a6e5350af..e1b9145213a 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseLaboratoryMapper.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseLaboratoryMapper.java @@ -84,11 +84,11 @@ public static String mapSampleMaterialToEpipulseCode(SampleMaterial sampleMateri * * @param testResult * SORMAS test result enum - * @return EpiPulse result code (POS/NEG/EQUI/NOTEST) + * @return EpiPulse result code (POS/NEG/EQUI/NOTEST), or null if input is null */ public static String mapTestResultToEpipulseCode(PathogenTestResultType testResult) { if (testResult == null) { - return "NOTEST"; + return null; } switch (testResult) { @@ -102,7 +102,7 @@ public static String mapTestResultToEpipulseCode(PathogenTestResultType testResu case NOT_DONE: return "NOTEST"; default: - return "NOTEST"; + return null; } } diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCsvExportOrchestrator.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCsvExportOrchestrator.java index ae47b069ea7..ee6f0dc56d4 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCsvExportOrchestrator.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCsvExportOrchestrator.java @@ -75,9 +75,6 @@ public class EpipulseCsvExportOrchestrator { */ public void orchestrateExport(String uuid, ExportFunction exportFunction, CsvExportStrategy csvStrategy) { - CSVWriter writer = null; - FileOutputStream fos = null; - OutputStreamWriter osw = null; EpipulseExport epipulseExport = null; EpipulseExportStatus exportStatus = EpipulseExportStatus.FAILED; boolean shouldUpdateStatus = false; @@ -119,22 +116,23 @@ public void orchestrateExport(String uuid, ExportFunction exportFunction, CsvExp EpipulseDiseaseExportResult exportResult = exportFunction.execute(exportDto, serverCountryCode, serverCountryName); totalRecords = exportResult.getExportEntryList().size(); - // Setup CSV writer with explicit stream management - fos = new FileOutputStream(exportFilePath); - osw = new OutputStreamWriter(fos, StandardCharsets.UTF_8); - writer = CSVUtils.createCSVWriter(osw, configFacadeEjb.getCsvSeparator()); + // Setup CSV writer with try-with-resources for automatic resource management + try (FileOutputStream fos = new FileOutputStream(exportFilePath); + OutputStreamWriter osw = new OutputStreamWriter(fos, StandardCharsets.UTF_8); + CSVWriter writer = CSVUtils.createCSVWriter(osw, configFacadeEjb.getCsvSeparator())) { - // Build column names using strategy - List columnNames = csvStrategy.buildColumnNames(exportResult); + // Build column names using strategy + List columnNames = csvStrategy.buildColumnNames(exportResult); - // Write headers - writer.writeNext(columnNames.toArray(new String[columnNames.size()])); + // Write headers + writer.writeNext(columnNames.toArray(new String[columnNames.size()])); - // Write entries using strategy - for (EpipulseDiseaseExportEntryDto dto : exportResult.getExportEntryList()) { - String[] exportLine = new String[columnNames.size()]; - csvStrategy.writeEntryRow(dto, exportLine, exportResult); - writer.writeNext(exportLine); + // Write entries using strategy + for (EpipulseDiseaseExportEntryDto dto : exportResult.getExportEntryList()) { + String[] exportLine = new String[columnNames.size()]; + csvStrategy.writeEntryRow(dto, exportLine, exportResult); + writer.writeNext(exportLine); + } } exportStatus = EpipulseExportStatus.COMPLETED; @@ -142,29 +140,6 @@ public void orchestrateExport(String uuid, ExportFunction exportFunction, CsvExp exportStatus = EpipulseExportStatus.FAILED; logger.error("Error during export with uuid " + uuid + ": " + e.getMessage(), e); } finally { - // Close resources in reverse order - if (writer != null) { - try { - writer.close(); - } catch (Exception e) { - logger.error("CRITICAL: Failed to close CSVWriter for uuid " + uuid + ": " + e.getMessage(), e); - } - } - if (osw != null) { - try { - osw.close(); - } catch (Exception e) { - logger.error("CRITICAL: Failed to close OutputStreamWriter for uuid " + uuid + ": " + e.getMessage(), e); - } - } - if (fos != null) { - try { - fos.close(); - } catch (Exception e) { - logger.error("CRITICAL: Failed to close FileOutputStream for uuid " + uuid + ": " + e.getMessage(), e); - } - } - // Calculate file size after writer is closed if (exportFilePath != null && exportStatus == EpipulseExportStatus.COMPLETED) { try { From 377b00cfd6210bc4c900122d9ead582d60e213ea Mon Sep 17 00:00:00 2001 From: Harold Asiimwe Date: Mon, 19 Jan 2026 13:05:34 +0300 Subject: [PATCH 7/9] #13772 - Add Epipulse export functionality for IPI disease --- .../EpipulseDiseaseExportEntryDto.java | 531 ++++++++++++++++++ .../epipulse/EpipulseDiseaseExportFacade.java | 2 + .../epipulse/EpipulseLaboratoryMapper.java | 350 ++++++++++++ .../api/epipulse/EpipulseSubjectCode.java | 3 +- .../referencevalue/EpipulseDiseaseRef.java | 3 +- sormas-api/src/main/resources/enum.properties | 8 +- .../EpipulseDiseaseExportFacadeEjb.java | 8 + .../EpipulseDiseaseExportService.java | 9 + .../epipulse/EpipulseExportTimerEjb.java | 3 + .../epipulse/EpipulseSqlCteBuilder.java | 8 +- .../strategy/IpiCsvExportStrategy.java | 178 ++++++ .../epipulse/strategy/IpiExportStrategy.java | 408 ++++++++++++++ .../strategy/MeaslesCsvExportStrategy.java | 144 +++-- .../strategy/MeaslesExportStrategy.java | 2 +- .../strategy/PertussisExportStrategy.java | 2 +- 15 files changed, 1569 insertions(+), 90 deletions(-) create mode 100644 sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/IpiCsvExportStrategy.java create mode 100644 sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/IpiExportStrategy.java diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportEntryDto.java b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportEntryDto.java index 8fcbee93860..60ad3fd47f7 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportEntryDto.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportEntryDto.java @@ -103,6 +103,69 @@ public class EpipulseDiseaseExportEntryDto { private List placeOfInfection; // EpiDataDto.exposures locations (repeatable) private String causeOfDeath; // PersonDto.causeOfDeathDetails + // IPI-specific laboratory fields + private String resultOfCulture; // PathogenTestResultType for CULTURE → POS/NEG/EQUI/NOTEST + private String resultOfPCR; // PathogenTestResultType for PCR_RT_PCR → POS/NEG/EQUI/NOTEST + private String serotype; // PathogenTest typingId for pneumococcal serotypes (1-93) + private String serogroupMethod; // PathogenTestType.SEROGROUPING presence → POS/NEG/NOTEST + private String penicillinResistance; // DrugSusceptibilityDto → SENS/RESIST/INTER/NOTEST + + // IPI-specific clinical fields + private List clinicalPresentation; // SymptomsDto (meningitis, septicaemia, etc.) → MENING/SEPT/PNEUM/OME/ASYMP (repeatable) + + // PNEU-specific fields (following metadata specification exactly) + // Demographics + private Boolean nrlData; // National Reference Laboratory data flag + + // Clinical/Diagnostic + private Date dateOfDiagnosis; // Date of diagnosis + private String clinicalCriteria; // REF: BACTERPNEUMO, MENI, MENISEPTI, OTH, SEPTI + + // Laboratory + private String pathogenDetectionMethod; // REF: COAGG, GDIFF, MPCR, OTH, PTEST, QUE, SLAGG + + // Vaccination - Summary fields + private String vaccine; // REF: PCV7, PCV10, PCV13, PCV15, PCV20, PCV3, PPV23 + private Date dateOfLastVaccination; // Date of last vaccination (shared with MEAS) + + // Vaccination - Detailed PCV doses (1-4) + private Boolean dosePCV1; + private Date datePCV1; + private String brandPCV1; // REF: PCV7, PCV10, PCV13, PCV15, PCV20 + private Boolean dosePCV2; + private Date datePCV2; + private String brandPCV2; + private Boolean dosePCV3; + private Date datePCV3; + private String brandPCV3; + private Boolean dosePCV4; + private Date datePCV4; + private String brandPCV4; + private Integer pcvDoses; // Total PCV dose count + + // Vaccination - PPV doses + private Boolean dosePPV; + private Date datePPV; + private Integer ppvDoses; // Total PPV dose count + + // Antimicrobial Susceptibility Testing (AST) + private String astMethod; // REF: AGARDIL, AUTOM, BROTHDIL, GRAD, OTH + + // CTX/CFX (Cefotaxime/Ceftriaxone) AST + private String micSign_CTX_CFX; // REF: <, <=, =, >, >= + private Double micValueAST_CTX_CFX; // MIC value + private String sir_CTX_CFX; // REF: I, R, S + + // ERY (Erythromycin) AST + private String micSign_ERY; + private Double micValueAST_ERY; + private String sir_ERY; + + // PEN (Penicillin) AST + private String micSign_PEN; + private Double micValueAST_PEN; + private String sir_PEN; + public String getReportingCountry() { return reportingCountry; } @@ -451,6 +514,7 @@ public String getAgeMonthForCsv() { switch (subjectCode) { case PERT: case MEAS: + case PNEU: if (ageYears != null && ageYears < 2) { return ageMonths == null ? null : ageMonths.toString(); } @@ -471,6 +535,7 @@ public String getPlaceOfResidenceForCsv() { switch (subjectCode) { case PERT: case MEAS: + case PNEU: if (addressCommunityNutsCode != null && !addressCommunityNutsCode.isEmpty()) { return addressCommunityNutsCode; } else if (addressDistrictNutsCode != null && !addressDistrictNutsCode.isEmpty()) { @@ -488,6 +553,7 @@ public String getPlaceOfNotificationForCsv() { switch (subjectCode) { case PERT: case MEAS: + case PNEU: if (responsibleCommunityNutsCode != null && !responsibleCommunityNutsCode.isEmpty()) { return responsibleCommunityNutsCode; } else if (responsibleDistrictNutsCode != null && !responsibleDistrictNutsCode.isEmpty()) { @@ -745,6 +811,313 @@ public void setCauseOfDeath(String causeOfDeath) { this.causeOfDeath = causeOfDeath; } + // IPI-specific laboratory field getters/setters + public String getResultOfCulture() { + return resultOfCulture; + } + + public void setResultOfCulture(String resultOfCulture) { + this.resultOfCulture = resultOfCulture; + } + + public String getResultOfPCR() { + return resultOfPCR; + } + + public void setResultOfPCR(String resultOfPCR) { + this.resultOfPCR = resultOfPCR; + } + + public String getSerotype() { + return serotype; + } + + public void setSerotype(String serotype) { + this.serotype = serotype; + } + + public String getSerogroupMethod() { + return serogroupMethod; + } + + public void setSerogroupMethod(String serogroupMethod) { + this.serogroupMethod = serogroupMethod; + } + + public String getPenicillinResistance() { + return penicillinResistance; + } + + public void setPenicillinResistance(String penicillinResistance) { + this.penicillinResistance = penicillinResistance; + } + + // IPI-specific clinical field getters/setters + public List getClinicalPresentation() { + return clinicalPresentation; + } + + public void setClinicalPresentation(List clinicalPresentation) { + this.clinicalPresentation = clinicalPresentation; + } + + // PNEU-specific field getters/setters + public Boolean getNrlData() { + return nrlData; + } + + public void setNrlData(Boolean nrlData) { + this.nrlData = nrlData; + } + + public Date getDateOfDiagnosis() { + return dateOfDiagnosis; + } + + public void setDateOfDiagnosis(Date dateOfDiagnosis) { + this.dateOfDiagnosis = dateOfDiagnosis; + } + + public String getClinicalCriteria() { + return clinicalCriteria; + } + + public void setClinicalCriteria(String clinicalCriteria) { + this.clinicalCriteria = clinicalCriteria; + } + + public String getPathogenDetectionMethod() { + return pathogenDetectionMethod; + } + + public void setPathogenDetectionMethod(String pathogenDetectionMethod) { + this.pathogenDetectionMethod = pathogenDetectionMethod; + } + + public String getVaccine() { + return vaccine; + } + + public void setVaccine(String vaccine) { + this.vaccine = vaccine; + } + + public Date getDateOfLastVaccination() { + return dateOfLastVaccination; + } + + public void setDateOfLastVaccination(Date dateOfLastVaccination) { + this.dateOfLastVaccination = dateOfLastVaccination; + } + + public Boolean getDosePCV1() { + return dosePCV1; + } + + public void setDosePCV1(Boolean dosePCV1) { + this.dosePCV1 = dosePCV1; + } + + public Date getDatePCV1() { + return datePCV1; + } + + public void setDatePCV1(Date datePCV1) { + this.datePCV1 = datePCV1; + } + + public String getBrandPCV1() { + return brandPCV1; + } + + public void setBrandPCV1(String brandPCV1) { + this.brandPCV1 = brandPCV1; + } + + public Boolean getDosePCV2() { + return dosePCV2; + } + + public void setDosePCV2(Boolean dosePCV2) { + this.dosePCV2 = dosePCV2; + } + + public Date getDatePCV2() { + return datePCV2; + } + + public void setDatePCV2(Date datePCV2) { + this.datePCV2 = datePCV2; + } + + public String getBrandPCV2() { + return brandPCV2; + } + + public void setBrandPCV2(String brandPCV2) { + this.brandPCV2 = brandPCV2; + } + + public Boolean getDosePCV3() { + return dosePCV3; + } + + public void setDosePCV3(Boolean dosePCV3) { + this.dosePCV3 = dosePCV3; + } + + public Date getDatePCV3() { + return datePCV3; + } + + public void setDatePCV3(Date datePCV3) { + this.datePCV3 = datePCV3; + } + + public String getBrandPCV3() { + return brandPCV3; + } + + public void setBrandPCV3(String brandPCV3) { + this.brandPCV3 = brandPCV3; + } + + public Boolean getDosePCV4() { + return dosePCV4; + } + + public void setDosePCV4(Boolean dosePCV4) { + this.dosePCV4 = dosePCV4; + } + + public Date getDatePCV4() { + return datePCV4; + } + + public void setDatePCV4(Date datePCV4) { + this.datePCV4 = datePCV4; + } + + public String getBrandPCV4() { + return brandPCV4; + } + + public void setBrandPCV4(String brandPCV4) { + this.brandPCV4 = brandPCV4; + } + + public Integer getPcvDoses() { + return pcvDoses; + } + + public void setPcvDoses(Integer pcvDoses) { + this.pcvDoses = pcvDoses; + } + + public Boolean getDosePPV() { + return dosePPV; + } + + public void setDosePPV(Boolean dosePPV) { + this.dosePPV = dosePPV; + } + + public Date getDatePPV() { + return datePPV; + } + + public void setDatePPV(Date datePPV) { + this.datePPV = datePPV; + } + + public Integer getPpvDoses() { + return ppvDoses; + } + + public void setPpvDoses(Integer ppvDoses) { + this.ppvDoses = ppvDoses; + } + + public String getAstMethod() { + return astMethod; + } + + public void setAstMethod(String astMethod) { + this.astMethod = astMethod; + } + + public String getMicSign_CTX_CFX() { + return micSign_CTX_CFX; + } + + public void setMicSign_CTX_CFX(String micSign_CTX_CFX) { + this.micSign_CTX_CFX = micSign_CTX_CFX; + } + + public Double getMicValueAST_CTX_CFX() { + return micValueAST_CTX_CFX; + } + + public void setMicValueAST_CTX_CFX(Double micValueAST_CTX_CFX) { + this.micValueAST_CTX_CFX = micValueAST_CTX_CFX; + } + + public String getSir_CTX_CFX() { + return sir_CTX_CFX; + } + + public void setSir_CTX_CFX(String sir_CTX_CFX) { + this.sir_CTX_CFX = sir_CTX_CFX; + } + + public String getMicSign_ERY() { + return micSign_ERY; + } + + public void setMicSign_ERY(String micSign_ERY) { + this.micSign_ERY = micSign_ERY; + } + + public Double getMicValueAST_ERY() { + return micValueAST_ERY; + } + + public void setMicValueAST_ERY(Double micValueAST_ERY) { + this.micValueAST_ERY = micValueAST_ERY; + } + + public String getSir_ERY() { + return sir_ERY; + } + + public void setSir_ERY(String sir_ERY) { + this.sir_ERY = sir_ERY; + } + + public String getMicSign_PEN() { + return micSign_PEN; + } + + public void setMicSign_PEN(String micSign_PEN) { + this.micSign_PEN = micSign_PEN; + } + + public Double getMicValueAST_PEN() { + return micValueAST_PEN; + } + + public void setMicValueAST_PEN(Double micValueAST_PEN) { + this.micValueAST_PEN = micValueAST_PEN; + } + + public String getSir_PEN() { + return sir_PEN; + } + + public void setSir_PEN(String sir_PEN) { + this.sir_PEN = sir_PEN; + } + // Phase 3: CSV getter methods public String getDateOfInvestigationForCsv() { return formatDateForCsv(dateOfInvestigation); @@ -814,6 +1187,164 @@ public String getCauseOfDeathForCsv() { return causeOfDeath != null ? causeOfDeath : ""; } + // IPI-specific CSV getter methods + public String getResultOfCultureForCsv() { + return resultOfCulture != null ? resultOfCulture : ""; + } + + public String getResultOfPCRForCsv() { + return resultOfPCR != null ? resultOfPCR : ""; + } + + public String getSerotypeForCsv() { + return serotype != null ? serotype : ""; + } + + public String getSerogroupMethodForCsv() { + return serogroupMethod != null ? serogroupMethod : ""; + } + + public String getPenicillinResistanceForCsv() { + return penicillinResistance != null ? penicillinResistance : ""; + } + + public List getClinicalPresentationForCsv(int maxCount) { + List presentations = new ArrayList<>(); + if (clinicalPresentation != null && !clinicalPresentation.isEmpty()) { + presentations.addAll(clinicalPresentation); + } + // Pad with empty strings to match maxCount + while (presentations.size() < maxCount) { + presentations.add(""); + } + return presentations; + } + + // PNEU-specific CSV getter methods + public String getNrlDataForCsv() { + return nrlData != null ? String.valueOf(nrlData) : ""; + } + + public String getDateOfDiagnosisForCsv() { + return formatDateForCsv(dateOfDiagnosis); + } + + public String getClinicalCriteriaForCsv() { + return clinicalCriteria != null ? clinicalCriteria : ""; + } + + public String getPathogenDetectionMethodForCsv() { + return pathogenDetectionMethod != null ? pathogenDetectionMethod : ""; + } + + public String getVaccineForCsv() { + return vaccine != null ? vaccine : ""; + } + + public String getDosePCV1ForCsv() { + return dosePCV1 != null ? String.valueOf(dosePCV1) : ""; + } + + public String getDatePCV1ForCsv() { + return formatDateForCsv(datePCV1); + } + + public String getBrandPCV1ForCsv() { + return brandPCV1 != null ? brandPCV1 : ""; + } + + public String getDosePCV2ForCsv() { + return dosePCV2 != null ? String.valueOf(dosePCV2) : ""; + } + + public String getDatePCV2ForCsv() { + return formatDateForCsv(datePCV2); + } + + public String getBrandPCV2ForCsv() { + return brandPCV2 != null ? brandPCV2 : ""; + } + + public String getDosePCV3ForCsv() { + return dosePCV3 != null ? String.valueOf(dosePCV3) : ""; + } + + public String getDatePCV3ForCsv() { + return formatDateForCsv(datePCV3); + } + + public String getBrandPCV3ForCsv() { + return brandPCV3 != null ? brandPCV3 : ""; + } + + public String getDosePCV4ForCsv() { + return dosePCV4 != null ? String.valueOf(dosePCV4) : ""; + } + + public String getDatePCV4ForCsv() { + return formatDateForCsv(datePCV4); + } + + public String getBrandPCV4ForCsv() { + return brandPCV4 != null ? brandPCV4 : ""; + } + + public String getPcvDosesForCsv() { + return pcvDoses != null ? String.valueOf(pcvDoses) : ""; + } + + public String getDosePPVForCsv() { + return dosePPV != null ? String.valueOf(dosePPV) : ""; + } + + public String getDatePPVForCsv() { + return formatDateForCsv(datePPV); + } + + public String getPpvDosesForCsv() { + return ppvDoses != null ? String.valueOf(ppvDoses) : ""; + } + + public String getAstMethodForCsv() { + return astMethod != null ? astMethod : ""; + } + + public String getMicSign_CTX_CFXForCsv() { + return micSign_CTX_CFX != null ? micSign_CTX_CFX : ""; + } + + public String getMicValueAST_CTX_CFXForCsv() { + return micValueAST_CTX_CFX != null ? String.valueOf(micValueAST_CTX_CFX) : ""; + } + + public String getSir_CTX_CFXForCsv() { + return sir_CTX_CFX != null ? sir_CTX_CFX : ""; + } + + public String getMicSign_ERYForCsv() { + return micSign_ERY != null ? micSign_ERY : ""; + } + + public String getMicValueAST_ERYForCsv() { + return micValueAST_ERY != null ? String.valueOf(micValueAST_ERY) : ""; + } + + public String getSir_ERYForCsv() { + return sir_ERY != null ? sir_ERY : ""; + } + + public String getMicSign_PENForCsv() { + return micSign_PEN != null ? micSign_PEN : ""; + } + + public String getMicValueAST_PENForCsv() { + return micValueAST_PEN != null ? String.valueOf(micValueAST_PEN) : ""; + } + + public String getSir_PENForCsv() { + return sir_PEN != null ? sir_PEN : ""; + } + public void calculateAge() { if (symptomOnsetDate == null || yearOfBirth == null || monthOfBirth == null || dayOfBirth == null) { return; diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportFacade.java b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportFacade.java index 6ea289db4ce..c9ec4520a78 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportFacade.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportFacade.java @@ -23,4 +23,6 @@ public interface EpipulseDiseaseExportFacade { public void startPertussisExport(String uuid); public void startMeaslesExport(String uuid); + + public void startIpiExport(String uuid); } diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseLaboratoryMapper.java b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseLaboratoryMapper.java index e1b9145213a..37361afb5e2 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseLaboratoryMapper.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseLaboratoryMapper.java @@ -324,4 +324,354 @@ public static Boolean deriveClinicalCriteriaStatus(YesNoUnknown clinicalConfirma } return clinicalConfirmation == YesNoUnknown.YES; } + + // ==================== IPI-Specific Mappers ==================== + + /** + * Maps SORMAS SampleMaterial enum to EpiPulse IPI specimen type codes. + *

+ * EpiPulse Reference Values for IPI: + * - BLOOD = Blood + * - CSF = Cerebrospinal fluid + * - OTH = Other + * - PLEURAL = Pleural fluid + * - SER = Serum + * - SYNOVIAL = Synovial fluid (joint fluid) + * - THROAT = Throat swab + * - NPSWAB = Nasopharyngeal swab + * + * @param sampleMaterial + * SORMAS sample material enum + * @return EpiPulse IPI specimen type code, or null if not mappable + */ + public static String mapSampleMaterialToIpiSpecimenCode(SampleMaterial sampleMaterial) { + if (sampleMaterial == null) { + return null; + } + + switch (sampleMaterial) { + case BLOOD: + return "BLOOD"; + case SERA: + return "SER"; // Serum + case CEREBROSPINAL_FLUID: + return "CSF"; + case PLEURAL_FLUID: + return "PLEURAL"; + case SYNOVIAL_FLUID: + return "SYNOVIAL"; + case THROAT_SWAB: + return "THROAT"; + case NP_SWAB: + return "NPSWAB"; + case OTHER: + return "OTH"; + default: + return null; + } + } + + /** + * Maps SORMAS Symptoms to EpiPulse IPI clinical presentation codes. + *

+ * EpiPulse Reference Values for IPI: + * - MENING = Meningitis + * - SEPT = Septicaemia + * - PNEUM = Pneumonia + * - OME = Otitis media + * - PERITON = Peritonitis + * - ARTH = Arthritis + * - OTH = Other + * - ASYMP = Asymptomatic + * - NONE = No clinical presentation recorded + * + * @param meningitis + * Meningitis symptom state (IPI-specific) + * @param septicaemia + * Septicaemia symptom state (IPI-specific) + * @param pneumonia + * Pneumonia symptom state + * @param otitisMedia + * Otitis media symptom state + * @param peritonitis + * Peritonitis symptom state + * @param arthritis + * Arthritis symptom state + * @param otherClinical + * Other clinical presentation symptom state + * @param asymptomatic + * Asymptomatic symptom state + * @return List of EpiPulse clinical presentation codes (empty list returns "NONE" in CSV) + */ + public static List mapSymptomsToClinicalPresentation( + SymptomState meningitis, + SymptomState septicaemia, + SymptomState pneumonia, + SymptomState otitisMedia, + SymptomState peritonitis, + SymptomState arthritis, + SymptomState otherClinical, + SymptomState asymptomatic) { + + List presentations = new ArrayList<>(); + + // Priority: IPI-defining symptoms first + if (meningitis == SymptomState.YES) { + presentations.add("MENING"); + } + if (septicaemia == SymptomState.YES) { + presentations.add("SEPT"); + } + if (pneumonia == SymptomState.YES) { + presentations.add("PNEUM"); + } + if (otitisMedia == SymptomState.YES) { + presentations.add("OME"); + } + if (peritonitis == SymptomState.YES) { + presentations.add("PERITON"); + } + if (arthritis == SymptomState.YES) { + presentations.add("ARTH"); + } + if (otherClinical == SymptomState.YES) { + presentations.add("OTH"); + } + if (asymptomatic == SymptomState.YES) { + presentations.add("ASYMP"); + } + + // If no presentations found, empty list will result in "NONE" in CSV + return presentations; + } + + /** + * Maps SORMAS drug susceptibility test result to EpiPulse antibiotic resistance codes. + *

+ * EpiPulse Reference Values: + * - RESIST = Resistant + * - SENS = Sensitive + * - INTER = Intermediate resistance + * - NOTEST = Not tested + * + * @param testResult + * SORMAS PathogenTestResultType from drug susceptibility test + * @return EpiPulse antibiotic resistance code + */ + public static String mapDrugSusceptibilityToEpipulseCode(PathogenTestResultType testResult) { + if (testResult == null) { + return null; + } + + switch (testResult) { + case POSITIVE: + return "RESIST"; // Positive drug susceptibility = Resistant + case NEGATIVE: + return "SENS"; // Negative drug susceptibility = Sensitive + case INDETERMINATE: + return "INTER"; // Intermediate resistance + case PENDING: + case NOT_DONE: + return "NOTEST"; + default: + return null; + } + } + + /** + * Validates and normalizes pneumococcal serotype string for EpiPulse export. + *

+ * Pneumococcal serotypes include 90+ types: 1, 2, 3, 4, 5, 6A, 6B, 7F, 8, 9N, 9V, 10A, etc. + * Accepts formats like "6A", "19F", "23F", "SEROTYPE 6A", "TYPE 19F", etc. + * + * @param serotypeText + * SORMAS serotype string (from PathogenTest typingId or serotype field) + * @return Normalized EpiPulse serotype code (e.g., "6A", "19F"), or null if invalid + */ + public static String normalizeSerotypeForEpipulse(String serotypeText) { + if (serotypeText == null || serotypeText.trim().isEmpty()) { + return null; + } + + String normalized = serotypeText.trim().toUpperCase(); + + // Remove common prefixes: "SEROTYPE ", "TYPE ", "S.", "PNEUMOCOCCAL ", etc. + normalized = normalized.replaceAll("^(SEROTYPE|TYPE|S\\.|PNEUMOCOCCAL|STREPTOCOCCUS PNEUMONIAE)\\s*", ""); + + // Validate format: digit(s) optionally followed by letter(s) + // Examples: "1", "6A", "6B", "19F", "23F", "15A", "33F" + if (normalized.matches("^\\d{1,2}[A-Z]{0,2}$")) { + return normalized; + } + + return null; // Invalid format + } + + /** + * Maps SORMAS Symptoms to EpiPulse ClinicalCriteria codes for PNEU. + *

+ * EpiPulse Reference Values for PNEU ClinicalCriteria: + * - BACTERPNEUMO = Bacteraemic pneumonia + * - MENI = Meningitis/Meningeal/Meningoencephalitic + * - MENISEPTI = Meningitis and septicaemia + * - OTH = Other + * - SEPTI = Septicaemia + * + * @param meningitis Meningitis symptom state + * @param septicaemia Septicaemia symptom state + * @param pneumonia Pneumonia symptom state (clinical or radiologic) + * @return EpiPulse clinical criteria code, or null if no criteria met + */ + public static String mapSymptomsToClinicalCriteria( + SymptomState meningitis, + SymptomState septicaemia, + SymptomState pneumonia) { + + boolean hasMeningitis = meningitis == SymptomState.YES; + boolean hasSepticaemia = septicaemia == SymptomState.YES; + boolean hasPneumonia = pneumonia == SymptomState.YES; + + // Priority order based on severity/specificity + if (hasMeningitis && hasSepticaemia) { + return "MENISEPTI"; // Both present + } else if (hasMeningitis) { + return "MENI"; // Meningitis only + } else if (hasSepticaemia) { + return "SEPTI"; // Septicaemia only + } else if (hasPneumonia) { + return "BACTERPNEUMO"; // Bacteraemic pneumonia + } + + return null; // No specific clinical criteria met + } + + /** + * Maps SORMAS Vaccine enum to EpiPulse vaccine codes for PNEU. + *

+ * EpiPulse Reference Values for PNEU Vaccine: + * - PCV7 = Pneumococcal conjugate vaccine 7 + * - PCV10 = Pneumococcal conjugate vaccine 10 + * - PCV13 = Pneumococcal conjugate vaccine 13 + * - PCV15 = Pneumococcal conjugate vaccine 15 + * - PCV20 = Pneumococcal conjugate vaccine 20 + * - PCV3 = Pneumococcal conjugate vaccine - third dose + * - PPV23 = Pneumococcal polysaccharide vaccine + * + * @param vaccineName SORMAS vaccine name/type + * @return EpiPulse vaccine code, or null if not pneumococcal vaccine + */ + public static String mapVaccineToEpipulseCode(String vaccineName) { + if (vaccineName == null || vaccineName.trim().isEmpty()) { + return null; + } + + String normalized = vaccineName.trim().toUpperCase(); + + // Map PCV vaccines + if (normalized.contains("PCV") || normalized.contains("CONJUGATE")) { + if (normalized.contains("20")) { + return "PCV20"; + } else if (normalized.contains("15")) { + return "PCV15"; + } else if (normalized.contains("13")) { + return "PCV13"; + } else if (normalized.contains("10")) { + return "PCV10"; + } else if (normalized.contains("7")) { + return "PCV7"; + } else if (normalized.contains("3")) { + return "PCV3"; + } + // Default PCV if no specific number + return "PCV13"; // Most common + } + + // Map PPV vaccine + if (normalized.contains("PPV") || normalized.contains("POLYSACCHARIDE") || normalized.contains("23")) { + return "PPV23"; + } + + // Check for general pneumococcal terms + if (normalized.contains("PNEUMO")) { + return "PCV13"; // Default to most common PCV + } + + return null; // Not a pneumococcal vaccine + } + + /** + * Maps DrugSusceptibilityType enum to EpiPulse SIR codes. + *

+ * EpiPulse Reference Values for SIR (Susceptible/Intermediate/Resistant): + * - S = Susceptible + * - I = Intermediate + * - R = Resistant + * + * @param susceptibility SORMAS drug susceptibility enum + * @return EpiPulse SIR code (S/I/R), or null if not tested + */ + public static String mapDrugSusceptibilityToSIR(String susceptibility) { + if (susceptibility == null || susceptibility.trim().isEmpty()) { + return null; + } + + String normalized = susceptibility.trim().toUpperCase(); + + if (normalized.equals("SUSCEPTIBLE") || normalized.equals("S")) { + return "S"; + } else if (normalized.equals("INTERMEDIATE") || normalized.equals("I")) { + return "I"; + } else if (normalized.equals("RESISTANT") || normalized.equals("R")) { + return "R"; + } + + return null; // Unknown susceptibility + } + + /** + * Maps SORMAS PathogenTestType to EpiPulse PathogenDetectionMethod codes for PNEU. + *

+ * EpiPulse Reference Values for PNEU PathogenDetectionMethod: + * - COAGG = Coagglutination + * - GDIFF = Gel diffusion + * - MPCR = Multiplex PCR + * - OTH = Other + * - PTEST = Pneumotest + * - QUE = Quellung + * - SLAGG = Slide agglutination + * + * @param testType SORMAS pathogen test type + * @return EpiPulse detection method code, or null if not mappable + */ + public static String mapPathogenTestTypeToDetectionMethod(String testType) { + if (testType == null || testType.trim().isEmpty()) { + return null; + } + + String normalized = testType.trim().toUpperCase(); + + // Map specific test types + if (normalized.contains("PCR") || normalized.contains("RT-PCR") || normalized.contains("RTPCR")) { + if (normalized.contains("MULTIPLEX")) { + return "MPCR"; // Multiplex PCR + } + return "MPCR"; // Default PCR to multiplex + } else if (normalized.contains("QUELLUNG")) { + return "QUE"; + } else if (normalized.contains("COAGG") || normalized.contains("CO-AGGLUTINATION")) { + return "COAGG"; + } else if (normalized.contains("SLIDE") && normalized.contains("AGGLUT")) { + return "SLAGG"; + } else if (normalized.contains("GEL") && normalized.contains("DIFF")) { + return "GDIFF"; + } else if (normalized.contains("PNEUMOTEST")) { + return "PTEST"; + } else if (normalized.contains("CULTURE")) { + return "QUE"; // Culture often followed by Quellung + } else if (normalized.contains("SEROGROUPING") || normalized.contains("SEROTYPING")) { + return "QUE"; // Serotyping typically uses Quellung + } + + return "OTH"; // Other methods + } } diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseSubjectCode.java b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseSubjectCode.java index 6f3af3bcedc..6e948475e72 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseSubjectCode.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseSubjectCode.java @@ -24,7 +24,8 @@ public enum EpipulseSubjectCode { PERT(true, Disease.PERTUSSIS, false), - MEAS(true, Disease.MEASLES, false); + MEAS(true, Disease.MEASLES, false), + PNEU(true, Disease.INVASIVE_PNEUMOCOCCAL_INFECTION, false); // Invasive Pneumococcal Infection private final boolean diseaseModel; private final Disease disease; diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/referencevalue/EpipulseDiseaseRef.java b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/referencevalue/EpipulseDiseaseRef.java index 636d0f67153..8db3db3bb10 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/referencevalue/EpipulseDiseaseRef.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/referencevalue/EpipulseDiseaseRef.java @@ -21,7 +21,8 @@ public enum EpipulseDiseaseRef { PERT(EpipulseSubjectCode.PERT), - MEAS(EpipulseSubjectCode.MEAS); + MEAS(EpipulseSubjectCode.MEAS), + PNEU(EpipulseSubjectCode.PNEU); // Invasive Pneumococcal Infection private final EpipulseSubjectCode[] subjectCodes; diff --git a/sormas-api/src/main/resources/enum.properties b/sormas-api/src/main/resources/enum.properties index 069789a8a1c..ea195b8a149 100644 --- a/sormas-api/src/main/resources/enum.properties +++ b/sormas-api/src/main/resources/enum.properties @@ -1002,7 +1002,7 @@ LivingStatus.REGISTERED_AT_A_RESIDENCE = Registered as living at a residence in MapCaseDisplayMode.CASE_ADDRESS = ... by home address MapCaseDisplayMode.FACILITY = ... by facility MapCaseDisplayMode.FACILITY_OR_CASE_ADDRESS = ... by facility or home address - + MapCaseClassificationOption.ALL_CASES = Show all cases MapCaseClassificationOption.CONFIRMED_CASES_ONLY = Show confirmed cases only @@ -1010,7 +1010,7 @@ MapPeriodType.DAILY = Daily MapPeriodType.WEEKLY = Weekly MapPeriodType.MONTHLY = Monthly MapPeriodType.YEARLY = Yearly - + MeansOfTransport.LOCAL_PUBLIC_TRANSPORT=Local public transport MeansOfTransport.BUS=Bus @@ -2861,7 +2861,9 @@ EpipulseExportStatus.CANCELLED=Cancelled # EpipulseSubjectCode EpipulseSubjectCode.PERT = Pertussis EpipulseSubjectCode.MEAS = Measles +EpipulseSubjectCode.PNEU = Invasive Pneumococcal Infection # EpipulseDiseaseRef EpipulseDiseaseRef.PERT = Pertussis -EpipulseDiseaseRef.MEAS = Measles \ No newline at end of file +EpipulseDiseaseRef.MEAS = Measles +EpipulseDiseaseRef.PNEU = Invasive Pneumococcal Infection diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportFacadeEjb.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportFacadeEjb.java index 1ef257bae55..34fd5a30724 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportFacadeEjb.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportFacadeEjb.java @@ -20,6 +20,7 @@ import javax.ejb.Stateless; import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportFacade; +import de.symeda.sormas.backend.epipulse.strategy.IpiCsvExportStrategy; import de.symeda.sormas.backend.epipulse.strategy.MeaslesCsvExportStrategy; import de.symeda.sormas.backend.epipulse.strategy.PertussisCsvExportStrategy; @@ -35,6 +36,9 @@ public class EpipulseDiseaseExportFacadeEjb implements EpipulseDiseaseExportFaca @EJB private MeaslesCsvExportStrategy measlesStrategy; + @EJB + private IpiCsvExportStrategy ipiStrategy; + @EJB private EpipulseDiseaseExportService diseaseExportService; @@ -46,6 +50,10 @@ public void startMeaslesExport(String uuid) { orchestrator.orchestrateExport(uuid, diseaseExportService::exportMeaslesCaseBased, measlesStrategy); } + public void startIpiExport(String uuid) { + orchestrator.orchestrateExport(uuid, diseaseExportService::exportIpiCaseBased, ipiStrategy); + } + @LocalBean @Stateless public static class EpipulseDiseaseExportFacadeEjbLocal extends EpipulseDiseaseExportFacadeEjb { diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportService.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportService.java index 4964744023e..10f7788cc36 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportService.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportService.java @@ -36,6 +36,7 @@ import de.symeda.sormas.api.epipulse.EpipulseExportDto; import de.symeda.sormas.api.epipulse.EpipulseExportStatus; import de.symeda.sormas.api.utils.DateHelper; +import de.symeda.sormas.backend.epipulse.strategy.IpiExportStrategy; import de.symeda.sormas.backend.epipulse.strategy.MeaslesExportStrategy; import de.symeda.sormas.backend.epipulse.strategy.PertussisExportStrategy; import de.symeda.sormas.backend.util.ModelConstants; @@ -57,6 +58,9 @@ public class EpipulseDiseaseExportService { @EJB private MeaslesExportStrategy measlesExportStrategy; + @EJB + private IpiExportStrategy ipiExportStrategy; + public EpipulseDiseaseExportResult exportPertussisCaseBased(EpipulseExportDto exportDto, String serverCountryLocale, String serverCountryName) throws SQLException, IllegalStateException, IllegalArgumentException { return pertussisExportStrategy.export(exportDto, serverCountryLocale, serverCountryName); @@ -67,6 +71,11 @@ public EpipulseDiseaseExportResult exportMeaslesCaseBased(EpipulseExportDto expo return measlesExportStrategy.export(exportDto, serverCountryLocale, serverCountryName); } + public EpipulseDiseaseExportResult exportIpiCaseBased(EpipulseExportDto exportDto, String serverCountryLocale, String serverCountryName) + throws SQLException, IllegalStateException, IllegalArgumentException { + return ipiExportStrategy.export(exportDto, serverCountryLocale, serverCountryName); + } + @TransactionAttribute(TransactionAttributeType.REQUIRES_NEW) public boolean tryClaimExportForProcessing(String exportUuid) { try { diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseExportTimerEjb.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseExportTimerEjb.java index 54cf245b560..aec73c45ce5 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseExportTimerEjb.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseExportTimerEjb.java @@ -70,6 +70,9 @@ public void exportDiseaseTimeout(Timer timer) { case MEAS: diseaseExportFacadeEjb.startMeaslesExport(uuid); break; + case PNEU: // Invasive Pneumococcal Infection + diseaseExportFacadeEjb.startIpiExport(uuid); + break; default: logger.warn("No export for subject code: {}", subjectCodeStr); break; diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseSqlCteBuilder.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseSqlCteBuilder.java index c426b278c96..25ea24bc373 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseSqlCteBuilder.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseSqlCteBuilder.java @@ -66,12 +66,12 @@ public String buildConfigDataCte() { /** * Builds the filtered_cases CTE that selects all cases matching the export criteria. * - * @param includeMeaslesFields - * if true, includes additional fields required for Measles export + * @param includeEpidataFields + * if true, includes additional fields required for diseases with epidemiology data * (epidata_id, investigateddate, clinicalconfirmation) * @return the filtered_cases CTE SQL fragment */ - public String buildFilteredCasesCte(boolean includeMeaslesFields) { + public String buildFilteredCasesCte(boolean includeEpidataFields) { StringBuilder cte = new StringBuilder(); //@formatter:off cte.append(" filtered_cases AS (SELECT c.id,") @@ -87,7 +87,7 @@ public String buildFilteredCasesCte(boolean includeMeaslesFields) { .append(" c.responsibledistrict_id,") .append(" c.responsiblecommunity_id"); - if (includeMeaslesFields) { + if (includeEpidataFields) { cte.append(",") .append(" c.epidata_id,") .append(" c.investigateddate,") diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/IpiCsvExportStrategy.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/IpiCsvExportStrategy.java new file mode 100644 index 00000000000..97c74fb9877 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/IpiCsvExportStrategy.java @@ -0,0 +1,178 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2024 Helmholtz-Zentrum für Infektionsforschung GmbH (HZI) + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.backend.epipulse.strategy; + +import java.util.List; + +import javax.ejb.LocalBean; +import javax.ejb.Stateless; + +import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportEntryDto; +import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportResult; + +/** + * CSV export strategy for PNEU (Invasive Pneumococcal Infection) disease. + * Follows EpiPulse metadata specification exactly with 49 fixed columns (no repeatable fields). + *

+ * Column structure: + * - 13 common demographic fields + * - 5 clinical/diagnostic fields (including DateOfDiagnosis, ClinicalCriteria) + * - 2 laboratory fields (PathogenDetectionMethod, Serotype) + * - 23 vaccination fields (detailed PCV1-4 and PPV tracking) + * - 9 AST fields (antimicrobial susceptibility for CTX/CFX, ERY, PEN) + *

+ * Total: 49 fixed columns + */ +@Stateless +@LocalBean +public class IpiCsvExportStrategy implements CsvExportStrategy { + + @Override + public List buildColumnNames(EpipulseDiseaseExportResult exportResult) { + // PNEU has 49 fixed columns - no repeatable fields according to metadata + return List.of( + // Common demographic fields (13) + "Disease", + "ReportingCountry", + "Status", + "SubjectCode", + "NationalRecordId", + "DataSource", + "DateUsedForStatistics", + "NRLData", + "Age", + "AgeMonth", + "Gender", + "PlaceOfResidence", + "PlaceOfNotification", + // Clinical/Diagnostic fields (5) + "CaseClassification", + "DateOfDiagnosis", + "DateOfNotification", + "Outcome", + "ClinicalCriteria", + // Laboratory fields (2) + "PathogenDetectionMethod", + "Serotype", + // Vaccination fields (23) + "DateOfLastVaccination", + "Vaccine", + "VaccinationStatus", + "DosePCV1", + "DatePCV1", + "BrandPCV1", + "DosePCV2", + "DatePCV2", + "BrandPCV2", + "DosePCV3", + "DatePCV3", + "BrandPCV3", + "DosePCV4", + "DatePCV4", + "BrandPCV4", + "PCVDoses", + "DosePPV", + "DatePPV", + "PPVDoses", + // AST fields (9) + "ASTMethod", + "MICSign_CTX_CFX", + "MICValueAST_CTX_CFX", + "SIR_CTX_CFX", + "MICSign_ERY", + "MICValueAST_ERY", + "SIR_ERY", + "MICSign_PEN", + "MICValueAST_PEN", + "SIR_PEN"); + } + + @Override + public int writeEntryRow(EpipulseDiseaseExportEntryDto dto, String[] exportLine, EpipulseDiseaseExportResult exportResult) { + int index = -1; + + // Common demographic fields (13) + exportLine[++index] = dto.getDiseaseForCsv(); + exportLine[++index] = dto.getReportingCountryForCsv(); + exportLine[++index] = dto.getStatusForCsv(); + exportLine[++index] = dto.getSubjectCodeForCsv(); + exportLine[++index] = dto.getNationalRecordIdForCsv(); + exportLine[++index] = dto.getDataSourceForCsv(); + exportLine[++index] = dto.getDateUsedForStatisticsCsv(); + exportLine[++index] = dto.getNrlDataForCsv(); + exportLine[++index] = dto.getAgeForCsv(); + exportLine[++index] = dto.getAgeMonthForCsv(); + exportLine[++index] = dto.getGenderForCsv(); + exportLine[++index] = dto.getPlaceOfResidenceForCsv(); + exportLine[++index] = dto.getPlaceOfNotificationForCsv(); + + // Clinical/Diagnostic fields (5) + exportLine[++index] = dto.getCaseClassificationForCsv(); + exportLine[++index] = dto.getDateOfDiagnosisForCsv(); + exportLine[++index] = dto.getDateOfNotificationForCsv(); + exportLine[++index] = dto.getOutcomeForCsv(); + exportLine[++index] = dto.getClinicalCriteriaForCsv(); + + // Laboratory fields (2) + exportLine[++index] = dto.getPathogenDetectionMethodForCsv(); + exportLine[++index] = dto.getSerotypeForCsv(); + + // Vaccination fields (23) + exportLine[++index] = dto.getDateOfLastVaccinationForCsv(); + exportLine[++index] = dto.getVaccineForCsv(); + exportLine[++index] = dto.getVaccinationStatusForCsv(); + + // PCV doses 1-4 + exportLine[++index] = dto.getDosePCV1ForCsv(); + exportLine[++index] = dto.getDatePCV1ForCsv(); + exportLine[++index] = dto.getBrandPCV1ForCsv(); + exportLine[++index] = dto.getDosePCV2ForCsv(); + exportLine[++index] = dto.getDatePCV2ForCsv(); + exportLine[++index] = dto.getBrandPCV2ForCsv(); + exportLine[++index] = dto.getDosePCV3ForCsv(); + exportLine[++index] = dto.getDatePCV3ForCsv(); + exportLine[++index] = dto.getBrandPCV3ForCsv(); + exportLine[++index] = dto.getDosePCV4ForCsv(); + exportLine[++index] = dto.getDatePCV4ForCsv(); + exportLine[++index] = dto.getBrandPCV4ForCsv(); + exportLine[++index] = dto.getPcvDosesForCsv(); + + // PPV doses + exportLine[++index] = dto.getDosePPVForCsv(); + exportLine[++index] = dto.getDatePPVForCsv(); + exportLine[++index] = dto.getPpvDosesForCsv(); + + // AST fields (9) + exportLine[++index] = dto.getAstMethodForCsv(); + + // CTX/CFX (Cefotaxime/Ceftriaxone) + exportLine[++index] = dto.getMicSign_CTX_CFXForCsv(); + exportLine[++index] = dto.getMicValueAST_CTX_CFXForCsv(); + exportLine[++index] = dto.getSir_CTX_CFXForCsv(); + + // ERY (Erythromycin) + exportLine[++index] = dto.getMicSign_ERYForCsv(); + exportLine[++index] = dto.getMicValueAST_ERYForCsv(); + exportLine[++index] = dto.getSir_ERYForCsv(); + + // PEN (Penicillin) + exportLine[++index] = dto.getMicSign_PENForCsv(); + exportLine[++index] = dto.getMicValueAST_PENForCsv(); + exportLine[++index] = dto.getSir_PENForCsv(); + + return index; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/IpiExportStrategy.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/IpiExportStrategy.java new file mode 100644 index 00000000000..1d82984c6e5 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/IpiExportStrategy.java @@ -0,0 +1,408 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2024 Helmholtz-Zentrum für Infektionsforschung GmbH (HZI) + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.backend.epipulse.strategy; + +import java.util.Date; +import java.util.List; + +import javax.ejb.LocalBean; +import javax.ejb.Stateless; + +import org.apache.commons.lang3.StringUtils; + +import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportEntryDto; +import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportResult; +import de.symeda.sormas.api.epipulse.EpipulseLaboratoryMapper; +import de.symeda.sormas.api.symptoms.SymptomState; + +/** + * Export strategy for PNEU (Invasive Pneumococcal Infection) disease exports. + * PNEU follows the EpiPulse metadata specification exactly with 49 fixed columns (no repeatable fields). + *

+ * Metadata specification: + * - 13 common demographic fields + * - 5 clinical/diagnostic fields (including DateOfDiagnosis, ClinicalCriteria) + * - 2 laboratory fields (PathogenDetectionMethod, Serotype) + * - 23 vaccination fields (detailed PCV1-4 and PPV tracking) + * - 9 AST fields (antimicrobial susceptibility for CTX/CFX, ERY, PEN) + */ +@Stateless +@LocalBean +public class IpiExportStrategy extends AbstractEpipulseDiseaseExportStrategy { + + @Override + protected String buildDiseaseExportQuery() { + StringBuilder query = new StringBuilder(); + + // Common CTEs (no epidata fields for PNEU metadata) + query.append(sqlCteBuilder.buildVariablesCte()); + query.append(sqlCteBuilder.buildConfigDataCte()); + query.append(sqlCteBuilder.buildFilteredCasesCte(false)); // No epidata fields needed + query.append(sqlCteBuilder.buildPreviousHospitalizationsCte()); + query.append(sqlCteBuilder.buildSamplesCte()); + query.append(sqlCteBuilder.buildPathogenTestsCte()); + query.append(sqlCteBuilder.buildImmunizationsCte()); + query.append(sqlCteBuilder.buildVaccinationsCte()); + + // PNEU-specific CTEs (following metadata specification) + query.append(buildPneuSerotypingDataCte()); + query.append(buildPneuPathogenDetectionMethodCte()); + query.append(buildPneuClinicalCriteriaCte()); + query.append(buildPneuVaccinationDetailsCte()); + // Note: AST data not currently stored in SORMAS for PNEU - fields will be NULL + // query.append(buildPneuAstDataCte()); + + // Main SELECT clause with all 49 PNEU fields + query.append(buildPneuSelectClause()); + + return query.toString(); + } + + /** + * Maps all 21 PNEU-specific fields from SQL result row to DTO + * (Common fields already mapped by parent class - 28 fields) + */ + @Override + protected void mapDiseaseSpecificFields(EpipulseDiseaseExportEntryDto dto, Object[] row, int startIndex) { + int index = startIndex; + + // Field 29: NRLData (Boolean) - currently not tracked + Boolean nrlData = (Boolean) row[++index]; + dto.setNrlData(nrlData); + + // Field 30: DateOfDiagnosis (Date) + Date dateOfDiagnosis = (Date) row[++index]; + dto.setDateOfDiagnosis(dateOfDiagnosis); + + // Fields 31-33: Clinical criteria symptoms (for mapping to ClinicalCriteria) + String meningitisRaw = (String) row[++index]; + String septicaemiaRaw = (String) row[++index]; + String pneumoniaRaw = (String) row[++index]; + + SymptomState meningitis = parseSymptomState(meningitisRaw); + SymptomState septicaemia = parseSymptomState(septicaemiaRaw); + SymptomState pneumonia = parseSymptomState(pneumoniaRaw); + + // Map symptoms to ClinicalCriteria codes (BACTERPNEUMO, MENI, MENISEPTI, SEPTI) + String clinicalCriteria = EpipulseLaboratoryMapper.mapSymptomsToClinicalCriteria(meningitis, septicaemia, pneumonia); + dto.setClinicalCriteria(clinicalCriteria); + + // Field 34: PathogenDetectionMethod (String - test type) + String detectionMethodRaw = (String) row[++index]; + if (!StringUtils.isBlank(detectionMethodRaw)) { + String detectionMethod = EpipulseLaboratoryMapper.mapPathogenTestTypeToDetectionMethod(detectionMethodRaw); + dto.setPathogenDetectionMethod(detectionMethod); + } + + // Field 35: Serotype (String) + String serotypeRaw = (String) row[++index]; + if (!StringUtils.isBlank(serotypeRaw)) { + dto.setSerotype(EpipulseLaboratoryMapper.normalizeSerotypeForEpipulse(serotypeRaw)); + } + + // Fields 36-48: Vaccination details - PCV doses 1-4 + Date datePCV1 = (Date) row[++index]; + dto.setDatePCV1(datePCV1); + dto.setDosePCV1(datePCV1 != null); + + String brandPCV1Raw = (String) row[++index]; + if (!StringUtils.isBlank(brandPCV1Raw)) { + dto.setBrandPCV1(EpipulseLaboratoryMapper.mapVaccineToEpipulseCode(brandPCV1Raw)); + } + + Date datePCV2 = (Date) row[++index]; + dto.setDatePCV2(datePCV2); + dto.setDosePCV2(datePCV2 != null); + + String brandPCV2Raw = (String) row[++index]; + if (!StringUtils.isBlank(brandPCV2Raw)) { + dto.setBrandPCV2(EpipulseLaboratoryMapper.mapVaccineToEpipulseCode(brandPCV2Raw)); + } + + Date datePCV3 = (Date) row[++index]; + dto.setDatePCV3(datePCV3); + dto.setDosePCV3(datePCV3 != null); + + String brandPCV3Raw = (String) row[++index]; + if (!StringUtils.isBlank(brandPCV3Raw)) { + dto.setBrandPCV3(EpipulseLaboratoryMapper.mapVaccineToEpipulseCode(brandPCV3Raw)); + } + + Date datePCV4 = (Date) row[++index]; + dto.setDatePCV4(datePCV4); + dto.setDosePCV4(datePCV4 != null); + + String brandPCV4Raw = (String) row[++index]; + if (!StringUtils.isBlank(brandPCV4Raw)) { + dto.setBrandPCV4(EpipulseLaboratoryMapper.mapVaccineToEpipulseCode(brandPCV4Raw)); + } + + // PCV dose count + Number pcvDosesNum = (Number) row[++index]; + dto.setPcvDoses(pcvDosesNum != null ? pcvDosesNum.intValue() : null); + + // Fields 49-50: Vaccination details - PPV doses + Date datePPV = (Date) row[++index]; + dto.setDatePPV(datePPV); + dto.setDosePPV(datePPV != null); + + Number ppvDosesNum = (Number) row[++index]; + dto.setPpvDoses(ppvDosesNum != null ? ppvDosesNum.intValue() : null); + + // Fields 51-60: AST (Antimicrobial Susceptibility Testing) data + // AST Method (not stored in SORMAS currently) + String astMethod = (String) row[++index]; + dto.setAstMethod(astMethod); + + // CTX/CFX (Ceftriaxone/Cefotaxime) + String micSignCTX = (String) row[++index]; + dto.setMicSign_CTX_CFX(micSignCTX); + + Double micValueCTX = (Double) row[++index]; + dto.setMicValueAST_CTX_CFX(micValueCTX); + + String sirCTXRaw = (String) row[++index]; + if (!StringUtils.isBlank(sirCTXRaw)) { + dto.setSir_CTX_CFX(EpipulseLaboratoryMapper.mapDrugSusceptibilityToSIR(sirCTXRaw)); + } + + // ERY (Erythromycin) + String micSignERY = (String) row[++index]; + dto.setMicSign_ERY(micSignERY); + + Double micValueERY = (Double) row[++index]; + dto.setMicValueAST_ERY(micValueERY); + + String sirERYRaw = (String) row[++index]; + if (!StringUtils.isBlank(sirERYRaw)) { + dto.setSir_ERY(EpipulseLaboratoryMapper.mapDrugSusceptibilityToSIR(sirERYRaw)); + } + + // PEN (Penicillin) + String micSignPEN = (String) row[++index]; + dto.setMicSign_PEN(micSignPEN); + + Double micValuePEN = (Double) row[++index]; + dto.setMicValueAST_PEN(micValuePEN); + + String sirPENRaw = (String) row[++index]; + if (!StringUtils.isBlank(sirPENRaw)) { + dto.setSir_PEN(EpipulseLaboratoryMapper.mapDrugSusceptibilityToSIR(sirPENRaw)); + } + } + + @Override + protected void calculateDiseaseSpecificMaxCounts(List entries, EpipulseDiseaseExportResult result) { + // PNEU has no repeatable fields according to metadata specification + // All 49 columns are fixed - no dynamic column generation needed + // Common repeatable fields (pathogenTests, immunizations) are handled by parent class + } + + // Helper methods for PNEU-specific CTEs (following metadata specification) + + /** + * CTE for serotype data from pathogen tests + */ + private String buildPneuSerotypingDataCte() { + //@formatter:off + return ", pneu_serotyping AS (SELECT c.id as case_id," + + " (SELECT pt.typingid " + + " FROM samples s " + + " JOIN pathogentest pt ON pt.sample_id = s.id " + + " WHERE s.associatedcase_id = c.id " + + " AND s.deleted = false " + + " AND pt.testtype IN ('SEROGROUPING', 'GENOTYPING', 'WHOLE_GENOME_SEQUENCING') " + + " AND pt.typingid IS NOT NULL " + + " ORDER BY pt.testdatetime DESC " + + " LIMIT 1) as serotype " + + " FROM filtered_cases c), "; + //@formatter:on + } + + /** + * CTE for pathogen detection method from pathogen tests + */ + private String buildPneuPathogenDetectionMethodCte() { + //@formatter:off + return "pneu_detection_method AS (SELECT c.id as case_id," + + " (SELECT CAST(pt.testtype AS text) " + + " FROM samples s " + + " JOIN pathogentest pt ON pt.sample_id = s.id " + + " WHERE s.associatedcase_id = c.id " + + " AND s.deleted = false " + + " AND pt.testresultverified = true " + + " ORDER BY pt.testdatetime DESC " + + " LIMIT 1) as detection_method " + + " FROM filtered_cases c), "; + //@formatter:on + } + + /** + * CTE for clinical criteria from symptoms + */ + private String buildPneuClinicalCriteriaCte() { + //@formatter:off + return "pneu_clinical_criteria AS (SELECT c.id as case_id," + + " CAST(s.meningitis AS text) as meningitis," + + " CAST(s.septicaemia AS text) as septicaemia," + + " CAST(s.pneumoniaclinicalorradiologic AS text) as pneumonia " + + " FROM filtered_cases c " + + " JOIN symptoms s ON c.symptoms_id = s.id), "; + //@formatter:on + } + + /** + * CTE for detailed vaccination data (PCV1-4, PPV doses) + */ + private String buildPneuVaccinationDetailsCte() { + //@formatter:off + return "pneu_vaccination_details AS (" + + " SELECT c.id as case_id," + + " MAX(CASE WHEN pcv_row_num = 1 THEN v.vaccinationdate END) as date_pcv1," + + " MAX(CASE WHEN pcv_row_num = 1 THEN CAST(v.vaccinename AS text) END) as brand_pcv1," + + " MAX(CASE WHEN pcv_row_num = 2 THEN v.vaccinationdate END) as date_pcv2," + + " MAX(CASE WHEN pcv_row_num = 2 THEN CAST(v.vaccinename AS text) END) as brand_pcv2," + + " MAX(CASE WHEN pcv_row_num = 3 THEN v.vaccinationdate END) as date_pcv3," + + " MAX(CASE WHEN pcv_row_num = 3 THEN CAST(v.vaccinename AS text) END) as brand_pcv3," + + " MAX(CASE WHEN pcv_row_num = 4 THEN v.vaccinationdate END) as date_pcv4," + + " MAX(CASE WHEN pcv_row_num = 4 THEN CAST(v.vaccinename AS text) END) as brand_pcv4," + + " SUM(CASE WHEN is_pcv THEN 1 ELSE 0 END) as pcv_doses," + + " MAX(CASE WHEN ppv_row_num = 1 THEN v.vaccinationdate END) as date_ppv," + + " SUM(CASE WHEN is_ppv THEN 1 ELSE 0 END) as ppv_doses " + + " FROM filtered_cases c " + + " LEFT JOIN vaccination v ON v.immunization_id IN (SELECT i.id FROM immunization i WHERE i.person_id = c.person_id AND i.disease = 'INVASIVE_PNEUMOCOCCAL_INFECTION') " + + " LEFT JOIN LATERAL (" + + " SELECT CASE " + + " WHEN CAST(v.vaccinename AS text) LIKE '%PCV%' OR CAST(v.vaccinename AS text) LIKE '%CONJUGATE%' THEN true " + + " ELSE false " + + " END as is_pcv," + + " CASE " + + " WHEN CAST(v.vaccinename AS text) LIKE '%PPV%' OR CAST(v.vaccinename AS text) LIKE '%POLYSACCHARIDE%' THEN true " + + " ELSE false " + + " END as is_ppv," + + " ROW_NUMBER() OVER (PARTITION BY c.id, " + + " CASE WHEN CAST(v.vaccinename AS text) LIKE '%PCV%' THEN 1 ELSE 0 END " + + " ORDER BY v.vaccinationdate) as pcv_row_num," + + " ROW_NUMBER() OVER (PARTITION BY c.id, " + + " CASE WHEN CAST(v.vaccinename AS text) LIKE '%PPV%' THEN 1 ELSE 0 END " + + " ORDER BY v.vaccinationdate) as ppv_row_num " + + " ) vax_info ON true " + + " GROUP BY c.id) "; + //@formatter:on + } + + /** + * CTE for antimicrobial susceptibility testing (AST) data + * Maps to CTX/CFX (Ceftriaxone), ERY (Erythromycin), PEN (Penicillin) + */ + private String buildPneuAstDataCte() { + //@formatter:off + return "pneu_ast_data AS (" + + " SELECT c.id as case_id," + + " MAX(CASE WHEN antibiotic = 'CEFTRIAXONE' THEN mic_value END) as mic_ctx," + + " MAX(CASE WHEN antibiotic = 'CEFTRIAXONE' THEN CAST(susceptibility AS text) END) as sir_ctx," + + " MAX(CASE WHEN antibiotic = 'ERYTHROMYCIN' THEN mic_value END) as mic_ery," + + " MAX(CASE WHEN antibiotic = 'ERYTHROMYCIN' THEN CAST(susceptibility AS text) END) as sir_ery," + + " MAX(CASE WHEN antibiotic = 'PENICILLIN' THEN mic_value END) as mic_pen," + + " MAX(CASE WHEN antibiotic = 'PENICILLIN' THEN CAST(susceptibility AS text) END) as sir_pen " + + " FROM filtered_cases c " + + " LEFT JOIN samples s ON s.associatedcase_id = c.id AND s.deleted = false " + + " LEFT JOIN pathogentest pt ON pt.sample_id = s.id AND pt.testtype = 'ANTIBIOTIC_SUSCEPTIBILITY' " + + " LEFT JOIN LATERAL (" + + " SELECT 'CEFTRIAXONE' as antibiotic, pt.ceftriaxonemic as mic_value, pt.ceftriaxonesusceptibility as susceptibility " + + " UNION ALL " + + " SELECT 'ERYTHROMYCIN', pt.erythromycinmic, pt.erythromycinsusceptibility " + + " UNION ALL " + + " SELECT 'PENICILLIN', pt.penicillinmic, pt.penicillinsusceptibility " + + " ) ast_data ON true " + + " GROUP BY c.id) "; + //@formatter:on + } + + /** + * Builds SELECT clause with all 49 PNEU fields following metadata specification + */ + private String buildPneuSelectClause() { + StringBuilder select = new StringBuilder(); + //@formatter:off + // Use common SELECT fields (28 fields) and append PNEU-specific fields (21 fields) + select.append("SELECT ") + .append(sqlCteBuilder.buildCommonSelectFields()) + .append(",") + // PNEU-specific fields start here + // Demographics: NRLData (field 29) + .append(" NULL as nrl_data,") // Not tracked in SORMAS currently + // Clinical/Diagnostic: DateOfDiagnosis, ClinicalCriteria (fields 30-31) + .append(" c.reportdate as date_of_diagnosis,") // Using report date as diagnosis date + .append(" pneu_clin.meningitis,") + .append(" pneu_clin.septicaemia,") + .append(" pneu_clin.pneumonia,") + // Laboratory: PathogenDetectionMethod, Serotype (fields 32-33) + .append(" pneu_detect.detection_method,") + .append(" pneu_sero.serotype,") + // Vaccination summary: DateOfLastVaccination, Vaccine, VaccinationStatus (fields 34-36) + // These come from common immunizations/vaccinations CTEs + // Vaccination details: PCV doses 1-4 (fields 37-48) + .append(" pneu_vax.date_pcv1,") + .append(" pneu_vax.brand_pcv1,") + .append(" pneu_vax.date_pcv2,") + .append(" pneu_vax.brand_pcv2,") + .append(" pneu_vax.date_pcv3,") + .append(" pneu_vax.brand_pcv3,") + .append(" pneu_vax.date_pcv4,") + .append(" pneu_vax.brand_pcv4,") + .append(" pneu_vax.pcv_doses,") + // Vaccination details: PPV doses (fields 49-50) + .append(" pneu_vax.date_ppv,") + .append(" pneu_vax.ppv_doses,") + // AST fields (fields 51-60) - Not currently stored in SORMAS + .append(" NULL as ast_method,") + .append(" NULL as mic_sign_ctx,") + .append(" NULL as mic_ctx,") + .append(" NULL as sir_ctx,") + .append(" NULL as mic_sign_ery,") + .append(" NULL as mic_ery,") + .append(" NULL as sir_ery,") + .append(" NULL as mic_sign_pen,") + .append(" NULL as mic_pen,") + .append(" NULL as sir_pen "); + + // Use common FROM and JOINs, then append PNEU-specific joins + select.append(sqlCteBuilder.buildCommonFromAndJoins()) + .append(" LEFT JOIN pneu_serotyping pneu_sero ON pneu_sero.case_id = c.id") + .append(" LEFT JOIN pneu_detection_method pneu_detect ON pneu_detect.case_id = c.id") + .append(" LEFT JOIN pneu_clinical_criteria pneu_clin ON pneu_clin.case_id = c.id") + .append(" LEFT JOIN pneu_vaccination_details pneu_vax ON pneu_vax.case_id = c.id"); + // Note: pneu_ast_data CTE not used - AST fields returned as NULL + + select.append(" ORDER BY c.reportdate"); + //@formatter:on + + return select.toString(); + } + + private SymptomState parseSymptomState(String value) { + if (StringUtils.isBlank(value)) { + return null; + } + try { + return SymptomState.valueOf(value); + } catch (IllegalArgumentException e) { + logger.warn("Invalid SymptomState value '{}', treating as null", value); + return null; + } + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesCsvExportStrategy.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesCsvExportStrategy.java index 0e40e9674cc..a35fccb6401 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesCsvExportStrategy.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesCsvExportStrategy.java @@ -26,9 +26,9 @@ /** * CSV export strategy for Measles disease. - * Handles 41+ columns with 5 repeatable field types: - * - TypeOfSpecimenCollected (virus detection) - * - TypeOfSpecimenForSerologicalAnalysis (serology) + * Handles 36+ columns with 5 repeatable field types: + * - SpecimenVirDetect (virus detection specimen) + * - SpecimenSero (serology specimen) * - ClusterSetting * - ComplicationDiagnosis * - PlaceOfInfection @@ -39,6 +39,7 @@ public class MeaslesCsvExportStrategy implements CsvExportStrategy { @Override public List buildColumnNames(EpipulseDiseaseExportResult exportResult) { + // Follow exact metadata order for MEAS subject code List columnNames = new ArrayList<>( List.of( "Disease", @@ -53,31 +54,21 @@ public List buildColumnNames(EpipulseDiseaseExportResult exportResult) { "Gender", "CaseClassification", "DateOfOnset", + "DateOfInvestigation", "DateOfNotification", "Hospitalisation", "Outcome", - "PlaceOfNotification", - "PlaceOfResidence", - "DateOfSpecimen", - "DateOfLaboratoryResult")); + "CauseOfDeath", + "ClinicalCriteriaStatus")); - // Repeatable field: TypeOfSpecimenCollected (virus detection) - if (exportResult.getMaxSpecimenVirDetect() > 0) { - for (int i = 1; i <= exportResult.getMaxSpecimenVirDetect(); i++) { - columnNames.add("TypeOfSpecimenCollected"); - } - } - - columnNames.addAll(List.of("ResultOfVirusDetection", "Genotype")); - - // Repeatable field: TypeOfSpecimenForSerologicalAnalysis - if (exportResult.getMaxSpecimenSero() > 0) { - for (int i = 1; i <= exportResult.getMaxSpecimenSero(); i++) { - columnNames.add("TypeOfSpecimenForSerologicalAnalysis"); + // Repeatable field: ComplicationDiagnosis + if (exportResult.getMaxComplicationDiagnosis() > 0) { + for (int i = 1; i <= exportResult.getMaxComplicationDiagnosis(); i++) { + columnNames.add("ComplicationDiagnosis"); } } - columnNames.addAll(List.of("ResultIgG", "ResultIgM", "DateOfInvestigation", "ClusterRelated", "ClusterIdentification")); + columnNames.addAll(List.of("ClusterRelated", "ClusterId")); // Repeatable field: ClusterSetting if (exportResult.getMaxClusterSettings() > 0) { @@ -86,16 +77,7 @@ public List buildColumnNames(EpipulseDiseaseExportResult exportResult) { } } - columnNames.add("ImportedStatus"); - - // Repeatable field: ComplicationDiagnosis - if (exportResult.getMaxComplicationDiagnosis() > 0) { - for (int i = 1; i <= exportResult.getMaxComplicationDiagnosis(); i++) { - columnNames.add("ComplicationDiagnosis"); - } - } - - columnNames.add("ClinicalCriteriaStatus"); + columnNames.addAll(List.of("DateOfLastVaccination", "VaccinationStatus", "ImportedStatus")); // Repeatable field: PlaceOfInfection if (exportResult.getMaxPlaceOfInfection() > 0) { @@ -104,14 +86,25 @@ public List buildColumnNames(EpipulseDiseaseExportResult exportResult) { } } - columnNames.add("CauseOfDeath"); + columnNames.addAll(List.of("PlaceOfNotification", "PlaceOfResidence", "DateOfSpecimen", "DateOfLabResult")); - // Add vaccination columns - if (exportResult.getMaxImmunizations() > 0) { - columnNames.add("DateOfLastVaccination"); + // Repeatable field: SpecimenVirDetect (virus detection) + if (exportResult.getMaxSpecimenVirDetect() > 0) { + for (int i = 1; i <= exportResult.getMaxSpecimenVirDetect(); i++) { + columnNames.add("SpecimenVirDetect"); + } } - columnNames.add("VaccinationStatus"); + columnNames.addAll(List.of("ResultVirDetect", "Genotype")); + + // Repeatable field: SpecimenSero (specimen for serology) + if (exportResult.getMaxSpecimenSero() > 0) { + for (int i = 1; i <= exportResult.getMaxSpecimenSero(); i++) { + columnNames.add("SpecimenSero"); + } + } + + columnNames.addAll(List.of("ResultIgG", "ResultIgM")); return columnNames; } @@ -120,7 +113,7 @@ public List buildColumnNames(EpipulseDiseaseExportResult exportResult) { public int writeEntryRow(EpipulseDiseaseExportEntryDto dto, String[] exportLine, EpipulseDiseaseExportResult exportResult) { int index = -1; - // Write fixed columns + // Write fixed columns in metadata order exportLine[++index] = dto.getDiseaseForCsv(); exportLine[++index] = dto.getReportingCountryForCsv(); exportLine[++index] = dto.getStatusForCsv(); @@ -133,40 +126,21 @@ public int writeEntryRow(EpipulseDiseaseExportEntryDto dto, String[] exportLine, exportLine[++index] = dto.getGenderForCsv(); exportLine[++index] = dto.getCaseClassificationForCsv(); exportLine[++index] = dto.getDateOfOnsetForCsv(); + exportLine[++index] = dto.getDateOfInvestigationForCsv(); exportLine[++index] = dto.getDateOfNotificationForCsv(); exportLine[++index] = dto.getHospitalizationForCsv(); exportLine[++index] = dto.getOutcomeForCsv(); - exportLine[++index] = dto.getPlaceOfNotificationForCsv(); - exportLine[++index] = dto.getPlaceOfResidenceForCsv(); - - // Laboratory fields - exportLine[++index] = dto.getDateOfSpecimenForCsv(); - exportLine[++index] = dto.getDateOfLaboratoryResultForCsv(); - - // Repeatable: TypeOfSpecimenCollected (virus detection) - if (exportResult.getMaxSpecimenVirDetect() > 0) { - List specimenCollected = dto.getTypeOfSpecimenCollectedForCsv(exportResult.getMaxSpecimenVirDetect()); - for (String specimen : specimenCollected) { - exportLine[++index] = specimen; - } - } - - exportLine[++index] = dto.getResultOfVirusDetectionForCsv(); - exportLine[++index] = dto.getGenotypeForCsv(); + exportLine[++index] = dto.getCauseOfDeathForCsv(); + exportLine[++index] = dto.getClinicalCriteriaStatusForCsv(); - // Repeatable: TypeOfSpecimenForSerologicalAnalysis - if (exportResult.getMaxSpecimenSero() > 0) { - List specimenSerology = dto.getTypeOfSpecimenSerologyForCsv(exportResult.getMaxSpecimenSero()); - for (String specimen : specimenSerology) { - exportLine[++index] = specimen; + // Repeatable: ComplicationDiagnosis + if (exportResult.getMaxComplicationDiagnosis() > 0) { + List complications = dto.getComplicationDiagnosisForCsv(exportResult.getMaxComplicationDiagnosis()); + for (String complication : complications) { + exportLine[++index] = complication; } } - exportLine[++index] = dto.getResultIgGForCsv(); - exportLine[++index] = dto.getResultIgMForCsv(); - - // Clinical and epidemiology fields - exportLine[++index] = dto.getDateOfInvestigationForCsv(); exportLine[++index] = dto.getClusterRelatedForCsv(); exportLine[++index] = dto.getClusterIdentificationForCsv(); @@ -178,18 +152,10 @@ public int writeEntryRow(EpipulseDiseaseExportEntryDto dto, String[] exportLine, } } + exportLine[++index] = dto.getDateOfLastVaccinationForCsv(); + exportLine[++index] = dto.getVaccinationStatusForCsv(); exportLine[++index] = dto.getImportedStatusForCsv(); - // Repeatable: ComplicationDiagnosis - if (exportResult.getMaxComplicationDiagnosis() > 0) { - List complications = dto.getComplicationDiagnosisForCsv(exportResult.getMaxComplicationDiagnosis()); - for (String complication : complications) { - exportLine[++index] = complication; - } - } - - exportLine[++index] = dto.getClinicalCriteriaStatusForCsv(); - // Repeatable: PlaceOfInfection if (exportResult.getMaxPlaceOfInfection() > 0) { List placesOfInfection = dto.getPlaceOfInfectionForCsv(exportResult.getMaxPlaceOfInfection()); @@ -198,14 +164,34 @@ public int writeEntryRow(EpipulseDiseaseExportEntryDto dto, String[] exportLine, } } - exportLine[++index] = dto.getCauseOfDeathForCsv(); + exportLine[++index] = dto.getPlaceOfNotificationForCsv(); + exportLine[++index] = dto.getPlaceOfResidenceForCsv(); + + // Laboratory fields + exportLine[++index] = dto.getDateOfSpecimenForCsv(); + exportLine[++index] = dto.getDateOfLaboratoryResultForCsv(); - // Vaccination columns - if (exportResult.getMaxImmunizations() > 0) { - exportLine[++index] = dto.getDateOfLastVaccinationForCsv(); + // Repeatable: SpecimenVirDetect (virus detection) + if (exportResult.getMaxSpecimenVirDetect() > 0) { + List specimenCollected = dto.getTypeOfSpecimenCollectedForCsv(exportResult.getMaxSpecimenVirDetect()); + for (String specimen : specimenCollected) { + exportLine[++index] = specimen; + } } - exportLine[++index] = dto.getVaccinationStatusForCsv(); + exportLine[++index] = dto.getResultOfVirusDetectionForCsv(); + exportLine[++index] = dto.getGenotypeForCsv(); + + // Repeatable: SpecimenSero (serology) + if (exportResult.getMaxSpecimenSero() > 0) { + List specimenSerology = dto.getTypeOfSpecimenSerologyForCsv(exportResult.getMaxSpecimenSero()); + for (String specimen : specimenSerology) { + exportLine[++index] = specimen; + } + } + + exportLine[++index] = dto.getResultIgGForCsv(); + exportLine[++index] = dto.getResultIgMForCsv(); return index; } diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesExportStrategy.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesExportStrategy.java index d502aca744b..856317d6a76 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesExportStrategy.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesExportStrategy.java @@ -50,7 +50,7 @@ protected String buildDiseaseExportQuery() { // Common CTEs query.append(sqlCteBuilder.buildVariablesCte()); query.append(sqlCteBuilder.buildConfigDataCte()); - query.append(sqlCteBuilder.buildFilteredCasesCte(true)); // Include Measles-specific fields (epidata_id, investigateddate, clinicalconfirmation) + query.append(sqlCteBuilder.buildFilteredCasesCte(true)); // Include epidata fields (epidata_id, investigateddate, clinicalconfirmation) query.append(sqlCteBuilder.buildPreviousHospitalizationsCte()); query.append(sqlCteBuilder.buildSamplesCte()); query.append(sqlCteBuilder.buildPathogenTestsCte()); diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/PertussisExportStrategy.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/PertussisExportStrategy.java index 6683cd102fa..488cbf06634 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/PertussisExportStrategy.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/PertussisExportStrategy.java @@ -38,7 +38,7 @@ protected String buildDiseaseExportQuery() { // Build query using common CTEs only query.append(sqlCteBuilder.buildVariablesCte()); query.append(sqlCteBuilder.buildConfigDataCte()); - query.append(sqlCteBuilder.buildFilteredCasesCte(false)); // No Measles-specific fields + query.append(sqlCteBuilder.buildFilteredCasesCte(false)); // No epidata fields needed for Pertussis query.append(sqlCteBuilder.buildPreviousHospitalizationsCte()); query.append(sqlCteBuilder.buildSamplesCte()); query.append(sqlCteBuilder.buildPathogenTestsCte()); From fee03b8c9a0c79de99cfcf0bb74902bc146cd2ab Mon Sep 17 00:00:00 2001 From: Harold Asiimwe Date: Mon, 19 Jan 2026 14:06:36 +0300 Subject: [PATCH 8/9] #13772 - Add Epipulse export functionality for IPI disease --- .../EpipulseDiseaseExportEntryDto.java | 4 ++- .../epipulse/EpipulseCommonDtoMapper.java | 5 ++-- .../EpipulseConfigurationLookupService.java | 9 +++++- .../epipulse/strategy/IpiExportStrategy.java | 28 +++++++++---------- 4 files changed, 27 insertions(+), 19 deletions(-) diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportEntryDto.java b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportEntryDto.java index 60ad3fd47f7..cdd816c4af4 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportEntryDto.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportEntryDto.java @@ -1211,7 +1211,9 @@ public String getPenicillinResistanceForCsv() { public List getClinicalPresentationForCsv(int maxCount) { List presentations = new ArrayList<>(); if (clinicalPresentation != null && !clinicalPresentation.isEmpty()) { - presentations.addAll(clinicalPresentation); + for (int i = 0; i < Math.min(maxCount, clinicalPresentation.size()); i++) { + presentations.add(clinicalPresentation.get(i)); + } } // Pad with empty strings to match maxCount while (presentations.size() < maxCount) { diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCommonDtoMapper.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCommonDtoMapper.java index 6323a89881e..9e91096721c 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCommonDtoMapper.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCommonDtoMapper.java @@ -76,9 +76,10 @@ public static int mapCommonFields( // Index 2: Subject Code String subjectCodeFromDb = (String) row[++index]; - if (!StringUtils.isBlank(subjectCodeFromDb)) { - dto.setSubjectCode(EpipulseSubjectCode.valueOf(subjectCodeFromDb)); + if (StringUtils.isBlank(subjectCodeFromDb)) { + throw new IllegalStateException("Subject code is missing for Epipulse export row"); } + dto.setSubjectCode(EpipulseSubjectCode.valueOf(subjectCodeFromDb)); // Index 3: National Record ID (case UUID) dto.setNationalRecordId((String) row[++index]); diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseConfigurationLookupService.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseConfigurationLookupService.java index 40533134a48..0f71cb1191f 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseConfigurationLookupService.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseConfigurationLookupService.java @@ -61,6 +61,13 @@ public class EpipulseConfigurationLookupService { public EpipulseConfigurationContext lookupConfiguration(EpipulseExportDto exportDto, String serverCountryLocale, String serverCountryName) throws IllegalArgumentException, IllegalStateException { + if (exportDto == null || exportDto.getSubjectCode() == null) { + throw new IllegalArgumentException("Subject code is required for Epipulse export"); + } + if (StringUtils.isBlank(serverCountryLocale)) { + throw new IllegalArgumentException("Server country code/locale is required for Epipulse export"); + } + String reportingCountry = lookupReportingCountry(serverCountryLocale); String serverCountryNutsCode = lookupServerCountryNutsCode(serverCountryName); String subjectCode = lookupSubjectCode(exportDto.getSubjectCode()); @@ -121,7 +128,7 @@ private String lookupServerCountryNutsCode(String countryName) { @SuppressWarnings("unchecked") String serverCountryNutsCode = (String) em.createNativeQuery(serverCountryQuery) - .setParameter("countryName", countryName.toLowerCase()) + .setParameter("countryName", countryName.toLowerCase(java.util.Locale.ROOT)) .getResultStream() .filter(java.util.Objects::nonNull) .findFirst() diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/IpiExportStrategy.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/IpiExportStrategy.java index 1d82984c6e5..7c7f1be9725 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/IpiExportStrategy.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/IpiExportStrategy.java @@ -271,16 +271,16 @@ private String buildPneuVaccinationDetailsCte() { //@formatter:off return "pneu_vaccination_details AS (" + " SELECT c.id as case_id," + - " MAX(CASE WHEN pcv_row_num = 1 THEN v.vaccinationdate END) as date_pcv1," + - " MAX(CASE WHEN pcv_row_num = 1 THEN CAST(v.vaccinename AS text) END) as brand_pcv1," + - " MAX(CASE WHEN pcv_row_num = 2 THEN v.vaccinationdate END) as date_pcv2," + - " MAX(CASE WHEN pcv_row_num = 2 THEN CAST(v.vaccinename AS text) END) as brand_pcv2," + - " MAX(CASE WHEN pcv_row_num = 3 THEN v.vaccinationdate END) as date_pcv3," + - " MAX(CASE WHEN pcv_row_num = 3 THEN CAST(v.vaccinename AS text) END) as brand_pcv3," + - " MAX(CASE WHEN pcv_row_num = 4 THEN v.vaccinationdate END) as date_pcv4," + - " MAX(CASE WHEN pcv_row_num = 4 THEN CAST(v.vaccinename AS text) END) as brand_pcv4," + + " MAX(CASE WHEN is_pcv AND pcv_row_num = 1 THEN v.vaccinationdate END) as date_pcv1," + + " MAX(CASE WHEN is_pcv AND pcv_row_num = 1 THEN CAST(v.vaccinename AS text) END) as brand_pcv1," + + " MAX(CASE WHEN is_pcv AND pcv_row_num = 2 THEN v.vaccinationdate END) as date_pcv2," + + " MAX(CASE WHEN is_pcv AND pcv_row_num = 2 THEN CAST(v.vaccinename AS text) END) as brand_pcv2," + + " MAX(CASE WHEN is_pcv AND pcv_row_num = 3 THEN v.vaccinationdate END) as date_pcv3," + + " MAX(CASE WHEN is_pcv AND pcv_row_num = 3 THEN CAST(v.vaccinename AS text) END) as brand_pcv3," + + " MAX(CASE WHEN is_pcv AND pcv_row_num = 4 THEN v.vaccinationdate END) as date_pcv4," + + " MAX(CASE WHEN is_pcv AND pcv_row_num = 4 THEN CAST(v.vaccinename AS text) END) as brand_pcv4," + " SUM(CASE WHEN is_pcv THEN 1 ELSE 0 END) as pcv_doses," + - " MAX(CASE WHEN ppv_row_num = 1 THEN v.vaccinationdate END) as date_ppv," + + " MAX(CASE WHEN is_ppv AND ppv_row_num = 1 THEN v.vaccinationdate END) as date_ppv," + " SUM(CASE WHEN is_ppv THEN 1 ELSE 0 END) as ppv_doses " + " FROM filtered_cases c " + " LEFT JOIN vaccination v ON v.immunization_id IN (SELECT i.id FROM immunization i WHERE i.person_id = c.person_id AND i.disease = 'INVASIVE_PNEUMOCOCCAL_INFECTION') " + @@ -293,12 +293,10 @@ private String buildPneuVaccinationDetailsCte() { " WHEN CAST(v.vaccinename AS text) LIKE '%PPV%' OR CAST(v.vaccinename AS text) LIKE '%POLYSACCHARIDE%' THEN true " + " ELSE false " + " END as is_ppv," + - " ROW_NUMBER() OVER (PARTITION BY c.id, " + - " CASE WHEN CAST(v.vaccinename AS text) LIKE '%PCV%' THEN 1 ELSE 0 END " + - " ORDER BY v.vaccinationdate) as pcv_row_num," + - " ROW_NUMBER() OVER (PARTITION BY c.id, " + - " CASE WHEN CAST(v.vaccinename AS text) LIKE '%PPV%' THEN 1 ELSE 0 END " + - " ORDER BY v.vaccinationdate) as ppv_row_num " + + " SUM(CASE WHEN CAST(v.vaccinename AS text) LIKE '%PCV%' OR CAST(v.vaccinename AS text) LIKE '%CONJUGATE%' THEN 1 ELSE 0 END) " + + " OVER (PARTITION BY c.id ORDER BY v.vaccinationdate) as pcv_row_num," + + " SUM(CASE WHEN CAST(v.vaccinename AS text) LIKE '%PPV%' OR CAST(v.vaccinename AS text) LIKE '%POLYSACCHARIDE%' THEN 1 ELSE 0 END) " + + " OVER (PARTITION BY c.id ORDER BY v.vaccinationdate) as ppv_row_num " + " ) vax_info ON true " + " GROUP BY c.id) "; //@formatter:on From ffc95fe25515b758c34dc4cbeb7a3f6928f756da Mon Sep 17 00:00:00 2001 From: Harold Asiimwe Date: Mon, 19 Jan 2026 15:02:24 +0300 Subject: [PATCH 9/9] #13772 - Add Epipulse export functionality for IPI disease --- .../strategy/IpiCsvExportStrategy.java | 28 +++--- .../epipulse/strategy/IpiExportStrategy.java | 93 ++++++++++--------- 2 files changed, 65 insertions(+), 56 deletions(-) diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/IpiCsvExportStrategy.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/IpiCsvExportStrategy.java index 97c74fb9877..c3afc16034b 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/IpiCsvExportStrategy.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/IpiCsvExportStrategy.java @@ -25,16 +25,20 @@ /** * CSV export strategy for PNEU (Invasive Pneumococcal Infection) disease. - * Follows EpiPulse metadata specification exactly with 49 fixed columns (no repeatable fields). + * Follows EpiPulse metadata specification with 49 fixed CSV columns (no repeatable fields). *

- * Column structure: - * - 13 common demographic fields - * - 5 clinical/diagnostic fields (including DateOfDiagnosis, ClinicalCriteria) + * CSV column structure: + * - 13 common demographic fields (Disease through PlaceOfNotification) + * - 5 clinical/diagnostic fields (CaseClassification, DateOfDiagnosis, DateOfNotification, Outcome, ClinicalCriteria) * - 2 laboratory fields (PathogenDetectionMethod, Serotype) - * - 23 vaccination fields (detailed PCV1-4 and PPV tracking) - * - 9 AST fields (antimicrobial susceptibility for CTX/CFX, ERY, PEN) + * - 19 vaccination fields (DateOfLastVaccination, Vaccine, VaccinationStatus, PCV1-4 details, PPV details) + * - 10 AST fields (ASTMethod, MIC/SIR for CTX_CFX, ERY, PEN) *

- * Total: 49 fixed columns + * Total: 49 CSV columns + *

+ * Note: The underlying SQL query returns 56 columns (28 common + 28 PNEU-specific) which are + * transformed during CSV export (e.g., symptom columns merged into ClinicalCriteria, dose flags + * derived from dates). */ @Stateless @LocalBean @@ -42,7 +46,7 @@ public class IpiCsvExportStrategy implements CsvExportStrategy { @Override public List buildColumnNames(EpipulseDiseaseExportResult exportResult) { - // PNEU has 49 fixed columns - no repeatable fields according to metadata + // PNEU has 49 fixed CSV columns - no repeatable fields return List.of( // Common demographic fields (13) "Disease", @@ -67,7 +71,7 @@ public List buildColumnNames(EpipulseDiseaseExportResult exportResult) { // Laboratory fields (2) "PathogenDetectionMethod", "Serotype", - // Vaccination fields (23) + // Vaccination fields (19) "DateOfLastVaccination", "Vaccine", "VaccinationStatus", @@ -87,7 +91,7 @@ public List buildColumnNames(EpipulseDiseaseExportResult exportResult) { "DosePPV", "DatePPV", "PPVDoses", - // AST fields (9) + // AST fields (10) "ASTMethod", "MICSign_CTX_CFX", "MICValueAST_CTX_CFX", @@ -130,7 +134,7 @@ public int writeEntryRow(EpipulseDiseaseExportEntryDto dto, String[] exportLine, exportLine[++index] = dto.getPathogenDetectionMethodForCsv(); exportLine[++index] = dto.getSerotypeForCsv(); - // Vaccination fields (23) + // Vaccination fields (19) exportLine[++index] = dto.getDateOfLastVaccinationForCsv(); exportLine[++index] = dto.getVaccineForCsv(); exportLine[++index] = dto.getVaccinationStatusForCsv(); @@ -155,7 +159,7 @@ public int writeEntryRow(EpipulseDiseaseExportEntryDto dto, String[] exportLine, exportLine[++index] = dto.getDatePPVForCsv(); exportLine[++index] = dto.getPpvDosesForCsv(); - // AST fields (9) + // AST fields (10) exportLine[++index] = dto.getAstMethodForCsv(); // CTX/CFX (Cefotaxime/Ceftriaxone) diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/IpiExportStrategy.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/IpiExportStrategy.java index 7c7f1be9725..5370b66ab4d 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/IpiExportStrategy.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/IpiExportStrategy.java @@ -30,14 +30,17 @@ /** * Export strategy for PNEU (Invasive Pneumococcal Infection) disease exports. - * PNEU follows the EpiPulse metadata specification exactly with 49 fixed columns (no repeatable fields). + * PNEU follows the EpiPulse metadata specification with 56 total columns (28 common + 28 PNEU-specific). *

- * Metadata specification: - * - 13 common demographic fields - * - 5 clinical/diagnostic fields (including DateOfDiagnosis, ClinicalCriteria) - * - 2 laboratory fields (PathogenDetectionMethod, Serotype) - * - 23 vaccination fields (detailed PCV1-4 and PPV tracking) - * - 9 AST fields (antimicrobial susceptibility for CTX/CFX, ERY, PEN) + * Column breakdown: + * - 28 common fields (demographic, case identification, hospitalization, outcome) + * - 28 PNEU-specific fields: + * - 1 NRL field (NRLData) + * - 5 clinical/diagnostic fields (DateOfDiagnosis, meningitis, septicaemia, pneumonia -> ClinicalCriteria) + * - 2 laboratory fields (PathogenDetectionMethod, Serotype) + * - 11 PCV vaccination fields (DatePCV1-4, BrandPCV1-4, DosePCV1-4 derived, PcvDoses) + * - 2 PPV vaccination fields (DatePPV, PpvDoses) + * - 10 AST fields (AstMethod, MicSign/MicValue/SIR for CTX_CFX, ERY, PEN) */ @Stateless @LocalBean @@ -65,15 +68,15 @@ protected String buildDiseaseExportQuery() { // Note: AST data not currently stored in SORMAS for PNEU - fields will be NULL // query.append(buildPneuAstDataCte()); - // Main SELECT clause with all 49 PNEU fields + // Main SELECT clause with all 56 columns (28 common + 28 PNEU-specific) query.append(buildPneuSelectClause()); return query.toString(); } /** - * Maps all 21 PNEU-specific fields from SQL result row to DTO - * (Common fields already mapped by parent class - 28 fields) + * Maps all 28 PNEU-specific fields from SQL result row to DTO. + * Common fields (28) are already mapped by parent class, giving 56 total columns. */ @Override protected void mapDiseaseSpecificFields(EpipulseDiseaseExportEntryDto dto, Object[] row, int startIndex) { @@ -206,8 +209,7 @@ protected void mapDiseaseSpecificFields(EpipulseDiseaseExportEntryDto dto, Objec @Override protected void calculateDiseaseSpecificMaxCounts(List entries, EpipulseDiseaseExportResult result) { - // PNEU has no repeatable fields according to metadata specification - // All 49 columns are fixed - no dynamic column generation needed + // PNEU has no repeatable fields - all 56 columns are fixed (no dynamic column generation needed) // Common repeatable fields (pathogenTests, immunizations) are handled by parent class } @@ -269,36 +271,39 @@ private String buildPneuClinicalCriteriaCte() { */ private String buildPneuVaccinationDetailsCte() { //@formatter:off - return "pneu_vaccination_details AS (" + - " SELECT c.id as case_id," + - " MAX(CASE WHEN is_pcv AND pcv_row_num = 1 THEN v.vaccinationdate END) as date_pcv1," + - " MAX(CASE WHEN is_pcv AND pcv_row_num = 1 THEN CAST(v.vaccinename AS text) END) as brand_pcv1," + - " MAX(CASE WHEN is_pcv AND pcv_row_num = 2 THEN v.vaccinationdate END) as date_pcv2," + - " MAX(CASE WHEN is_pcv AND pcv_row_num = 2 THEN CAST(v.vaccinename AS text) END) as brand_pcv2," + - " MAX(CASE WHEN is_pcv AND pcv_row_num = 3 THEN v.vaccinationdate END) as date_pcv3," + - " MAX(CASE WHEN is_pcv AND pcv_row_num = 3 THEN CAST(v.vaccinename AS text) END) as brand_pcv3," + - " MAX(CASE WHEN is_pcv AND pcv_row_num = 4 THEN v.vaccinationdate END) as date_pcv4," + - " MAX(CASE WHEN is_pcv AND pcv_row_num = 4 THEN CAST(v.vaccinename AS text) END) as brand_pcv4," + - " SUM(CASE WHEN is_pcv THEN 1 ELSE 0 END) as pcv_doses," + - " MAX(CASE WHEN is_ppv AND ppv_row_num = 1 THEN v.vaccinationdate END) as date_ppv," + - " SUM(CASE WHEN is_ppv THEN 1 ELSE 0 END) as ppv_doses " + - " FROM filtered_cases c " + - " LEFT JOIN vaccination v ON v.immunization_id IN (SELECT i.id FROM immunization i WHERE i.person_id = c.person_id AND i.disease = 'INVASIVE_PNEUMOCOCCAL_INFECTION') " + - " LEFT JOIN LATERAL (" + - " SELECT CASE " + - " WHEN CAST(v.vaccinename AS text) LIKE '%PCV%' OR CAST(v.vaccinename AS text) LIKE '%CONJUGATE%' THEN true " + - " ELSE false " + - " END as is_pcv," + - " CASE " + - " WHEN CAST(v.vaccinename AS text) LIKE '%PPV%' OR CAST(v.vaccinename AS text) LIKE '%POLYSACCHARIDE%' THEN true " + - " ELSE false " + - " END as is_ppv," + - " SUM(CASE WHEN CAST(v.vaccinename AS text) LIKE '%PCV%' OR CAST(v.vaccinename AS text) LIKE '%CONJUGATE%' THEN 1 ELSE 0 END) " + - " OVER (PARTITION BY c.id ORDER BY v.vaccinationdate) as pcv_row_num," + - " SUM(CASE WHEN CAST(v.vaccinename AS text) LIKE '%PPV%' OR CAST(v.vaccinename AS text) LIKE '%POLYSACCHARIDE%' THEN 1 ELSE 0 END) " + - " OVER (PARTITION BY c.id ORDER BY v.vaccinationdate) as ppv_row_num " + - " ) vax_info ON true " + - " GROUP BY c.id) "; + return "pneu_vaccination_details AS (" + + " SELECT vax.case_id," + + " MAX(CASE WHEN vax.is_pcv AND vax.pcv_row_num = 1 THEN vax.vaccinationdate END) as date_pcv1," + + " MAX(CASE WHEN vax.is_pcv AND vax.pcv_row_num = 1 THEN CAST(vax.vaccinename AS text) END) as brand_pcv1," + + " MAX(CASE WHEN vax.is_pcv AND vax.pcv_row_num = 2 THEN vax.vaccinationdate END) as date_pcv2," + + " MAX(CASE WHEN vax.is_pcv AND vax.pcv_row_num = 2 THEN CAST(vax.vaccinename AS text) END) as brand_pcv2," + + " MAX(CASE WHEN vax.is_pcv AND vax.pcv_row_num = 3 THEN vax.vaccinationdate END) as date_pcv3," + + " MAX(CASE WHEN vax.is_pcv AND vax.pcv_row_num = 3 THEN CAST(vax.vaccinename AS text) END) as brand_pcv3," + + " MAX(CASE WHEN vax.is_pcv AND vax.pcv_row_num = 4 THEN vax.vaccinationdate END) as date_pcv4," + + " MAX(CASE WHEN vax.is_pcv AND vax.pcv_row_num = 4 THEN CAST(vax.vaccinename AS text) END) as brand_pcv4," + + " SUM(CASE WHEN vax.is_pcv THEN 1 ELSE 0 END) as pcv_doses," + + " MAX(CASE WHEN vax.is_ppv AND vax.ppv_row_num = 1 THEN vax.vaccinationdate END) as date_ppv," + + " SUM(CASE WHEN vax.is_ppv THEN 1 ELSE 0 END) as ppv_doses " + + " FROM (" + + " SELECT c.id as case_id," + + " v.vaccinationdate," + + " v.vaccinename," + + " CASE " + + " WHEN CAST(v.vaccinename AS text) LIKE '%PCV%' OR CAST(v.vaccinename AS text) LIKE '%CONJUGATE%' THEN true " + + " ELSE false " + + " END as is_pcv," + + " CASE " + + " WHEN CAST(v.vaccinename AS text) LIKE '%PPV%' OR CAST(v.vaccinename AS text) LIKE '%POLYSACCHARIDE%' THEN true " + + " ELSE false " + + " END as is_ppv," + + " SUM(CASE WHEN CAST(v.vaccinename AS text) LIKE '%PCV%' OR CAST(v.vaccinename AS text) LIKE '%CONJUGATE%' THEN 1 ELSE 0 END) " + + " OVER (PARTITION BY c.id ORDER BY v.vaccinationdate) as pcv_row_num," + + " SUM(CASE WHEN CAST(v.vaccinename AS text) LIKE '%PPV%' OR CAST(v.vaccinename AS text) LIKE '%POLYSACCHARIDE%' THEN 1 ELSE 0 END) " + + " OVER (PARTITION BY c.id ORDER BY v.vaccinationdate) as ppv_row_num " + + " FROM filtered_cases c " + + " LEFT JOIN vaccination v ON v.immunization_id IN (SELECT i.id FROM immunization i WHERE i.person_id = c.person_id AND i.disease = 'INVASIVE_PNEUMOCOCCAL_INFECTION') " + + " ) vax " + + " GROUP BY vax.case_id) "; //@formatter:on } @@ -331,12 +336,12 @@ private String buildPneuAstDataCte() { } /** - * Builds SELECT clause with all 49 PNEU fields following metadata specification + * Builds SELECT clause with all 56 columns (28 common + 28 PNEU-specific). */ private String buildPneuSelectClause() { StringBuilder select = new StringBuilder(); //@formatter:off - // Use common SELECT fields (28 fields) and append PNEU-specific fields (21 fields) + // Use common SELECT fields (28 fields) and append PNEU-specific fields (28 fields) select.append("SELECT ") .append(sqlCteBuilder.buildCommonSelectFields()) .append(",")