From 5b943613436833db0e07eb1aeb62fec2843d6083 Mon Sep 17 00:00:00 2001 From: vishwab1 Date: Fri, 25 Sep 2026 09:52:47 +0530 Subject: [PATCH 1/4] chore: bump pom version to 3.9.1 Co-Authored-By: Claude Opus 5.5 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 1d3e8872..bfec2491 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.iemr.common.flw flw-api - 3.8.4 + 3.9.1 war FLW-API From 0e6d12a5164cd92e1999e3dc476145b50dfa2708 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Tue, 15 Sep 2026 16:37:55 +0530 Subject: [PATCH 2/4] Dropping duplicate columns in table tb_stoptb_general_examination --- .../domain/iemr/StopTBGeneralExamination.java | 17 ++++------------- .../flw/service/impl/StopTBServiceImpl.java | 17 +++++++++-------- 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/iemr/flw/domain/iemr/StopTBGeneralExamination.java b/src/main/java/com/iemr/flw/domain/iemr/StopTBGeneralExamination.java index 9972d916..59b271e1 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/StopTBGeneralExamination.java +++ b/src/main/java/com/iemr/flw/domain/iemr/StopTBGeneralExamination.java @@ -72,19 +72,10 @@ public class StopTBGeneralExamination { @Column(name = "clubbing") private String clubbing; - // JSON arrays serialised from mobile - @Column(name = "key_population_risk_factor_ids", columnDefinition = "TEXT") - private String keyPopulationRiskFactorIds; - - @Column(name = "key_population_risk_factors", columnDefinition = "TEXT") - private String keyPopulationRiskFactors; - - @Column(name = "hiv_status_id") - private Integer hivStatusId; - - // "Positive" | "Reactive" | "Negative" | "Unknown" - @Column(name = "hiv_status") - private String hivStatus; + // key_population_risk_factor_ids/_factors and hiv_status_id/hiv_status were removed from + // this table — TB Screening (TBScreening) is the single source of truth for these fields + // (see StopTBServiceImpl#examToMap, and the AMRIT-DB migration that backfilled tb_screening + // from this table's historical data before dropping the columns here). @Column(name = "referral_to_hwc_needed_id") private Integer referralToHWCNeededId; diff --git a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java index c95364ee..90e9d6ba 100644 --- a/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/StopTBServiceImpl.java @@ -134,10 +134,8 @@ public List> saveGeneralExamination(List exam.setCyanosis(getString(data, "cyanosis")); exam.setClubbingId(getInt(data, "clubbingId")); exam.setClubbing(getString(data, "clubbing")); - exam.setKeyPopulationRiskFactorIds(toJsonString(data.get("keyPopulationRiskFactorIds"))); - exam.setKeyPopulationRiskFactors(toJsonString(data.get("keyPopulationRiskFactors"))); - exam.setHivStatusId(getInt(data, "hivStatusId")); - exam.setHivStatus(getString(data, "hivStatus")); + // hivStatus*/keyPopulationRiskFactor* are intentionally not set here — TB Screening + // is the single source of truth for these fields (see examToMap). exam.setCreatedBy(getString(data, "createdBy")); exam.setModifiedBy(getString(data, "createdBy")); exam.setDeleted(false); @@ -203,10 +201,13 @@ private Map examToMap(StopTBGeneralExamination e) { m.put("cyanosis", e.getCyanosis()); m.put("clubbingId", e.getClubbingId()); m.put("clubbing", e.getClubbing()); - m.put("keyPopulationRiskFactorIds", e.getKeyPopulationRiskFactorIds()); - m.put("keyPopulationRiskFactors", e.getKeyPopulationRiskFactors()); - m.put("hivStatusId", e.getHivStatusId()); - m.put("hivStatus", e.getHivStatus()); + // hivStatus*/keyPopulationRiskFactor* are sourced from TB Screening (the source of + // truth for these fields) rather than from this entity's own (deprecated) columns. + TBScreening screening = tbScreeningRepo.findByBenRegIDAndVisitCode(e.getBeneficiaryRegID(), e.getVisitCode()); + m.put("keyPopulationRiskFactorIds", screening != null ? screening.getKeyPopulationRiskFactorIds() : null); + m.put("keyPopulationRiskFactors", screening != null ? screening.getKeyPopulationRiskFactors() : null); + m.put("hivStatusId", screening != null ? screening.getHivStatusId() : null); + m.put("hivStatus", screening != null ? screening.getHivStatus() : null); m.put("referralToHWCNeeded", e.getReferralToHWCNeeded()); m.put("createdBy", e.getCreatedBy()); m.put("createdDate", e.getCreatedDate()); From e85c27c2f2c1eb6eeb575ffd2fa89402b9b54045 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Tue, 22 Sep 2026 10:37:07 +0530 Subject: [PATCH 3/4] Adding migration applier and removing column migration file --- .../domain/iemr/DynamicFormMigrationLog.java | 53 +++ .../iemr/flw/domain/iemr/QuestionOption.java | 5 + .../iemr/flw/domain/iemr/SectionQuestion.java | 5 + .../com/iemr/flw/dto/iemr/FormSectionDTO.java | 2 +- .../iemr/flw/dto/iemr/QuestionOptionDTO.java | 3 +- .../iemr/flw/dto/iemr/SectionQuestionDTO.java | 2 +- .../iemr/DynamicFormMigrationLogRepo.java | 34 ++ .../iemr/flw/repo/iemr/FormSectionRepo.java | 13 + .../flw/repo/iemr/OptionConditionRepo.java | 8 + .../flw/repo/iemr/QuestionOptionRepo.java | 17 +- .../flw/repo/iemr/QuestionValidationRepo.java | 6 + .../flw/repo/iemr/SectionQuestionRepo.java | 20 +- .../DynamicFormMigrationApplier.java | 49 +++ .../migration/DynamicFormMigrationRunner.java | 73 ++++ .../migration/FormStructureMigration.java | 78 +++++ .../migration/V001_ReplaceTfuAdrOptions.java | 104 ++++++ .../DynamicFormReconciliationService.java | 101 ++++++ .../DynamicFormReconciliationServiceImpl.java | 324 ++++++++++++++++++ 18 files changed, 891 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/iemr/flw/domain/iemr/DynamicFormMigrationLog.java create mode 100644 src/main/java/com/iemr/flw/repo/iemr/DynamicFormMigrationLogRepo.java create mode 100644 src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationApplier.java create mode 100644 src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationRunner.java create mode 100644 src/main/java/com/iemr/flw/seeder/migration/FormStructureMigration.java create mode 100644 src/main/java/com/iemr/flw/seeder/migration/V001_ReplaceTfuAdrOptions.java create mode 100644 src/main/java/com/iemr/flw/service/DynamicFormReconciliationService.java create mode 100644 src/main/java/com/iemr/flw/service/impl/DynamicFormReconciliationServiceImpl.java diff --git a/src/main/java/com/iemr/flw/domain/iemr/DynamicFormMigrationLog.java b/src/main/java/com/iemr/flw/domain/iemr/DynamicFormMigrationLog.java new file mode 100644 index 00000000..1ff0158d --- /dev/null +++ b/src/main/java/com/iemr/flw/domain/iemr/DynamicFormMigrationLog.java @@ -0,0 +1,53 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * 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 https://www.gnu.org/licenses/. + */ +package com.iemr.flw.domain.iemr; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +import java.sql.Timestamp; + +/** + * One row per applied {@code com.iemr.flw.seeder.migration.FormStructureMigration}, keyed by its + * stable migrationId — lets {@code DynamicFormMigrationRunner} skip a migration it has already run. + */ +@Entity +@Table(name = "t_dynamic_form_migration_log", schema = "db_iemr") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DynamicFormMigrationLog { + + @Id + @Column(name = "migrationId", length = 50) + private String migrationId; + + @CreationTimestamp + @Column(name = "appliedAt", nullable = false, updatable = false) + private Timestamp appliedAt; +} diff --git a/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java b/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java index 124ea6fa..645fa0d1 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java +++ b/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java @@ -76,6 +76,11 @@ public class QuestionOption { @Column(name = "displayOrder", nullable = false) private Integer displayOrder; + /** Soft-delete flag: false means removed from its question. Never hard-deleted — historical + * QuestionResponse rows reference optionId as a plain Long outside JPA's cascade graph. */ + @Column(name = "isActive", nullable = false) + private Boolean isActive = true; + @Column(name = "created_by", length = 100) private String createdBy; diff --git a/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java b/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java index 1aa324cb..a50418d0 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java +++ b/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java @@ -98,6 +98,11 @@ public class SectionQuestion { @Column(name = "containsPii", nullable = false) private Boolean containsPii = false; + /** Soft-delete flag: false means unlinked from its section. Never hard-deleted — historical + * QuestionResponse rows reference questionId as a plain Long outside JPA's cascade graph. */ + @Column(name = "isActive", nullable = false) + private Boolean isActive = true; + @Column(name = "created_by", length = 100) private String createdBy; diff --git a/src/main/java/com/iemr/flw/dto/iemr/FormSectionDTO.java b/src/main/java/com/iemr/flw/dto/iemr/FormSectionDTO.java index 6bb10795..0d950d2a 100644 --- a/src/main/java/com/iemr/flw/dto/iemr/FormSectionDTO.java +++ b/src/main/java/com/iemr/flw/dto/iemr/FormSectionDTO.java @@ -55,7 +55,7 @@ public class FormSectionDTO { private Boolean isRequired = true; - @NotNull(message = "displayOrder is required") + /** Omit to auto-append after existing siblings; set explicitly to insert at that position (existing siblings shift up). */ private Integer displayOrder; private Boolean hasSubmitButton = false; diff --git a/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java b/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java index 6682404a..d1547c3b 100644 --- a/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java +++ b/src/main/java/com/iemr/flw/dto/iemr/QuestionOptionDTO.java @@ -23,7 +23,6 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -51,7 +50,7 @@ public class QuestionOptionDTO { private String optionValueHindi; - @NotNull(message = "displayOrder is required") + /** Omit to auto-append after existing siblings; set explicitly to insert at that position (existing siblings shift up). */ private Integer displayOrder; @Valid diff --git a/src/main/java/com/iemr/flw/dto/iemr/SectionQuestionDTO.java b/src/main/java/com/iemr/flw/dto/iemr/SectionQuestionDTO.java index 11a81430..2198ccc4 100644 --- a/src/main/java/com/iemr/flw/dto/iemr/SectionQuestionDTO.java +++ b/src/main/java/com/iemr/flw/dto/iemr/SectionQuestionDTO.java @@ -56,7 +56,7 @@ public class SectionQuestionDTO { private Boolean isMandatory = true; - @NotNull(message = "displayOrder is required") + /** Omit to auto-append after existing siblings; set explicitly to insert at (or move to) that position (existing siblings shift). */ private Integer displayOrder; private Integer maxLength; diff --git a/src/main/java/com/iemr/flw/repo/iemr/DynamicFormMigrationLogRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DynamicFormMigrationLogRepo.java new file mode 100644 index 00000000..9f993076 --- /dev/null +++ b/src/main/java/com/iemr/flw/repo/iemr/DynamicFormMigrationLogRepo.java @@ -0,0 +1,34 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * 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 https://www.gnu.org/licenses/. + */ +package com.iemr.flw.repo.iemr; + +import com.iemr.flw.domain.iemr.DynamicFormMigrationLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * Repository for applied form-structure migration records. + * Id type is the migrationId String itself — existsById/save need no custom methods. + */ +@Repository +public interface DynamicFormMigrationLogRepo extends JpaRepository { +} diff --git a/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java index 9f135959..fa6acdf1 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java @@ -24,12 +24,14 @@ import com.iemr.flw.domain.iemr.FormSection; import com.iemr.flw.masterEnum.SectionPhase; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Collection; import java.util.List; +import java.util.Optional; /** * Repository for form sections. @@ -40,6 +42,17 @@ public interface FormSectionRepo extends JpaRepository { List findByFormVersion_VersionIdOrderByDisplayOrderAsc(Long versionId); + Optional findByFormVersion_VersionIdAndSectionUuid(Long versionId, String sectionUuid); + + Optional findTopByFormVersion_VersionIdOrderByDisplayOrderDesc(Long versionId); + + /** Shifts displayOrder by delta for every section in [from, to] within a version — used to make room for/close a gap around an inserted or moved sibling. */ + @Modifying + @Query("UPDATE FormSection s SET s.displayOrder = s.displayOrder + :delta " + + "WHERE s.formVersion.versionId = :versionId AND s.displayOrder BETWEEN :from AND :to") + void shiftDisplayOrder(@Param("versionId") Long versionId, @Param("from") int from, + @Param("to") int to, @Param("delta") int delta); + @Query("SELECT fs.formVersion.versionId, COUNT(fs) FROM FormSection fs " + "WHERE fs.formVersion.versionId IN :versionIds " + "AND fs.sectionPhase = :sectionPhase " + diff --git a/src/main/java/com/iemr/flw/repo/iemr/OptionConditionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/OptionConditionRepo.java index 63341889..c82040ea 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/OptionConditionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/OptionConditionRepo.java @@ -29,6 +29,7 @@ import java.util.Collection; import java.util.List; +import java.util.Optional; /** * Repository for option conditions. @@ -38,6 +39,13 @@ public interface OptionConditionRepo extends JpaRepository findByQuestionOption_OptionId(Long optionId); + /** No surrogate natural key on conditions — matched by (actionType, target) within an option. */ + Optional findByQuestionOption_OptionIdAndActionTypeAndTargetQuestion_QuestionUuid( + Long optionId, String actionType, String targetQuestionUuid); + + Optional findByQuestionOption_OptionIdAndActionTypeAndTargetSection_SectionUuid( + Long optionId, String actionType, String targetSectionUuid); + @Query("SELECT oc.targetQuestion.questionId FROM OptionCondition oc " + "WHERE oc.questionOption.sectionQuestion.formSection.formVersion.versionId = :versionId " + "AND oc.targetQuestion IS NOT NULL") diff --git a/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java index f7512055..613abb26 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java @@ -23,12 +23,14 @@ import com.iemr.flw.domain.iemr.QuestionOption; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Collection; import java.util.List; +import java.util.Optional; /** * Repository for question options. @@ -38,11 +40,24 @@ public interface QuestionOptionRepo extends JpaRepository List findBySectionQuestion_QuestionIdOrderByDisplayOrderAsc(Long questionId); + Optional findBySectionQuestion_QuestionIdAndOptionValue(Long questionId, String optionValue); + + Optional findTopBySectionQuestion_QuestionIdOrderByDisplayOrderDesc(Long questionId); + + /** Shifts displayOrder by delta for every option in [from, to] within a question — used to make room for/close a gap around an inserted or moved sibling. */ + @Modifying + @Query("UPDATE QuestionOption o SET o.displayOrder = o.displayOrder + :delta " + + "WHERE o.sectionQuestion.questionId = :questionId AND o.displayOrder BETWEEN :from AND :to") + void shiftDisplayOrder(@Param("questionId") Long questionId, @Param("from") int from, + @Param("to") int to, @Param("delta") int delta); + /** * Loads all options for a set of questions in one query. * JOIN FETCH ensures sectionQuestion is hydrated so callers can group by questionId without extra queries. + * Excludes removed (isActive=false) options — used only by read paths; reconciliation matching + * uses the natural-key finder above, which must see inactive rows too. */ @Query("SELECT o FROM QuestionOption o JOIN FETCH o.sectionQuestion " - + "WHERE o.sectionQuestion.questionId IN :questionIds ORDER BY o.displayOrder ASC") + + "WHERE o.sectionQuestion.questionId IN :questionIds AND o.isActive = true ORDER BY o.displayOrder ASC") List findByQuestionIdsOrderByDisplayOrderAsc(@Param("questionIds") Collection questionIds); } diff --git a/src/main/java/com/iemr/flw/repo/iemr/QuestionValidationRepo.java b/src/main/java/com/iemr/flw/repo/iemr/QuestionValidationRepo.java index d1cf4187..ad8be0fa 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/QuestionValidationRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/QuestionValidationRepo.java @@ -22,6 +22,7 @@ package com.iemr.flw.repo.iemr; import com.iemr.flw.domain.iemr.QuestionValidation; +import com.iemr.flw.masterEnum.ValidationType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -29,6 +30,7 @@ import java.util.Collection; import java.util.List; +import java.util.Optional; /** * Repository for question validation rules. @@ -38,6 +40,10 @@ public interface QuestionValidationRepo extends JpaRepository findBySectionQuestion_QuestionId(Long questionId); + /** No surrogate natural key on validations — matched by the (type, param) content tuple within a question. */ + Optional findBySectionQuestion_QuestionIdAndValidationTypeAndValidationParam( + Long questionId, ValidationType validationType, String validationParam); + /** * Loads all validations for a set of questions in one query. * JOIN FETCH ensures sectionQuestion is hydrated so callers can group by questionId without extra queries. diff --git a/src/main/java/com/iemr/flw/repo/iemr/SectionQuestionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/SectionQuestionRepo.java index 6a117e96..ae2a6744 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/SectionQuestionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/SectionQuestionRepo.java @@ -23,12 +23,14 @@ import com.iemr.flw.domain.iemr.SectionQuestion; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Collection; import java.util.List; +import java.util.Optional; /** * Repository for section questions. @@ -38,11 +40,27 @@ public interface SectionQuestionRepo extends JpaRepository findByFormSection_SectionIdOrderByDisplayOrderAsc(Long sectionId); + Optional findByFormSection_SectionIdAndQuestionUuid(Long sectionId, String questionUuid); + + /** Version-scoped lookup — a condition's target question may live in a different section of the same version. */ + Optional findByFormSection_FormVersion_VersionIdAndQuestionUuid(Long versionId, String questionUuid); + + Optional findTopByFormSection_SectionIdOrderByDisplayOrderDesc(Long sectionId); + + /** Shifts displayOrder by delta for every question in [from, to] within a section — used to make room for/close a gap around an inserted or moved sibling. */ + @Modifying + @Query("UPDATE SectionQuestion q SET q.displayOrder = q.displayOrder + :delta " + + "WHERE q.formSection.sectionId = :sectionId AND q.displayOrder BETWEEN :from AND :to") + void shiftDisplayOrder(@Param("sectionId") Long sectionId, @Param("from") int from, + @Param("to") int to, @Param("delta") int delta); + /** * Loads all questions for a set of sections in one query. * JOIN FETCH ensures formSection is hydrated so callers can group by sectionId without extra queries. + * Excludes unlinked (isActive=false) questions — used only by read paths; reconciliation matching + * uses the natural-key finders above, which must see inactive rows too. */ @Query("SELECT q FROM SectionQuestion q JOIN FETCH q.formSection " - + "WHERE q.formSection.sectionId IN :sectionIds ORDER BY q.displayOrder ASC") + + "WHERE q.formSection.sectionId IN :sectionIds AND q.isActive = true ORDER BY q.displayOrder ASC") List findBySectionIdsOrderByDisplayOrderAsc(@Param("sectionIds") Collection sectionIds); } diff --git a/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationApplier.java b/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationApplier.java new file mode 100644 index 00000000..ae4de9c6 --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationApplier.java @@ -0,0 +1,49 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * 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 https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.domain.iemr.DynamicFormMigrationLog; +import com.iemr.flw.repo.iemr.DynamicFormMigrationLogRepo; +import com.iemr.flw.service.DynamicFormReconciliationService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Applies one {@link FormStructureMigration} and records it as applied, as a single atomic unit. + * Kept as its own bean (rather than a method on {@link DynamicFormMigrationRunner}) so the + * {@code @Transactional} proxy is actually honored — a runner calling this method on itself would + * bypass Spring AOP's self-invocation limitation. + */ +@Service +@RequiredArgsConstructor +public class DynamicFormMigrationApplier { + + private final DynamicFormReconciliationService reconciliationService; + private final DynamicFormMigrationLogRepo migrationLogRepo; + + @Transactional + public void apply(FormStructureMigration migration) { + migration.apply(reconciliationService); + migrationLogRepo.save(new DynamicFormMigrationLog(migration.migrationId(), null)); + } +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationRunner.java b/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationRunner.java new file mode 100644 index 00000000..2071b61e --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationRunner.java @@ -0,0 +1,73 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * 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 https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.repo.iemr.DynamicFormMigrationLogRepo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +import java.util.Comparator; +import java.util.List; + +/** + * Applies every {@link FormStructureMigration} bean, in {@link FormStructureMigration#migrationId()} + * order, skipping ones already recorded in {@code t_dynamic_form_migration_log}. + * + * Runs as an {@link ApplicationRunner} rather than another {@code @PostConstruct} specifically so it + * always runs after the 6 form seeders: Spring Boot invokes ApplicationRunners only once the whole + * application context has finished refreshing, which is strictly after every bean's + * {@code @PostConstruct} (the seeders included) has already completed — no {@code @Order} + * coordination needed between the two mechanisms. + * + * A migration that throws propagates out of this loop and aborts startup — a broken migration + * should fail loudly, not be silently skipped, and later migrations shouldn't apply out of order + * behind a failed one. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DynamicFormMigrationRunner implements ApplicationRunner { + + private final List migrations; + private final DynamicFormMigrationLogRepo migrationLogRepo; + private final DynamicFormMigrationApplier applier; + + @Override + public void run(ApplicationArguments args) { + migrations.stream() + .sorted(Comparator.comparing(FormStructureMigration::migrationId)) + .forEach(this::applyIfNeeded); + } + + private void applyIfNeeded(FormStructureMigration migration) { + String id = migration.migrationId(); + if (migrationLogRepo.existsById(id)) { + log.info("Form structure migration '{}' already applied — skipping.", id); + return; + } + applier.apply(migration); + log.info("Form structure migration '{}' applied.", id); + } +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/FormStructureMigration.java b/src/main/java/com/iemr/flw/seeder/migration/FormStructureMigration.java new file mode 100644 index 00000000..969bb04f --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/FormStructureMigration.java @@ -0,0 +1,78 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * 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 https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.service.DynamicFormReconciliationService; + +/** + * One incremental, one-off structural change to a form that has already been created by one of the + * {@code com.iemr.flw.seeder} classes — e.g. "add this new question to that existing section", or + * "add this new option to that existing question". {@code DynamicFormMigrationRunner} discovers + * every Spring bean implementing this interface, sorts them by {@link #migrationId()}, and applies + * each exactly once, recording it in {@code t_dynamic_form_migration_log} so it is skipped on + * every later restart. A migration should never be edited after it has shipped — write a new one + * instead — since the log only records "has this id run", not what it actually did. + * + * Naming convention: {@code V_ShortDescription}, e.g. {@code V001_AddSideEffectsQuestion}, + * zero-padded so lexicographic sort matches numeric order. Give each class a javadoc stating the + * business reason for the change — that, plus the class itself, is the permanent changelog. + * + * Example: + *
{@code
+ * @Component
+ * public class V001_AddSideEffectsQuestionToTbCounselling implements FormStructureMigration {
+ *
+ *     @Override
+ *     public String migrationId() {
+ *         return "V001";
+ *     }
+ *
+ *     @Override
+ *     public void apply(DynamicFormReconciliationService svc) {
+ *         FormVersion version = svc.latestVersion("TB_COUNSELLING")
+ *                 .orElseThrow(() -> new IllegalStateException(
+ *                         "TB_COUNSELLING not found — must run after TbCounsellingFormSeeder"));
+ *         FormSection section = svc.section(version, "TB_SEC_B")
+ *                 .orElseThrow(() -> new IllegalStateException("Section TB_SEC_B not found"));
+ *
+ *         SectionQuestionDTO question = new SectionQuestionDTO();
+ *         question.setQuestionUuid("TB_B_Q7");
+ *         question.setQuestionText("Side effects reported?");
+ *         question.setQuestionType(QuestionType.RADIO);
+ *         question.setOptions(List.of(yesOption, noOption));
+ *         svc.ensureQuestion(section, question);
+ *     }
+ * }
+ * }
+ */ +public interface FormStructureMigration { + + /** Stable id, e.g. "V001" — the migration-log primary key and the sort key for apply order. Never change it once shipped. */ + String migrationId(); + + /** + * Applies this migration's change(s) via the reconciliation service's ensureX methods. + * Should remain safe to re-run (ensureX no-ops on rows that already exist) even though the + * runner normally calls this only once per migrationId, per the tracking log. + */ + void apply(DynamicFormReconciliationService reconciliationService); +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/V001_ReplaceTfuAdrOptions.java b/src/main/java/com/iemr/flw/seeder/migration/V001_ReplaceTfuAdrOptions.java new file mode 100644 index 00000000..6fc90ff6 --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/V001_ReplaceTfuAdrOptions.java @@ -0,0 +1,104 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * 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 https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.domain.iemr.FormSection; +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.domain.iemr.SectionQuestion; +import com.iemr.flw.dto.iemr.QuestionOptionDTO; +import com.iemr.flw.service.DynamicFormReconciliationService; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Replaces the TPT Follow Up form's "Adverse Drug Reactions" (TFU_ADR) option list with the + * clinically revised set of symptoms, per the updated ADR checklist supplied for this form. + * + * 7 of the 24 existing options already match the new list exactly (same wording) and are left + * untouched by {@code ensureOption}'s natural-key match: NAUSEA, VOMITING, ABDOMINAL_PAIN, + * DARK_COLOUR_URINE, ITCHING, TINGLING_BURNING_NUMBNESS_HANDS_FEET, FLU_LIKE_SYNDROME. The other + * 17 old options are no longer part of the revised list and are soft-removed (never hard-deleted — + * historical responses may reference them); the 13 new options are added. + */ +@Component +public class V001_ReplaceTfuAdrOptions implements FormStructureMigration { + + private static final List REMOVED_OPTION_VALUES = List.of( + "UNKNOWN", + "FLU_LIKE_SYNDROME_WITH_DIZZINESS_HEADACHE", + "YELLOWISH_DISCOLORATION_SKIN_EYES", + "DISCOLORATION_BODY_FLUIDS", + "PALE_STOOL", + "SKIN_RASH", + "SLEEPINESS_LETHARGY", + "PERSISTENT_UNFORMED_WATERY_STOOLS", + "MENTAL_CHANGES_SIGNS_BLEEDING", + "CONVULSIONS", + "ANAEMIA", + "ARTHRALGIA", + "DECREASED_APPETITE", + "HYPOTENSION_SYNCOPE", + "CONJUCTIVITIES", + "SHOCK", + "NONE_REPORTED"); + + @Override + public String migrationId() { + return "V001"; + } + + @Override + public void apply(DynamicFormReconciliationService svc) { + FormVersion version = svc.latestVersion("TPT_FOLLOW_UP") + .orElseThrow(() -> new IllegalStateException( + "TPT_FOLLOW_UP not found — must run after TptFollowUpFormSeeder")); + FormSection section = svc.section(version, "TFU_SEC_2") + .orElseThrow(() -> new IllegalStateException("Section TFU_SEC_2 not found")); + SectionQuestion question = svc.question(section, "TFU_ADR") + .orElseThrow(() -> new IllegalStateException("Question TFU_ADR not found")); + + REMOVED_OPTION_VALUES.forEach(value -> svc.option(question, value) + .ifPresent(opt -> svc.removeOption(opt.getOptionId()))); + + addOption(svc, question, "Yellowness of Skin", "YELLOWNESS_OF_SKIN"); + addOption(svc, question, "Loose motions >4 times in a day", "LOOSE_MOTIONS_GT4_PER_DAY"); + addOption(svc, question, "Rashes", "RASHES"); + addOption(svc, question, "Pain in Joints", "PAIN_IN_JOINTS"); + addOption(svc, question, "Impaired vision: Pain, Blurring of vision, Disturbance in colour vision", "IMPAIRED_VISION"); + addOption(svc, question, "Swelling of face or legs, Less or no urine", "SWELLING_FACE_LEGS_LESS_NO_URINE"); + addOption(svc, question, "Seeing abnormal things, changes of thoughts, suicidal thoughts", "ABNORMAL_THOUGHTS_SUICIDAL"); + addOption(svc, question, "Tiredness, lethargy, headache, giddiness, pale look, palpitations", "TIREDNESS_LETHARGY_GIDDINESS_PALPITATIONS"); + addOption(svc, question, "Ringing in ears, Loss of hearing, dizziness and loss of balance leading to recurrent fall", "TINNITUS_HEARING_LOSS_DIZZINESS_FALL"); + addOption(svc, question, "Slowness of activities, Swelling of face or neck, dis-appropriate weight gain", "SLOWNESS_FACIAL_NECK_SWELLING_WEIGHT_GAIN"); + addOption(svc, question, "Pain and swelling in muscles and Tendons, difficulty in movement", "MUSCLE_TENDON_PAIN_SWELLING"); + addOption(svc, question, "Convulsion", "CONVULSION"); + addOption(svc, question, "Orange and red color of urine, sweat, sputum, saliva or tears", "ORANGE_RED_DISCOLORATION_BODY_FLUIDS"); + } + + private void addOption(DynamicFormReconciliationService svc, SectionQuestion question, String label, String value) { + QuestionOptionDTO dto = new QuestionOptionDTO(); + dto.setOptionLabel(label); + dto.setOptionValue(value); + svc.ensureOption(question, dto); + } +} diff --git a/src/main/java/com/iemr/flw/service/DynamicFormReconciliationService.java b/src/main/java/com/iemr/flw/service/DynamicFormReconciliationService.java new file mode 100644 index 00000000..f558f007 --- /dev/null +++ b/src/main/java/com/iemr/flw/service/DynamicFormReconciliationService.java @@ -0,0 +1,101 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * 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 https://www.gnu.org/licenses/. + */ +package com.iemr.flw.service; + +import com.iemr.flw.domain.iemr.DynamicForm; +import com.iemr.flw.domain.iemr.FormSection; +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.domain.iemr.OptionCondition; +import com.iemr.flw.domain.iemr.QuestionOption; +import com.iemr.flw.domain.iemr.QuestionValidation; +import com.iemr.flw.domain.iemr.SectionQuestion; +import com.iemr.flw.dto.iemr.DynamicFormDTO; +import com.iemr.flw.dto.iemr.FormSectionDTO; +import com.iemr.flw.dto.iemr.OptionConditionDTO; +import com.iemr.flw.dto.iemr.QuestionOptionDTO; +import com.iemr.flw.dto.iemr.QuestionValidationDTO; +import com.iemr.flw.dto.iemr.SectionQuestionDTO; + +import java.util.Optional; + +/** + * Idempotent, in-place reconciliation for dynamic form structure — inserts whatever is missing + * from the current latest {@link FormVersion} and leaves existing rows untouched. Never creates a + * new version (unlike {@link DynamicFormDefinitionService#updateForm}, which always clones one). + * + * Used both by the startup seeders (walking their full hardcoded DTO tree every run) and by the + * granular structure endpoints (a single section/question/option at a time). + * + * Each {@code ensureX} method operates on exactly one level — callers walk the tree themselves and + * call the next level down, since only the caller knows whether it's safe yet to wire cross-sibling + * references (see {@link #ensureCondition}). + */ +public interface DynamicFormReconciliationService { + + /** Finds the form by formUuid, or creates it. If found inactive, reactivates it (touches no other field). */ + DynamicForm ensureForm(DynamicFormDTO dto); + + /** Finds the form's isLatest=true version, or creates version 1 if none exists yet. */ + FormVersion ensureLatestVersion(DynamicForm form); + + /** Finds a section by sectionUuid within the version, or creates it. */ + FormSection ensureSection(FormVersion version, FormSectionDTO dto); + + /** Finds a question by questionUuid within the section, or creates it (a section's question is "linked" the moment it's created — no separate link step exists). */ + SectionQuestion ensureQuestion(FormSection section, SectionQuestionDTO dto); + + /** Finds an option by optionValue within the question, or creates it. */ + QuestionOption ensureOption(SectionQuestion question, QuestionOptionDTO dto); + + /** Finds a validation by (validationType, validationParam) within the question, or creates it — validations carry no natural key of their own. */ + QuestionValidation ensureValidation(SectionQuestion question, QuestionValidationDTO dto); + + /** + * Finds a condition by (actionType, target) within the option, or creates it, resolving + * targetQuestionUuid/targetSectionUuid against the given version. Call only once every sibling + * section/question this condition might target already exists as a row. + */ + OptionCondition ensureCondition(QuestionOption option, OptionConditionDTO dto, FormVersion version); + + /** Updates a question's scalar fields only (options/validations are managed by their own endpoints). A displayOrder change moves the question among its siblings, shifting them accordingly. */ + SectionQuestion updateQuestion(Long questionId, SectionQuestionDTO dto); + + /** Soft-deletes (isActive=false) a question — never hard-deleted; historical responses may reference it. */ + void unlinkQuestion(Long questionId); + + /** Soft-deletes (isActive=false) an option — never hard-deleted; historical responses may reference it. */ + void removeOption(Long optionId); + + // ── Read-only lookups, for FormStructureMigration classes to locate what they're changing ── + + /** Finds the given form's current latest version, if the form exists at all. */ + Optional latestVersion(String formUuid); + + /** Finds a section by sectionUuid within the given version. */ + Optional section(FormVersion version, String sectionUuid); + + /** Finds a question by questionUuid within the given section. */ + Optional question(FormSection section, String questionUuid); + + /** Finds an option by optionValue within the given question. */ + Optional option(SectionQuestion question, String optionValue); +} diff --git a/src/main/java/com/iemr/flw/service/impl/DynamicFormReconciliationServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DynamicFormReconciliationServiceImpl.java new file mode 100644 index 00000000..d5de312b --- /dev/null +++ b/src/main/java/com/iemr/flw/service/impl/DynamicFormReconciliationServiceImpl.java @@ -0,0 +1,324 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * 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 https://www.gnu.org/licenses/. + */ +package com.iemr.flw.service.impl; + +import com.iemr.flw.domain.iemr.DynamicForm; +import com.iemr.flw.domain.iemr.FormSection; +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.domain.iemr.OptionCondition; +import com.iemr.flw.domain.iemr.QuestionOption; +import com.iemr.flw.domain.iemr.QuestionValidation; +import com.iemr.flw.domain.iemr.SectionQuestion; +import com.iemr.flw.dto.iemr.DynamicFormDTO; +import com.iemr.flw.dto.iemr.FormSectionDTO; +import com.iemr.flw.dto.iemr.OptionConditionDTO; +import com.iemr.flw.dto.iemr.QuestionOptionDTO; +import com.iemr.flw.dto.iemr.QuestionValidationDTO; +import com.iemr.flw.dto.iemr.SectionQuestionDTO; +import com.iemr.flw.mapper.DynamicFormMapper; +import com.iemr.flw.repo.iemr.DynamicFormRepo; +import com.iemr.flw.repo.iemr.FormSectionRepo; +import com.iemr.flw.repo.iemr.FormVersionRepo; +import com.iemr.flw.repo.iemr.OptionConditionRepo; +import com.iemr.flw.repo.iemr.QuestionOptionRepo; +import com.iemr.flw.repo.iemr.QuestionValidationRepo; +import com.iemr.flw.repo.iemr.SectionQuestionRepo; +import com.iemr.flw.service.DynamicFormReconciliationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.function.IntConsumer; +import java.util.function.Supplier; + +/** + * In-place reconciliation for dynamic form structure — see {@link DynamicFormReconciliationService}. + * Standalone: does not depend on {@link DynamicFormDefinitionServiceImpl}'s private helpers, since + * that service's write path (full version clone) is a deliberately different mechanism from this one. + * + * @author Piramal Swasthya + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class DynamicFormReconciliationServiceImpl implements DynamicFormReconciliationService { + + private final DynamicFormRepo formRepo; + private final FormVersionRepo versionRepo; + private final FormSectionRepo sectionRepo; + private final SectionQuestionRepo questionRepo; + private final QuestionOptionRepo optionRepo; + private final QuestionValidationRepo validationRepo; + private final OptionConditionRepo conditionRepo; + private final DynamicFormMapper mapper; + + // ── Form / Version ──────────────────────────────────────────────────────── + + @Override + @Transactional + public DynamicForm ensureForm(DynamicFormDTO dto) { + Optional existing = formRepo.findByFormUuid(dto.getFormUuid()); + if (existing.isPresent()) { + DynamicForm form = existing.get(); + if (Boolean.FALSE.equals(form.getIsActive())) { + form.setIsActive(true); + form = formRepo.save(form); + log.info("Form '{}' was inactive — reactivated.", dto.getFormUuid()); + } + return form; + } + DynamicForm form = mapper.toEntity(dto); + form.setFormId(null); + return formRepo.save(form); + } + + @Override + @Transactional + public FormVersion ensureLatestVersion(DynamicForm form) { + return versionRepo.findByDynamicForm_FormIdAndIsLatest(form.getFormId(), true) + .orElseGet(() -> { + FormVersion version = new FormVersion(); + version.setDynamicForm(form); + version.setVersionNumber(1); + version.setIsLatest(true); + return versionRepo.save(version); + }); + } + + // ── Section / Question / Option / Validation / Condition ───────────────── + + @Override + @Transactional + public FormSection ensureSection(FormVersion version, FormSectionDTO dto) { + return sectionRepo.findByFormVersion_VersionIdAndSectionUuid(version.getVersionId(), dto.getSectionUuid()) + .orElseGet(() -> createSection(version, dto)); + } + + @Override + @Transactional + public SectionQuestion ensureQuestion(FormSection section, SectionQuestionDTO dto) { + return questionRepo.findByFormSection_SectionIdAndQuestionUuid(section.getSectionId(), dto.getQuestionUuid()) + .orElseGet(() -> createQuestion(section, dto)); + } + + @Override + @Transactional + public QuestionOption ensureOption(SectionQuestion question, QuestionOptionDTO dto) { + return optionRepo.findBySectionQuestion_QuestionIdAndOptionValue(question.getQuestionId(), dto.getOptionValue()) + .orElseGet(() -> createOption(question, dto)); + } + + @Override + @Transactional + public QuestionValidation ensureValidation(SectionQuestion question, QuestionValidationDTO dto) { + return validationRepo.findBySectionQuestion_QuestionIdAndValidationTypeAndValidationParam( + question.getQuestionId(), dto.getValidationType(), dto.getValidationParam()) + .orElseGet(() -> createValidation(question, dto)); + } + + @Override + @Transactional + public OptionCondition ensureCondition(QuestionOption option, OptionConditionDTO dto, FormVersion version) { + Optional existing; + if (dto.getTargetQuestionUuid() != null) { + existing = conditionRepo.findByQuestionOption_OptionIdAndActionTypeAndTargetQuestion_QuestionUuid( + option.getOptionId(), dto.getActionType(), dto.getTargetQuestionUuid()); + } else if (dto.getTargetSectionUuid() != null) { + existing = conditionRepo.findByQuestionOption_OptionIdAndActionTypeAndTargetSection_SectionUuid( + option.getOptionId(), dto.getActionType(), dto.getTargetSectionUuid()); + } else { + existing = Optional.empty(); + } + return existing.orElseGet(() -> createCondition(option, dto, version)); + } + + // ── Update / Remove ──────────────────────────────────────────────────────── + + @Override + @Transactional + public SectionQuestion updateQuestion(Long questionId, SectionQuestionDTO dto) { + SectionQuestion question = questionRepo.findById(questionId) + .orElseThrow(() -> new NoSuchElementException("Question not found: " + questionId)); + question.setQuestionText(dto.getQuestionText()); + question.setQuestionTextHindi(dto.getQuestionTextHindi()); + question.setQuestionType(dto.getQuestionType()); + question.setIsMandatory(dto.getIsMandatory()); + question.setMaxLength(dto.getMaxLength()); + question.setDefaultValue(dto.getDefaultValue()); + question.setContainsPii(dto.getContainsPii()); + if (dto.getDisplayOrder() != null && !dto.getDisplayOrder().equals(question.getDisplayOrder())) { + moveQuestion(question, dto.getDisplayOrder()); + } + return questionRepo.save(question); + } + + @Override + @Transactional + public void unlinkQuestion(Long questionId) { + SectionQuestion question = questionRepo.findById(questionId) + .orElseThrow(() -> new NoSuchElementException("Question not found: " + questionId)); + question.setIsActive(false); + questionRepo.save(question); + } + + @Override + @Transactional + public void removeOption(Long optionId) { + QuestionOption option = optionRepo.findById(optionId) + .orElseThrow(() -> new NoSuchElementException("Option not found: " + optionId)); + option.setIsActive(false); + optionRepo.save(option); + } + + // ── Read-only lookups ────────────────────────────────────────────────────── + + @Override + public Optional latestVersion(String formUuid) { + return formRepo.findByFormUuid(formUuid) + .flatMap(form -> versionRepo.findByDynamicForm_FormIdAndIsLatest(form.getFormId(), true)); + } + + @Override + public Optional section(FormVersion version, String sectionUuid) { + return sectionRepo.findByFormVersion_VersionIdAndSectionUuid(version.getVersionId(), sectionUuid); + } + + @Override + public Optional question(FormSection section, String questionUuid) { + return questionRepo.findByFormSection_SectionIdAndQuestionUuid(section.getSectionId(), questionUuid); + } + + @Override + public Optional option(SectionQuestion question, String optionValue) { + return optionRepo.findBySectionQuestion_QuestionIdAndOptionValue(question.getQuestionId(), optionValue); + } + + // ── Create helpers ───────────────────────────────────────────────────────── + + private FormSection createSection(FormVersion version, FormSectionDTO dto) { + int order = resolveOrder(dto.getDisplayOrder(), + () -> sectionRepo.findTopByFormVersion_VersionIdOrderByDisplayOrderDesc(version.getVersionId()) + .map(FormSection::getDisplayOrder), + from -> sectionRepo.shiftDisplayOrder(version.getVersionId(), from, Integer.MAX_VALUE, 1)); + FormSection section = mapper.toEntity(dto); + section.setSectionId(null); + section.setFormVersion(version); + section.setDisplayOrder(order); + section.setQuestions(new ArrayList<>()); + return sectionRepo.save(section); + } + + private SectionQuestion createQuestion(FormSection section, SectionQuestionDTO dto) { + int order = resolveOrder(dto.getDisplayOrder(), + () -> questionRepo.findTopByFormSection_SectionIdOrderByDisplayOrderDesc(section.getSectionId()) + .map(SectionQuestion::getDisplayOrder), + from -> questionRepo.shiftDisplayOrder(section.getSectionId(), from, Integer.MAX_VALUE, 1)); + SectionQuestion question = mapper.toEntity(dto); + question.setQuestionId(null); + question.setFormSection(section); + question.setDisplayOrder(order); + question.setOptions(new ArrayList<>()); + question.setValidations(new ArrayList<>()); + return questionRepo.save(question); + } + + private QuestionOption createOption(SectionQuestion question, QuestionOptionDTO dto) { + int order = resolveOrder(dto.getDisplayOrder(), + () -> optionRepo.findTopBySectionQuestion_QuestionIdOrderByDisplayOrderDesc(question.getQuestionId()) + .map(QuestionOption::getDisplayOrder), + from -> optionRepo.shiftDisplayOrder(question.getQuestionId(), from, Integer.MAX_VALUE, 1)); + QuestionOption option = mapper.toEntity(dto); + option.setOptionId(null); + option.setSectionQuestion(question); + option.setDisplayOrder(order); + option.setConditions(new ArrayList<>()); + return optionRepo.save(option); + } + + private QuestionValidation createValidation(SectionQuestion question, QuestionValidationDTO dto) { + QuestionValidation validation = mapper.toEntity(dto); + validation.setValidationId(null); + validation.setSectionQuestion(question); + return validationRepo.save(validation); + } + + private OptionCondition createCondition(QuestionOption option, OptionConditionDTO dto, FormVersion version) { + OptionCondition condition = mapper.toEntity(dto); + condition.setConditionId(null); + condition.setQuestionOption(option); + + if (dto.getTargetQuestionUuid() != null) { + condition.setTargetQuestion(questionRepo + .findByFormSection_FormVersion_VersionIdAndQuestionUuid(version.getVersionId(), dto.getTargetQuestionUuid()) + .orElseThrow(() -> new NoSuchElementException( + "targetQuestionUuid not found in this form version: " + dto.getTargetQuestionUuid()))); + } else if (dto.getTargetQuestionId() != null) { + condition.setTargetQuestion(questionRepo.findById(dto.getTargetQuestionId()) + .orElseThrow(() -> new NoSuchElementException("Question not found: " + dto.getTargetQuestionId()))); + } + + if (dto.getTargetSectionUuid() != null) { + condition.setTargetSection(sectionRepo + .findByFormVersion_VersionIdAndSectionUuid(version.getVersionId(), dto.getTargetSectionUuid()) + .orElseThrow(() -> new NoSuchElementException( + "targetSectionUuid not found in this form version: " + dto.getTargetSectionUuid()))); + } else if (dto.getTargetSectionId() != null) { + condition.setTargetSection(sectionRepo.findById(dto.getTargetSectionId()) + .orElseThrow(() -> new NoSuchElementException("Section not found: " + dto.getTargetSectionId()))); + } + + return conditionRepo.save(condition); + } + + /** + * Omitted displayOrder → append after the current max sibling (no shift needed). + * Explicit displayOrder → push every sibling at or after that position up by one, then use it as-is. + */ + private int resolveOrder(Integer requested, Supplier> currentMaxSupplier, + IntConsumer shiftFromInclusive) { + if (requested == null) { + return currentMaxSupplier.get().orElse(0) + 1; + } + shiftFromInclusive.accept(requested); + return requested; + } + + /** + * Moves a question already at oldPos to newPos within its section, shifting the siblings in + * between to keep displayOrder contiguous — same three-branch logic a full reorder needs + * regardless of direction. + */ + private void moveQuestion(SectionQuestion question, int newPos) { + int oldPos = question.getDisplayOrder(); + Long sectionId = question.getFormSection().getSectionId(); + if (newPos < oldPos) { + questionRepo.shiftDisplayOrder(sectionId, newPos, oldPos - 1, 1); + } else { + questionRepo.shiftDisplayOrder(sectionId, oldPos + 1, newPos, -1); + } + question.setDisplayOrder(newPos); + } +} From 021a29f320ec98150056ec3a3281f24e999ce2e9 Mon Sep 17 00:00:00 2001 From: Sehjot Singh Pannu Date: Mon, 28 Sep 2026 16:10:57 +0530 Subject: [PATCH 4/4] Updated the device integration according to updated workflow --- src/main/environment/common_ci.properties | 2 + src/main/environment/common_docker.properties | 2 + .../environment/common_example.properties | 6 + .../controller/DiagnosticOrderController.java | 20 +- .../iemr/flw/domain/iemr/DiagnosticOrder.java | 13 +- .../flw/dto/DiagnosticOrderRequestDto.java | 5 +- .../flw/dto/DiagnosticOrderResultDto.java | 2 +- .../dto/DiagnosticOrderStatusSummaryDto.java | 3 +- .../dto/ManualDiagnosticResultRequestDto.java | 4 +- .../provider/DiagnosticCancelResult.java | 14 + .../provider/DiagnosticProvider.java | 10 + .../provider/emrlite/EmrLiteProvider.java | 39 ++- .../emrlite/dto/EmrLiteCancelRequest.java | 13 + .../flw/masterEnum/DiagnosticOrderStatus.java | 7 +- .../flw/repo/iemr/DiagnosticOrderRepo.java | 23 +- .../flw/service/DiagnosticOrderService.java | 18 +- .../impl/DiagnosticDocumentServiceImpl.java | 2 +- .../impl/DiagnosticOrderServiceImpl.java | 239 ++++++++++++++---- .../impl/DiagnosticPollSchedulerService.java | 20 +- 19 files changed, 333 insertions(+), 109 deletions(-) create mode 100644 src/main/java/com/iemr/flw/integration/provider/DiagnosticCancelResult.java create mode 100644 src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCancelRequest.java diff --git a/src/main/environment/common_ci.properties b/src/main/environment/common_ci.properties index 0bd9638b..ad996282 100644 --- a/src/main/environment/common_ci.properties +++ b/src/main/environment/common_ci.properties @@ -68,6 +68,7 @@ diagnostic.emrlite.xray.refresh-url=${diagnostic.emrlite.xray.base-url}/api/v1/i diagnostic.emrlite.xray.order-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.xray.result-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.xray.ping-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/ping/ +diagnostic.emrlite.xray.cancel-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/cancel/ diagnostic.emrlite.xray.username=@env.DIAGNOSTIC_EMRLITE_XRAY_USERNAME@ diagnostic.emrlite.xray.password=@env.DIAGNOSTIC_EMRLITE_XRAY_PASSWORD@ @@ -78,6 +79,7 @@ diagnostic.emrlite.truenat.refresh-url=${diagnostic.emrlite.truenat.base-url}/ap diagnostic.emrlite.truenat.order-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.truenat.result-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.truenat.ping-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/ping/ +diagnostic.emrlite.truenat.cancel-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/cancel/ diagnostic.emrlite.truenat.username=@env.DIAGNOSTIC_EMRLITE_TRUENAT_USERNAME@ diagnostic.emrlite.truenat.password=@env.DIAGNOSTIC_EMRLITE_TRUENAT_PASSWORD@ diff --git a/src/main/environment/common_docker.properties b/src/main/environment/common_docker.properties index 2b19223c..b37a8e00 100644 --- a/src/main/environment/common_docker.properties +++ b/src/main/environment/common_docker.properties @@ -67,6 +67,7 @@ diagnostic.emrlite.xray.refresh-url=${diagnostic.emrlite.xray.base-url}/api/v1/i diagnostic.emrlite.xray.order-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.xray.result-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.xray.ping-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/ping/ +diagnostic.emrlite.xray.cancel-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/cancel/ diagnostic.emrlite.xray.username=${DIAGNOSTIC_EMRLITE_XRAY_USERNAME} diagnostic.emrlite.xray.password=${DIAGNOSTIC_EMRLITE_XRAY_PASSWORD} @@ -77,6 +78,7 @@ diagnostic.emrlite.truenat.refresh-url=${diagnostic.emrlite.truenat.base-url}/ap diagnostic.emrlite.truenat.order-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.truenat.result-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.truenat.ping-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/ping/ +diagnostic.emrlite.truenat.cancel-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/cancel/ diagnostic.emrlite.truenat.username=${DIAGNOSTIC_EMRLITE_TRUENAT_USERNAME} diagnostic.emrlite.truenat.password=${DIAGNOSTIC_EMRLITE_TRUENAT_PASSWORD} diff --git a/src/main/environment/common_example.properties b/src/main/environment/common_example.properties index 9238d8be..b0238b0c 100644 --- a/src/main/environment/common_example.properties +++ b/src/main/environment/common_example.properties @@ -49,6 +49,10 @@ abha.client.secret= abha.token.url= abha.xcmid= +# GovThealth API Config +govthealth.user.details.url={$GOVTHEALTH_USER_DETAILS_URL} +govthealth.user.id={$GOVTHEALTH_USER_ID} +govthealth.password={$GOVTHEALTH_PASSWORD} # ── Diagnostic Device Integration ──────────────────────────────────────────── # diagnostic.provider.xray / diagnostic.provider.truenat select the vendor per orderType family @@ -64,6 +68,7 @@ diagnostic.emrlite.xray.refresh-url=${diagnostic.emrlite.xray.base-url}/api/v1/i diagnostic.emrlite.xray.order-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.xray.result-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.xray.ping-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/ping/ +diagnostic.emrlite.xray.cancel-url=${diagnostic.emrlite.xray.base-url}/api/v1/integrations/orders/cancel/ diagnostic.emrlite.xray.username=${DIAGNOSTIC_EMRLITE_XRAY_USERNAME} diagnostic.emrlite.xray.password=${DIAGNOSTIC_EMRLITE_XRAY_PASSWORD} @@ -74,6 +79,7 @@ diagnostic.emrlite.truenat.refresh-url=${diagnostic.emrlite.truenat.base-url}/ap diagnostic.emrlite.truenat.order-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/ diagnostic.emrlite.truenat.result-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/result/ diagnostic.emrlite.truenat.ping-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/ping/ +diagnostic.emrlite.truenat.cancel-url=${diagnostic.emrlite.truenat.base-url}/api/v1/integrations/orders/cancel/ diagnostic.emrlite.truenat.username=${DIAGNOSTIC_EMRLITE_TRUENAT_USERNAME} diagnostic.emrlite.truenat.password=${DIAGNOSTIC_EMRLITE_TRUENAT_PASSWORD} diff --git a/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java b/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java index 8eb9476b..1725b5af 100644 --- a/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java +++ b/src/main/java/com/iemr/flw/controller/DiagnosticOrderController.java @@ -37,10 +37,11 @@ public DiagnosticOrderController(DiagnosticOrderService diagnosticOrderService) @PostMapping("/order/push") @Operation(summary = "Push a diagnostic order to the configured provider") - public String pushOrder(@RequestBody @Valid DiagnosticOrderRequestDto request) { + public String pushOrder(@RequestBody @Valid DiagnosticOrderRequestDto request, + @RequestHeader("JwtToken") String jwtToken) { OutputResponse response = new OutputResponse(); try { - DiagnosticOrder order = diagnosticOrderService.createAndPushOrder(request); + DiagnosticOrder order = diagnosticOrderService.createAndPushOrderByUser(request, jwtToken); response.setResponse(new Gson().toJson(order)); } catch (Exception e) { logger.error("Error in pushOrder: {}", e.getMessage()); @@ -128,14 +129,17 @@ public String pollOrder(@RequestParam Long beneficiaryId, @RequestParam String o } @PostMapping("/order/manualResult") - @Operation(summary = "Manually submit a diagnostic result for a beneficiary's latest order of the given " - + "type, for use when no vendor device is integrated. Stored as-is; tbPresence, tbConfidence, and " - + "drugResistancePresence are left null since nothing derives them. Rejected if that order is " - + "already COMPLETED.") - public String submitManualResult(@RequestBody @Valid ManualDiagnosticResultRequestDto request) { + @Operation(summary = "Exactly one of resultSummary/reasonToClose is required. resultSummary: manually submit " + + "a diagnostic result for a beneficiary's latest order of the given type, for use when no vendor " + + "device is integrated. Stored as-is; tbPresence, tbConfidence, and drugResistancePresence are left " + + "null since nothing derives them. Rejected if that order is already COMPLETED. reasonToClose: close " + + "the order instead (same behavior as reasonToClose on /order/push), sourcing patient/orderEvent " + + "from the beneficiary's existing order for this type.") + public String submitManualResult(@RequestBody @Valid ManualDiagnosticResultRequestDto request, + @RequestHeader("JwtToken") String jwtToken) { OutputResponse response = new OutputResponse(); try { - DiagnosticOrderResultDto result = diagnosticOrderService.submitManualResult(request); + DiagnosticOrderResultDto result = diagnosticOrderService.submitManualResult(request, jwtToken); response.setResponse(new GsonBuilder().serializeNulls().create().toJson(result)); } catch (Exception e) { logger.error("Error in submitManualResult: {}", e.getMessage()); diff --git a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java index 197999fe..f8f69e03 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java +++ b/src/main/java/com/iemr/flw/domain/iemr/DiagnosticOrder.java @@ -8,8 +8,7 @@ import java.sql.Timestamp; @Entity -@Table(name = "tb_diagnostic_order", schema = "db_iemr", - uniqueConstraints = @UniqueConstraint(columnNames = {"beneficiary_id", "visitCode", "order_type"})) +@Table(name = "tb_diagnostic_order", schema = "db_iemr") @Data public class DiagnosticOrder { @@ -53,12 +52,15 @@ public class DiagnosticOrder { @Column(name = "error_message", columnDefinition = "TEXT") private String errorMessage; - @Column(name = "reason_for_refusal", columnDefinition = "TEXT") - private String reasonForRefusal; + @Column(name = "reason_to_close", columnDefinition = "TEXT") + private String reasonToClose; @Column(name = "push_response_json", columnDefinition = "LONGTEXT") private String pushResponseJson; + @Column(name = "cancel_response_json", columnDefinition = "LONGTEXT") + private String cancelResponseJson; + @Column(name = "patient_first_name", length = 100) private String patientFirstName; @@ -82,6 +84,9 @@ public class DiagnosticOrder { @Column(name = "modified_by") private String modifiedBy; + @Column(name = "manually_entered_by") + private String manuallyEnteredBy; + @UpdateTimestamp @Column(name = "last_mod_date") private Timestamp lastModDate; diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java b/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java index 6ed0e03f..abf8475e 100644 --- a/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java +++ b/src/main/java/com/iemr/flw/dto/DiagnosticOrderRequestDto.java @@ -14,15 +14,12 @@ public class DiagnosticOrderRequestDto { @NotNull private Long beneficiaryId; - @NotNull - private Long visitCode; - @NotNull private String orderType; private String orderEvent; - private String reasonForRefusal; + private String reasonToClose; @NotNull @Valid diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java b/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java index 92eaa03d..531800f7 100644 --- a/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java +++ b/src/main/java/com/iemr/flw/dto/DiagnosticOrderResultDto.java @@ -13,7 +13,7 @@ public class DiagnosticOrderResultDto { private String providerStatus; private String resultSummary; private String errorMessage; - private String reasonForRefusal; + private String reasonToClose; private Boolean tbPresence; private Double tbConfidence; private Boolean drugResistancePresence; diff --git a/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java b/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java index 4e18e0bb..88eafcab 100644 --- a/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java +++ b/src/main/java/com/iemr/flw/dto/DiagnosticOrderStatusSummaryDto.java @@ -12,8 +12,7 @@ public class DiagnosticOrderStatusSummaryDto { private List awaitingProviderResult; private List completed; - private List pollingTimedOut; private List failed; - private List refused; + private List closed; private List awaitingManualEntry; } diff --git a/src/main/java/com/iemr/flw/dto/ManualDiagnosticResultRequestDto.java b/src/main/java/com/iemr/flw/dto/ManualDiagnosticResultRequestDto.java index 12bd70cd..cd6a10a8 100644 --- a/src/main/java/com/iemr/flw/dto/ManualDiagnosticResultRequestDto.java +++ b/src/main/java/com/iemr/flw/dto/ManualDiagnosticResultRequestDto.java @@ -1,6 +1,5 @@ package com.iemr.flw.dto; -import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.Data; import lombok.NoArgsConstructor; @@ -15,6 +14,7 @@ public class ManualDiagnosticResultRequestDto { @NotNull private String orderType; - @NotBlank private String resultSummary; + + private String reasonToClose; } diff --git a/src/main/java/com/iemr/flw/integration/provider/DiagnosticCancelResult.java b/src/main/java/com/iemr/flw/integration/provider/DiagnosticCancelResult.java new file mode 100644 index 00000000..f025bacb --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/DiagnosticCancelResult.java @@ -0,0 +1,14 @@ +package com.iemr.flw.integration.provider; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DiagnosticCancelResult { + private boolean success; + private String rawResponseJson; + private String errorMessage; +} \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java b/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java index 9737cba0..47cd3ee3 100644 --- a/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java +++ b/src/main/java/com/iemr/flw/integration/provider/DiagnosticProvider.java @@ -11,6 +11,16 @@ public interface DiagnosticProvider { DiagnosticPollResult pollResult(DiagnosticOrder order, boolean includeAssets) throws Exception; + /** + * Notifies the vendor that this order is being closed out without (or regardless of) a result, + * so it stops tracking/processing it on their end. Only call for an order actually known to the + * vendor (i.e. previously pushed) — calling this for an order that was never pushed is meaningless. + * The raw response is returned (on both success and a provider-level rejection) so the caller can + * persist it; only a transport-level failure (unreachable, timeout) throws instead, since there's + * no response body to return in that case. + */ + DiagnosticCancelResult cancelOrder(DiagnosticOrder order, String reason) throws Exception; + /** * Lightweight liveness check against the vendor group serving this orderType. Never throws — * returns false on any failure (unreachable, non-2xx, unparsable response, unconfigured URL). diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java index 0aa20d63..754711ce 100644 --- a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteProvider.java @@ -4,6 +4,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.iemr.flw.domain.iemr.DiagnosticOrder; +import com.iemr.flw.integration.provider.DiagnosticCancelResult; import com.iemr.flw.integration.provider.DiagnosticDocumentAsset; import com.iemr.flw.integration.provider.DiagnosticPollResult; import com.iemr.flw.integration.provider.DiagnosticProvider; @@ -60,6 +61,12 @@ public class EmrLiteProvider implements DiagnosticProvider { @Value("${diagnostic.emrlite.truenat.ping-url}") private String truenatPingUrl; + @Value("${diagnostic.emrlite.xray.cancel-url}") + private String xrayCancelUrl; + + @Value("${diagnostic.emrlite.truenat.cancel-url}") + private String truenatCancelUrl; + @Autowired @Qualifier("emrLiteRestTemplate") private RestTemplate restTemplate; @@ -158,6 +165,34 @@ public DiagnosticPollResult pollResult(DiagnosticOrder order, boolean includeAss tbPresence, tbConfidence, drugResistancePresence); } + @Override + public DiagnosticCancelResult cancelOrder(DiagnosticOrder order, String reason) throws Exception { + EmrLiteCancelRequest request = new EmrLiteCancelRequest(order.getExternalOrderId(), reason); + String cancelUrl = DiagnosticOrderType.XRAY_CHEST.name().equals(order.getOrderType()) + ? xrayCancelUrl : truenatCancelUrl; + String responseBody; + try { + responseBody = doPost(cancelUrl, gson.toJson(request)); + } catch (HttpStatusCodeException e) { + String body = e.getResponseBodyAsString(); + logger.warn("Provider HTTP error on order cancel: externalOrderId={}, status={}", + order.getExternalOrderId(), e.getStatusCode()); + return new DiagnosticCancelResult(false, body, "HTTP " + e.getStatusCode()); + } + + EmrLiteProviderResponse envelope = gson.fromJson(responseBody, EmrLiteProviderResponse.class); + if (envelope == null || !envelope.isSuccess()) { + logger.warn("Provider rejected order cancel: externalOrderId={}, message={}", + order.getExternalOrderId(), envelope != null ? envelope.getMessage() : null); + return new DiagnosticCancelResult(false, responseBody, + envelope != null ? envelope.getMessage() : "empty response"); + } + + logger.info("Order cancelled on provider: externalOrderId={}, reason={}", + order.getExternalOrderId(), reason); + return new DiagnosticCancelResult(true, responseBody, null); + } + @Override public boolean checkHealth(DiagnosticOrderType orderType) { String pingUrl = DiagnosticOrderType.XRAY_CHEST.equals(orderType) ? xrayPingUrl : truenatPingUrl; @@ -236,8 +271,8 @@ private DiagnosticOrderStatus deriveOrderStatus(EmrLiteResultResponse result) { DiagnosticOrderStatus topLevelStatus = DiagnosticOrderStatus.fromString(result.getStatus()); if (topLevelStatus == DiagnosticOrderStatus.COMPLETED || topLevelStatus == DiagnosticOrderStatus.FAILED) { // Components contradict a terminal top-level status — only the components map may - // resolve to a terminal state; treat this as still in progress. - return DiagnosticOrderStatus.IN_PROGRESS; + // resolve to a terminal state; treat this as still pending. + return DiagnosticOrderStatus.PENDING; } return topLevelStatus; } diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCancelRequest.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCancelRequest.java new file mode 100644 index 00000000..51f8320d --- /dev/null +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/dto/EmrLiteCancelRequest.java @@ -0,0 +1,13 @@ +package com.iemr.flw.integration.provider.emrlite.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class EmrLiteCancelRequest { + private String externalOrderId; + private String reason; +} diff --git a/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java b/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java index 9a0a8ec2..ce444fcb 100644 --- a/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java +++ b/src/main/java/com/iemr/flw/masterEnum/DiagnosticOrderStatus.java @@ -5,13 +5,14 @@ public enum DiagnosticOrderStatus { IN_PROGRESS, COMPLETED, FAILED, - CANCELLED, - EXPIRED, - REFUSED, + CLOSED, MANUAL_ENTRY; public static DiagnosticOrderStatus fromString(String value) { if (value == null) return PENDING; + // IN_PROGRESS is kept only as a legacy/historical value (old rows, backward-compat reporting + // queries) — it is never freshly assigned. A vendor reporting "IN_PROGRESS" is just PENDING. + if (IN_PROGRESS.name().equalsIgnoreCase(value)) return PENDING; for (DiagnosticOrderStatus status : values()) { if (status.name().equalsIgnoreCase(value)) { return status; diff --git a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java index fd7c7289..fa3e79b4 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java @@ -16,11 +16,8 @@ public interface DiagnosticOrderRepo extends JpaRepository findByExternalOrderId(String externalOrderId); - @Query("SELECT o FROM DiagnosticOrder o WHERE o.beneficiaryId = :beneficiaryId AND o.visitCode = :visitCode " + - "AND o.orderType = :orderType AND o.deleted = false") - Optional findByBeneficiaryIdAndVisitCodeAndOrderType(@Param("beneficiaryId") Long beneficiaryId, - @Param("visitCode") Long visitCode, - @Param("orderType") String orderType); + Optional findFirstByBeneficiaryIdAndVisitCodeAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc( + Long beneficiaryId, Long visitCode, String orderType); Optional findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc( Long beneficiaryId, String orderType); @@ -55,7 +52,7 @@ Optional findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrd // beneficiary with an old terminal order and a new in-flight retest of the same orderType // is bucketed only by the retest, not both. @Query("SELECT o.beneficiaryId FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + - "AND o.status NOT IN ('COMPLETED', 'EXPIRED', 'FAILED', 'REFUSED', 'MANUAL_ENTRY') " + + "AND o.status IN ('PENDING', 'IN_PROGRESS') " + "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + "WHERE o2.beneficiaryId = o.beneficiaryId AND o2.orderType = :orderType AND o2.deleted = false) " + "AND o.beneficiaryId IN (SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.deleted = false " + @@ -74,16 +71,6 @@ List findBeneficiaryIdsAwaitingProviderResult(@Param("orderType") String o List findBeneficiaryIdsCompleted(@Param("orderType") String orderType, @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); - @Query("SELECT o.beneficiaryId FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + - "AND o.status = 'EXPIRED' " + - "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + - "WHERE o2.beneficiaryId = o.beneficiaryId AND o2.orderType = :orderType AND o2.deleted = false) " + - "AND o.beneficiaryId IN (SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.deleted = false " + - "AND (:villageId IS NULL OR b.villageID = :villageId) " + - "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") - List findBeneficiaryIdsPollingTimedOut(@Param("orderType") String orderType, - @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); - @Query("SELECT o.beneficiaryId FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + "AND o.status = 'FAILED' " + "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + @@ -95,13 +82,13 @@ List findBeneficiaryIdsFailed(@Param("orderType") String orderType, @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); @Query("SELECT o.beneficiaryId FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + - "AND o.status = 'REFUSED' " + + "AND o.status IN ('CLOSED', 'CANCELLED', 'EXPIRED', 'REFUSED') " + "AND o.id = (SELECT MAX(o2.id) FROM DiagnosticOrder o2 " + "WHERE o2.beneficiaryId = o.beneficiaryId AND o2.orderType = :orderType AND o2.deleted = false) " + "AND o.beneficiaryId IN (SELECT b.beneficiaryID FROM BenFlowStatus b WHERE b.deleted = false " + "AND (:villageId IS NULL OR b.villageID = :villageId) " + "AND (:providerServiceMapId IS NULL OR b.providerServiceMapId = :providerServiceMapId))") - List findBeneficiaryIdsRefused(@Param("orderType") String orderType, + List findBeneficiaryIdsClosed(@Param("orderType") String orderType, @Param("villageId") Integer villageId, @Param("providerServiceMapId") Integer providerServiceMapId); @Query("SELECT o.beneficiaryId FROM DiagnosticOrder o WHERE o.orderType = :orderType AND o.deleted = false " + diff --git a/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java b/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java index 0bd5207b..250aa143 100644 --- a/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java +++ b/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java @@ -12,7 +12,14 @@ public interface DiagnosticOrderService { - DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) throws Exception; + DiagnosticOrder createAndPushOrderByUser(DiagnosticOrderRequestDto request, String jwtToken) throws Exception; + + /** + * Same create/push logic as createAndPushOrder, attributed to "SYSTEM" instead of a JWT-derived + * user. For callers with no authenticated user in context, e.g. the poll scheduler auto-recreating + * an order after an invalid result. + */ + DiagnosticOrder createAndPushOrderAsSystem(DiagnosticOrderRequestDto request) throws Exception; DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticPollResult result) throws Exception; @@ -22,6 +29,13 @@ public interface DiagnosticOrderService { DiagnosticOrder retryPoll(Long beneficiaryId, String orderType, Long visitCode) throws Exception; + /** + * Best-effort: tells the vendor this order is closed out (no result coming), so they stop + * tracking it. Never throws — a provider failure is logged and swallowed, since the order's own + * CLOSED status is what actually matters locally. No-op if the order was never pushed to a vendor. + */ + void notifyProviderOrderClosed(DiagnosticOrder order, String reason); + DiagnosticOrderResultDto getOrderResult(Long beneficiaryId, String orderType, Long visitCode); DiagnosticOrder getOrder(Long beneficiaryId, String orderType, Long visitCode) throws Exception; @@ -32,5 +46,5 @@ public interface DiagnosticOrderService { VendorHealthDto checkVendorHealth(String orderType) throws Exception; - DiagnosticOrderResultDto submitManualResult(ManualDiagnosticResultRequestDto request) throws Exception; + DiagnosticOrderResultDto submitManualResult(ManualDiagnosticResultRequestDto request, String jwtToken) throws Exception; } diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java index b0f92ab4..6c0f1f58 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java @@ -116,7 +116,7 @@ public DiagnosticDocumentContent fetch(Long beneficiaryId, DiagnosticDocumentTyp String orderType = documentType.impliedOrderType().name(); DiagnosticOrder order; if (visitCode != null) { - order = diagnosticOrderRepo.findByBeneficiaryIdAndVisitCodeAndOrderType(beneficiaryId, visitCode, orderType) + order = diagnosticOrderRepo.findFirstByBeneficiaryIdAndVisitCodeAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, visitCode, orderType) .orElseThrow(() -> new Exception("No diagnostic order found for beneficiaryId=" + beneficiaryId + ", visitCode=" + visitCode + ", orderType=" + orderType)); } else { diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java index 3e5ca60d..8aeb045b 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java @@ -1,5 +1,6 @@ package com.iemr.flw.service.impl; +import com.iemr.flw.domain.iemr.BenVisitDetail; import com.iemr.flw.domain.iemr.DiagnosticOrder; import com.iemr.flw.domain.iemr.DiagnosticResult; import com.iemr.flw.domain.iemr.TBSuspected; @@ -8,6 +9,7 @@ import com.iemr.flw.dto.DiagnosticOrderStatusSummaryDto; import com.iemr.flw.dto.ManualDiagnosticResultRequestDto; import com.iemr.flw.dto.VendorHealthDto; +import com.iemr.flw.integration.provider.DiagnosticCancelResult; import com.iemr.flw.integration.provider.DiagnosticDocumentAsset; import com.iemr.flw.integration.provider.DiagnosticPollResult; import com.iemr.flw.integration.provider.DiagnosticProvider; @@ -15,12 +17,17 @@ import com.iemr.flw.integration.provider.DiagnosticPushResult; import com.iemr.flw.masterEnum.DiagnosticOrderStatus; import com.iemr.flw.masterEnum.DiagnosticOrderType; +import com.iemr.flw.dto.iemr.UserServiceRoleDTO; +import com.iemr.flw.repo.identity.BeneficiaryRepo; import com.iemr.flw.repo.iemr.DiagnosticOrderRepo; import com.iemr.flw.repo.iemr.DiagnosticResultRepo; import com.iemr.flw.repo.iemr.TBSuspectedRepo; +import com.iemr.flw.repo.iemr.UserServiceRoleRepo; import com.iemr.flw.service.CampConfigService; import com.iemr.flw.service.DiagnosticDocumentService; import com.iemr.flw.service.DiagnosticOrderService; +import com.iemr.flw.service.TBStopVisitService; +import com.iemr.flw.utils.JwtUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -31,6 +38,7 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.UUID; @Service public class DiagnosticOrderServiceImpl implements DiagnosticOrderService { @@ -39,10 +47,7 @@ public class DiagnosticOrderServiceImpl implements DiagnosticOrderService { private static final Set BLOCKING_STATUSES = Set.of( DiagnosticOrderStatus.PENDING.name(), - DiagnosticOrderStatus.IN_PROGRESS.name(), - DiagnosticOrderStatus.EXPIRED.name(), - DiagnosticOrderStatus.COMPLETED.name(), - DiagnosticOrderStatus.MANUAL_ENTRY.name()); + DiagnosticOrderStatus.COMPLETED.name()); @Autowired private DiagnosticOrderRepo diagnosticOrderRepo; @@ -62,10 +67,29 @@ public class DiagnosticOrderServiceImpl implements DiagnosticOrderService { @Autowired private CampConfigService campConfigService; + @Autowired + private BeneficiaryRepo beneficiaryRepo; + + @Autowired + private TBStopVisitService tbStopVisitService; + + @Autowired + private JwtUtil jwtUtil; + @Override - public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) throws Exception { + public DiagnosticOrder createAndPushOrderByUser(DiagnosticOrderRequestDto request, String jwtToken) throws Exception { + // Username (JWT subject), not the numeric user id — matches modifiedBy/createdBy elsewhere + // in this codebase being a display username, not an id. + return createAndPushOrder(request, jwtUtil.extractUsername(jwtToken)); + } + + @Override + public DiagnosticOrder createAndPushOrderAsSystem(DiagnosticOrderRequestDto request) throws Exception { + return createAndPushOrder(request, "SYSTEM"); + } + + private DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request, String createdBy) throws Exception { Long beneficiaryId = request.getBeneficiaryId(); - Long visitCode = request.getVisitCode(); DiagnosticOrderType orderType = DiagnosticOrderType.fromCode(request.getOrderType()); String orderEvent = request.getOrderEvent(); String patientFirstName = request.getPatient().getFirstName(); @@ -73,24 +97,43 @@ public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) thr String patientDateOfBirth = request.getPatient().getDateOfBirth(); String patientSex = request.getPatient().getSex(); - String providerCode = providerFactory.getProviderCodeForOrderType(orderType); - String externalOrderId = String.format("%d-%d-%s", beneficiaryId, visitCode, orderType.name()); - - String reasonForRefusal = request.getReasonForRefusal(); - if (reasonForRefusal != null) { - return saveRefusedOrder(beneficiaryId, visitCode, orderType, orderEvent, providerCode, externalOrderId, - patientFirstName, patientLastName, patientDateOfBirth, patientSex, reasonForRefusal); + String reasonToClose = request.getReasonToClose(); + if (reasonToClose != null) { + return closeOrder(beneficiaryId, orderType, orderEvent, patientFirstName, patientLastName, + patientDateOfBirth, patientSex, reasonToClose, createdBy); + } + + Integer vanID = campConfigService.getVanID(); + Integer parkingPlaceID = campConfigService.getParkingPlaceID(); + + Long beneficiaryRegID = beneficiaryRepo.getRegIDFromBenId(beneficiaryId); + if (beneficiaryRegID == null) { + throw new Exception("No beneficiaryRegID found for beneficiaryId=" + beneficiaryId); } + BenVisitDetail visit = tbStopVisitService.getOrCreateVisitForToday(beneficiaryRegID, null, createdBy, vanID, + parkingPlaceID); + Long visitCode = visit.getVisitCode(); + + String providerCode = providerFactory.getProviderCodeForOrderType(orderType); + String externalOrderId = String.format("%s-%d-%s", UUID.randomUUID(), visitCode, orderType.name()); Optional latestForType = diagnosticOrderRepo .findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, orderType.name()); Optional existing = (latestForType.isPresent() && visitCode.equals(latestForType.get().getVisitCode())) ? latestForType - : diagnosticOrderRepo.findByBeneficiaryIdAndVisitCodeAndOrderType(beneficiaryId, visitCode, orderType.name()); - - if (existing.isPresent() && !DiagnosticOrderStatus.FAILED.name().equals(existing.get().getStatus())) { - return existing.get(); + : diagnosticOrderRepo.findFirstByBeneficiaryIdAndVisitCodeAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, visitCode, orderType.name()); + + if (existing.isPresent()) { + String existingStatus = existing.get().getStatus(); + if (!DiagnosticOrderStatus.FAILED.name().equals(existingStatus) + && !DiagnosticOrderStatus.CLOSED.name().equals(existingStatus)) { + // Still active or already resolved (PENDING/COMPLETED/legacy IN_PROGRESS/MANUAL_ENTRY) + // — returned as-is. + return existing.get(); + } + // FAILED and CLOSED are never reused, even for the same visit — both stay as-is for + // history, and a fresh push always creates a brand new row instead of overwriting either. } if (latestForType.isPresent() @@ -103,11 +146,11 @@ public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) thr return blocker; } - DiagnosticOrder order = existing.orElseGet(DiagnosticOrder::new); - if (order.getVanID() == null) { - order.setVanID(campConfigService.getVanID()); - order.setParkingPlaceID(campConfigService.getParkingPlaceID()); - } + // Neither FAILED nor CLOSED is ever reused above, so this is always a brand new row — a + // beneficiary's diagnostic order history is a sequence of rows, not one row overwritten in place. + DiagnosticOrder order = new DiagnosticOrder(); + order.setVanID(vanID); + order.setParkingPlaceID(parkingPlaceID); order.setOrderEvent(orderEvent); order.setBeneficiaryId(beneficiaryId); order.setVisitCode(visitCode); @@ -115,10 +158,7 @@ public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) thr order.setProviderCode(providerCode); order.setOrderType(orderType.name()); order.setExternalOrderId(externalOrderId); - // Reset required when reusing a previously-FAILED row, otherwise a successful retry would - // leave status=FAILED and findDueForPoll (PENDING/IN_PROGRESS only) would never poll it. order.setStatus(DiagnosticOrderStatus.PENDING.name()); - order.setErrorMessage(null); order.setPatientFirstName(patientFirstName); order.setPatientLastName(patientLastName); order.setPatientDateOfBirth(patientDateOfBirth); @@ -128,7 +168,7 @@ public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) thr order = diagnosticOrderRepo.save(order); } catch (DataIntegrityViolationException dive) { Optional winner = diagnosticOrderRepo - .findByBeneficiaryIdAndVisitCodeAndOrderType(beneficiaryId, visitCode, orderType.name()); + .findFirstByBeneficiaryIdAndVisitCodeAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, visitCode, orderType.name()); if (winner.isPresent()) { logger.warn("Lost create race for beneficiaryId={}, visitCode={}, orderType={} — returning existing order id={}", beneficiaryId, visitCode, orderType, winner.get().getId()); @@ -140,7 +180,7 @@ public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) thr if (providerCode == null || providerCode.isBlank()) { logger.info("No active vendor configured for orderType={}, beneficiaryId={} — order saved for manual entry", orderType, beneficiaryId); - order.setStatus(DiagnosticOrderStatus.MANUAL_ENTRY.name()); + // Status stays PENDING (set above) — awaiting manual entry via submitManualResult. order = diagnosticOrderRepo.save(order); if (order.getVanSerialNo() == null) diagnosticOrderRepo.updateVanSerialNo(order.getId()); return order; @@ -171,16 +211,55 @@ public DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request) thr return order; } + // Shared by createAndPushOrder (push with reasonToClose) and submitManualResult (manualResult + // with reasonToClose) — resolves the same visit/provider/externalOrderId a normal push would, then + // closes the order via saveRefusedOrder. Identical outcome regardless of which endpoint triggered it. + private DiagnosticOrder closeOrder(Long beneficiaryId, DiagnosticOrderType orderType, String orderEvent, + String patientFirstName, String patientLastName, String patientDateOfBirth, String patientSex, + String reasonToClose, String actingUserId) throws Exception { + Integer vanID = campConfigService.getVanID(); + Integer parkingPlaceID = campConfigService.getParkingPlaceID(); + + Long beneficiaryRegID = beneficiaryRepo.getRegIDFromBenId(beneficiaryId); + if (beneficiaryRegID == null) { + throw new Exception("No beneficiaryRegID found for beneficiaryId=" + beneficiaryId); + } + BenVisitDetail visit = tbStopVisitService.getOrCreateVisitForToday(beneficiaryRegID, null, actingUserId, vanID, + parkingPlaceID); + Long visitCode = visit.getVisitCode(); + + String providerCode = providerFactory.getProviderCodeForOrderType(orderType); + String externalOrderId = String.format("%s-%d-%s", UUID.randomUUID(), visitCode, orderType.name()); + return saveRefusedOrder(beneficiaryId, visitCode, orderType, orderEvent, providerCode, externalOrderId, + patientFirstName, patientLastName, patientDateOfBirth, patientSex, reasonToClose, actingUserId); + } + + // manualResult's reasonToClose variant has no patient/orderEvent in its request — it sources them + // from the beneficiary's own most recent order for this orderType (whatever its status), since that + // information already exists there. No prior order at all means there's nothing to source it from. + private DiagnosticOrder closeOrderManually(Long beneficiaryId, DiagnosticOrderType orderType, + String reasonToClose, String actingUserId) throws Exception { + DiagnosticOrder source = diagnosticOrderRepo + .findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, orderType.name()) + .orElseThrow(() -> new Exception("No diagnostic order found for beneficiaryId=" + beneficiaryId + + ", orderType=" + orderType.name() + " — cannot close a record that was never created")); + return closeOrder(beneficiaryId, orderType, source.getOrderEvent(), source.getPatientFirstName(), + source.getPatientLastName(), source.getPatientDateOfBirth(), source.getPatientSex(), reasonToClose, + actingUserId); + } + // Refusals are keyed to the latest order for this beneficiary+orderType (not the exact visitCode // of this request), since a refusal can be recorded outside the visit that originally created the - // order. A COMPLETED latest order is left untouched (treated as "not found") and a new REFUSED row - // is created instead. Refused orders are saved as-is and never pushed to the vendor. + // order. A COMPLETED or already-CLOSED latest order is left untouched (treated as "not found") and + // a new CLOSED row is created instead. Refused orders are saved as-is and never pushed to the vendor. private DiagnosticOrder saveRefusedOrder(Long beneficiaryId, Long visitCode, DiagnosticOrderType orderType, String orderEvent, String providerCode, String externalOrderId, String patientFirstName, - String patientLastName, String patientDateOfBirth, String patientSex, String reasonForRefusal) { + String patientLastName, String patientDateOfBirth, String patientSex, String reasonToClose, + String actingUserId) { Optional latest = diagnosticOrderRepo .findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, orderType.name()); - if (latest.isPresent() && DiagnosticOrderStatus.COMPLETED.name().equals(latest.get().getStatus())) { + if (latest.isPresent() && (DiagnosticOrderStatus.COMPLETED.name().equals(latest.get().getStatus()) + || DiagnosticOrderStatus.CLOSED.name().equals(latest.get().getStatus()))) { latest = Optional.empty(); } @@ -202,17 +281,21 @@ private DiagnosticOrder saveRefusedOrder(Long beneficiaryId, Long visitCode, Dia order.setPatientDateOfBirth(patientDateOfBirth); order.setPatientSex(patientSex); } - order.setStatus(DiagnosticOrderStatus.REFUSED.name()); - order.setReasonForRefusal(reasonForRefusal); + order.setStatus(DiagnosticOrderStatus.CLOSED.name()); + order.setReasonToClose(reasonToClose); order.setErrorMessage(null); + order.setModifiedBy(actingUserId); + order.setManuallyEnteredBy(actingUserId); + order.setProcessed("N"); try { order = diagnosticOrderRepo.save(order); if (order.getVanSerialNo() == null) diagnosticOrderRepo.updateVanSerialNo(order.getId()); + notifyProviderOrderClosed(order, reasonToClose); return order; } catch (DataIntegrityViolationException dive) { Optional winner = diagnosticOrderRepo - .findByBeneficiaryIdAndVisitCodeAndOrderType(beneficiaryId, visitCode, orderType.name()); + .findFirstByBeneficiaryIdAndVisitCodeAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, visitCode, orderType.name()); if (winner.isPresent()) { logger.warn("Lost create race for beneficiaryId={}, visitCode={}, orderType={} — returning existing order id={}", beneficiaryId, visitCode, orderType, winner.get().getId()); @@ -222,6 +305,28 @@ private DiagnosticOrder saveRefusedOrder(Long beneficiaryId, Long visitCode, Dia } } + @Override + public void notifyProviderOrderClosed(DiagnosticOrder order, String reason) { + String providerCode = order.getProviderCode(); + if (providerCode == null || providerCode.isBlank()) { + return; // no vendor was ever involved (manual-entry order) + } + if (order.getPushResponseJson() == null || order.getPushResponseJson().isEmpty()) { + return; // never actually confirmed as pushed to the vendor — nothing to cancel there + } + try { + DiagnosticCancelResult result = providerFactory.getProvider(providerCode).cancelOrder(order, reason); + order.setCancelResponseJson(result.getRawResponseJson()); + order.setProcessed("N"); + diagnosticOrderRepo.save(order); + } catch (Exception e) { + // Transport-level failure only (unreachable, timeout) — a provider-level rejection is + // already captured in the returned result above, not thrown. + logger.warn("Failed to notify provider of order cancellation for externalOrderId={}: {}", + order.getExternalOrderId(), e.getMessage()); + } + } + @Override public DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticPollResult pollResult) throws Exception { Optional existingResult = diagnosticResultRepo.findByExternalOrderIdAndDeletedFalse(order.getExternalOrderId()); @@ -262,6 +367,7 @@ public DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticP order.setProviderOrderId(pollResult.getProviderOrderId()); } order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); + order.setProcessed("N"); diagnosticOrderRepo.save(order); if (DiagnosticOrderStatus.COMPLETED.name().equals(order.getStatus())) { @@ -273,7 +379,7 @@ public DiagnosticOrderResultDto processResult(DiagnosticOrder order, DiagnosticP dto.setOrderType(order.getOrderType()); dto.setStatus(order.getStatus()); dto.setErrorMessage(order.getErrorMessage()); - dto.setReasonForRefusal(order.getReasonForRefusal()); + dto.setReasonToClose(order.getReasonToClose()); dto.setProviderStatus(result.getProviderStatus()); dto.setResultSummary(result.getResultSummary()); dto.setTbPresence(result.getTbPresence()); @@ -338,6 +444,7 @@ public DiagnosticPollResult pollOnce(DiagnosticOrder order) throws Exception { order.setStatus(DiagnosticOrderStatus.FAILED.name()); order.setErrorMessage("No push response recorded for this order — cannot poll"); order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); + order.setProcessed("N"); diagnosticOrderRepo.save(order); return null; } @@ -373,7 +480,7 @@ private DiagnosticOrder resolveOrder(Long beneficiaryId, String orderType, Long return findLatestOrder(beneficiaryId, orderType); } DiagnosticOrderType type = DiagnosticOrderType.fromCode(orderType); - return diagnosticOrderRepo.findByBeneficiaryIdAndVisitCodeAndOrderType(beneficiaryId, visitCode, type.name()) + return diagnosticOrderRepo.findFirstByBeneficiaryIdAndVisitCodeAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, visitCode, type.name()) .orElseThrow(() -> new Exception("DiagnosticOrder not found for beneficiaryId=" + beneficiaryId + ", visitCode=" + visitCode + ", orderType=" + orderType)); } @@ -392,10 +499,9 @@ public DiagnosticOrder retryPoll(Long beneficiaryId, String orderType, Long visi String status = order.getStatus(); if (DiagnosticOrderStatus.COMPLETED.name().equals(status) - || DiagnosticOrderStatus.CANCELLED.name().equals(status) - || DiagnosticOrderStatus.REFUSED.name().equals(status) - || DiagnosticOrderStatus.MANUAL_ENTRY.name().equals(status)) { - throw new IllegalStateException("Cannot retry polling for order in terminal status " + status); + || DiagnosticOrderStatus.CLOSED.name().equals(status)) { + throw new IllegalStateException("Cannot retry polling for order in terminal status " + status + + " — create a new order instead"); } // retriedAt is kept as an audit timestamp only — the scheduler no longer uses it to anchor a @@ -404,6 +510,7 @@ public DiagnosticOrder retryPoll(Long beneficiaryId, String orderType, Long visi order.setRetriedAt(new Timestamp(System.currentTimeMillis())); order.setStatus(DiagnosticOrderStatus.PENDING.name()); order.setErrorMessage(null); + order.setProcessed("N"); return diagnosticOrderRepo.save(order); } @@ -419,22 +526,18 @@ public List getOrdersByBeneficiaryId(Long beneficiaryId) throws @Override public DiagnosticOrderResultDto getOrderResult(Long beneficiaryId, String orderType, Long visitCode) { - DiagnosticOrderResultDto dto = new DiagnosticOrderResultDto(); - dto.setOrderType(orderType); - Optional orderOpt = visitCode != null - ? diagnosticOrderRepo.findByBeneficiaryIdAndVisitCodeAndOrderType(beneficiaryId, visitCode, orderType) + ? diagnosticOrderRepo.findFirstByBeneficiaryIdAndVisitCodeAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, visitCode, orderType) : diagnosticOrderRepo.findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrderByCreatedDateDesc(beneficiaryId, orderType); if (orderOpt.isEmpty()) { + DiagnosticOrderResultDto dto = new DiagnosticOrderResultDto(); + dto.setOrderType(orderType); dto.setStatus("NOT_FOUND"); return dto; } DiagnosticOrder order = orderOpt.get(); - dto.setExternalOrderId(order.getExternalOrderId()); - dto.setStatus(order.getStatus()); - dto.setErrorMessage(order.getErrorMessage()); - dto.setReasonForRefusal(order.getReasonForRefusal()); + DiagnosticOrderResultDto dto = toResultDto(order); diagnosticResultRepo.findByExternalOrderIdAndDeletedFalse(order.getExternalOrderId()).ifPresent(result -> { dto.setProviderStatus(result.getProviderStatus()); @@ -446,6 +549,16 @@ public DiagnosticOrderResultDto getOrderResult(Long beneficiaryId, String orderT return dto; } + private DiagnosticOrderResultDto toResultDto(DiagnosticOrder order) { + DiagnosticOrderResultDto dto = new DiagnosticOrderResultDto(); + dto.setExternalOrderId(order.getExternalOrderId()); + dto.setOrderType(order.getOrderType()); + dto.setStatus(order.getStatus()); + dto.setErrorMessage(order.getErrorMessage()); + dto.setReasonToClose(order.getReasonToClose()); + return dto; + } + @Override public DiagnosticOrderStatusSummaryDto getOrderStatusSummary(String orderType, Integer villageId, Integer providerServiceMapId) { @@ -454,16 +567,13 @@ public DiagnosticOrderStatusSummaryDto getOrderStatusSummary(String orderType, I .findBeneficiaryIdsAwaitingProviderResult(type.name(), villageId, providerServiceMapId); List completed = diagnosticOrderRepo .findBeneficiaryIdsCompleted(type.name(), villageId, providerServiceMapId); - List pollingTimedOut = diagnosticOrderRepo - .findBeneficiaryIdsPollingTimedOut(type.name(), villageId, providerServiceMapId); List failed = diagnosticOrderRepo .findBeneficiaryIdsFailed(type.name(), villageId, providerServiceMapId); - List refused = diagnosticOrderRepo - .findBeneficiaryIdsRefused(type.name(), villageId, providerServiceMapId); + List closed = diagnosticOrderRepo + .findBeneficiaryIdsClosed(type.name(), villageId, providerServiceMapId); List awaitingManualEntry = diagnosticOrderRepo .findBeneficiaryIdsAwaitingManualEntry(type.name(), villageId, providerServiceMapId); - return new DiagnosticOrderStatusSummaryDto(awaitingProviderResult, completed, pollingTimedOut, failed, refused, - awaitingManualEntry); + return new DiagnosticOrderStatusSummaryDto(awaitingProviderResult, completed, failed, closed, awaitingManualEntry); } @Override @@ -484,12 +594,31 @@ public VendorHealthDto checkVendorHealth(String orderType) throws Exception { } @Override - public DiagnosticOrderResultDto submitManualResult(ManualDiagnosticResultRequestDto request) throws Exception { + public DiagnosticOrderResultDto submitManualResult(ManualDiagnosticResultRequestDto request, String jwtToken) + throws Exception { + boolean hasResult = request.getResultSummary() != null && !request.getResultSummary().isBlank(); + boolean hasReasonToClose = request.getReasonToClose() != null && !request.getReasonToClose().isBlank(); + if (hasResult == hasReasonToClose) { + throw new IllegalArgumentException("Exactly one of resultSummary or reasonToClose must be provided"); + } + + // Username (JWT subject), not the numeric user id — matches modifiedBy/createdBy elsewhere + // in this codebase being a display username, not an id. + String actingUserName = jwtUtil.extractUsername(jwtToken); + + if (hasReasonToClose) { + DiagnosticOrderType orderType = DiagnosticOrderType.fromCode(request.getOrderType()); + DiagnosticOrder closed = closeOrderManually(request.getBeneficiaryId(), orderType, + request.getReasonToClose(), actingUserName); + return toResultDto(closed); + } + DiagnosticOrder order = findLatestOrder(request.getBeneficiaryId(), request.getOrderType()); if (DiagnosticOrderStatus.COMPLETED.name().equals(order.getStatus())) { throw new IllegalStateException("Cannot submit manual result: order is already COMPLETED (beneficiaryId=" + request.getBeneficiaryId() + ", orderType=" + request.getOrderType() + ")"); } + order.setManuallyEnteredBy(actingUserName); DiagnosticPollResult pollResult = new DiagnosticPollResult( DiagnosticOrderStatus.COMPLETED, null, request.getResultSummary(), null, null, null, null, null, null); return processResult(order, pollResult); diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java index b21c7095..2dbb183f 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java @@ -13,14 +13,16 @@ import org.springframework.stereotype.Service; import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.ZoneId; import java.util.List; -// Every due order (status PENDING/IN_PROGRESS) is polled on every tick from the moment it's created — +// Every due order (status PENDING) is polled on every tick from the moment it's created — // there is no per-order initial delay or rolling give-up window anymore. A tick is skipped entirely // for an order-type family if diagnostic.provider.xray / diagnostic.provider.truenat has no active // vendor configured right now (checked live via DiagnosticProviderFactory, not any value stored on the -// order row). Once a day, expireOutstandingOrders sweeps up everything still PENDING/IN_PROGRESS (for -// order-type families that do have an active vendor) and marks it EXPIRED. +// order row). Once a day, expireOutstandingOrders sweeps up everything still PENDING (for +// order-type families that do have an active vendor) and marks it CLOSED. @Service public class DiagnosticPollSchedulerService { @@ -67,7 +69,7 @@ public void pollTrueNatOrders() { logger.info("Polled {} pending TrueNat diagnostic orders", candidates.size()); } - @Scheduled(cron = "${diagnostic.poll.expiry-cron:0 0 18 * * *}") + @Scheduled(cron = "${diagnostic.poll.expiry-cron:0 50 23 * * *}") public void expireOutstandingOrders() { List outstanding = diagnosticOrderRepo.findAllOutstandingOrders(); int expired = 0; @@ -79,7 +81,7 @@ public void expireOutstandingOrders() { expired++; } if (expired > 0) { - logger.warn("Daily polling cutoff reached: marked {} outstanding order(s) EXPIRED", expired); + logger.warn("Daily polling cutoff reached: marked {} outstanding order(s) CLOSED", expired); } } @@ -89,10 +91,13 @@ private boolean noActiveVendor(DiagnosticOrderType orderType) { } private void expire(DiagnosticOrder order) { - order.setStatus(DiagnosticOrderStatus.EXPIRED.name()); - order.setErrorMessage("Daily polling cutoff reached without a result"); + String reason = "Order validity expired for the day " + LocalDate.now(ZoneId.of("Asia/Kolkata")); + order.setStatus(DiagnosticOrderStatus.CLOSED.name()); + order.setReasonToClose(reason); order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); + order.setProcessed("N"); diagnosticOrderRepo.save(order); + diagnosticOrderService.notifyProviderOrderClosed(order, reason); } private void pollSingle(DiagnosticOrder order) { @@ -105,6 +110,7 @@ private void pollSingle(DiagnosticOrder order) { order.setRetryCount(order.getRetryCount() + 1); order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); order.setErrorMessage(e.getMessage()); + order.setProcessed("N"); diagnosticOrderRepo.save(order); } }