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..cdd816c4af4 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportEntryDto.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportEntryDto.java @@ -82,6 +82,90 @@ 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 + + // IPI-specific laboratory fields + private String resultOfCulture; // PathogenTestResultType for CULTURE → POS/NEG/EQUI/NOTEST + private String resultOfPCR; // PathogenTestResultType for PCR_RT_PCR → POS/NEG/EQUI/NOTEST + private String serotype; // PathogenTest typingId for pneumococcal serotypes (1-93) + private String serogroupMethod; // PathogenTestType.SEROGROUPING presence → POS/NEG/NOTEST + private String penicillinResistance; // DrugSusceptibilityDto → SENS/RESIST/INTER/NOTEST + + // IPI-specific clinical fields + private List clinicalPresentation; // SymptomsDto (meningitis, septicaemia, etc.) → MENING/SEPT/PNEUM/OME/ASYMP (repeatable) + + // PNEU-specific fields (following metadata specification exactly) + // Demographics + private Boolean nrlData; // National Reference Laboratory data flag + + // Clinical/Diagnostic + private Date dateOfDiagnosis; // Date of diagnosis + private String clinicalCriteria; // REF: BACTERPNEUMO, MENI, MENISEPTI, OTH, SEPTI + + // Laboratory + private String pathogenDetectionMethod; // REF: COAGG, GDIFF, MPCR, OTH, PTEST, QUE, SLAGG + + // Vaccination - Summary fields + private String vaccine; // REF: PCV7, PCV10, PCV13, PCV15, PCV20, PCV3, PPV23 + private Date dateOfLastVaccination; // Date of last vaccination (shared with MEAS) + + // Vaccination - Detailed PCV doses (1-4) + private Boolean dosePCV1; + private Date datePCV1; + private String brandPCV1; // REF: PCV7, PCV10, PCV13, PCV15, PCV20 + private Boolean dosePCV2; + private Date datePCV2; + private String brandPCV2; + private Boolean dosePCV3; + private Date datePCV3; + private String brandPCV3; + private Boolean dosePCV4; + private Date datePCV4; + private String brandPCV4; + private Integer pcvDoses; // Total PCV dose count + + // Vaccination - PPV doses + private Boolean dosePPV; + private Date datePPV; + private Integer ppvDoses; // Total PPV dose count + + // Antimicrobial Susceptibility Testing (AST) + private String astMethod; // REF: AGARDIL, AUTOM, BROTHDIL, GRAD, OTH + + // CTX/CFX (Cefotaxime/Ceftriaxone) AST + private String micSign_CTX_CFX; // REF: <, <=, =, >, >= + private Double micValueAST_CTX_CFX; // MIC value + private String sir_CTX_CFX; // REF: I, R, S + + // ERY (Erythromycin) AST + private String micSign_ERY; + private Double micValueAST_ERY; + private String sir_ERY; + + // PEN (Penicillin) AST + private String micSign_PEN; + private Double micValueAST_PEN; + private String sir_PEN; + public String getReportingCountry() { return reportingCountry; } @@ -326,6 +410,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 +513,8 @@ public String getAgeForCsv() { public String getAgeMonthForCsv() { switch (subjectCode) { case PERT: + case MEAS: + case PNEU: if (ageYears != null && ageYears < 2) { return ageMonths == null ? null : ageMonths.toString(); } @@ -384,6 +534,8 @@ public String getGenderForCsv() { public String getPlaceOfResidenceForCsv() { switch (subjectCode) { case PERT: + case MEAS: + case PNEU: if (addressCommunityNutsCode != null && !addressCommunityNutsCode.isEmpty()) { return addressCommunityNutsCode; } else if (addressDistrictNutsCode != null && !addressDistrictNutsCode.isEmpty()) { @@ -400,6 +552,8 @@ public String getPlaceOfResidenceForCsv() { public String getPlaceOfNotificationForCsv() { switch (subjectCode) { case PERT: + case MEAS: + case PNEU: if (responsibleCommunityNutsCode != null && !responsibleCommunityNutsCode.isEmpty()) { return responsibleCommunityNutsCode; } else if (responsibleDistrictNutsCode != null && !responsibleDistrictNutsCode.isEmpty()) { @@ -529,6 +683,670 @@ 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; + } + + // IPI-specific laboratory field getters/setters + public String getResultOfCulture() { + return resultOfCulture; + } + + public void setResultOfCulture(String resultOfCulture) { + this.resultOfCulture = resultOfCulture; + } + + public String getResultOfPCR() { + return resultOfPCR; + } + + public void setResultOfPCR(String resultOfPCR) { + this.resultOfPCR = resultOfPCR; + } + + public String getSerotype() { + return serotype; + } + + public void setSerotype(String serotype) { + this.serotype = serotype; + } + + public String getSerogroupMethod() { + return serogroupMethod; + } + + public void setSerogroupMethod(String serogroupMethod) { + this.serogroupMethod = serogroupMethod; + } + + public String getPenicillinResistance() { + return penicillinResistance; + } + + public void setPenicillinResistance(String penicillinResistance) { + this.penicillinResistance = penicillinResistance; + } + + // IPI-specific clinical field getters/setters + public List getClinicalPresentation() { + return clinicalPresentation; + } + + public void setClinicalPresentation(List clinicalPresentation) { + this.clinicalPresentation = clinicalPresentation; + } + + // PNEU-specific field getters/setters + public Boolean getNrlData() { + return nrlData; + } + + public void setNrlData(Boolean nrlData) { + this.nrlData = nrlData; + } + + public Date getDateOfDiagnosis() { + return dateOfDiagnosis; + } + + public void setDateOfDiagnosis(Date dateOfDiagnosis) { + this.dateOfDiagnosis = dateOfDiagnosis; + } + + public String getClinicalCriteria() { + return clinicalCriteria; + } + + public void setClinicalCriteria(String clinicalCriteria) { + this.clinicalCriteria = clinicalCriteria; + } + + public String getPathogenDetectionMethod() { + return pathogenDetectionMethod; + } + + public void setPathogenDetectionMethod(String pathogenDetectionMethod) { + this.pathogenDetectionMethod = pathogenDetectionMethod; + } + + public String getVaccine() { + return vaccine; + } + + public void setVaccine(String vaccine) { + this.vaccine = vaccine; + } + + public Date getDateOfLastVaccination() { + return dateOfLastVaccination; + } + + public void setDateOfLastVaccination(Date dateOfLastVaccination) { + this.dateOfLastVaccination = dateOfLastVaccination; + } + + public Boolean getDosePCV1() { + return dosePCV1; + } + + public void setDosePCV1(Boolean dosePCV1) { + this.dosePCV1 = dosePCV1; + } + + public Date getDatePCV1() { + return datePCV1; + } + + public void setDatePCV1(Date datePCV1) { + this.datePCV1 = datePCV1; + } + + public String getBrandPCV1() { + return brandPCV1; + } + + public void setBrandPCV1(String brandPCV1) { + this.brandPCV1 = brandPCV1; + } + + public Boolean getDosePCV2() { + return dosePCV2; + } + + public void setDosePCV2(Boolean dosePCV2) { + this.dosePCV2 = dosePCV2; + } + + public Date getDatePCV2() { + return datePCV2; + } + + public void setDatePCV2(Date datePCV2) { + this.datePCV2 = datePCV2; + } + + public String getBrandPCV2() { + return brandPCV2; + } + + public void setBrandPCV2(String brandPCV2) { + this.brandPCV2 = brandPCV2; + } + + public Boolean getDosePCV3() { + return dosePCV3; + } + + public void setDosePCV3(Boolean dosePCV3) { + this.dosePCV3 = dosePCV3; + } + + public Date getDatePCV3() { + return datePCV3; + } + + public void setDatePCV3(Date datePCV3) { + this.datePCV3 = datePCV3; + } + + public String getBrandPCV3() { + return brandPCV3; + } + + public void setBrandPCV3(String brandPCV3) { + this.brandPCV3 = brandPCV3; + } + + public Boolean getDosePCV4() { + return dosePCV4; + } + + public void setDosePCV4(Boolean dosePCV4) { + this.dosePCV4 = dosePCV4; + } + + public Date getDatePCV4() { + return datePCV4; + } + + public void setDatePCV4(Date datePCV4) { + this.datePCV4 = datePCV4; + } + + public String getBrandPCV4() { + return brandPCV4; + } + + public void setBrandPCV4(String brandPCV4) { + this.brandPCV4 = brandPCV4; + } + + public Integer getPcvDoses() { + return pcvDoses; + } + + public void setPcvDoses(Integer pcvDoses) { + this.pcvDoses = pcvDoses; + } + + public Boolean getDosePPV() { + return dosePPV; + } + + public void setDosePPV(Boolean dosePPV) { + this.dosePPV = dosePPV; + } + + public Date getDatePPV() { + return datePPV; + } + + public void setDatePPV(Date datePPV) { + this.datePPV = datePPV; + } + + public Integer getPpvDoses() { + return ppvDoses; + } + + public void setPpvDoses(Integer ppvDoses) { + this.ppvDoses = ppvDoses; + } + + public String getAstMethod() { + return astMethod; + } + + public void setAstMethod(String astMethod) { + this.astMethod = astMethod; + } + + public String getMicSign_CTX_CFX() { + return micSign_CTX_CFX; + } + + public void setMicSign_CTX_CFX(String micSign_CTX_CFX) { + this.micSign_CTX_CFX = micSign_CTX_CFX; + } + + public Double getMicValueAST_CTX_CFX() { + return micValueAST_CTX_CFX; + } + + public void setMicValueAST_CTX_CFX(Double micValueAST_CTX_CFX) { + this.micValueAST_CTX_CFX = micValueAST_CTX_CFX; + } + + public String getSir_CTX_CFX() { + return sir_CTX_CFX; + } + + public void setSir_CTX_CFX(String sir_CTX_CFX) { + this.sir_CTX_CFX = sir_CTX_CFX; + } + + public String getMicSign_ERY() { + return micSign_ERY; + } + + public void setMicSign_ERY(String micSign_ERY) { + this.micSign_ERY = micSign_ERY; + } + + public Double getMicValueAST_ERY() { + return micValueAST_ERY; + } + + public void setMicValueAST_ERY(Double micValueAST_ERY) { + this.micValueAST_ERY = micValueAST_ERY; + } + + public String getSir_ERY() { + return sir_ERY; + } + + public void setSir_ERY(String sir_ERY) { + this.sir_ERY = sir_ERY; + } + + public String getMicSign_PEN() { + return micSign_PEN; + } + + public void setMicSign_PEN(String micSign_PEN) { + this.micSign_PEN = micSign_PEN; + } + + public Double getMicValueAST_PEN() { + return micValueAST_PEN; + } + + public void setMicValueAST_PEN(Double micValueAST_PEN) { + this.micValueAST_PEN = micValueAST_PEN; + } + + public String getSir_PEN() { + return sir_PEN; + } + + public void setSir_PEN(String sir_PEN) { + this.sir_PEN = sir_PEN; + } + + // Phase 3: CSV getter methods + public String getDateOfInvestigationForCsv() { + return formatDateForCsv(dateOfInvestigation); + } + + 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 : ""; + } + + // IPI-specific CSV getter methods + public String getResultOfCultureForCsv() { + return resultOfCulture != null ? resultOfCulture : ""; + } + + public String getResultOfPCRForCsv() { + return resultOfPCR != null ? resultOfPCR : ""; + } + + public String getSerotypeForCsv() { + return serotype != null ? serotype : ""; + } + + public String getSerogroupMethodForCsv() { + return serogroupMethod != null ? serogroupMethod : ""; + } + + public String getPenicillinResistanceForCsv() { + return penicillinResistance != null ? penicillinResistance : ""; + } + + public List getClinicalPresentationForCsv(int maxCount) { + List presentations = new ArrayList<>(); + if (clinicalPresentation != null && !clinicalPresentation.isEmpty()) { + for (int i = 0; i < Math.min(maxCount, clinicalPresentation.size()); i++) { + presentations.add(clinicalPresentation.get(i)); + } + } + // Pad with empty strings to match maxCount + while (presentations.size() < maxCount) { + presentations.add(""); + } + return presentations; + } + + // PNEU-specific CSV getter methods + public String getNrlDataForCsv() { + return nrlData != null ? String.valueOf(nrlData) : ""; + } + + public String getDateOfDiagnosisForCsv() { + return formatDateForCsv(dateOfDiagnosis); + } + + public String getClinicalCriteriaForCsv() { + return clinicalCriteria != null ? clinicalCriteria : ""; + } + + public String getPathogenDetectionMethodForCsv() { + return pathogenDetectionMethod != null ? pathogenDetectionMethod : ""; + } + + public String getVaccineForCsv() { + return vaccine != null ? vaccine : ""; + } + + public String getDosePCV1ForCsv() { + return dosePCV1 != null ? String.valueOf(dosePCV1) : ""; + } + + public String getDatePCV1ForCsv() { + return formatDateForCsv(datePCV1); + } + + public String getBrandPCV1ForCsv() { + return brandPCV1 != null ? brandPCV1 : ""; + } + + public String getDosePCV2ForCsv() { + return dosePCV2 != null ? String.valueOf(dosePCV2) : ""; + } + + public String getDatePCV2ForCsv() { + return formatDateForCsv(datePCV2); + } + + public String getBrandPCV2ForCsv() { + return brandPCV2 != null ? brandPCV2 : ""; + } + + public String getDosePCV3ForCsv() { + return dosePCV3 != null ? String.valueOf(dosePCV3) : ""; + } + + public String getDatePCV3ForCsv() { + return formatDateForCsv(datePCV3); + } + + public String getBrandPCV3ForCsv() { + return brandPCV3 != null ? brandPCV3 : ""; + } + + public String getDosePCV4ForCsv() { + return dosePCV4 != null ? String.valueOf(dosePCV4) : ""; + } + + public String getDatePCV4ForCsv() { + return formatDateForCsv(datePCV4); + } + + public String getBrandPCV4ForCsv() { + return brandPCV4 != null ? brandPCV4 : ""; + } + + public String getPcvDosesForCsv() { + return pcvDoses != null ? String.valueOf(pcvDoses) : ""; + } + + public String getDosePPVForCsv() { + return dosePPV != null ? String.valueOf(dosePPV) : ""; + } + + public String getDatePPVForCsv() { + return formatDateForCsv(datePPV); + } + + public String getPpvDosesForCsv() { + return ppvDoses != null ? String.valueOf(ppvDoses) : ""; + } + + public String getAstMethodForCsv() { + return astMethod != null ? astMethod : ""; + } + + public String getMicSign_CTX_CFXForCsv() { + return micSign_CTX_CFX != null ? micSign_CTX_CFX : ""; + } + + public String getMicValueAST_CTX_CFXForCsv() { + return micValueAST_CTX_CFX != null ? String.valueOf(micValueAST_CTX_CFX) : ""; + } + + public String getSir_CTX_CFXForCsv() { + return sir_CTX_CFX != null ? sir_CTX_CFX : ""; + } + + public String getMicSign_ERYForCsv() { + return micSign_ERY != null ? micSign_ERY : ""; + } + + public String getMicValueAST_ERYForCsv() { + return micValueAST_ERY != null ? String.valueOf(micValueAST_ERY) : ""; + } + + public String getSir_ERYForCsv() { + return sir_ERY != null ? sir_ERY : ""; + } + + public String getMicSign_PENForCsv() { + return micSign_PEN != null ? micSign_PEN : ""; + } + + public String getMicValueAST_PENForCsv() { + return micValueAST_PEN != null ? String.valueOf(micValueAST_PEN) : ""; + } + + public String getSir_PENForCsv() { + return sir_PEN != null ? sir_PEN : ""; + } + public void calculateAge() { if (symptomOnsetDate == null || yearOfBirth == null || monthOfBirth == null || dayOfBirth == null) { return; diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportFacade.java b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportFacade.java index 1f1e9bfacf2..c9ec4520a78 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportFacade.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseDiseaseExportFacade.java @@ -21,4 +21,8 @@ public interface EpipulseDiseaseExportFacade { public void startPertussisExport(String uuid); + + public void startMeaslesExport(String uuid); + + public void startIpiExport(String uuid); } diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/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..37361afb5e2 --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseLaboratoryMapper.java @@ -0,0 +1,677 @@ +/* + * 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; + } + + // ==================== IPI-Specific Mappers ==================== + + /** + * Maps SORMAS SampleMaterial enum to EpiPulse IPI specimen type codes. + *

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

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

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

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

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

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

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

+ * EpiPulse Reference Values for PNEU PathogenDetectionMethod: + * - COAGG = Coagglutination + * - GDIFF = Gel diffusion + * - MPCR = Multiplex PCR + * - OTH = Other + * - PTEST = Pneumotest + * - QUE = Quellung + * - SLAGG = Slide agglutination + * + * @param testType SORMAS pathogen test type + * @return EpiPulse detection method code, or null if not mappable + */ + public static String mapPathogenTestTypeToDetectionMethod(String testType) { + if (testType == null || testType.trim().isEmpty()) { + return null; + } + + String normalized = testType.trim().toUpperCase(); + + // Map specific test types + if (normalized.contains("PCR") || normalized.contains("RT-PCR") || normalized.contains("RTPCR")) { + if (normalized.contains("MULTIPLEX")) { + return "MPCR"; // Multiplex PCR + } + return "MPCR"; // Default PCR to multiplex + } else if (normalized.contains("QUELLUNG")) { + return "QUE"; + } else if (normalized.contains("COAGG") || normalized.contains("CO-AGGLUTINATION")) { + return "COAGG"; + } else if (normalized.contains("SLIDE") && normalized.contains("AGGLUT")) { + return "SLAGG"; + } else if (normalized.contains("GEL") && normalized.contains("DIFF")) { + return "GDIFF"; + } else if (normalized.contains("PNEUMOTEST")) { + return "PTEST"; + } else if (normalized.contains("CULTURE")) { + return "QUE"; // Culture often followed by Quellung + } else if (normalized.contains("SEROGROUPING") || normalized.contains("SEROTYPING")) { + return "QUE"; // Serotyping typically uses Quellung + } + + return "OTH"; // Other methods + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseSubjectCode.java b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseSubjectCode.java index 6b8e83f526b..6e948475e72 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseSubjectCode.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/EpipulseSubjectCode.java @@ -23,7 +23,9 @@ public enum EpipulseSubjectCode { - PERT(true, Disease.PERTUSSIS, false); + PERT(true, Disease.PERTUSSIS, false), + MEAS(true, Disease.MEASLES, false), + PNEU(true, Disease.INVASIVE_PNEUMOCOCCAL_INFECTION, false); // Invasive Pneumococcal Infection private final boolean diseaseModel; private final Disease disease; diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/referencevalue/EpipulseDiseaseRef.java b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/referencevalue/EpipulseDiseaseRef.java index 177eb7425d2..8db3db3bb10 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/referencevalue/EpipulseDiseaseRef.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/epipulse/referencevalue/EpipulseDiseaseRef.java @@ -20,7 +20,9 @@ public enum EpipulseDiseaseRef { - PERT(EpipulseSubjectCode.PERT); + PERT(EpipulseSubjectCode.PERT), + MEAS(EpipulseSubjectCode.MEAS), + PNEU(EpipulseSubjectCode.PNEU); // Invasive Pneumococcal Infection private final EpipulseSubjectCode[] subjectCodes; diff --git a/sormas-api/src/main/resources/enum.properties b/sormas-api/src/main/resources/enum.properties index 41671c60bd7..ea195b8a149 100644 --- a/sormas-api/src/main/resources/enum.properties +++ b/sormas-api/src/main/resources/enum.properties @@ -1002,7 +1002,7 @@ LivingStatus.REGISTERED_AT_A_RESIDENCE = Registered as living at a residence in MapCaseDisplayMode.CASE_ADDRESS = ... by home address MapCaseDisplayMode.FACILITY = ... by facility MapCaseDisplayMode.FACILITY_OR_CASE_ADDRESS = ... by facility or home address - + MapCaseClassificationOption.ALL_CASES = Show all cases MapCaseClassificationOption.CONFIRMED_CASES_ONLY = Show confirmed cases only @@ -1010,7 +1010,7 @@ MapPeriodType.DAILY = Daily MapPeriodType.WEEKLY = Weekly MapPeriodType.MONTHLY = Monthly MapPeriodType.YEARLY = Yearly - + MeansOfTransport.LOCAL_PUBLIC_TRANSPORT=Local public transport MeansOfTransport.BUS=Bus @@ -2859,4 +2859,11 @@ EpipulseExportStatus.FAILED=Failed EpipulseExportStatus.CANCELLED=Cancelled # EpipulseSubjectCode -EpipulseSubjectCode.PERT = Pertussis \ No newline at end of file +EpipulseSubjectCode.PERT = Pertussis +EpipulseSubjectCode.MEAS = Measles +EpipulseSubjectCode.PNEU = Invasive Pneumococcal Infection + +# EpipulseDiseaseRef +EpipulseDiseaseRef.PERT = Pertussis +EpipulseDiseaseRef.MEAS = Measles +EpipulseDiseaseRef.PNEU = Invasive Pneumococcal Infection 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..9e91096721c --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseCommonDtoMapper.java @@ -0,0 +1,195 @@ +/* + * 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)) { + throw new IllegalStateException("Subject code is missing for Epipulse export row"); + } + dto.setSubjectCode(EpipulseSubjectCode.valueOf(subjectCodeFromDb)); + + // Index 3: National Record ID (case UUID) + dto.setNationalRecordId((String) row[++index]); + + // 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..0f71cb1191f --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseConfigurationLookupService.java @@ -0,0 +1,171 @@ +/* + * 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 { + + if (exportDto == null || exportDto.getSubjectCode() == null) { + throw new IllegalArgumentException("Subject code is required for Epipulse export"); + } + if (StringUtils.isBlank(serverCountryLocale)) { + throw new IllegalArgumentException("Server country code/locale is required for Epipulse export"); + } + + String reportingCountry = lookupReportingCountry(serverCountryLocale); + String serverCountryNutsCode = lookupServerCountryNutsCode(serverCountryName); + String subjectCode = lookupSubjectCode(exportDto.getSubjectCode()); + + 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(java.util.Locale.ROOT)) + .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..34fd5a30724 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportFacadeEjb.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportFacadeEjb.java @@ -15,214 +15,43 @@ 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.IpiCsvExportStrategy; +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 EpipulseCsvExportOrchestrator orchestrator; @EJB - private EpipulseDiseaseExportService diseaseExportService; + private PertussisCsvExportStrategy pertussisStrategy; @EJB - private EpipulseExportFacadeEjb.EpipulseExportFacadeEjbLocal epipulseExportEjb; + private MeaslesCsvExportStrategy measlesStrategy; @EJB - private EpipulseExportService epipulseExportService; + private IpiCsvExportStrategy ipiStrategy; @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); - } - } + public void startMeaslesExport(String uuid) { + orchestrator.orchestrateExport(uuid, diseaseExportService::exportMeaslesCaseBased, measlesStrategy); + } - 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 startIpiExport(String uuid) { + orchestrator.orchestrateExport(uuid, diseaseExportService::exportIpiCaseBased, ipiStrategy); } @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..10f7788cc36 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportService.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseDiseaseExportService.java @@ -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,13 @@ 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.IpiExportStrategy; +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 +52,61 @@ public class EpipulseDiseaseExportService { @PersistenceContext(unitName = ModelConstants.PERSISTENCE_UNIT_NAME) private EntityManager em; - public EpipulseDiseaseExportResult exportPertussisCaseBased(EpipulseExportDto exportDto, String serverCountryLocale, String serverCountryName) - throws SQLException, IllegalStateException, IllegalArgumentException { - - EpipulseDiseaseExportResult exportResult = new EpipulseDiseaseExportResult(); - - try { - //lookup reporting country - //@formatter:off - String reportingCountryQuery = - "select code as reporting_country " + - "from epipulse_location_configuration " + - "where type='Country' and country_iso2_code = :countryIso2Code"; - //@formatter:on - - @SuppressWarnings("unchecked") - String reportingCountry = (String) em.createNativeQuery(reportingCountryQuery) - .setParameter("countryIso2Code", serverCountryLocale) - .getResultStream() - .filter(java.util.Objects::nonNull) - .findFirst() - .orElse(null); - - if (StringUtils.isBlank(reportingCountry)) { - throw new IllegalArgumentException("Invalid server country code: " + serverCountryLocale); - } - - //lookup server country nuts code - //@formatter:off - String serverCountryQuery = - "select nutscode " + - "from country " + - "where lower(defaultname) = :countryName"; - //@formatter:on - - @SuppressWarnings("unchecked") - String serverCountryNutsCode = (String) em.createNativeQuery(serverCountryQuery) - .setParameter("countryName", serverCountryName.toLowerCase()) - .getResultStream() - .filter(java.util.Objects::nonNull) - .findFirst() - .orElse(null); - - //get subject code - //@formatter:off - String subjectCodeQuery = - "select subjectcode " + - "from epipulse_subjectcode_configuration " + - "where disease=:disease and aggregatedreporting='No'"; - //@formatter:on - - @SuppressWarnings("unchecked") - String subjectCode = (String) em.createNativeQuery(subjectCodeQuery) - .setParameter("disease", exportDto.getSubjectCode().name()) - .getResultStream() - .filter(java.util.Objects::nonNull) - .findFirst() - .orElse(null); - - 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; + @EJB + private PertussisExportStrategy pertussisExportStrategy; - dto = new EpipulseDiseaseExportEntryDto(); - dto.setReportingCountry((String) row[++index]); - dto.setDeleted((Boolean) row[++index]); + @EJB + private MeaslesExportStrategy measlesExportStrategy; - String subjectCodeFromDb = (String) row[++index]; - if (!StringUtils.isBlank(subjectCodeFromDb)) { - dto.setSubjectCode(EpipulseSubjectCode.valueOf(subjectCodeFromDb)); - } - - dto.setNationalRecordId((String) row[++index]); + @EJB + private IpiExportStrategy ipiExportStrategy; - 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)); - } + public EpipulseDiseaseExportResult exportPertussisCaseBased(EpipulseExportDto exportDto, String serverCountryLocale, String serverCountryName) + throws SQLException, IllegalStateException, IllegalArgumentException { + return pertussisExportStrategy.export(exportDto, serverCountryLocale, serverCountryName); + } - 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])); + public EpipulseDiseaseExportResult exportMeaslesCaseBased(EpipulseExportDto exportDto, String serverCountryLocale, String serverCountryName) + throws SQLException, IllegalStateException, IllegalArgumentException { + return measlesExportStrategy.export(exportDto, serverCountryLocale, serverCountryName); + } - dto.calculateAge(); + public EpipulseDiseaseExportResult exportIpiCaseBased(EpipulseExportDto exportDto, String serverCountryLocale, String serverCountryName) + throws SQLException, IllegalStateException, IllegalArgumentException { + return ipiExportStrategy.export(exportDto, serverCountryLocale, serverCountryName); + } - pathogenTestCount = dto.getPathogenTests().size(); - if (pathogenTestCount > maxPathogenTests) { - maxPathogenTests = pathogenTestCount; - } + @TransactionAttribute(TransactionAttributeType.REQUIRES_NEW) + public boolean tryClaimExportForProcessing(String exportUuid) { + try { + String sql = "UPDATE epipulse_export SET " + + "status = :newStatus, " + + "status_change_date = now(), " + + "changedate = now() " + + "WHERE uuid = :uuid AND status = :expectedStatus"; + + int updated = em.createNativeQuery(sql) + .setParameter("newStatus", EpipulseExportStatus.IN_PROGRESS.name()) + .setParameter("uuid", exportUuid) + .setParameter("expectedStatus", EpipulseExportStatus.PENDING.name()) + .executeUpdate(); - 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..aec73c45ce5 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseExportTimerEjb.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseExportTimerEjb.java @@ -67,6 +67,12 @@ public void exportDiseaseTimeout(Timer timer) { case PERT: diseaseExportFacadeEjb.startPertussisExport(uuid); break; + case MEAS: + diseaseExportFacadeEjb.startMeaslesExport(uuid); + break; + case PNEU: // Invasive Pneumococcal Infection + diseaseExportFacadeEjb.startIpiExport(uuid); + break; default: logger.warn("No export for subject code: {}", subjectCodeStr); break; diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseSqlCteBuilder.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/EpipulseSqlCteBuilder.java new file mode 100644 index 00000000000..25ea24bc373 --- /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 includeEpidataFields + * if true, includes additional fields required for diseases with epidemiology data + * (epidata_id, investigateddate, clinicalconfirmation) + * @return the filtered_cases CTE SQL fragment + */ + public String buildFilteredCasesCte(boolean includeEpidataFields) { + 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 (includeEpidataFields) { + 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/IpiCsvExportStrategy.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/IpiCsvExportStrategy.java new file mode 100644 index 00000000000..c3afc16034b --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/IpiCsvExportStrategy.java @@ -0,0 +1,182 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2024 Helmholtz-Zentrum für Infektionsforschung GmbH (HZI) + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.backend.epipulse.strategy; + +import java.util.List; + +import javax.ejb.LocalBean; +import javax.ejb.Stateless; + +import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportEntryDto; +import de.symeda.sormas.api.epipulse.EpipulseDiseaseExportResult; + +/** + * CSV export strategy for PNEU (Invasive Pneumococcal Infection) disease. + * Follows EpiPulse metadata specification with 49 fixed CSV columns (no repeatable fields). + *

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

+ * Total: 49 CSV columns + *

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

+ * Column breakdown: + * - 28 common fields (demographic, case identification, hospitalization, outcome) + * - 28 PNEU-specific fields: + * - 1 NRL field (NRLData) + * - 5 clinical/diagnostic fields (DateOfDiagnosis, meningitis, septicaemia, pneumonia -> ClinicalCriteria) + * - 2 laboratory fields (PathogenDetectionMethod, Serotype) + * - 11 PCV vaccination fields (DatePCV1-4, BrandPCV1-4, DosePCV1-4 derived, PcvDoses) + * - 2 PPV vaccination fields (DatePPV, PpvDoses) + * - 10 AST fields (AstMethod, MicSign/MicValue/SIR for CTX_CFX, ERY, PEN) + */ +@Stateless +@LocalBean +public class IpiExportStrategy extends AbstractEpipulseDiseaseExportStrategy { + + @Override + protected String buildDiseaseExportQuery() { + StringBuilder query = new StringBuilder(); + + // Common CTEs (no epidata fields for PNEU metadata) + query.append(sqlCteBuilder.buildVariablesCte()); + query.append(sqlCteBuilder.buildConfigDataCte()); + query.append(sqlCteBuilder.buildFilteredCasesCte(false)); // No epidata fields needed + query.append(sqlCteBuilder.buildPreviousHospitalizationsCte()); + query.append(sqlCteBuilder.buildSamplesCte()); + query.append(sqlCteBuilder.buildPathogenTestsCte()); + query.append(sqlCteBuilder.buildImmunizationsCte()); + query.append(sqlCteBuilder.buildVaccinationsCte()); + + // PNEU-specific CTEs (following metadata specification) + query.append(buildPneuSerotypingDataCte()); + query.append(buildPneuPathogenDetectionMethodCte()); + query.append(buildPneuClinicalCriteriaCte()); + query.append(buildPneuVaccinationDetailsCte()); + // Note: AST data not currently stored in SORMAS for PNEU - fields will be NULL + // query.append(buildPneuAstDataCte()); + + // Main SELECT clause with all 56 columns (28 common + 28 PNEU-specific) + query.append(buildPneuSelectClause()); + + return query.toString(); + } + + /** + * Maps all 28 PNEU-specific fields from SQL result row to DTO. + * Common fields (28) are already mapped by parent class, giving 56 total columns. + */ + @Override + protected void mapDiseaseSpecificFields(EpipulseDiseaseExportEntryDto dto, Object[] row, int startIndex) { + int index = startIndex; + + // Field 29: NRLData (Boolean) - currently not tracked + Boolean nrlData = (Boolean) row[++index]; + dto.setNrlData(nrlData); + + // Field 30: DateOfDiagnosis (Date) + Date dateOfDiagnosis = (Date) row[++index]; + dto.setDateOfDiagnosis(dateOfDiagnosis); + + // Fields 31-33: Clinical criteria symptoms (for mapping to ClinicalCriteria) + String meningitisRaw = (String) row[++index]; + String septicaemiaRaw = (String) row[++index]; + String pneumoniaRaw = (String) row[++index]; + + SymptomState meningitis = parseSymptomState(meningitisRaw); + SymptomState septicaemia = parseSymptomState(septicaemiaRaw); + SymptomState pneumonia = parseSymptomState(pneumoniaRaw); + + // Map symptoms to ClinicalCriteria codes (BACTERPNEUMO, MENI, MENISEPTI, SEPTI) + String clinicalCriteria = EpipulseLaboratoryMapper.mapSymptomsToClinicalCriteria(meningitis, septicaemia, pneumonia); + dto.setClinicalCriteria(clinicalCriteria); + + // Field 34: PathogenDetectionMethod (String - test type) + String detectionMethodRaw = (String) row[++index]; + if (!StringUtils.isBlank(detectionMethodRaw)) { + String detectionMethod = EpipulseLaboratoryMapper.mapPathogenTestTypeToDetectionMethod(detectionMethodRaw); + dto.setPathogenDetectionMethod(detectionMethod); + } + + // Field 35: Serotype (String) + String serotypeRaw = (String) row[++index]; + if (!StringUtils.isBlank(serotypeRaw)) { + dto.setSerotype(EpipulseLaboratoryMapper.normalizeSerotypeForEpipulse(serotypeRaw)); + } + + // Fields 36-48: Vaccination details - PCV doses 1-4 + Date datePCV1 = (Date) row[++index]; + dto.setDatePCV1(datePCV1); + dto.setDosePCV1(datePCV1 != null); + + String brandPCV1Raw = (String) row[++index]; + if (!StringUtils.isBlank(brandPCV1Raw)) { + dto.setBrandPCV1(EpipulseLaboratoryMapper.mapVaccineToEpipulseCode(brandPCV1Raw)); + } + + Date datePCV2 = (Date) row[++index]; + dto.setDatePCV2(datePCV2); + dto.setDosePCV2(datePCV2 != null); + + String brandPCV2Raw = (String) row[++index]; + if (!StringUtils.isBlank(brandPCV2Raw)) { + dto.setBrandPCV2(EpipulseLaboratoryMapper.mapVaccineToEpipulseCode(brandPCV2Raw)); + } + + Date datePCV3 = (Date) row[++index]; + dto.setDatePCV3(datePCV3); + dto.setDosePCV3(datePCV3 != null); + + String brandPCV3Raw = (String) row[++index]; + if (!StringUtils.isBlank(brandPCV3Raw)) { + dto.setBrandPCV3(EpipulseLaboratoryMapper.mapVaccineToEpipulseCode(brandPCV3Raw)); + } + + Date datePCV4 = (Date) row[++index]; + dto.setDatePCV4(datePCV4); + dto.setDosePCV4(datePCV4 != null); + + String brandPCV4Raw = (String) row[++index]; + if (!StringUtils.isBlank(brandPCV4Raw)) { + dto.setBrandPCV4(EpipulseLaboratoryMapper.mapVaccineToEpipulseCode(brandPCV4Raw)); + } + + // PCV dose count + Number pcvDosesNum = (Number) row[++index]; + dto.setPcvDoses(pcvDosesNum != null ? pcvDosesNum.intValue() : null); + + // Fields 49-50: Vaccination details - PPV doses + Date datePPV = (Date) row[++index]; + dto.setDatePPV(datePPV); + dto.setDosePPV(datePPV != null); + + Number ppvDosesNum = (Number) row[++index]; + dto.setPpvDoses(ppvDosesNum != null ? ppvDosesNum.intValue() : null); + + // Fields 51-60: AST (Antimicrobial Susceptibility Testing) data + // AST Method (not stored in SORMAS currently) + String astMethod = (String) row[++index]; + dto.setAstMethod(astMethod); + + // CTX/CFX (Ceftriaxone/Cefotaxime) + String micSignCTX = (String) row[++index]; + dto.setMicSign_CTX_CFX(micSignCTX); + + Double micValueCTX = (Double) row[++index]; + dto.setMicValueAST_CTX_CFX(micValueCTX); + + String sirCTXRaw = (String) row[++index]; + if (!StringUtils.isBlank(sirCTXRaw)) { + dto.setSir_CTX_CFX(EpipulseLaboratoryMapper.mapDrugSusceptibilityToSIR(sirCTXRaw)); + } + + // ERY (Erythromycin) + String micSignERY = (String) row[++index]; + dto.setMicSign_ERY(micSignERY); + + Double micValueERY = (Double) row[++index]; + dto.setMicValueAST_ERY(micValueERY); + + String sirERYRaw = (String) row[++index]; + if (!StringUtils.isBlank(sirERYRaw)) { + dto.setSir_ERY(EpipulseLaboratoryMapper.mapDrugSusceptibilityToSIR(sirERYRaw)); + } + + // PEN (Penicillin) + String micSignPEN = (String) row[++index]; + dto.setMicSign_PEN(micSignPEN); + + Double micValuePEN = (Double) row[++index]; + dto.setMicValueAST_PEN(micValuePEN); + + String sirPENRaw = (String) row[++index]; + if (!StringUtils.isBlank(sirPENRaw)) { + dto.setSir_PEN(EpipulseLaboratoryMapper.mapDrugSusceptibilityToSIR(sirPENRaw)); + } + } + + @Override + protected void calculateDiseaseSpecificMaxCounts(List entries, EpipulseDiseaseExportResult result) { + // PNEU has no repeatable fields - all 56 columns are fixed (no dynamic column generation needed) + // Common repeatable fields (pathogenTests, immunizations) are handled by parent class + } + + // Helper methods for PNEU-specific CTEs (following metadata specification) + + /** + * CTE for serotype data from pathogen tests + */ + private String buildPneuSerotypingDataCte() { + //@formatter:off + return ", pneu_serotyping AS (SELECT c.id as case_id," + + " (SELECT pt.typingid " + + " FROM samples s " + + " JOIN pathogentest pt ON pt.sample_id = s.id " + + " WHERE s.associatedcase_id = c.id " + + " AND s.deleted = false " + + " AND pt.testtype IN ('SEROGROUPING', 'GENOTYPING', 'WHOLE_GENOME_SEQUENCING') " + + " AND pt.typingid IS NOT NULL " + + " ORDER BY pt.testdatetime DESC " + + " LIMIT 1) as serotype " + + " FROM filtered_cases c), "; + //@formatter:on + } + + /** + * CTE for pathogen detection method from pathogen tests + */ + private String buildPneuPathogenDetectionMethodCte() { + //@formatter:off + return "pneu_detection_method AS (SELECT c.id as case_id," + + " (SELECT CAST(pt.testtype AS text) " + + " FROM samples s " + + " JOIN pathogentest pt ON pt.sample_id = s.id " + + " WHERE s.associatedcase_id = c.id " + + " AND s.deleted = false " + + " AND pt.testresultverified = true " + + " ORDER BY pt.testdatetime DESC " + + " LIMIT 1) as detection_method " + + " FROM filtered_cases c), "; + //@formatter:on + } + + /** + * CTE for clinical criteria from symptoms + */ + private String buildPneuClinicalCriteriaCte() { + //@formatter:off + return "pneu_clinical_criteria AS (SELECT c.id as case_id," + + " CAST(s.meningitis AS text) as meningitis," + + " CAST(s.septicaemia AS text) as septicaemia," + + " CAST(s.pneumoniaclinicalorradiologic AS text) as pneumonia " + + " FROM filtered_cases c " + + " JOIN symptoms s ON c.symptoms_id = s.id), "; + //@formatter:on + } + + /** + * CTE for detailed vaccination data (PCV1-4, PPV doses) + */ + private String buildPneuVaccinationDetailsCte() { + //@formatter:off + return "pneu_vaccination_details AS (" + + " SELECT vax.case_id," + + " MAX(CASE WHEN vax.is_pcv AND vax.pcv_row_num = 1 THEN vax.vaccinationdate END) as date_pcv1," + + " MAX(CASE WHEN vax.is_pcv AND vax.pcv_row_num = 1 THEN CAST(vax.vaccinename AS text) END) as brand_pcv1," + + " MAX(CASE WHEN vax.is_pcv AND vax.pcv_row_num = 2 THEN vax.vaccinationdate END) as date_pcv2," + + " MAX(CASE WHEN vax.is_pcv AND vax.pcv_row_num = 2 THEN CAST(vax.vaccinename AS text) END) as brand_pcv2," + + " MAX(CASE WHEN vax.is_pcv AND vax.pcv_row_num = 3 THEN vax.vaccinationdate END) as date_pcv3," + + " MAX(CASE WHEN vax.is_pcv AND vax.pcv_row_num = 3 THEN CAST(vax.vaccinename AS text) END) as brand_pcv3," + + " MAX(CASE WHEN vax.is_pcv AND vax.pcv_row_num = 4 THEN vax.vaccinationdate END) as date_pcv4," + + " MAX(CASE WHEN vax.is_pcv AND vax.pcv_row_num = 4 THEN CAST(vax.vaccinename AS text) END) as brand_pcv4," + + " SUM(CASE WHEN vax.is_pcv THEN 1 ELSE 0 END) as pcv_doses," + + " MAX(CASE WHEN vax.is_ppv AND vax.ppv_row_num = 1 THEN vax.vaccinationdate END) as date_ppv," + + " SUM(CASE WHEN vax.is_ppv THEN 1 ELSE 0 END) as ppv_doses " + + " FROM (" + + " SELECT c.id as case_id," + + " v.vaccinationdate," + + " v.vaccinename," + + " CASE " + + " WHEN CAST(v.vaccinename AS text) LIKE '%PCV%' OR CAST(v.vaccinename AS text) LIKE '%CONJUGATE%' THEN true " + + " ELSE false " + + " END as is_pcv," + + " CASE " + + " WHEN CAST(v.vaccinename AS text) LIKE '%PPV%' OR CAST(v.vaccinename AS text) LIKE '%POLYSACCHARIDE%' THEN true " + + " ELSE false " + + " END as is_ppv," + + " SUM(CASE WHEN CAST(v.vaccinename AS text) LIKE '%PCV%' OR CAST(v.vaccinename AS text) LIKE '%CONJUGATE%' THEN 1 ELSE 0 END) " + + " OVER (PARTITION BY c.id ORDER BY v.vaccinationdate) as pcv_row_num," + + " SUM(CASE WHEN CAST(v.vaccinename AS text) LIKE '%PPV%' OR CAST(v.vaccinename AS text) LIKE '%POLYSACCHARIDE%' THEN 1 ELSE 0 END) " + + " OVER (PARTITION BY c.id ORDER BY v.vaccinationdate) as ppv_row_num " + + " FROM filtered_cases c " + + " LEFT JOIN vaccination v ON v.immunization_id IN (SELECT i.id FROM immunization i WHERE i.person_id = c.person_id AND i.disease = 'INVASIVE_PNEUMOCOCCAL_INFECTION') " + + " ) vax " + + " GROUP BY vax.case_id) "; + //@formatter:on + } + + /** + * CTE for antimicrobial susceptibility testing (AST) data + * Maps to CTX/CFX (Ceftriaxone), ERY (Erythromycin), PEN (Penicillin) + */ + private String buildPneuAstDataCte() { + //@formatter:off + return "pneu_ast_data AS (" + + " SELECT c.id as case_id," + + " MAX(CASE WHEN antibiotic = 'CEFTRIAXONE' THEN mic_value END) as mic_ctx," + + " MAX(CASE WHEN antibiotic = 'CEFTRIAXONE' THEN CAST(susceptibility AS text) END) as sir_ctx," + + " MAX(CASE WHEN antibiotic = 'ERYTHROMYCIN' THEN mic_value END) as mic_ery," + + " MAX(CASE WHEN antibiotic = 'ERYTHROMYCIN' THEN CAST(susceptibility AS text) END) as sir_ery," + + " MAX(CASE WHEN antibiotic = 'PENICILLIN' THEN mic_value END) as mic_pen," + + " MAX(CASE WHEN antibiotic = 'PENICILLIN' THEN CAST(susceptibility AS text) END) as sir_pen " + + " FROM filtered_cases c " + + " LEFT JOIN samples s ON s.associatedcase_id = c.id AND s.deleted = false " + + " LEFT JOIN pathogentest pt ON pt.sample_id = s.id AND pt.testtype = 'ANTIBIOTIC_SUSCEPTIBILITY' " + + " LEFT JOIN LATERAL (" + + " SELECT 'CEFTRIAXONE' as antibiotic, pt.ceftriaxonemic as mic_value, pt.ceftriaxonesusceptibility as susceptibility " + + " UNION ALL " + + " SELECT 'ERYTHROMYCIN', pt.erythromycinmic, pt.erythromycinsusceptibility " + + " UNION ALL " + + " SELECT 'PENICILLIN', pt.penicillinmic, pt.penicillinsusceptibility " + + " ) ast_data ON true " + + " GROUP BY c.id) "; + //@formatter:on + } + + /** + * Builds SELECT clause with all 56 columns (28 common + 28 PNEU-specific). + */ + private String buildPneuSelectClause() { + StringBuilder select = new StringBuilder(); + //@formatter:off + // Use common SELECT fields (28 fields) and append PNEU-specific fields (28 fields) + select.append("SELECT ") + .append(sqlCteBuilder.buildCommonSelectFields()) + .append(",") + // PNEU-specific fields start here + // Demographics: NRLData (field 29) + .append(" NULL as nrl_data,") // Not tracked in SORMAS currently + // Clinical/Diagnostic: DateOfDiagnosis, ClinicalCriteria (fields 30-31) + .append(" c.reportdate as date_of_diagnosis,") // Using report date as diagnosis date + .append(" pneu_clin.meningitis,") + .append(" pneu_clin.septicaemia,") + .append(" pneu_clin.pneumonia,") + // Laboratory: PathogenDetectionMethod, Serotype (fields 32-33) + .append(" pneu_detect.detection_method,") + .append(" pneu_sero.serotype,") + // Vaccination summary: DateOfLastVaccination, Vaccine, VaccinationStatus (fields 34-36) + // These come from common immunizations/vaccinations CTEs + // Vaccination details: PCV doses 1-4 (fields 37-48) + .append(" pneu_vax.date_pcv1,") + .append(" pneu_vax.brand_pcv1,") + .append(" pneu_vax.date_pcv2,") + .append(" pneu_vax.brand_pcv2,") + .append(" pneu_vax.date_pcv3,") + .append(" pneu_vax.brand_pcv3,") + .append(" pneu_vax.date_pcv4,") + .append(" pneu_vax.brand_pcv4,") + .append(" pneu_vax.pcv_doses,") + // Vaccination details: PPV doses (fields 49-50) + .append(" pneu_vax.date_ppv,") + .append(" pneu_vax.ppv_doses,") + // AST fields (fields 51-60) - Not currently stored in SORMAS + .append(" NULL as ast_method,") + .append(" NULL as mic_sign_ctx,") + .append(" NULL as mic_ctx,") + .append(" NULL as sir_ctx,") + .append(" NULL as mic_sign_ery,") + .append(" NULL as mic_ery,") + .append(" NULL as sir_ery,") + .append(" NULL as mic_sign_pen,") + .append(" NULL as mic_pen,") + .append(" NULL as sir_pen "); + + // Use common FROM and JOINs, then append PNEU-specific joins + select.append(sqlCteBuilder.buildCommonFromAndJoins()) + .append(" LEFT JOIN pneu_serotyping pneu_sero ON pneu_sero.case_id = c.id") + .append(" LEFT JOIN pneu_detection_method pneu_detect ON pneu_detect.case_id = c.id") + .append(" LEFT JOIN pneu_clinical_criteria pneu_clin ON pneu_clin.case_id = c.id") + .append(" LEFT JOIN pneu_vaccination_details pneu_vax ON pneu_vax.case_id = c.id"); + // Note: pneu_ast_data CTE not used - AST fields returned as NULL + + select.append(" ORDER BY c.reportdate"); + //@formatter:on + + return select.toString(); + } + + private SymptomState parseSymptomState(String value) { + if (StringUtils.isBlank(value)) { + return null; + } + try { + return SymptomState.valueOf(value); + } catch (IllegalArgumentException e) { + logger.warn("Invalid SymptomState value '{}', treating as null", value); + return null; + } + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesCsvExportStrategy.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesCsvExportStrategy.java new file mode 100644 index 00000000000..a35fccb6401 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesCsvExportStrategy.java @@ -0,0 +1,198 @@ +/* + * 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 36+ columns with 5 repeatable field types: + * - SpecimenVirDetect (virus detection specimen) + * - SpecimenSero (serology specimen) + * - ClusterSetting + * - ComplicationDiagnosis + * - PlaceOfInfection + */ +@Stateless +@LocalBean +public class MeaslesCsvExportStrategy implements CsvExportStrategy { + + @Override + public List buildColumnNames(EpipulseDiseaseExportResult exportResult) { + // Follow exact metadata order for MEAS subject code + List columnNames = new ArrayList<>( + List.of( + "Disease", + "ReportingCountry", + "Status", + "SubjectCode", + "NationalRecordId", + "DataSource", + "DateUsedForStatistics", + "Age", + "AgeMonth", + "Gender", + "CaseClassification", + "DateOfOnset", + "DateOfInvestigation", + "DateOfNotification", + "Hospitalisation", + "Outcome", + "CauseOfDeath", + "ClinicalCriteriaStatus")); + + // Repeatable field: ComplicationDiagnosis + if (exportResult.getMaxComplicationDiagnosis() > 0) { + for (int i = 1; i <= exportResult.getMaxComplicationDiagnosis(); i++) { + columnNames.add("ComplicationDiagnosis"); + } + } + + columnNames.addAll(List.of("ClusterRelated", "ClusterId")); + + // Repeatable field: ClusterSetting + if (exportResult.getMaxClusterSettings() > 0) { + for (int i = 1; i <= exportResult.getMaxClusterSettings(); i++) { + columnNames.add("ClusterSetting"); + } + } + + columnNames.addAll(List.of("DateOfLastVaccination", "VaccinationStatus", "ImportedStatus")); + + // Repeatable field: PlaceOfInfection + if (exportResult.getMaxPlaceOfInfection() > 0) { + for (int i = 1; i <= exportResult.getMaxPlaceOfInfection(); i++) { + columnNames.add("PlaceOfInfection"); + } + } + + columnNames.addAll(List.of("PlaceOfNotification", "PlaceOfResidence", "DateOfSpecimen", "DateOfLabResult")); + + // Repeatable field: SpecimenVirDetect (virus detection) + if (exportResult.getMaxSpecimenVirDetect() > 0) { + for (int i = 1; i <= exportResult.getMaxSpecimenVirDetect(); i++) { + columnNames.add("SpecimenVirDetect"); + } + } + + columnNames.addAll(List.of("ResultVirDetect", "Genotype")); + + // Repeatable field: SpecimenSero (specimen for serology) + if (exportResult.getMaxSpecimenSero() > 0) { + for (int i = 1; i <= exportResult.getMaxSpecimenSero(); i++) { + columnNames.add("SpecimenSero"); + } + } + + columnNames.addAll(List.of("ResultIgG", "ResultIgM")); + + return columnNames; + } + + @Override + public int writeEntryRow(EpipulseDiseaseExportEntryDto dto, String[] exportLine, EpipulseDiseaseExportResult exportResult) { + int index = -1; + + // Write fixed columns in metadata order + 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.getDateOfInvestigationForCsv(); + exportLine[++index] = dto.getDateOfNotificationForCsv(); + exportLine[++index] = dto.getHospitalizationForCsv(); + exportLine[++index] = dto.getOutcomeForCsv(); + exportLine[++index] = dto.getCauseOfDeathForCsv(); + exportLine[++index] = dto.getClinicalCriteriaStatusForCsv(); + + // Repeatable: ComplicationDiagnosis + if (exportResult.getMaxComplicationDiagnosis() > 0) { + List complications = dto.getComplicationDiagnosisForCsv(exportResult.getMaxComplicationDiagnosis()); + for (String complication : complications) { + exportLine[++index] = complication; + } + } + + 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.getDateOfLastVaccinationForCsv(); + exportLine[++index] = dto.getVaccinationStatusForCsv(); + exportLine[++index] = dto.getImportedStatusForCsv(); + + // Repeatable: PlaceOfInfection + if (exportResult.getMaxPlaceOfInfection() > 0) { + List placesOfInfection = dto.getPlaceOfInfectionForCsv(exportResult.getMaxPlaceOfInfection()); + for (String place : placesOfInfection) { + exportLine[++index] = place; + } + } + + exportLine[++index] = dto.getPlaceOfNotificationForCsv(); + exportLine[++index] = dto.getPlaceOfResidenceForCsv(); + + // Laboratory fields + exportLine[++index] = dto.getDateOfSpecimenForCsv(); + exportLine[++index] = dto.getDateOfLaboratoryResultForCsv(); + + // Repeatable: SpecimenVirDetect (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: SpecimenSero (serology) + if (exportResult.getMaxSpecimenSero() > 0) { + List specimenSerology = dto.getTypeOfSpecimenSerologyForCsv(exportResult.getMaxSpecimenSero()); + for (String specimen : specimenSerology) { + exportLine[++index] = specimen; + } + } + + exportLine[++index] = dto.getResultIgGForCsv(); + exportLine[++index] = dto.getResultIgMForCsv(); + + return index; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesExportStrategy.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epipulse/strategy/MeaslesExportStrategy.java new file mode 100644 index 00000000000..856317d6a76 --- /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 epidata 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..488cbf06634 --- /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 epidata fields needed for Pertussis + 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; + } +}