_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_1_CreateContactTracingVersion2.java b/src/main/java/com/iemr/flw/seeder/migration/V001_1_CreateContactTracingVersion2.java
new file mode 100644
index 000000000..78c7cd645
--- /dev/null
+++ b/src/main/java/com/iemr/flw/seeder/migration/V001_1_CreateContactTracingVersion2.java
@@ -0,0 +1,108 @@
+/*
+ * 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.FormVersion;
+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.service.DynamicFormDefinitionService;
+import com.iemr.flw.service.DynamicFormReconciliationService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * Clones the seeded version 1 of both contact tracing forms — Community Contact Tracing and
+ * Occupational Contact Tracing — into a new version 2, so the later structural changes to them are
+ * applied to version 2 only and version 1 stays exactly as the seeders created it:
+ * COMMUNITY_CONTACT_TRACING (V002, V003, V006, V007, V008) and OCCUPATION_CONTACT_TRACING (V002, V004,
+ * V005). Responses keep the versionId they were submitted against; new responses and the app use the
+ * latest version.
+ *
+ * Id "V001_1" sorts between V001 and V002, so this runs before the first migration that touches
+ * either form — every migration resolves a form via {@code latestVersion}, which is version 2 from
+ * here on.
+ *
+ * Uses {@link DynamicFormDefinitionService#updateForm}, which copies every section, question, option,
+ * validation and condition into a new version and marks the current one isLatest=false. All ids are
+ * cleared from the version-1 DTO first: the mapper copies them onto the new entities, and saving an
+ * entity with an existing id would move that version-1 row into version 2 instead of copying it.
+ * Conditions are re-wired by targetQuestionUuid/targetSectionUuid, which are kept.
+ */
+@Component
+@RequiredArgsConstructor
+public class V001_1_CreateContactTracingVersion2 implements FormStructureMigration {
+
+ private static final List FORM_UUIDS = List.of(
+ "COMMUNITY_CONTACT_TRACING",
+ "OCCUPATION_CONTACT_TRACING");
+
+ private final DynamicFormDefinitionService definitionService;
+
+ @Override
+ public String migrationId() {
+ return "V001_1";
+ }
+
+ @Override
+ public void apply(DynamicFormReconciliationService svc) {
+ FORM_UUIDS.forEach(formUuid -> createVersion2(svc, formUuid));
+ }
+
+ private void createVersion2(DynamicFormReconciliationService svc, String formUuid) {
+ FormVersion latest = svc.latestVersion(formUuid)
+ .orElseThrow(() -> new IllegalStateException(formUuid + " not found — must run after its seeder"));
+ if (latest.getVersionNumber() > 1) {
+ return;
+ }
+
+ Long formId = latest.getDynamicForm().getFormId();
+ DynamicFormDTO version1 = definitionService.getFormDefinition(formId);
+ clearIds(version1);
+ definitionService.updateForm(formId, version1);
+ }
+
+ private void clearIds(DynamicFormDTO form) {
+ for (FormSectionDTO section : form.getSections()) {
+ section.setSectionId(null);
+ for (SectionQuestionDTO question : section.getQuestions()) {
+ question.setQuestionId(null);
+ for (QuestionValidationDTO validation : question.getValidations()) {
+ validation.setValidationId(null);
+ }
+ for (QuestionOptionDTO option : question.getOptions()) {
+ option.setOptionId(null);
+ for (OptionConditionDTO condition : option.getConditions()) {
+ condition.setConditionId(null);
+ condition.setTargetQuestionId(null);
+ condition.setTargetSectionId(null);
+ }
+ }
+ }
+ }
+ }
+}
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 000000000..6fc90ff6e
--- /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/seeder/migration/V002_RenameNoOfContactsQuestion.java b/src/main/java/com/iemr/flw/seeder/migration/V002_RenameNoOfContactsQuestion.java
new file mode 100644
index 000000000..836c1b1e3
--- /dev/null
+++ b/src/main/java/com/iemr/flw/seeder/migration/V002_RenameNoOfContactsQuestion.java
@@ -0,0 +1,83 @@
+/*
+ * 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.SectionQuestionDTO;
+import com.iemr.flw.service.DynamicFormReconciliationService;
+import org.springframework.stereotype.Component;
+
+/**
+ * Renames the "No. of contacts" question to "Total No. of contacts" (and its Hindi translation)
+ * in both the Community Contact Tracing (CCT_NO_OF_CONTACTS) and Occupational Contact Tracing
+ * (OCT_NO_OF_CONTACTS) forms, per the revised field-label wording supplied for these forms.
+ *
+ * Only the label text changes — type, mandatoriness and position are read off the existing row
+ * and passed back unchanged, since {@code updateQuestion} overwrites all of its scalar fields
+ * unconditionally.
+ */
+@Component
+public class V002_RenameNoOfContactsQuestion implements FormStructureMigration {
+
+ private static final String NEW_TEXT = "Total No. of contacts";
+ private static final String NEW_TEXT_HINDI = "कुल संपर्कों की संख्या";
+
+ @Override
+ public String migrationId() {
+ return "V002";
+ }
+
+ @Override
+ public void apply(DynamicFormReconciliationService svc) {
+ renameQuestion(svc, "COMMUNITY_CONTACT_TRACING", "CCT_SEC_1", "CCT_NO_OF_CONTACTS");
+ renameQuestion(svc, "OCCUPATION_CONTACT_TRACING", "OCT_SEC_1", "OCT_NO_OF_CONTACTS");
+ }
+
+ private void renameQuestion(DynamicFormReconciliationService svc, String formUuid, String sectionUuid,
+ String questionUuid) {
+ FormVersion version = svc.latestVersion(formUuid)
+ .orElseThrow(() -> new IllegalStateException(
+ formUuid + " not found — must run after its seeder"));
+ FormSection section = svc.section(version, sectionUuid)
+ .orElseThrow(() -> new IllegalStateException("Section " + sectionUuid + " not found"));
+ SectionQuestion question = svc.question(section, questionUuid)
+ .orElseThrow(() -> new IllegalStateException("Question " + questionUuid + " not found"));
+
+ if (NEW_TEXT.equals(question.getQuestionText()) && NEW_TEXT_HINDI.equals(question.getQuestionTextHindi())) {
+ return;
+ }
+
+ SectionQuestionDTO dto = new SectionQuestionDTO();
+ dto.setQuestionText(NEW_TEXT);
+ dto.setQuestionTextHindi(NEW_TEXT_HINDI);
+ dto.setQuestionType(question.getQuestionType());
+ dto.setIsMandatory(question.getIsMandatory());
+ dto.setMaxLength(question.getMaxLength());
+ dto.setDefaultValue(question.getDefaultValue());
+ dto.setContainsPii(question.getContainsPii());
+ dto.setDisplayOrder(question.getDisplayOrder());
+
+ svc.updateQuestion(question.getQuestionId(), dto);
+ }
+}
diff --git a/src/main/java/com/iemr/flw/seeder/migration/V003_ChangeExposureSettingToDropdownMulti.java b/src/main/java/com/iemr/flw/seeder/migration/V003_ChangeExposureSettingToDropdownMulti.java
new file mode 100644
index 000000000..ba69d37b4
--- /dev/null
+++ b/src/main/java/com/iemr/flw/seeder/migration/V003_ChangeExposureSettingToDropdownMulti.java
@@ -0,0 +1,76 @@
+/*
+ * 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.SectionQuestionDTO;
+import com.iemr.flw.masterEnum.QuestionType;
+import com.iemr.flw.service.DynamicFormReconciliationService;
+import org.springframework.stereotype.Component;
+
+/**
+ * Changes the Community Contact Tracing form's "Place of exposure" question (CCT_EXPOSURE_SETTING)
+ * from CHECKBOX_MULTI to DROPDOWN_MULTI, so the 12 place options render as a multi-select dropdown
+ * instead of a long checkbox list.
+ *
+ * Both types submit {@code optionValues} and store one response row per selected option, so
+ * existing responses remain valid. Options and the OTHER_PLACE → CCT_EXPOSURE_SETTING_OTHER
+ * conditional rule are untouched. All other scalar fields are read off the existing row and passed
+ * back unchanged, since {@code updateQuestion} overwrites all of them unconditionally.
+ */
+@Component
+public class V003_ChangeExposureSettingToDropdownMulti implements FormStructureMigration {
+
+ @Override
+ public String migrationId() {
+ return "V003";
+ }
+
+ @Override
+ public void apply(DynamicFormReconciliationService svc) {
+ FormVersion version = svc.latestVersion("COMMUNITY_CONTACT_TRACING")
+ .orElseThrow(() -> new IllegalStateException(
+ "COMMUNITY_CONTACT_TRACING not found — must run after CommunityContactTracingFormSeeder"));
+ FormSection section = svc.section(version, "CCT_SEC_1")
+ .orElseThrow(() -> new IllegalStateException("Section CCT_SEC_1 not found"));
+ SectionQuestion question = svc.question(section, "CCT_EXPOSURE_SETTING")
+ .orElseThrow(() -> new IllegalStateException("Question CCT_EXPOSURE_SETTING not found"));
+
+ if (question.getQuestionType() == QuestionType.DROPDOWN_MULTI) {
+ return;
+ }
+
+ SectionQuestionDTO dto = new SectionQuestionDTO();
+ dto.setQuestionText(question.getQuestionText());
+ dto.setQuestionTextHindi(question.getQuestionTextHindi());
+ dto.setQuestionType(QuestionType.DROPDOWN_MULTI);
+ dto.setIsMandatory(question.getIsMandatory());
+ dto.setMaxLength(question.getMaxLength());
+ dto.setDefaultValue(question.getDefaultValue());
+ dto.setContainsPii(question.getContainsPii());
+ dto.setDisplayOrder(question.getDisplayOrder());
+
+ svc.updateQuestion(question.getQuestionId(), dto);
+ }
+}
diff --git a/src/main/java/com/iemr/flw/seeder/migration/V004_ChangeOccupationToDropdown.java b/src/main/java/com/iemr/flw/seeder/migration/V004_ChangeOccupationToDropdown.java
new file mode 100644
index 000000000..24db1f454
--- /dev/null
+++ b/src/main/java/com/iemr/flw/seeder/migration/V004_ChangeOccupationToDropdown.java
@@ -0,0 +1,76 @@
+/*
+ * 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.SectionQuestionDTO;
+import com.iemr.flw.masterEnum.QuestionType;
+import com.iemr.flw.service.DynamicFormReconciliationService;
+import org.springframework.stereotype.Component;
+
+/**
+ * Changes the Occupational Contact Tracing form's "Occupation" question (OCT_OCCUPATION) from RADIO
+ * to DROPDOWN, so the 11 occupation options render as a single-select dropdown instead of a long
+ * radio list.
+ *
+ * Both types submit a single {@code optionValue} and store one response row, so existing responses
+ * remain valid. Options and their conditional rules (address questions, OTHER_OCCUPATION →
+ * OCT_OCCUPATION_OTHER) are untouched. All other scalar fields are read off the existing row and
+ * passed back unchanged, since {@code updateQuestion} overwrites all of them unconditionally.
+ */
+@Component
+public class V004_ChangeOccupationToDropdown implements FormStructureMigration {
+
+ @Override
+ public String migrationId() {
+ return "V004";
+ }
+
+ @Override
+ public void apply(DynamicFormReconciliationService svc) {
+ FormVersion version = svc.latestVersion("OCCUPATION_CONTACT_TRACING")
+ .orElseThrow(() -> new IllegalStateException(
+ "OCCUPATION_CONTACT_TRACING not found — must run after OccupationalContactTracingFormSeeder"));
+ FormSection section = svc.section(version, "OCT_SEC_1")
+ .orElseThrow(() -> new IllegalStateException("Section OCT_SEC_1 not found"));
+ SectionQuestion question = svc.question(section, "OCT_OCCUPATION")
+ .orElseThrow(() -> new IllegalStateException("Question OCT_OCCUPATION not found"));
+
+ if (question.getQuestionType() == QuestionType.DROPDOWN) {
+ return;
+ }
+
+ SectionQuestionDTO dto = new SectionQuestionDTO();
+ dto.setQuestionText(question.getQuestionText());
+ dto.setQuestionTextHindi(question.getQuestionTextHindi());
+ dto.setQuestionType(QuestionType.DROPDOWN);
+ dto.setIsMandatory(question.getIsMandatory());
+ dto.setMaxLength(question.getMaxLength());
+ dto.setDefaultValue(question.getDefaultValue());
+ dto.setContainsPii(question.getContainsPii());
+ dto.setDisplayOrder(question.getDisplayOrder());
+
+ svc.updateQuestion(question.getQuestionId(), dto);
+ }
+}
diff --git a/src/main/java/com/iemr/flw/seeder/migration/V005_AddAreaOfSharedSpaceQuestion.java b/src/main/java/com/iemr/flw/seeder/migration/V005_AddAreaOfSharedSpaceQuestion.java
new file mode 100644
index 000000000..11a6a3d0c
--- /dev/null
+++ b/src/main/java/com/iemr/flw/seeder/migration/V005_AddAreaOfSharedSpaceQuestion.java
@@ -0,0 +1,115 @@
+/*
+ * 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.QuestionOption;
+import com.iemr.flw.domain.iemr.SectionQuestion;
+import com.iemr.flw.dto.iemr.OptionConditionDTO;
+import com.iemr.flw.dto.iemr.QuestionValidationDTO;
+import com.iemr.flw.dto.iemr.SectionQuestionDTO;
+import com.iemr.flw.masterEnum.QuestionType;
+import com.iemr.flw.masterEnum.ValidationType;
+import com.iemr.flw.service.DynamicFormReconciliationService;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * Adds an "Approximate Area of Shared Space" question to the Occupational Contact Tracing form,
+ * directly after "Type of Space" (OCT_TYPE_OF_SPACE), per the revised criteria for this form:
+ * enabled once a Type of Space is selected, whole square metres only, accepted range 50–5000.
+ *
+ * Mapped to Type of Space as a condition: hidden by default, and each of the 3 OCT_TYPE_OF_SPACE
+ * options reveals it (SHOW_QUESTION) and makes it mandatory (MANDATORY_IF) — conditions attach to
+ * options, so "any Type of Space selected" is one condition per option, all targeting this single
+ * question. Conventions used, since questions carry no dedicated fields for these:
+ * the helper text is stored in {@code defaultValue}, and the unit as a {@link ValidationType#UNIT}
+ * validation whose {@code validationParam} is the unit label.
+ *
+ * Inserted at display order 7 — {@code ensureQuestion} shifts "Daily duration of contact" to 8 and
+ * "Remarks" to 9.
+ */
+@Component
+public class V005_AddAreaOfSharedSpaceQuestion implements FormStructureMigration {
+
+ private static final String QUESTION_UUID = "OCT_AREA_OF_SHARED_SPACE";
+
+ private static final List TYPE_OF_SPACE_VALUES = List.of(
+ "OPEN_SPACE",
+ "CLOSED_SPACE_VENTILATED",
+ "CLOSED_SPACE_NOT_VENTILATED");
+
+ @Override
+ public String migrationId() {
+ return "V005";
+ }
+
+ @Override
+ public void apply(DynamicFormReconciliationService svc) {
+ FormVersion version = svc.latestVersion("OCCUPATION_CONTACT_TRACING")
+ .orElseThrow(() -> new IllegalStateException(
+ "OCCUPATION_CONTACT_TRACING not found — must run after OccupationalContactTracingFormSeeder"));
+ FormSection section = svc.section(version, "OCT_SEC_1")
+ .orElseThrow(() -> new IllegalStateException("Section OCT_SEC_1 not found"));
+ SectionQuestion typeOfSpace = svc.question(section, "OCT_TYPE_OF_SPACE")
+ .orElseThrow(() -> new IllegalStateException("Question OCT_TYPE_OF_SPACE not found"));
+
+ SectionQuestionDTO dto = new SectionQuestionDTO();
+ dto.setQuestionUuid(QUESTION_UUID);
+ dto.setQuestionText("Approximate Area of Shared Space");
+ dto.setQuestionTextHindi("साझा स्थान का अनुमानित क्षेत्रफल");
+ dto.setQuestionType(QuestionType.NUMBER_PICKER);
+ dto.setDefaultValue("Give estimated measurement of area");
+ dto.setIsMandatory(true);
+ dto.setDisplayOrder(7);
+ dto.setVisibleByDefault(false);
+ dto.setOptions(List.of());
+ dto.setValidations(List.of());
+ SectionQuestion area = svc.ensureQuestion(section, dto);
+
+ svc.ensureValidation(area, validation(ValidationType.REGEX,
+ "^([5-9][0-9]|[1-9][0-9]{2}|[1-4][0-9]{3}|5000)$", "Enter a value between 50 and 5000."));
+ svc.ensureValidation(area, validation(ValidationType.UNIT, "sq. m.", "Value is in sq. m."));
+
+ for (String value : TYPE_OF_SPACE_VALUES) {
+ svc.ensureValidation(area, validation(ValidationType.MANDATORY_IF,
+ "OCT_TYPE_OF_SPACE=" + value, "This field is mandatory"));
+
+ QuestionOption option = svc.option(typeOfSpace, value)
+ .orElseThrow(() -> new IllegalStateException("Option OCT_TYPE_OF_SPACE=" + value + " not found"));
+ OptionConditionDTO show = new OptionConditionDTO();
+ show.setActionType("SHOW_QUESTION");
+ show.setTargetQuestionUuid(QUESTION_UUID);
+ svc.ensureCondition(option, show, version);
+ }
+ }
+
+ private QuestionValidationDTO validation(ValidationType type, String param, String errorMessage) {
+ QuestionValidationDTO v = new QuestionValidationDTO();
+ v.setValidationType(type);
+ v.setValidationParam(param);
+ v.setErrorMessage(errorMessage);
+ return v;
+ }
+}
diff --git a/src/main/java/com/iemr/flw/seeder/migration/V006_PerRelationshipTypeOfSpace.java b/src/main/java/com/iemr/flw/seeder/migration/V006_PerRelationshipTypeOfSpace.java
new file mode 100644
index 000000000..822ea0867
--- /dev/null
+++ b/src/main/java/com/iemr/flw/seeder/migration/V006_PerRelationshipTypeOfSpace.java
@@ -0,0 +1,172 @@
+/*
+ * 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.QuestionOption;
+import com.iemr.flw.domain.iemr.SectionQuestion;
+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.masterEnum.QuestionType;
+import com.iemr.flw.masterEnum.ValidationType;
+import com.iemr.flw.service.DynamicFormReconciliationService;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * Replaces the Community Contact Tracing form's single, standalone "Type of Space" question
+ * (CCT_TYPE_OF_SPACE) with one per "Relationship with community contacts" (CCT_RELATIONSHIP)
+ * option, each followed by its own "Approximate Area of Shared Space", so the exposure setting is
+ * captured per relationship type:
+ *
+ *
+ * CCT_RELATIONSHIP=<R> ─ SHOW_QUESTION ─► CCT_TYPE_OF_SPACE_<R>
+ * └─ each option ─ SHOW_QUESTION ─► CCT_AREA_OF_SHARED_SPACE_<R>
+ *
+ *
+ * The old CCT_TYPE_OF_SPACE is soft-removed (never hard-deleted — historical responses may
+ * reference it). The 14 new questions (a Type of Space / Area pair per relationship) are inserted
+ * into its slot, all hidden by default and mandatory only when revealed (MANDATORY_IF). Area
+ * questions follow V006's conventions: helper text in {@code defaultValue}, unit as a
+ * {@link ValidationType#UNIT} validation, whole square metres 50–5000.
+ */
+@Component
+public class V006_PerRelationshipTypeOfSpace implements FormStructureMigration {
+
+ private static final List RELATIONSHIP_VALUES = List.of(
+ "NEIGHBOUR",
+ "FRIEND",
+ "FELLOW_WORSHIPPER",
+ "FELLOW_COMMUTER",
+ "COMMUNITY_GROUP_MEMBER",
+ "FELLOW_PATIENT",
+ "OTHER");
+
+ /** {label, labelHindi, value} — same options as the retired CCT_TYPE_OF_SPACE. */
+ private static final List TYPE_OF_SPACE_OPTIONS = List.of(
+ new String[] {"Open space", "खुला स्थान", "OPEN_SPACE"},
+ new String[] {"Closed space with ventilation", "वेंटिलेशन के साथ बंद स्थान", "CLOSED_SPACE_VENTILATED"},
+ new String[] {"Closed space with no ventilation", "बिना वेंटिलेशन के बंद स्थान", "CLOSED_SPACE_NOT_VENTILATED"});
+
+ private static final String AREA_REGEX = "^([5-9][0-9]|[1-9][0-9]{2}|[1-4][0-9]{3}|5000)$";
+ private static final String MANDATORY_ERROR = "This field is mandatory";
+
+ @Override
+ public String migrationId() {
+ return "V006";
+ }
+
+ @Override
+ public void apply(DynamicFormReconciliationService svc) {
+ FormVersion version = svc.latestVersion("COMMUNITY_CONTACT_TRACING")
+ .orElseThrow(() -> new IllegalStateException(
+ "COMMUNITY_CONTACT_TRACING not found — must run after CommunityContactTracingFormSeeder"));
+ FormSection section = svc.section(version, "CCT_SEC_1")
+ .orElseThrow(() -> new IllegalStateException("Section CCT_SEC_1 not found"));
+ SectionQuestion relationship = svc.question(section, "CCT_RELATIONSHIP")
+ .orElseThrow(() -> new IllegalStateException("Question CCT_RELATIONSHIP not found"));
+ SectionQuestion oldTypeOfSpace = svc.question(section, "CCT_TYPE_OF_SPACE")
+ .orElseThrow(() -> new IllegalStateException("Question CCT_TYPE_OF_SPACE not found"));
+
+ int base = oldTypeOfSpace.getDisplayOrder();
+ if (Boolean.TRUE.equals(oldTypeOfSpace.getIsActive())) {
+ svc.unlinkQuestion(oldTypeOfSpace.getQuestionId());
+ }
+
+ for (int i = 0; i < RELATIONSHIP_VALUES.size(); i++) {
+ String r = RELATIONSHIP_VALUES.get(i);
+ String typeOfSpaceUuid = "CCT_TYPE_OF_SPACE_" + r;
+ String areaUuid = "CCT_AREA_OF_SHARED_SPACE_" + r;
+
+ // Type of Space for this relationship — revealed by CCT_RELATIONSHIP=
+ SectionQuestion typeOfSpace = svc.ensureQuestion(section, question(typeOfSpaceUuid,
+ "Type of Space", "स्थान का प्रकार", QuestionType.RADIO, null, base + 1 + 2 * i));
+ svc.ensureValidation(typeOfSpace, validation(ValidationType.MANDATORY_IF,
+ "CCT_RELATIONSHIP=" + r, MANDATORY_ERROR));
+ QuestionOption relationshipOption = svc.option(relationship, r)
+ .orElseThrow(() -> new IllegalStateException("Option CCT_RELATIONSHIP=" + r + " not found"));
+ svc.ensureCondition(relationshipOption, showQuestion(typeOfSpaceUuid), version);
+
+ // Approximate Area of Shared Space for this relationship — revealed by any of its Type of Space options
+ SectionQuestion area = svc.ensureQuestion(section, question(areaUuid,
+ "Approximate Area of Shared Space", "साझा स्थान का अनुमानित क्षेत्रफल", QuestionType.NUMBER_PICKER,
+ "Give estimated measurement of area", base + 2 + 2 * i));
+ svc.ensureValidation(area, validation(ValidationType.REGEX, AREA_REGEX, "Enter a value between 50 and 5000."));
+ svc.ensureValidation(area, validation(ValidationType.UNIT, "sq. m.", "Value is in sq. m."));
+
+ for (int j = 0; j < TYPE_OF_SPACE_OPTIONS.size(); j++) {
+ String[] o = TYPE_OF_SPACE_OPTIONS.get(j);
+ QuestionOption spaceOption = svc.ensureOption(typeOfSpace, option(o[0], o[1], o[2], j + 1));
+ svc.ensureValidation(area, validation(ValidationType.MANDATORY_IF,
+ typeOfSpaceUuid + "=" + o[2], MANDATORY_ERROR));
+ svc.ensureCondition(spaceOption, showQuestion(areaUuid), version);
+ }
+ }
+
+ }
+
+ private SectionQuestionDTO question(String uuid, String text, String textHindi, QuestionType type,
+ String defaultValue, int order) {
+ SectionQuestionDTO q = new SectionQuestionDTO();
+ q.setQuestionUuid(uuid);
+ q.setQuestionText(text);
+ q.setQuestionTextHindi(textHindi);
+ q.setQuestionType(type);
+ q.setDefaultValue(defaultValue);
+ q.setIsMandatory(true);
+ q.setDisplayOrder(order);
+ q.setVisibleByDefault(false);
+ q.setOptions(List.of());
+ q.setValidations(List.of());
+ return q;
+ }
+
+ private QuestionOptionDTO option(String label, String labelHindi, String value, int order) {
+ QuestionOptionDTO o = new QuestionOptionDTO();
+ o.setOptionLabel(label);
+ o.setOptionLabelHindi(labelHindi);
+ o.setOptionValue(value);
+ o.setOptionValueHindi(labelHindi);
+ o.setDisplayOrder(order);
+ o.setConditions(List.of());
+ return o;
+ }
+
+ private QuestionValidationDTO validation(ValidationType type, String param, String errorMessage) {
+ QuestionValidationDTO v = new QuestionValidationDTO();
+ v.setValidationType(type);
+ v.setValidationParam(param);
+ v.setErrorMessage(errorMessage);
+ return v;
+ }
+
+ private OptionConditionDTO showQuestion(String targetQuestionUuid) {
+ OptionConditionDTO c = new OptionConditionDTO();
+ c.setActionType("SHOW_QUESTION");
+ c.setTargetQuestionUuid(targetQuestionUuid);
+ return c;
+ }
+}
diff --git a/src/main/java/com/iemr/flw/seeder/migration/V007_AddRelationshipHoursQuestions.java b/src/main/java/com/iemr/flw/seeder/migration/V007_AddRelationshipHoursQuestions.java
new file mode 100644
index 000000000..3486eea07
--- /dev/null
+++ b/src/main/java/com/iemr/flw/seeder/migration/V007_AddRelationshipHoursQuestions.java
@@ -0,0 +1,129 @@
+/*
+ * 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.QuestionOption;
+import com.iemr.flw.domain.iemr.SectionQuestion;
+import com.iemr.flw.dto.iemr.OptionConditionDTO;
+import com.iemr.flw.dto.iemr.QuestionValidationDTO;
+import com.iemr.flw.dto.iemr.SectionQuestionDTO;
+import com.iemr.flw.masterEnum.QuestionType;
+import com.iemr.flw.masterEnum.ValidationType;
+import com.iemr.flw.service.DynamicFormReconciliationService;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * Adds a mandatory "Time spent with contact in hours" number picker to the Community Contact
+ * Tracing form for each "Relationship with community contacts" (CCT_RELATIONSHIP) option, so the
+ * exposure duration is captured per relationship type alongside its existing contact count.
+ *
+ * One question per option (CCT_RELATIONSHIP_HOURS_<OPTION_VALUE>) rather than a shared one,
+ * since CCT_RELATIONSHIP is multi-select and a shared field couldn't disambiguate which duration
+ * belongs to which relationship. Each is hidden by default, revealed by a SHOW_QUESTION condition
+ * on its option, and mandatory only when that option is selected (MANDATORY_IF).
+ *
+ * Each question is inserted directly after its relationship's "Approximate Area of Shared Space"
+ * (CCT_AREA_OF_SHARED_SPACE_<OPTION_VALUE>), so every relationship's revealed questions read
+ * Type of Space, Area, Time spent; {@code ensureQuestion} shifts everything after it down by one.
+ * Must run after V006_PerRelationshipTypeOfSpace, which creates those Area questions — and since it
+ * runs later, each option's SHOW_QUESTION conditions are also created in that same order.
+ */
+@Component
+public class V007_AddRelationshipHoursQuestions implements FormStructureMigration {
+
+ private static final List RELATIONSHIP_VALUES = List.of(
+ "NEIGHBOUR",
+ "FRIEND",
+ "FELLOW_WORSHIPPER",
+ "FELLOW_COMMUTER",
+ "COMMUNITY_GROUP_MEMBER",
+ "FELLOW_PATIENT",
+ "OTHER");
+
+ private static final String QUESTION_TEXT = "Time spent with contact in hours";
+ private static final String QUESTION_TEXT_HINDI = "संपर्क के साथ बिताया गया समय (घंटों में)";
+ private static final String HOURS_REGEX = "^([0-9]|1[0-9]|2[0-4])$";
+ private static final String HOURS_ERROR = "Enter whole hours between 0 and 24";
+
+ @Override
+ public String migrationId() {
+ return "V007";
+ }
+
+ @Override
+ public void apply(DynamicFormReconciliationService svc) {
+ FormVersion version = svc.latestVersion("COMMUNITY_CONTACT_TRACING")
+ .orElseThrow(() -> new IllegalStateException(
+ "COMMUNITY_CONTACT_TRACING not found — must run after CommunityContactTracingFormSeeder"));
+ FormSection section = svc.section(version, "CCT_SEC_1")
+ .orElseThrow(() -> new IllegalStateException("Section CCT_SEC_1 not found"));
+ SectionQuestion relationship = svc.question(section, "CCT_RELATIONSHIP")
+ .orElseThrow(() -> new IllegalStateException("Question CCT_RELATIONSHIP not found"));
+
+ for (String value : RELATIONSHIP_VALUES) {
+ String questionUuid = "CCT_RELATIONSHIP_HOURS_" + value;
+ String areaUuid = "CCT_AREA_OF_SHARED_SPACE_" + value;
+
+ SectionQuestion area = svc.question(section, areaUuid)
+ .orElseThrow(() -> new IllegalStateException(
+ "Question " + areaUuid + " not found — must run after V006_PerRelationshipTypeOfSpace"));
+ SectionQuestion hours = svc.ensureQuestion(section,
+ hoursQuestion(questionUuid, area.getDisplayOrder() + 1));
+ svc.ensureValidation(hours, validation(ValidationType.REGEX, HOURS_REGEX, HOURS_ERROR));
+ svc.ensureValidation(hours, validation(ValidationType.MANDATORY_IF,
+ "CCT_RELATIONSHIP=" + value, "This field is mandatory"));
+
+ QuestionOption option = svc.option(relationship, value)
+ .orElseThrow(() -> new IllegalStateException("Option CCT_RELATIONSHIP=" + value + " not found"));
+ OptionConditionDTO show = new OptionConditionDTO();
+ show.setActionType("SHOW_QUESTION");
+ show.setTargetQuestionUuid(questionUuid);
+ svc.ensureCondition(option, show, version);
+ }
+ }
+
+ private SectionQuestionDTO hoursQuestion(String uuid, int order) {
+ SectionQuestionDTO q = new SectionQuestionDTO();
+ q.setQuestionUuid(uuid);
+ q.setQuestionText(QUESTION_TEXT);
+ q.setQuestionTextHindi(QUESTION_TEXT_HINDI);
+ q.setQuestionType(QuestionType.NUMBER_PICKER);
+ q.setIsMandatory(true);
+ q.setDisplayOrder(order);
+ q.setVisibleByDefault(false);
+ q.setOptions(List.of());
+ q.setValidations(List.of());
+ return q;
+ }
+
+ private QuestionValidationDTO validation(ValidationType type, String param, String errorMessage) {
+ QuestionValidationDTO v = new QuestionValidationDTO();
+ v.setValidationType(type);
+ v.setValidationParam(param);
+ v.setErrorMessage(errorMessage);
+ return v;
+ }
+}
diff --git a/src/main/java/com/iemr/flw/seeder/migration/V008_ChangeRelationshipToDropdownMulti.java b/src/main/java/com/iemr/flw/seeder/migration/V008_ChangeRelationshipToDropdownMulti.java
new file mode 100644
index 000000000..7db08a382
--- /dev/null
+++ b/src/main/java/com/iemr/flw/seeder/migration/V008_ChangeRelationshipToDropdownMulti.java
@@ -0,0 +1,77 @@
+/*
+ * 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.SectionQuestionDTO;
+import com.iemr.flw.masterEnum.QuestionType;
+import com.iemr.flw.service.DynamicFormReconciliationService;
+import org.springframework.stereotype.Component;
+
+/**
+ * Changes the Community Contact Tracing form's "Relationship with community contacts" question
+ * (CCT_RELATIONSHIP) from CHECKBOX_MULTI to DROPDOWN_MULTI, so the 7 relationship options render as
+ * a multi-select dropdown instead of a checkbox list.
+ *
+ * Both types submit {@code optionValues} and store one response row per selected option, so
+ * existing responses remain valid. Options and their conditions (per-relationship count, Type of
+ * Space and hours questions, and "Other" text) are untouched. All other scalar fields are read off
+ * the existing row and passed back unchanged, since {@code updateQuestion} overwrites all of them
+ * unconditionally.
+ */
+@Component
+public class V008_ChangeRelationshipToDropdownMulti implements FormStructureMigration {
+
+ @Override
+ public String migrationId() {
+ return "V008";
+ }
+
+ @Override
+ public void apply(DynamicFormReconciliationService svc) {
+ FormVersion version = svc.latestVersion("COMMUNITY_CONTACT_TRACING")
+ .orElseThrow(() -> new IllegalStateException(
+ "COMMUNITY_CONTACT_TRACING not found — must run after CommunityContactTracingFormSeeder"));
+ FormSection section = svc.section(version, "CCT_SEC_1")
+ .orElseThrow(() -> new IllegalStateException("Section CCT_SEC_1 not found"));
+ SectionQuestion question = svc.question(section, "CCT_RELATIONSHIP")
+ .orElseThrow(() -> new IllegalStateException("Question CCT_RELATIONSHIP not found"));
+
+ if (question.getQuestionType() == QuestionType.DROPDOWN_MULTI) {
+ return;
+ }
+
+ SectionQuestionDTO dto = new SectionQuestionDTO();
+ dto.setQuestionText(question.getQuestionText());
+ dto.setQuestionTextHindi(question.getQuestionTextHindi());
+ dto.setQuestionType(QuestionType.DROPDOWN_MULTI);
+ dto.setIsMandatory(question.getIsMandatory());
+ dto.setMaxLength(question.getMaxLength());
+ dto.setDefaultValue(question.getDefaultValue());
+ dto.setContainsPii(question.getContainsPii());
+ dto.setDisplayOrder(question.getDisplayOrder());
+
+ svc.updateQuestion(question.getQuestionId(), dto);
+ }
+}
diff --git a/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java b/src/main/java/com/iemr/flw/service/DiagnosticOrderService.java
index 0bd5207b8..250aa143b 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/DynamicFormDefinitionService.java b/src/main/java/com/iemr/flw/service/DynamicFormDefinitionService.java
index c7aae24df..ed2d92d56 100644
--- a/src/main/java/com/iemr/flw/service/DynamicFormDefinitionService.java
+++ b/src/main/java/com/iemr/flw/service/DynamicFormDefinitionService.java
@@ -47,7 +47,7 @@ public interface DynamicFormDefinitionService {
/** Returns a specific version's full definition tree (no cache). */
DynamicFormDTO getFormDefinitionByVersion(Long formId, Integer versionNumber);
- /** Returns metadata for all active forms (without full tree). */
+ /** Returns every active form's latest full definition, with each version's full definition in {@code versions}. */
List getAllForms();
/** Sets the form's isActive flag to true. */
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 000000000..f558f0078
--- /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/DiagnosticDocumentServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticDocumentServiceImpl.java
index b0f92ab4e..6c0f1f586 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 3e5ca60d6..8aeb045b7 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 b21c7095c..2dbb183f9 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);
}
}
diff --git a/src/main/java/com/iemr/flw/service/impl/DynamicFormDefinitionServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DynamicFormDefinitionServiceImpl.java
index c40b65d7b..e61bc5c0a 100644
--- a/src/main/java/com/iemr/flw/service/impl/DynamicFormDefinitionServiceImpl.java
+++ b/src/main/java/com/iemr/flw/service/impl/DynamicFormDefinitionServiceImpl.java
@@ -150,7 +150,9 @@ public List getAllForms() {
.stream()
.map(form -> {
try {
- return loadLatestFromDb(form.getFormId());
+ DynamicFormDTO latest = loadLatestFromDb(form.getFormId());
+ latest.setVersions(loadAllVersionsFromDb(form.getFormId()));
+ return latest;
} catch (RuntimeException e) {
log.warn("Skipping form {} — no active version: {}", form.getFormId(), e.getMessage());
return null;
@@ -266,6 +268,14 @@ private QuestionValidation buildValidationEntity(QuestionValidationDTO dto,
// ── LOAD / READ HELPERS ───────────────────────────────────────────────────
+ /** Full definition of every active version of the form, oldest first. */
+ private List loadAllVersionsFromDb(Long formId) {
+ return versionRepo.findByDynamicForm_FormIdOrderByVersionNumberAsc(formId).stream()
+ .filter(version -> Boolean.TRUE.equals(version.getIsActive()))
+ .map(version -> buildFormDto(version.getDynamicForm(), version))
+ .collect(Collectors.toList());
+ }
+
private DynamicFormDTO loadLatestFromDb(Long formId) {
FormVersion version = versionRepo.findByDynamicForm_FormIdAndIsLatest(formId, true)
.orElseThrow(() -> new RuntimeException("No active version for formId: " + formId));
@@ -280,7 +290,7 @@ private DynamicFormDTO loadLatestFromDb(Long formId) {
private DynamicFormDTO buildFormDto(DynamicForm form, FormVersion version) {
// Query 1: sections
List sections = sectionRepo
- .findByFormVersion_VersionIdOrderByDisplayOrderAsc(version.getVersionId());
+ .findByFormVersion_VersionIdAndIsActiveTrueOrderByDisplayOrderAsc(version.getVersionId());
if (sections.isEmpty()) {
DynamicFormDTO dto = mapper.toDto(form);
dto.setVersionNumber(version.getVersionNumber());
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 000000000..d5de312bd
--- /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);
+ }
+}
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 c95364ee9..90e9d6ba1 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