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/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/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..e1b9145213a --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseLaboratoryMapper.java @@ -0,0 +1,327 @@ +/* + * 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 null; + } + } + + /** + * 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), or null if input is null + */ + public static String mapTestResultToEpipulseCode(PathogenTestResultType testResult) { + if (testResult == null) { + return null; + } + + switch (testResult) { + case POSITIVE: + return "POS"; + case NEGATIVE: + return "NEG"; + case INDETERMINATE: + return "EQUI"; + case PENDING: + case NOT_DONE: + return "NOTEST"; + default: + return null; + } + } + + /** + * 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, validate the suffix and return if valid + if (normalized.startsWith("MEASV_")) { + 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 + // 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; + } + + // 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 (isValidMeaslesGenotype(extracted)) { + return "MEASV_" + extracted; + } + + // Return null for ambiguous or unparseable inputs + 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. + * + * @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); + } + + 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) { + if (clinicalConfirmation == null || clinicalConfirmation == YesNoUnknown.UNKNOWN) { + return null; + } + 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/EpipulseCommonDtoMapper.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCommonDtoMapper.java new file mode 100644 index 00000000000..6323a89881e --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCommonDtoMapper.java @@ -0,0 +1,194 @@ +/* + * 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) { + if (entry.getPathogenTests() != null) { + int pathogenTestCount = entry.getPathogenTests().size(); + if (pathogenTestCount > maxPathogenTests) { + maxPathogenTests = pathogenTestCount; + } + } + + if (entry.getImmunizations() != null) { + 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..40533134a48 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseConfigurationLookupService.java @@ -0,0 +1,164 @@ +/* + * 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) { + if (countryName == null) { + return null; + } + + //@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..ee6f0dc56d4 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCsvExportOrchestrator.java @@ -0,0 +1,187 @@ +/* + * 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) { + + 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; + } + + // 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; + + // 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 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); + + // 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); + } + } + + exportStatus = EpipulseExportStatus.COMPLETED; + } catch (Exception e) { + exportStatus = EpipulseExportStatus.FAILED; + logger.error("Error during export with uuid " + uuid + ": " + e.getMessage(), e); + } finally { + // 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 6a988bedf8f..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,214 +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) { + orchestrator.orchestrateExport(uuid, diseaseExportService::exportPertussisCaseBased, pertussisStrategy); + } - 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); - } - } - } + public void startMeaslesExport(String uuid) { + 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 e08ab82a46d..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 @@ -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,20 +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.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.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.PathogenTestType; 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 @@ -61,330 +51,53 @@ public class EpipulseDiseaseExportService { @PersistenceContext(unitName = ModelConstants.PERSISTENCE_UNIT_NAME) private EntityManager em; + @EJB + private PertussisExportStrategy pertussisExportStrategy; + + @EJB + private MeaslesExportStrategy measlesExportStrategy; + public EpipulseDiseaseExportResult exportPertussisCaseBased(EpipulseExportDto exportDto, String serverCountryLocale, String serverCountryName) throws SQLException, IllegalStateException, IllegalArgumentException { + return pertussisExportStrategy.export(exportDto, serverCountryLocale, serverCountryName); + } - EpipulseDiseaseExportResult exportResult = new EpipulseDiseaseExportResult(); + public EpipulseDiseaseExportResult exportMeaslesCaseBased(EpipulseExportDto exportDto, String serverCountryLocale, String serverCountryName) + throws SQLException, IllegalStateException, IllegalArgumentException { + return measlesExportStrategy.export(exportDto, serverCountryLocale, serverCountryName); + } + @TransactionAttribute(TransactionAttributeType.REQUIRES_NEW) + public boolean tryClaimExportForProcessing(String exportUuid) { 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" + - " 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)); - } - - 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; - } + 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(); - immunizationCount = dto.getImmunizations().size(); - if (immunizationCount > maxImmunizations) { - maxImmunizations = immunizationCount; - } + em.flush(); - exportEntryList.add(dto); + 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; } - 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; + logger.error("Failed to claim export {} for processing: {}", exportUuid, e.getMessage(), e); + return false; + } finally { + em.clear(); } - - return exportResult; } @TransactionAttribute(TransactionAttributeType.REQUIRES_NEW) 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; 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..1fc316cb6a2 --- /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(), e); + 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..d502aca744b --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesExportStrategy.java @@ -0,0 +1,480 @@ +/* + * 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]; + dto.setTypeOfSpecimenCollected(parseSpecimenTypes(specimenTypesVirusRaw)); + + String virusDetectionResultRaw = (String) row[++index]; + if (!StringUtils.isBlank(virusDetectionResultRaw)) { + PathogenTestResultType virusDetectionResult = parsePathogenTestResultType(virusDetectionResultRaw); + if (virusDetectionResult != null) { + dto.setResultOfVirusDetection(EpipulseLaboratoryMapper.mapTestResultToEpipulseCode(virusDetectionResult)); + } + } + + String genotypeRaw = (String) row[++index]; + if (!StringUtils.isBlank(genotypeRaw)) { + dto.setGenotype(EpipulseLaboratoryMapper.normalizeGenotypeForEpipulse(genotypeRaw)); + } + + String specimenTypesSerologyRaw = (String) row[++index]; + dto.setTypeOfSpecimenSerology(parseSpecimenTypes(specimenTypesSerologyRaw)); + + String iggResultRaw = (String) row[++index]; + if (!StringUtils.isBlank(iggResultRaw)) { + PathogenTestResultType iggResult = parsePathogenTestResultType(iggResultRaw); + if (iggResult != null) { + dto.setResultIgG(EpipulseLaboratoryMapper.mapTestResultToEpipulseCode(iggResult)); + } + } + + String igmResultRaw = (String) row[++index]; + if (!StringUtils.isBlank(igmResultRaw)) { + PathogenTestResultType igmResult = parsePathogenTestResultType(igmResultRaw); + if (igmResult != null) { + dto.setResultIgM(EpipulseLaboratoryMapper.mapTestResultToEpipulseCode(igmResult)); + } + } + + // 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)) { + 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)) { + CaseImportedStatus caseImportedStatus = parseCaseImportedStatus(caseImportedStatusRaw); + if (caseImportedStatus != null) { + dto.setImportedStatus(EpipulseLaboratoryMapper.mapCaseImportedStatusToEpipulseCode(caseImportedStatus)); + } + } + + // 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)) { + YesNoUnknown clinicalConfirmation = parseYesNoUnknown(clinicalConfirmationRaw); + if (clinicalConfirmation != null) { + dto.setClinicalCriteriaStatus(EpipulseLaboratoryMapper.deriveClinicalCriteriaStatus(clinicalConfirmation)); + } + } + + // 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 (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 " + + " 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 " + + " WHEN l.details IS NOT NULL THEN l.details " + + " ELSE 'Unknown' " + + " END, " + + " '; ' " + + " ORDER BY e.startdate DESC" + + " ) as infection_locations " + + " FROM exposures e " + + " 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), "; + //@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 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; + } + try { + return SymptomState.valueOf(value); + } catch (IllegalArgumentException e) { + logger.warn("Invalid SymptomState value '{}', treating as null", 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; + } + } +} 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; + } +}